cards & filters ui refactor

stable
Juanfran 2016-06-03 08:20:35 +02:00
parent e6ef8ffa34
commit 59bf55fc30
90 changed files with 3695 additions and 2812 deletions

View File

@ -23,6 +23,10 @@
- Add Wiki history - Add Wiki history
- Third party integrations: - Third party integrations:
- Included gogs as builtin integration. - Included gogs as builtin integration.
- Filters refactor
- Cards ui refactor with zoom
- Kanban filters
- Taskboard filters
### Misc ### Misc
- Lots of small and not so small bugfixes. - Lots of small and not so small bugfixes.

View File

@ -1,185 +0,0 @@
###
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino Garcia <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán Merino <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Juan Francisco Alcántara <juanfran.alcantara@kaleidos.net>
# Copyright (C) 2014-2016 Xavi Julian <xavier.julian@kaleidos.net>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: modules/backlog/main.coffee
###
taiga = @.taiga
mixOf = @.taiga.mixOf
toggleText = @.taiga.toggleText
scopeDefer = @.taiga.scopeDefer
bindOnce = @.taiga.bindOnce
groupBy = @.taiga.groupBy
debounceLeading = @.taiga.debounceLeading
module = angular.module("taigaBacklog")
#############################################################################
## Issues Filters Directive
#############################################################################
BacklogFiltersDirective = ($q, $log, $location, $template, $compile) ->
template = $template.get("backlog/filters.html", true)
templateSelected = $template.get("backlog/filter-selected.html", true)
link = ($scope, $el, $attrs) ->
currentFiltersType = ''
$ctrl = $el.closest(".wrapper").controller()
selectedFilters = []
showFilters = (title, type) ->
$el.find(".filters-cats").hide()
$el.find(".filter-list").removeClass("hidden")
$el.find("h2.breadcrumb").removeClass("hidden")
$el.find("h2 a.subfilter span.title").html(title)
$el.find("h2 a.subfilter span.title").prop("data-type", type)
currentFiltersType = getFiltersType()
showCategories = ->
$el.find(".filters-cats").show()
$el.find(".filter-list").addClass("hidden")
$el.find("h2.breadcrumb").addClass("hidden")
initializeSelectedFilters = () ->
showCategories()
selectedFilters = []
for name, values of $scope.filters
for val in values
selectedFilters.push(val) if val.selected
renderSelectedFilters()
renderSelectedFilters = ->
_.map selectedFilters, (f) =>
if f.color
f.style = "border-left: 3px solid #{f.color}"
html = templateSelected({filters: selectedFilters})
html = $compile(html)($scope)
$el.find(".filters-applied").html(html)
renderFilters = (filters) ->
_.map filters, (f) =>
if f.color
f.style = "border-left: 3px solid #{f.color}"
html = template({filters:filters})
html = $compile(html)($scope)
$el.find(".filter-list").html(html)
getFiltersType = () ->
return $el.find("h2 a.subfilter span.title").prop('data-type')
reloadUserstories = () ->
currentFiltersType = getFiltersType()
$q.all([$ctrl.loadUserstories(true), $ctrl.generateFilters()]).then () ->
currentFilters = $scope.filters[currentFiltersType]
renderFilters(_.reject(currentFilters, "selected"))
toggleFilterSelection = (type, id) ->
currentFiltersType = getFiltersType()
filters = $scope.filters[type]
filter = _.find(filters, {id: id})
filter.selected = (not filter.selected)
if filter.selected
selectedFilters.push(filter)
$scope.$apply ->
$ctrl.selectFilter(type, id)
else
selectedFilters = _.reject selectedFilters, (selected) ->
return filter.type == selected.type && filter.id == selected.id
$ctrl.unselectFilter(type, id)
renderSelectedFilters(selectedFilters)
if type == currentFiltersType
renderFilters(_.reject(filters, "selected"))
reloadUserstories()
selectQFilter = debounceLeading 100, (value) ->
return if value is undefined
if value.length == 0
$ctrl.replaceFilter("q", null)
else
$ctrl.replaceFilter("q", value)
reloadUserstories()
$scope.$watch("filtersQ", selectQFilter)
## Angular Watchers
$scope.$on "backlog:loaded", (ctx) ->
initializeSelectedFilters()
$scope.$on "filters:update", (ctx) ->
$ctrl.generateFilters().then () ->
filters = $scope.filters[currentFiltersType]
if currentFiltersType
renderFilters(_.reject(filters, "selected"))
## Dom Event Handlers
$el.on "click", ".filters-cats > ul > li > a", (event) ->
event.preventDefault()
target = angular.element(event.currentTarget)
tags = $scope.filters[target.data("type")]
renderFilters(_.reject(tags, "selected"))
showFilters(target.attr("title"), target.data('type'))
$el.on "click", ".filters-inner > .filters-step-cat > .breadcrumb > .back", (event) ->
event.preventDefault()
showCategories()
$el.on "click", ".remove-filter", (event) ->
event.preventDefault()
target = angular.element(event.currentTarget).parent()
id = target.data("id")
type = target.data("type")
toggleFilterSelection(type, id)
$el.on "click", ".filter-list .single-filter", (event) ->
event.preventDefault()
target = angular.element(event.currentTarget)
if target.hasClass("active")
target.removeClass("active")
else
target.addClass("active")
id = target.data("id")
type = target.data("type")
toggleFilterSelection(type, id)
return {link:link}
module.directive("tgBacklogFilters", ["$q", "$log", "$tgLocation", "$tgTemplate", "$compile", BacklogFiltersDirective])

View File

@ -39,7 +39,7 @@ module = angular.module("taigaBacklog")
## Backlog Controller ## Backlog Controller
############################################################################# #############################################################################
class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin) class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin, taiga.UsFiltersMixin)
@.$inject = [ @.$inject = [
"$scope", "$scope",
"$rootScope", "$rootScope",
@ -57,18 +57,30 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
"$tgLoading", "$tgLoading",
"tgResources", "tgResources",
"$tgQueueModelTransformation", "$tgQueueModelTransformation",
"tgErrorHandlingService" "tgErrorHandlingService",
"$tgStorage",
"tgFilterRemoteStorageService"
] ]
storeCustomFiltersName: 'backlog-custom-filters'
storeFiltersName: 'backlog-filters'
backlogOrder: {}
milestonesOrder: {}
constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location, @appMetaService, @navUrls, constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location, @appMetaService, @navUrls,
@events, @analytics, @translate, @loading, @rs2, @modelTransform, @errorHandlingService) -> @events, @analytics, @translate, @loading, @rs2, @modelTransform, @errorHandlingService, @storage, @filterRemoteStorageService) ->
bindMethods(@) bindMethods(@)
@.backlogOrder = {}
@.milestonesOrder = {}
@.page = 1 @.page = 1
@.disablePagination = false @.disablePagination = false
@.firstLoadComplete = false @.firstLoadComplete = false
@scope.userstories = [] @scope.userstories = []
return if @.applyStoredFilters(@params.pslug, "backlog-filters")
@scope.sectionName = @translate.instant("BACKLOG.SECTION_NAME") @scope.sectionName = @translate.instant("BACKLOG.SECTION_NAME")
@showTags = false @showTags = false
@activeFilters = false @activeFilters = false
@ -97,6 +109,9 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
# On Error # On Error
promise.then null, @.onInitialDataError.bind(@) promise.then null, @.onInitialDataError.bind(@)
filtersReloadContent: () ->
@.loadUserstories(true)
initializeEventHandlers: -> initializeEventHandlers: ->
@scope.$on "usform:bulk:success", => @scope.$on "usform:bulk:success", =>
@.loadUserstories(true) @.loadUserstories(true)
@ -175,6 +190,12 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
@scope.showGraphPlaceholder = !(stats.total_points? && stats.total_milestones?) @scope.showGraphPlaceholder = !(stats.total_points? && stats.total_milestones?)
return stats return stats
setMilestonesOrder: (sprints) ->
for sprint in sprints
@.milestonesOrder[sprint.id] = {}
for it in sprint.user_stories
@.milestonesOrder[sprint.id][it.id] = it.sprint_order
unloadClosedSprints: -> unloadClosedSprints: ->
@scope.$apply => @scope.$apply =>
@scope.closedSprints = [] @scope.closedSprints = []
@ -185,6 +206,8 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
return @rs.sprints.list(@scope.projectId, params).then (result) => return @rs.sprints.list(@scope.projectId, params).then (result) =>
sprints = result.milestones sprints = result.milestones
@.setMilestonesOrder(sprints)
@scope.totalClosedMilestones = result.closed @scope.totalClosedMilestones = result.closed
# NOTE: Fix order of USs because the filter orderBy does not work propertly in partials files # NOTE: Fix order of USs because the filter orderBy does not work propertly in partials files
@ -200,6 +223,8 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
return @rs.sprints.list(@scope.projectId, params).then (result) => return @rs.sprints.list(@scope.projectId, params).then (result) =>
sprints = result.milestones sprints = result.milestones
@.setMilestonesOrder(sprints)
@scope.totalMilestones = sprints @scope.totalMilestones = sprints
@scope.totalClosedMilestones = result.closed @scope.totalClosedMilestones = result.closed
@scope.totalOpenMilestones = result.open @scope.totalOpenMilestones = result.open
@ -221,47 +246,6 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
return sprints return sprints
restoreFilters: ->
selectedTags = @scope.oldSelectedTags
selectedStatuses = @scope.oldSelectedStatuses
return if !selectedStatuses and !selectedStatuses
@scope.filtersQ = @scope.filtersQOld
@.replaceFilter("q", @scope.filtersQ)
_.each [selectedTags, selectedStatuses], (filterGrp) =>
_.each filterGrp, (item) =>
filters = @scope.filters[item.type]
filter = _.find(filters, {id: item.id})
filter.selected = true
@.selectFilter(item.type, item.id)
@.loadUserstories()
resetFilters: ->
selectedTags = _.filter(@scope.filters.tags, "selected")
selectedStatuses = _.filter(@scope.filters.status, "selected")
@scope.oldSelectedTags = selectedTags
@scope.oldSelectedStatuses = selectedStatuses
@scope.filtersQOld = @scope.filtersQ
@scope.filtersQ = undefined
@.replaceFilter("q", @scope.filtersQ)
_.each [selectedTags, selectedStatuses], (filterGrp) =>
_.each filterGrp, (item) =>
filters = @scope.filters[item.type]
filter = _.find(filters, {id: item.id})
filter.selected = false
@.unselectFilter(item.type, item.id)
@.loadUserstories()
loadAllPaginatedUserstories: () -> loadAllPaginatedUserstories: () ->
page = @.page page = @.page
@ -273,15 +257,15 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
@.loadingUserstories = true @.loadingUserstories = true
@.disablePagination = true @.disablePagination = true
@scope.httpParams = @.getUrlFilters() params = _.clone(@location.search())
@rs.userstories.storeQueryParams(@scope.projectId, @scope.httpParams) @rs.userstories.storeQueryParams(@scope.projectId, params)
if resetPagination if resetPagination
@.page = 1 @.page = 1
@scope.httpParams.page = @.page params.page = @.page
promise = @rs.userstories.listUnassigned(@scope.projectId, @scope.httpParams, pageSize) promise = @rs.userstories.listUnassigned(@scope.projectId, params, pageSize)
return promise.then (result) => return promise.then (result) =>
userstories = result[0] userstories = result[0]
@ -293,7 +277,8 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
# NOTE: Fix order of USs because the filter orderBy does not work propertly in the partials files # NOTE: Fix order of USs because the filter orderBy does not work propertly in the partials files
@scope.userstories = @scope.userstories.concat(_.sortBy(userstories, "backlog_order")) @scope.userstories = @scope.userstories.concat(_.sortBy(userstories, "backlog_order"))
@.setSearchDataFilters() for it in @scope.userstories
@.backlogOrder[it.id] = it.backlog_order
@.loadingUserstories = false @.loadingUserstories = false
@ -354,242 +339,142 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
return items return items
# --move us api behavior--
# if your are moving multiples USs you must use the bulk api
# if there is only one US you must use patch (repo.save)
# the new US position is the position of the previous US + 1
# if the previous US has a position value that it is equal to other USs, you must send all the USs with that position value only if they are before of the target position
# with this USs if it's a patch you must add them to the header, if is a bulk you must send them with the other USs
moveUs: (ctx, usList, newUsIndex, newSprintId) -> moveUs: (ctx, usList, newUsIndex, newSprintId) ->
oldSprintId = usList[0].milestone oldSprintId = usList[0].milestone
project = usList[0].project project = usList[0].project
movedFromClosedSprint = false if oldSprintId
movedToClosedSprint = false sprint = @scope.sprintsById[oldSprintId] || @scope.closedSprintsById[oldSprintId]
sprint = @scope.sprintsById[oldSprintId] if newSprintId
newSprint = @scope.sprintsById[newSprintId] || @scope.closedSprintsById[newSprintId]
# Move from closed sprint currentSprintId = if newSprintId != oldSprintId then newSprintId else oldSprintId
if !sprint && @scope.closedSprintsById
sprint = @scope.closedSprintsById[oldSprintId]
movedFromClosedSprint = true if sprint
newSprint = @scope.sprintsById[newSprintId] orderList = null
orderField = ""
# Move to closed sprint if newSprintId != oldSprintId
if !newSprint && newSprintId if newSprintId == null # From sprint to backlog
newSprint = @scope.closedSprintsById[newSprintId] for us, key in usList # delete from sprint userstories
movedToClosedSprint = true if newSprint _.remove sprint.user_stories, (it) -> it.id == us.id
# In the same sprint or in the backlog orderField = "backlog_order"
if newSprintId == oldSprintId orderList = @.backlogOrder
items = null
userstories = null
if newSprintId == null beforeDestination = _.slice(@scope.userstories, 0, newUsIndex)
userstories = @scope.userstories afterDestination = _.slice(@scope.userstories, newUsIndex)
@scope.userstories = @scope.userstories.concat(usList)
else # From backlog to sprint
for us in usList # delete from sprint userstories
_.remove @scope.userstories, (it) -> it.id == us.id
orderField = "sprint_order"
orderList = @.milestonesOrder[newSprint.id]
beforeDestination = _.slice(newSprint.user_stories, 0, newUsIndex)
afterDestination = _.slice(newSprint.user_stories, newUsIndex)
newSprint.user_stories = newSprint.user_stories.concat(usList)
else else
userstories = newSprint.user_stories if oldSprintId == null # backlog
orderField = "backlog_order"
orderList = @.backlogOrder
@scope.$apply -> list = _.filter @scope.userstories, (listIt) -> # Remove moved US from list
for us, key in usList return !_.find usList, (moveIt) -> return listIt.id == moveIt.id
r = userstories.indexOf(us)
userstories.splice(r, 1)
args = [newUsIndex, 0].concat(usList) beforeDestination = _.slice(list, 0, newUsIndex)
Array.prototype.splice.apply(userstories, args) afterDestination = _.slice(list, newUsIndex)
else # sprint
orderField = "sprint_order"
orderList = @.milestonesOrder[sprint.id]
# If in backlog list = _.filter newSprint.user_stories, (listIt) -> # Remove moved US from list
if newSprintId == null return !_.find usList, (moveIt) -> return listIt.id == moveIt.id
# Rehash userstories order field
items = @.resortUserStories(userstories, "backlog_order") beforeDestination = _.slice(list, 0, newUsIndex)
data = @.prepareBulkUpdateData(items, "backlog_order") afterDestination = _.slice(list, newUsIndex)
# Persist in bulk all affected # previous us
# userstories with order change previous = beforeDestination[beforeDestination.length - 1]
@rs.userstories.bulkUpdateBacklogOrder(project, data).then =>
# this will store the previous us with the same position
setPreviousOrders = []
if !previous
startIndex = 0
else if previous
startIndex = orderList[previous.id] + 1
previousWithTheSameOrder = _.filter beforeDestination, (it) -> it[orderField] == orderList[previous.id]
# we must send the USs previous to the dropped USs to tell the backend which USs are before the dropped USs,
# if they have the same value to order, the backend doens't know after which one do you want to drop the USs
if previousWithTheSameOrder.length > 1
setPreviousOrders = _.map previousWithTheSameOrder, (it) -> {us_id: it.id, order: orderList[it.id]}
modifiedUs = []
for us, key in usList # update sprint and new position
us.milestone = currentSprintId
us[orderField] = startIndex + key
orderList[us.id] = us[orderField]
modifiedUs.push({us_id: us.id, order: us[orderField]})
startIndex = orderList[usList[usList.length - 1].id]
for it, key in afterDestination # increase position of the us after the dragged us's
orderList[it.id] = startIndex + key + 1
# refresh order
@scope.userstories = _.sortBy @scope.userstories, (it) => @.backlogOrder[it.id]
for sprint in @scope.sprints
sprint.user_stories = _.sortBy sprint.user_stories, (it) => @.milestonesOrder[sprint.id][it.id]
for sprint in @scope.closedSprints
sprint.user_stories = _.sortBy sprint.user_stories, (it) => @.milestonesOrder[sprint.id][it.id]
#saving
if usList.length > 1 && (newSprintId != oldSprintId) # drag multiple to sprint
data = modifiedUs.concat(setPreviousOrders)
promise = @rs.userstories.bulkUpdateMilestone(project, newSprintId, data)
else if usList.length > 1 # drag multiple in backlog
data = modifiedUs.concat(setPreviousOrders)
promise = @rs.userstories.bulkUpdateBacklogOrder(project, data)
else # drag single
setOrders = {}
for it in setPreviousOrders
setOrders[it.us_id] = it.order
options = {
headers: {
"set-orders": JSON.stringify(setOrders)
}
}
promise = @repo.save(usList[0], true, {}, options, true)
promise.then () =>
@rootscope.$broadcast("sprint:us:moved") @rootscope.$broadcast("sprint:us:moved")
# For sprint if @scope.closedSprintsById && @scope.closedSprintsById[oldSprintId]
else
# Rehash userstories order field
items = @.resortUserStories(userstories, "sprint_order")
data = @.prepareBulkUpdateData(items, "sprint_order")
# Persist in bulk all affected
# userstories with order change
@rs.userstories.bulkUpdateSprintOrder(project, data).then =>
@rootscope.$broadcast("sprint:us:moved")
return promise
# From sprint to backlog
if newSprintId == null
us.milestone = null for us in usList
@scope.$apply =>
# Add new us to backlog userstories list
# @scope.userstories.splice(newUsIndex, 0, us)
args = [newUsIndex, 0].concat(usList)
Array.prototype.splice.apply(@scope.userstories, args)
for us, key in usList
r = sprint.user_stories.indexOf(us)
sprint.user_stories.splice(r, 1)
# Persist the milestone change of userstory
promise = @repo.save(us)
# Rehash userstories order field
# and persist in bulk all changes.
promise = promise.then =>
items = @.resortUserStories(@scope.userstories, "backlog_order")
data = @.prepareBulkUpdateData(items, "backlog_order")
return @rs.userstories.bulkUpdateBacklogOrder(us.project, data).then =>
@rootscope.$broadcast("sprint:us:moved")
if movedFromClosedSprint
@rootscope.$broadcast("backlog:load-closed-sprints") @rootscope.$broadcast("backlog:load-closed-sprints")
promise.then null, ->
console.log "FAIL" # TODO
return promise return promise
# From backlog to sprint
if oldSprintId == null
us.milestone = newSprintId for us in usList
args = [newUsIndex, 0].concat(usList)
# Add moving us to sprint user stories list
Array.prototype.splice.apply(newSprint.user_stories, args)
# Remove moving us from backlog userstories lists.
for us, key in usList
r = @scope.userstories.indexOf(us)
@scope.userstories.splice(r, 1)
# From sprint to sprint
else
us.milestone = newSprintId for us in usList
@scope.$apply =>
args = [newUsIndex, 0].concat(usList)
# Add new us to backlog userstories list
Array.prototype.splice.apply(newSprint.user_stories, args)
# Remove the us from the sprint list.
for us in usList
r = sprint.user_stories.indexOf(us)
sprint.user_stories.splice(r, 1)
#Persist the milestone change of userstory
promises = _.map usList, (us) => @repo.save(us)
#Rehash userstories order field
#and persist in bulk all changes.
promise = @q.all(promises).then =>
items = @.resortUserStories(newSprint.user_stories, "sprint_order")
data = @.prepareBulkUpdateData(items, "sprint_order")
@rs.userstories.bulkUpdateSprintOrder(project, data).then (result) =>
@rootscope.$broadcast("sprint:us:moved")
@rs.userstories.bulkUpdateBacklogOrder(project, data).then =>
@rootscope.$broadcast("sprint:us:moved")
if movedToClosedSprint || movedFromClosedSprint
@scope.$broadcast("backlog:load-closed-sprints")
promise.then null, ->
console.log "FAIL" # TODO
return promise
isFilterSelected: (type, id) ->
if @searchdata[type]? and @searchdata[type][id]
return true
return false
setSearchDataFilters: () ->
urlfilters = @.getUrlFilters()
if urlfilters.q
@scope.filtersQ = @scope.filtersQ or urlfilters.q
@searchdata = {}
for name, value of urlfilters
if not @searchdata[name]?
@searchdata[name] = {}
for val in taiga.toString(value).split(",")
@searchdata[name][val] = true
getUrlFilters: ->
return _.pick(@location.search(), "status", "tags", "q")
generateFilters: ->
urlfilters = @.getUrlFilters()
@scope.filters = {}
loadFilters = {}
loadFilters.project = @scope.projectId
loadFilters.tags = urlfilters.tags
loadFilters.status = urlfilters.status
loadFilters.q = urlfilters.q
loadFilters.milestone = 'null'
return @rs.userstories.filtersData(loadFilters).then (data) =>
choicesFiltersFormat = (choices, type, byIdObject) =>
_.map choices, (t) ->
t.type = type
return t
tagsFilterFormat = (tags) =>
return _.map tags, (t) ->
t.id = t.name
t.type = 'tags'
return t
# Build filters data structure
@scope.filters.status = choicesFiltersFormat(data.statuses, "status", @scope.usStatusById)
@scope.filters.tags = tagsFilterFormat(data.tags)
selectedTags = _.filter(@scope.filters.tags, "selected")
selectedTags = _.map(selectedTags, "id")
selectedStatuses = _.filter(@scope.filters.status, "selected")
selectedStatuses = _.map(selectedStatuses, "id")
@.markSelectedFilters(@scope.filters, urlfilters)
#store query params
@rs.userstories.storeQueryParams(@scope.projectId, {
"status": selectedStatuses,
"tags": selectedTags,
"project": @scope.projectId
"milestone": null
})
markSelectedFilters: (filters, urlfilters) ->
# Build selected filters (from url) fast lookup data structure
searchdata = {}
for name, value of _.omit(urlfilters, "page", "orderBy")
if not searchdata[name]?
searchdata[name] = {}
for val in "#{value}".split(",")
searchdata[name][val] = true
isSelected = (type, id) ->
if searchdata[type]? and searchdata[type][id]
return true
return false
for key, value of filters
for obj in value
obj.selected = if isSelected(obj.type, obj.id) then true else undefined
## Template actions ## Template actions
updateUserStoryStatus: () -> updateUserStoryStatus: () ->
@.setSearchDataFilters()
@.generateFilters().then () => @.generateFilters().then () =>
@rootscope.$broadcast("filters:update") @rootscope.$broadcast("filters:update")
@.loadProjectStats() @.loadProjectStats()
@ -807,8 +692,15 @@ BacklogDirective = ($repo, $rootscope, $translate) ->
text = $translate.instant("BACKLOG.TAGS.SHOW") text = $translate.instant("BACKLOG.TAGS.SHOW")
elm.text(text) elm.text(text)
openFilterInit = ($scope, $el, $ctrl) ->
sidebar = $el.find("sidebar.backlog-filter")
sidebar.addClass("active")
$ctrl.activeFilters = true
showHideFilter = ($scope, $el, $ctrl) -> showHideFilter = ($scope, $el, $ctrl) ->
sidebar = $el.find("sidebar.filters-bar") sidebar = $el.find("sidebar.backlog-filter")
sidebar.one "transitionend", () -> sidebar.one "transitionend", () ->
timeout 150, -> timeout 150, ->
$rootscope.$broadcast("resize") $rootscope.$broadcast("resize")
@ -824,11 +716,6 @@ BacklogDirective = ($repo, $rootscope, $translate) ->
toggleText(target, [hideText, showText]) toggleText(target, [hideText, showText])
if !sidebar.hasClass("active")
$ctrl.resetFilters()
else
$ctrl.restoreFilters()
$ctrl.toggleActiveFilters() $ctrl.toggleActiveFilters()
## Filters Link ## Filters Link
@ -847,11 +734,13 @@ BacklogDirective = ($repo, $rootscope, $translate) ->
linkFilters($scope, $el, $attrs, $ctrl) linkFilters($scope, $el, $attrs, $ctrl)
linkDoomLine($scope, $el, $attrs, $ctrl) linkDoomLine($scope, $el, $attrs, $ctrl)
filters = $ctrl.getUrlFilters() filters = $ctrl.location.search()
if filters.status || if filters.status ||
filters.tags || filters.tags ||
filters.q filters.q ||
showHideFilter($scope, $el, $ctrl) filters.assigned_to ||
filters.owner
openFilterInit($scope, $el, $ctrl)
$scope.$on "showTags", () -> $scope.$on "showTags", () ->
showHideTags($ctrl) showHideTags($ctrl)

View File

@ -42,7 +42,7 @@ deleteElement = (el) ->
$(el).off() $(el).off()
$(el).remove() $(el).remove()
BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) -> BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm) ->
link = ($scope, $el, $attrs) -> link = ($scope, $el, $attrs) ->
bindOnce $scope, "project", (project) -> bindOnce $scope, "project", (project) ->
# If the user has not enough permissions we don't enable the sortable # If the user has not enough permissions we don't enable the sortable
@ -51,10 +51,6 @@ BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) ->
initIsBacklog = false initIsBacklog = false
filterError = ->
text = $translate.instant("BACKLOG.SORTABLE_FILTER_ERROR")
$tgConfirm.notify("error", text)
drake = dragula([$el[0], $('.empty-backlog')[0]], { drake = dragula([$el[0], $('.empty-backlog')[0]], {
copySortSource: false, copySortSource: false,
copy: false, copy: false,
@ -63,18 +59,11 @@ BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) ->
if !$(item).hasClass('row') if !$(item).hasClass('row')
return false return false
# it doesn't move is the filter is open
parent = $(item).parent()
initIsBacklog = parent.hasClass('backlog-table-body')
if initIsBacklog && $el.hasClass("active-filters")
filterError()
return false
return true return true
}) })
drake.on 'drag', (item, container) -> drake.on 'drag', (item, container) ->
# it doesn't move is the filter is open
parent = $(item).parent() parent = $(item).parent()
initIsBacklog = parent.hasClass('backlog-table-body') initIsBacklog = parent.hasClass('backlog-table-body')
@ -88,6 +77,8 @@ BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) ->
$(item).addClass('backlog-us-mirror') $(item).addClass('backlog-us-mirror')
drake.on 'dragend', (item) -> drake.on 'dragend', (item) ->
parent = $(item).parent()
$('.doom-line').remove() $('.doom-line').remove()
parent = $(item).parent() parent = $(item).parent()
@ -102,8 +93,6 @@ BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) ->
$(document.body).removeClass("drag-active") $(document.body).removeClass("drag-active")
items = $(item).parent().find('.row')
sprint = null sprint = null
firstElement = if dragMultipleItems.length then dragMultipleItems[0] else item firstElement = if dragMultipleItems.length then dragMultipleItems[0] else item
@ -131,11 +120,7 @@ BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) ->
usList = _.map dragMultipleItems, (item) -> usList = _.map dragMultipleItems, (item) ->
return item = $(item).scope().us return item = $(item).scope().us
else else
usList = _.map items, (item) -> usList = [$(item).scope().us]
item = $(item)
itemUs = item.scope().us
return itemUs
$scope.$emit("sprint:us:move", usList, index, sprint) $scope.$emit("sprint:us:move", usList, index, sprint)
@ -158,6 +143,5 @@ module.directive("tgBacklogSortable", [
"$tgResources", "$tgResources",
"$rootScope", "$rootScope",
"$tgConfirm", "$tgConfirm",
"$translate",
BacklogSortableDirective BacklogSortableDirective
]) ])

View File

@ -41,7 +41,7 @@ class RepositoryService extends taiga.Service
defered = @q.defer() defered = @q.defer()
url = @urls.resolve(name) url = @urls.resolve(name)
promise = @http.post(url, JSON.stringify(data)) promise = @http.post(url, JSON.stringify(data), extraParams)
promise.success (_data, _status) => promise.success (_data, _status) =>
defered.resolve(@model.make_model(name, _data, null, dataTypes)) defered.resolve(@model.make_model(name, _data, null, dataTypes))
@ -67,7 +67,7 @@ class RepositoryService extends taiga.Service
promises = _.map(models, (x) => @.save(x, true)) promises = _.map(models, (x) => @.save(x, true))
return @q.all(promises) return @q.all(promises)
save: (model, patch=true) -> save: (model, patch=true, params = {}, options, returnHeaders = false) ->
defered = @q.defer() defered = @q.defer()
if not model.isModified() and patch if not model.isModified() and patch
@ -75,19 +75,24 @@ class RepositoryService extends taiga.Service
return defered.promise return defered.promise
url = @.resolveUrlForModel(model) url = @.resolveUrlForModel(model)
data = JSON.stringify(model.getAttrs(patch)) data = JSON.stringify(model.getAttrs(patch))
if patch if patch
promise = @http.patch(url, data) promise = @http.patch(url, data, params, options)
else else
promise = @http.put(url, data) promise = @http.put(url, data, params, options)
promise.success (data, status) => promise.success (data, status, headers, response) =>
model._isModified = false model._isModified = false
model._attrs = _.extend(model.getAttrs(), data) model._attrs = _.extend(model.getAttrs(), data)
model._modifiedAttrs = {} model._modifiedAttrs = {}
model.applyCasts() model.applyCasts()
if returnHeaders
defered.resolve([model, headers()])
else
defered.resolve(model) defered.resolve(model)
promise.error (data, status) -> promise.error (data, status) ->

View File

@ -378,22 +378,28 @@ CreateEditUserstoryDirective = ($repo, $model, $rs, $rootScope, lightboxService,
.target(submitButton) .target(submitButton)
.start() .start()
params = {
include_attachments: true,
include_tasks: true
}
if $scope.isNew if $scope.isNew
promise = $repo.create("userstories", $scope.us) promise = $repo.create("userstories", $scope.us)
broadcastEvent = "usform:new:success" broadcastEvent = "usform:new:success"
else else
promise = $repo.save($scope.us) promise = $repo.save($scope.us, true)
broadcastEvent = "usform:edit:success" broadcastEvent = "usform:edit:success"
promise.then (data) -> promise.then (data) ->
deleteAttachments(data).then () => createAttachments(data) deleteAttachments(data)
.then () => createAttachments(data)
return data .then () =>
promise.then (data) ->
currentLoading.finish() currentLoading.finish()
lightboxService.close($el) lightboxService.close($el)
$rootScope.$broadcast(broadcastEvent, data)
$rs.userstories.getByRef(data.project, data.ref, params).then (us) ->
$rootScope.$broadcast(broadcastEvent, us)
promise.then null, (data) -> promise.then null, (data) ->
currentLoading.finish() currentLoading.finish()
@ -433,7 +439,7 @@ module.directive("tgLbCreateEditUserstory", [
"$translate", "$translate",
"$tgConfirm", "$tgConfirm",
"$q", "$q",
"tgAttachmentsService", "tgAttachmentsService"
CreateEditUserstoryDirective CreateEditUserstoryDirective
]) ])
@ -442,7 +448,7 @@ module.directive("tgLbCreateEditUserstory", [
## Creare Bulk Userstories Lightbox Directive ## Creare Bulk Userstories Lightbox Directive
############################################################################# #############################################################################
CreateBulkUserstoriesDirective = ($repo, $rs, $rootscope, lightboxService, $loading) -> CreateBulkUserstoriesDirective = ($repo, $rs, $rootscope, lightboxService, $loading, $model) ->
link = ($scope, $el, attrs) -> link = ($scope, $el, attrs) ->
form = null form = null
@ -469,6 +475,7 @@ CreateBulkUserstoriesDirective = ($repo, $rs, $rootscope, lightboxService, $load
promise = $rs.userstories.bulkCreate($scope.new.projectId, $scope.new.statusId, $scope.new.bulk) promise = $rs.userstories.bulkCreate($scope.new.projectId, $scope.new.statusId, $scope.new.bulk)
promise.then (result) -> promise.then (result) ->
result = _.map(result.data, (x) => $model.make_model('userstories', x))
currentLoading.finish() currentLoading.finish()
$rootscope.$broadcast("usform:bulk:success", result) $rootscope.$broadcast("usform:bulk:success", result)
lightboxService.close($el) lightboxService.close($el)
@ -494,6 +501,7 @@ module.directive("tgLbCreateBulkUserstories", [
"$rootScope", "$rootScope",
"lightboxService", "lightboxService",
"$tgLoading", "$tgLoading",
"$tgModel",
CreateBulkUserstoriesDirective CreateBulkUserstoriesDirective
]) ])
@ -535,7 +543,7 @@ AssignedToLightboxDirective = (lightboxService, lightboxKeyboardNavigationServic
visibleUsers = _.map visibleUsers, (user) -> visibleUsers = _.map visibleUsers, (user) ->
user.avatar = avatarService.getAvatar(user) user.avatar = avatarService.getAvatar(user)
selected.avatar = avatarService.getAvatar(selected) selected.avatar = avatarService.getAvatar(selected) if selected
ctx = { ctx = {
selected: selected selected: selected

View File

@ -110,4 +110,179 @@ class FiltersMixin
location = if load then @location else @location.noreload(@scope) location = if load then @location else @location.noreload(@scope)
location.search(name, value) location.search(name, value)
applyStoredFilters: (projectSlug, key) ->
if _.isEmpty(@location.search())
filters = @.getFilters(projectSlug, key)
if Object.keys(filters).length
@location.search(filters)
@location.replace()
return true
return false
storeFilters: (projectSlug, params, filtersHashSuffix) ->
ns = "#{projectSlug}:#{filtersHashSuffix}"
hash = taiga.generateHash([projectSlug, ns])
@storage.set(hash, params)
getFilters: (projectSlug, filtersHashSuffix) ->
ns = "#{projectSlug}:#{filtersHashSuffix}"
hash = taiga.generateHash([projectSlug, ns])
return @storage.get(hash) or {}
formatSelectedFilters: (type, list, urlIds) ->
selectedIds = urlIds.split(',')
selectedFilters = _.filter list, (it) ->
selectedIds.indexOf(_.toString(it.id)) != -1
return _.map selectedFilters, (it) ->
return {
id: it.id
key: type + ":" + it.id
dataType: type,
name: it.name
color: it.color
}
taiga.FiltersMixin = FiltersMixin taiga.FiltersMixin = FiltersMixin
#############################################################################
## Us Filters Mixin
#############################################################################
class UsFiltersMixin
changeQ: (q) ->
@.replaceFilter("q", q)
@.filtersReloadContent()
@.generateFilters()
removeFilter: (filter) ->
@.unselectFilter(filter.dataType, filter.id)
@.filtersReloadContent()
@.generateFilters()
addFilter: (newFilter) ->
@.selectFilter(newFilter.category.dataType, newFilter.filter.id)
@.filtersReloadContent()
@.generateFilters()
selectCustomFilter: (customFilter) ->
@.replaceAllFilters(customFilter.filter)
@.filtersReloadContent()
@.generateFilters()
saveCustomFilter: (name) ->
filters = {}
urlfilters = @location.search()
filters.tags = urlfilters.tags
filters.status = urlfilters.status
filters.assigned_to = urlfilters.assigned_to
filters.owner = urlfilters.owner
@filterRemoteStorageService.getFilters(@scope.projectId, @.storeCustomFiltersName).then (userFilters) =>
userFilters[name] = filters
@filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, @.storeCustomFiltersName).then(@.generateFilters)
removeCustomFilter: (customFilter) ->
@filterRemoteStorageService.getFilters(@scope.projectId, @.storeCustomFiltersName).then (userFilters) =>
delete userFilters[customFilter.id]
@filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, @.storeCustomFiltersName).then(@.generateFilters)
@.generateFilters()
generateFilters: ->
@.storeFilters(@params.pslug, @location.search(), @.storeFiltersName)
urlfilters = @location.search()
loadFilters = {}
loadFilters.project = @scope.projectId
loadFilters.tags = urlfilters.tags
loadFilters.status = urlfilters.status
loadFilters.assigned_to = urlfilters.assigned_to
loadFilters.owner = urlfilters.owner
loadFilters.q = urlfilters.q
return @q.all([
@rs.userstories.filtersData(loadFilters),
@filterRemoteStorageService.getFilters(@scope.projectId, @.storeCustomFiltersName)
]).then (result) =>
data = result[0]
customFiltersRaw = result[1]
statuses = _.map data.statuses, (it) ->
it.id = it.id.toString()
return it
tags = _.map data.tags, (it) ->
it.id = it.name
return it
assignedTo = _.map data.assigned_to, (it) ->
if it.id
it.id = it.id.toString()
else
it.id = "null"
it.name = it.full_name || "Unassigned"
return it
owner = _.map data.owners, (it) ->
it.id = it.id.toString()
it.name = it.full_name
return it
@.selectedFilters = []
if loadFilters.status
selected = @.formatSelectedFilters("status", statuses, loadFilters.status)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.tags
selected = @.formatSelectedFilters("tags", tags, loadFilters.tags)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.assigned_to
selected = @.formatSelectedFilters("assigned_to", assignedTo, loadFilters.assigned_to)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.owner
selected = @.formatSelectedFilters("owner", owner, loadFilters.owner)
@.selectedFilters = @.selectedFilters.concat(selected)
@.filterQ = loadFilters.q
@.filters = [
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.STATUS"),
dataType: "status",
content: statuses
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.TAGS"),
dataType: "tags",
content: tags,
hideEmpty: true
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.ASSIGNED_TO"),
dataType: "assigned_to",
content: assignedTo
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.CREATED_BY"),
dataType: "owner",
content: owner
}
];
@.customFilters = []
_.forOwn customFiltersRaw, (value, key) =>
@.customFilters.push({id: key, name: key, filter: value})
taiga.UsFiltersMixin = UsFiltersMixin

View File

@ -32,6 +32,7 @@ groupBy = @.taiga.groupBy
bindOnce = @.taiga.bindOnce bindOnce = @.taiga.bindOnce
debounceLeading = @.taiga.debounceLeading debounceLeading = @.taiga.debounceLeading
startswith = @.taiga.startswith startswith = @.taiga.startswith
bindMethods = @.taiga.bindMethods
module = angular.module("taigaIssues") module = angular.module("taigaIssues")
@ -55,21 +56,23 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
"$tgEvents", "$tgEvents",
"$tgAnalytics", "$tgAnalytics",
"$translate", "$translate",
"tgErrorHandlingService" "tgErrorHandlingService",
"$tgStorage",
"tgFilterRemoteStorageService"
] ]
filtersHashSuffix: "issues-filters"
myFiltersHashSuffix: "issues-my-filters"
constructor: (@scope, @rootscope, @repo, @confirm, @rs, @urls, @params, @q, @location, @appMetaService, constructor: (@scope, @rootscope, @repo, @confirm, @rs, @urls, @params, @q, @location, @appMetaService,
@navUrls, @events, @analytics, @translate, @errorHandlingService) -> @navUrls, @events, @analytics, @translate, @errorHandlingService, @storage, @filterRemoteStorageService) ->
bindMethods(@)
@scope.sectionName = "Issues" @scope.sectionName = "Issues"
@scope.filters = {} @scope.filters = {}
@.voting = false @.voting = false
if _.isEmpty(@location.search()) return if @.applyStoredFilters(@params.pslug, @.filtersHashSuffix)
filters = @rs.issues.getFilters(@params.pslug)
filters.page = 1
@location.search(filters)
@location.replace()
return
promise = @.loadInitialData() promise = @.loadInitialData()
@ -89,13 +92,196 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
@analytics.trackEvent("issue", "create", "create issue on issues list", 1) @analytics.trackEvent("issue", "create", "create issue on issues list", 1)
@.loadIssues() @.loadIssues()
changeQ: (q) ->
@.unselectFilter("page")
@.replaceFilter("q", q)
@.loadIssues()
@.generateFilters()
removeFilter: (filter) ->
@.unselectFilter("page")
@.unselectFilter(filter.dataType, filter.id)
@.loadIssues()
@.generateFilters()
addFilter: (newFilter) ->
@.unselectFilter("page")
@.selectFilter(newFilter.category.dataType, newFilter.filter.id)
@.loadIssues()
@.generateFilters()
selectCustomFilter: (customFilter) ->
orderBy = @location.search().order_by
if orderBy
customFilter.filter.order_by = orderBy
@.unselectFilter("page")
@.replaceAllFilters(customFilter.filter)
@.loadIssues()
@.generateFilters()
removeCustomFilter: (customFilter) ->
console.log "oooo"
@filterRemoteStorageService.getFilters(@scope.projectId, @.myFiltersHashSuffix).then (userFilters) =>
console.log userFilters[customFilter.id]
delete userFilters[customFilter.id]
@filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, @.myFiltersHashSuffix).then(@.generateFilters)
saveCustomFilter: (name) ->
filters = {}
urlfilters = @location.search()
filters.tags = urlfilters.tags
filters.status = urlfilters.status
filters.type = urlfilters.type
filters.severity = urlfilters.severity
filters.priority = urlfilters.priority
filters.assigned_to = urlfilters.assigned_to
filters.owner = urlfilters.owner
@filterRemoteStorageService.getFilters(@scope.projectId, @.myFiltersHashSuffix).then (userFilters) =>
userFilters[name] = filters
@filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, @.myFiltersHashSuffix).then(@.generateFilters)
generateFilters: ->
@.storeFilters(@params.pslug, @location.search(), @.filtersHashSuffix)
urlfilters = @location.search()
loadFilters = {}
loadFilters.project = @scope.projectId
loadFilters.tags = urlfilters.tags
loadFilters.status = urlfilters.status
loadFilters.type = urlfilters.type
loadFilters.severity = urlfilters.severity
loadFilters.priority = urlfilters.priority
loadFilters.assigned_to = urlfilters.assigned_to
loadFilters.owner = urlfilters.owner
loadFilters.q = urlfilters.q
return @q.all([
@rs.issues.filtersData(loadFilters),
@filterRemoteStorageService.getFilters(@scope.projectId, @.myFiltersHashSuffix)
]).then (result) =>
data = result[0]
customFiltersRaw = result[1]
statuses = _.map data.statuses, (it) ->
it.id = it.id.toString()
return it
type = _.map data.types, (it) ->
it.id = it.id.toString()
return it
severity = _.map data.severities, (it) ->
it.id = it.id.toString()
return it
priority = _.map data.priorities, (it) ->
it.id = it.id.toString()
return it
tags = _.map data.tags, (it) ->
it.id = it.name
return it
assignedTo = _.map data.assigned_to, (it) ->
if it.id
it.id = it.id.toString()
else
it.id = "null"
it.name = it.full_name || "Unassigned"
return it
owner = _.map data.owners, (it) ->
it.id = it.id.toString()
it.name = it.full_name
return it
@.selectedFilters = []
if loadFilters.status
selected = @.formatSelectedFilters("status", statuses, loadFilters.status)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.tags
selected = @.formatSelectedFilters("tags", tags, loadFilters.tags)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.assigned_to
selected = @.formatSelectedFilters("assigned_to", assignedTo, loadFilters.assigned_to)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.owner
selected = @.formatSelectedFilters("owner", owner, loadFilters.owner)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.type
selected = @.formatSelectedFilters("type", type, loadFilters.type)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.severity
selected = @.formatSelectedFilters("severity", severity, loadFilters.severity)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.priority
selected = @.formatSelectedFilters("priority", priority, loadFilters.priority)
@.selectedFilters = @.selectedFilters.concat(selected)
@.filterQ = loadFilters.q
@.filters = [
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.TYPE"),
dataType: "type",
content: type
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.SEVERITY"),
dataType: "severity",
content: severity
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.PRIORITIES"),
dataType: "priority",
content: priority
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.STATUS"),
dataType: "status",
content: statuses
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.TAGS"),
dataType: "tags",
content: tags
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.ASSIGNED_TO"),
dataType: "assigned_to",
content: assignedTo
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.CREATED_BY"),
dataType: "owner",
content: owner
}
];
@.customFilters = []
_.forOwn customFiltersRaw, (value, key) =>
@.customFilters.push({id: key, name: key, filter: value})
initializeSubscription: -> initializeSubscription: ->
routingKey = "changes.project.#{@scope.projectId}.issues" routingKey = "changes.project.#{@scope.projectId}.issues"
@events.subscribe @scope, routingKey, (message) => @events.subscribe @scope, routingKey, (message) =>
@.loadIssues() @.loadIssues()
storeFilters: ->
@rs.issues.storeFilters(@params.pslug, @location.search())
loadProject: -> loadProject: ->
return @rs.projects.getBySlug(@params.pslug).then (project) => return @rs.projects.getBySlug(@params.pslug).then (project) =>
@ -117,160 +303,15 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
return project return project
getUrlFilters: ->
filters = _.pick(@location.search(), "page", "tags", "status", "types",
"q", "severities", "priorities",
"assignedTo", "createdBy", "orderBy")
filters.page = 1 if not filters.page
return filters
getUrlFilter: (name) ->
filters = _.pick(@location.search(), name)
return filters[name]
loadMyFilters: ->
return @rs.issues.getMyFilters(@scope.projectId).then (filters) =>
return _.map filters, (value, key) =>
return {id: key, name: key, type: "myFilters", selected: false}
removeNotExistingFiltersFromUrl: ->
currentSearch = @location.search()
urlfilters = @.getUrlFilters()
for filterName, filterValue of urlfilters
if filterName == "page" or filterName == "orderBy" or filterName == "q"
continue
if filterName == "tags"
splittedValues = _.map("#{filterValue}".split(","))
else
splittedValues = _.map("#{filterValue}".split(","), (x) -> if x == "null" then null else parseInt(x))
existingValues = _.intersection(splittedValues, _.map(@scope.filters[filterName], "id"))
if splittedValues.length != existingValues.length
@location.search(filterName, existingValues.join())
if currentSearch != @location.search()
@location.replace()
markSelectedFilters: (filters, urlfilters) ->
# Build selected filters (from url) fast lookup data structure
searchdata = {}
for name, value of _.omit(urlfilters, "page", "orderBy")
if not searchdata[name]?
searchdata[name] = {}
for val in "#{value}".split(",")
searchdata[name][val] = true
isSelected = (type, id) ->
if searchdata[type]? and searchdata[type][id]
return true
return false
for key, value of filters
for obj in value
obj.selected = if isSelected(obj.type, obj.id) then true else undefined
loadFilters: () ->
urlfilters = @.getUrlFilters()
if urlfilters.q
@scope.filtersQ = urlfilters.q
# Load My Filters
promise = @.loadMyFilters().then (myFilters) =>
@scope.filters.myFilters = myFilters
return myFilters
loadFilters = {}
loadFilters.project = @scope.projectId
loadFilters.tags = urlfilters.tags
loadFilters.status = urlfilters.status
loadFilters.q = urlfilters.q
loadFilters.types = urlfilters.types
loadFilters.severities = urlfilters.severities
loadFilters.priorities = urlfilters.priorities
loadFilters.assigned_to = urlfilters.assignedTo
loadFilters.owner = urlfilters.createdBy
# Load default filters data
promise = promise.then =>
return @rs.issues.filtersData(loadFilters)
# Format filters and set them on scope
return promise.then (data) =>
usersFiltersFormat = (users, type, unknownOption) =>
reformatedUsers = _.map users, (t) =>
t.type = type
t.name = if t.full_name then t.full_name else unknownOption
return t
unknownItem = _.remove(reformatedUsers, (u) -> not u.id)
reformatedUsers = _.sortBy(reformatedUsers, (u) -> u.name.toUpperCase())
if unknownItem.length > 0
reformatedUsers.unshift(unknownItem[0])
return reformatedUsers
choicesFiltersFormat = (choices, type, byIdObject) =>
_.map choices, (t) ->
t.type = type
return t
tagsFilterFormat = (tags) =>
return _.map tags, (t) ->
t.id = t.name
t.type = 'tags'
return t
# Build filters data structure
@scope.filters.status = choicesFiltersFormat(data.statuses, "status", @scope.issueStatusById)
@scope.filters.severities = choicesFiltersFormat(data.severities, "severities", @scope.severityById)
@scope.filters.priorities = choicesFiltersFormat(data.priorities, "priorities", @scope.priorityById)
@scope.filters.assignedTo = usersFiltersFormat(data.assigned_to, "assignedTo", "Unassigned")
@scope.filters.createdBy = usersFiltersFormat(data.owners, "createdBy", "Unknown")
@scope.filters.types = choicesFiltersFormat(data.types, "types", @scope.issueTypeById)
@scope.filters.tags = tagsFilterFormat(data.tags)
@.removeNotExistingFiltersFromUrl()
@.markSelectedFilters(@scope.filters, urlfilters)
@rootscope.$broadcast("filters:loaded", @scope.filters)
# We need to guarantee that the last petition done here is the finally used # We need to guarantee that the last petition done here is the finally used
# When searching by text loadIssues can be called fastly with different parameters and # When searching by text loadIssues can be called fastly with different parameters and
# can be resolved in a different order than generated # can be resolved in a different order than generated
# We count the requests made and only if the callback is for the last one data is updated # We count the requests made and only if the callback is for the last one data is updated
loadIssuesRequests: 0 loadIssuesRequests: 0
loadIssues: => loadIssues: =>
@scope.urlFilters = @.getUrlFilters() params = @location.search()
# Convert stored filters to http parameters promise = @rs.issues.list(@scope.projectId, params)
# ready filters (the name difference exists
# because of some automatic lookups and is
# the simplest way todo it without adding
# additional complexity to code.
@scope.httpParams = {}
for name, values of @scope.urlFilters
if name == "severities"
name = "severity"
else if name == "orderBy"
name = "order_by"
else if name == "priorities"
name = "priority"
else if name == "assignedTo"
name = "assigned_to"
else if name == "createdBy"
name = "owner"
else if name == "status"
name = "status"
else if name == "types"
name = "type"
@scope.httpParams[name] = values
promise = @rs.issues.list(@scope.projectId, @scope.httpParams)
@.loadIssuesRequests += 1 @.loadIssuesRequests += 1
promise.index = @.loadIssuesRequests promise.index = @.loadIssuesRequests
promise.then (data) => promise.then (data) =>
@ -289,26 +330,10 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
return promise.then (project) => return promise.then (project) =>
@.fillUsersAndRoles(project.members, project.roles) @.fillUsersAndRoles(project.members, project.roles)
@.initializeSubscription() @.initializeSubscription()
@.loadFilters() @.generateFilters()
return @.loadIssues() return @.loadIssues()
saveCurrentFiltersTo: (newFilter) ->
deferred = @q.defer()
@rs.issues.getMyFilters(@scope.projectId).then (filters) =>
filters[newFilter] = @location.search()
@rs.issues.storeMyFilters(@scope.projectId, filters).then =>
deferred.resolve()
return deferred.promise
deleteMyFilter: (filter) ->
deferred = @q.defer()
@rs.issues.getMyFilters(@scope.projectId).then (filters) =>
delete filters[filter]
@rs.issues.storeMyFilters(@scope.projectId, filters).then =>
deferred.resolve()
return deferred.promise
# Functions used from templates # Functions used from templates
addNewIssue: -> addNewIssue: ->
@rootscope.$broadcast("issueform:new", @scope.project) @rootscope.$broadcast("issueform:new", @scope.project)
@ -338,6 +363,12 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
return @rs.issues.downvote(issueId).then(onSuccess, onError) return @rs.issues.downvote(issueId).then(onSuccess, onError)
getOrderBy: ->
if _.isString(@location.search().order_by)
return @location.search().order_by
else
return "created_date"
module.controller("IssuesController", IssuesController) module.controller("IssuesController", IssuesController)
############################################################################# #############################################################################
@ -431,28 +462,40 @@ IssuesDirective = ($log, $location, $template, $compile) ->
## Issues Filters ## Issues Filters
linkOrdering = ($scope, $el, $attrs, $ctrl) -> linkOrdering = ($scope, $el, $attrs, $ctrl) ->
# Draw the arrow the first time # Draw the arrow the first time
currentOrder = $ctrl.getUrlFilter("orderBy") or "created_date"
currentOrder = $ctrl.getOrderBy()
if currentOrder if currentOrder
icon = if startswith(currentOrder, "-") then "icon-arrow-up" else "icon-arrow-bottom" icon = if startswith(currentOrder, "-") then "icon-arrow-up" else "icon-arrow-down"
colHeadElement = $el.find(".row.title > div[data-fieldname='#{trim(currentOrder, "-")}']") colHeadElement = $el.find(".row.title > div[data-fieldname='#{trim(currentOrder, "-")}']")
colHeadElement.html("#{colHeadElement.html()}<span class='icon #{icon}'></span>")
svg = $("<tg-svg>").attr("svg-icon", icon)
colHeadElement.append(svg)
$compile(colHeadElement.contents())($scope);
$el.on "click", ".row.title > div", (event) -> $el.on "click", ".row.title > div", (event) ->
target = angular.element(event.currentTarget) target = angular.element(event.currentTarget)
currentOrder = $ctrl.getUrlFilter("orderBy") currentOrder = $ctrl.getOrderBy()
newOrder = target.data("fieldname") newOrder = target.data("fieldname")
finalOrder = if currentOrder == newOrder then "-#{newOrder}" else newOrder finalOrder = if currentOrder == newOrder then "-#{newOrder}" else newOrder
$scope.$apply -> $scope.$apply ->
$ctrl.replaceFilter("orderBy", finalOrder) $ctrl.replaceFilter("order_by", finalOrder)
$ctrl.storeFilters()
$ctrl.storeFilters($ctrl.params.pslug, $location.search(), $ctrl.filtersHashSuffix)
$ctrl.loadIssues().then -> $ctrl.loadIssues().then ->
# Update the arrow # Update the arrow
$el.find(".row.title > div > span.icon").remove() $el.find(".row.title > div > tg-svg").remove()
icon = if startswith(finalOrder, "-") then "icon-arrow-up" else "icon-arrow-bottom" icon = if startswith(finalOrder, "-") then "icon-arrow-up" else "icon-arrow-down"
target.html("#{target.html()}<span class='icon #{icon}'></span>")
svg = $("<tg-svg>")
.attr("svg-icon", icon)
target.append(svg)
$compile(target.contents())($scope);
## Issues Link ## Issues Link
link = ($scope, $el, $attrs) -> link = ($scope, $el, $attrs) ->
@ -468,253 +511,6 @@ IssuesDirective = ($log, $location, $template, $compile) ->
module.directive("tgIssues", ["$log", "$tgLocation", "$tgTemplate", "$compile", IssuesDirective]) module.directive("tgIssues", ["$log", "$tgLocation", "$tgTemplate", "$compile", IssuesDirective])
#############################################################################
## Issues Filters Directive
#############################################################################
IssuesFiltersDirective = ($q, $log, $location, $rs, $confirm, $loading, $template, $translate, $compile, $auth) ->
template = $template.get("issue/issues-filters.html", true)
templateSelected = $template.get("issue/issues-filters-selected.html", true)
link = ($scope, $el, $attrs) ->
$ctrl = $el.closest(".wrapper").controller()
selectedFilters = []
showFilters = (title, type) ->
$el.find(".filters-cats").hide()
$el.find(".filter-list").removeClass("hidden")
$el.find(".breadcrumb").removeClass("hidden")
$el.find("h2 .subfilter .title").html(title)
$el.find("h2 .subfilter .title").prop("data-type", type)
showCategories = ->
$el.find(".filters-cats").show()
$el.find(".filter-list").addClass("hidden")
$el.find(".breadcrumb").addClass("hidden")
initializeSelectedFilters = (filters) ->
selectedFilters = []
for name, values of filters
for val in values
selectedFilters.push(val) if val.selected
renderSelectedFilters(selectedFilters)
renderSelectedFilters = (selectedFilters) ->
_.filter selectedFilters, (f) =>
if f.color
f.style = "border-left: 3px solid #{f.color}"
html = templateSelected({filters:selectedFilters})
html = $compile(html)($scope)
$el.find(".filters-applied").html(html)
if $auth.isAuthenticated() && selectedFilters.length > 0
$el.find(".save-filters").show()
else
$el.find(".save-filters").hide()
renderFilters = (filters) ->
_.filter filters, (f) =>
if f.color
f.style = "border-left: 3px solid #{f.color}"
html = template({filters:filters})
html = $compile(html)($scope)
$el.find(".filter-list").html(html)
getFiltersType = () ->
return $el.find(".subfilter .title").prop('data-type')
reloadIssues = () ->
currentFiltersType = getFiltersType()
$q.all([$ctrl.loadIssues(), $ctrl.loadFilters()]).then () ->
filters = $scope.filters[currentFiltersType]
renderFilters(_.reject(filters, "selected"))
toggleFilterSelection = (type, id) ->
if type == "myFilters"
$rs.issues.getMyFilters($scope.projectId).then (data) ->
myFilters = data
filters = myFilters[id]
filters.page = 1
$ctrl.replaceAllFilters(filters)
$ctrl.storeFilters()
$ctrl.loadIssues()
$ctrl.markSelectedFilters($scope.filters, filters)
initializeSelectedFilters($scope.filters)
return null
filters = $scope.filters[type]
filterId = if type == 'tags' then taiga.toString(id) else id
filter = _.find(filters, {id: filterId})
filter.selected = (not filter.selected)
# Convert id to null as string for properly
# put null value on url parameters
id = "null" if id is null
if filter.selected
selectedFilters.push(filter)
$ctrl.selectFilter(type, id)
$ctrl.selectFilter("page", 1)
$ctrl.storeFilters()
else
selectedFilters = _.reject selectedFilters, (f) ->
return f.id == filter.id && f.type == filter.type
$ctrl.unselectFilter(type, id)
$ctrl.selectFilter("page", 1)
$ctrl.storeFilters()
reloadIssues()
renderSelectedFilters(selectedFilters)
currentFiltersType = getFiltersType()
if type == currentFiltersType
renderFilters(_.reject(filters, "selected"))
# Angular Watchers
$scope.$on "filters:loaded", (ctx, filters) ->
initializeSelectedFilters(filters)
$scope.$on "filters:issueupdate", (ctx, filters) ->
html = template({filters:filters.status})
html = $compile(html)($scope)
$el.find(".filter-list").html(html)
selectQFilter = debounceLeading 100, (value, oldValue) ->
return if value is undefined or value == oldValue
$ctrl.replaceFilter("page", null, true)
if value.length == 0
$ctrl.replaceFilter("q", null)
$ctrl.storeFilters()
else
$ctrl.replaceFilter("q", value)
$ctrl.storeFilters()
reloadIssues()
unwatchIssues = $scope.$watch "issues", (newValue) ->
if !_.isUndefined(newValue)
$scope.$watch("filtersQ", selectQFilter)
unwatchIssues()
# Dom Event Handlers
$el.on "click", ".filters-cat-single", (event) ->
event.preventDefault()
target = angular.element(event.currentTarget)
tags = $scope.filters[target.data("type")]
renderFilters(_.reject(tags, "selected"))
showFilters(target.attr("title"), target.data("type"))
$el.on "click", ".back", (event) ->
event.preventDefault()
showCategories($el)
$el.on "click", ".filters-applied .remove-filter", (event) ->
event.preventDefault()
target = angular.element(event.currentTarget).parent()
id = target.data("id") or null
type = target.data("type")
toggleFilterSelection(type, id)
$el.on "click", ".filter-list .single-filter", (event) ->
event.preventDefault()
target = angular.element(event.currentTarget)
target.toggleClass("active")
id = target.data("id") or null
type = target.data("type")
# A saved filter can't be active
if type == "myFilters"
target.removeClass("active")
toggleFilterSelection(type, id)
$el.on "click", ".filter-list .remove-filter", (event) ->
event.preventDefault()
event.stopPropagation()
target = angular.element(event.currentTarget)
customFilterName = target.parent().data('id')
title = $translate.instant("ISSUES.FILTERS.CONFIRM_DELETE.TITLE")
message = $translate.instant("ISSUES.FILTERS.CONFIRM_DELETE.MESSAGE", {customFilterName: customFilterName})
$confirm.askOnDelete(title, message).then (askResponse) ->
promise = $ctrl.deleteMyFilter(customFilterName)
promise.then ->
promise = $ctrl.loadMyFilters()
promise.then (filters) ->
askResponse.finish()
$scope.filters.myFilters = filters
renderFilters($scope.filters.myFilters)
promise.then null, ->
askResponse.finish()
promise.then null, ->
askResponse.finish(false)
$confirm.notify("error")
$el.on "click", ".save-filters", (event) ->
event.preventDefault()
renderFilters($scope.filters["myFilters"])
showFilters("My filters", "myFilters")
$el.find('.save-filters').hide()
$el.find('.my-filter-name').removeClass("hidden")
$el.find('.my-filter-name').focus()
$scope.$apply()
$el.on "keyup", ".my-filter-name", (event) ->
event.preventDefault()
if event.keyCode == 13
target = angular.element(event.currentTarget)
newFilter = target.val()
currentLoading = $loading()
.target($el.find(".new"))
.start()
promise = $ctrl.saveCurrentFiltersTo(newFilter)
promise.then ->
loadPromise = $ctrl.loadMyFilters()
loadPromise.then (filters) ->
currentLoading.finish()
$scope.filters.myFilters = filters
currentfilterstype = $el.find("h2 .subfilter .title").prop('data-type')
if currentfilterstype == "myFilters"
renderFilters($scope.filters.myFilters)
$el.find('.my-filter-name').addClass("hidden")
$el.find('.save-filters').show()
loadPromise.then null, ->
currentLoading.finish()
$confirm.notify("error", "Error loading custom filters")
promise.then null, ->
currentLoading.finish()
$el.find(".my-filter-name").val(newFilter).focus().select()
$confirm.notify("error", "Filter not saved")
else if event.keyCode == 27
$el.find('.my-filter-name').val('')
$el.find('.my-filter-name').addClass("hidden")
$el.find('.save-filters').show()
return {link:link}
module.directive("tgIssuesFilters", ["$q", "$log", "$tgLocation", "$tgResources", "$tgConfirm", "$tgLoading",
"$tgTemplate", "$translate", "$compile", "$tgAuth", IssuesFiltersDirective])
############################################################################# #############################################################################
## Issue status Directive (popover for change status) ## Issue status Directive (popover for change status)
############################################################################# #############################################################################

View File

@ -0,0 +1,189 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: kanban-userstories.service.coffee
###
groupBy = @.taiga.groupBy
class KanbanUserstoriesService extends taiga.Service
@.$inject = []
constructor: () ->
@.reset()
reset: () ->
@.userstoriesRaw = []
@.archivedStatus = []
@.statusHide = []
@.foldStatusChanged = {}
@.usByStatus = Immutable.Map()
init: (project, usersById) ->
@.project = project
@.usersById = usersById
resetFolds: () ->
@.foldStatusChanged = {}
@.refresh()
toggleFold: (usId) ->
@.foldStatusChanged[usId] = !@.foldStatusChanged[usId]
@.refresh()
set: (userstories) ->
@.userstoriesRaw = userstories
@.refreshRawOrder()
@.refresh()
add: (us) ->
@.userstoriesRaw = @.userstoriesRaw.concat(us)
@.refreshRawOrder()
@.refresh()
addArchivedStatus: (statusId) ->
@.archivedStatus.push(statusId)
isUsInArchivedHiddenStatus: (usId) ->
us = @.getUsModel(usId)
return @.archivedStatus.indexOf(us.status) != -1 &&
@.statusHide.indexOf(us.status) != -1
hideStatus: (statusId) ->
@.deleteStatus(statusId)
@.statusHide.push(statusId)
showStatus: (statusId) ->
_.remove @.statusHide, (it) -> return it == statusId
getStatus: (statusId) ->
return _.filter @.userstoriesRaw, (us) -> return us.status == statusId
deleteStatus: (statusId) ->
toDelete = _.filter @.userstoriesRaw, (us) -> return us.status == statusId
toDelete = _.map (it) -> return it.id
@.archived = _.difference(@.archived, toDelete)
@.userstoriesRaw = _.filter @.userstoriesRaw, (us) -> return us.status != statusId
@.refresh()
refreshRawOrder: () ->
@.order = {}
@.order[it.id] = it.kanban_order for it in @.userstoriesRaw
assignOrders: (order) ->
order = _.invert(order)
@.order = _.assign(@.order, order)
@.refresh()
move: (id, statusId, index) ->
us = @.getUsModel(id)
usByStatus = _.filter @.userstoriesRaw, (it) =>
return it.status == statusId
usByStatus = _.sortBy usByStatus, (it) => @.order[it.id]
usByStatusWithoutMoved = _.filter usByStatus, (it) => it.id != id
beforeDestination = _.slice(usByStatusWithoutMoved, 0, index)
afterDestination = _.slice(usByStatusWithoutMoved, index)
setOrders = {}
previous = beforeDestination[beforeDestination.length - 1]
previousWithTheSameOrder = _.filter beforeDestination, (it) =>
@.order[it.id] == @.order[previous.id]
if previousWithTheSameOrder.length > 1
for it in previousWithTheSameOrder
setOrders[it.id] = @.order[it.id]
if !previous
@.order[us.id] = 0
else if previous
@.order[us.id] = @.order[previous.id] + 1
for it, key in afterDestination
@.order[it.id] = @.order[us.id] + key + 1
us.status = statusId
us.kanban_order = @.order[us.id]
@.refresh()
return {"us_id": us.id, "order": @.order[us.id], "set_orders": setOrders}
replace: (us) ->
@.usByStatus = @.usByStatus.map (status) ->
findedIndex = status.findIndex (usItem) ->
return usItem.get('id') == us.get('id')
if findedIndex != -1
status = status.set(findedIndex, us)
return status
replaceModel: (us) ->
@.userstoriesRaw = _.map @.userstoriesRaw, (usItem) ->
if us.id == usItem.id
return us
else
return usItem
@.refresh()
getUs: (id) ->
findedUs = null
@.usByStatus.forEach (status) ->
findedUs = status.find (us) -> return us.get('id') == id
return false if findedUs
return findedUs
getUsModel: (id) ->
return _.find @.userstoriesRaw, (us) -> return us.id == id
refresh: ->
@.userstoriesRaw = _.sortBy @.userstoriesRaw, (it) => @.order[it.id]
userstories = @.userstoriesRaw
userstories = _.map userstories, (usModel) =>
us = {}
us.foldStatusChanged = @.foldStatusChanged[usModel.id]
us.model = usModel.getAttrs()
us.images = _.filter usModel.attachments, (it) -> return !!it.thumbnail_card_url
us.id = usModel.id
us.assigned_to = @.usersById[usModel.assigned_to]
us.colorized_tags = _.map us.model.tags, (tag) =>
color = @.project.tags_colors[tag]
return {name: tag, color: color}
return us
usByStatus = _.groupBy userstories, (us) ->
return us.model.status
@.usByStatus = Immutable.fromJS(usByStatus)
angular.module("taigaKanban").service("tgKanbanUserstories", KanbanUserstoriesService)

View File

@ -34,26 +34,18 @@ bindMethods = @.taiga.bindMethods
module = angular.module("taigaKanban") module = angular.module("taigaKanban")
# Vars
defaultViewMode = "maximized"
viewModes = [
"maximized",
"minimized"
]
############################################################################# #############################################################################
## Kanban Controller ## Kanban Controller
############################################################################# #############################################################################
class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin) class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin, taiga.UsFiltersMixin)
@.$inject = [ @.$inject = [
"$scope", "$scope",
"$rootScope", "$rootScope",
"$tgRepo", "$tgRepo",
"$tgConfirm", "$tgConfirm",
"$tgResources", "$tgResources",
"tgResources",
"$routeParams", "$routeParams",
"$q", "$q",
"$tgLocation", "$tgLocation",
@ -62,16 +54,26 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
"$tgEvents", "$tgEvents",
"$tgAnalytics", "$tgAnalytics",
"$translate", "$translate",
"tgErrorHandlingService" "tgErrorHandlingService",
"$tgModel",
"tgKanbanUserstories",
"$tgStorage",
"tgFilterRemoteStorageService"
] ]
constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location, storeCustomFiltersName: 'kanban-custom-filters'
@appMetaService, @navUrls, @events, @analytics, @translate, @errorHandlingService) -> storeFiltersName: 'kanban-filters'
constructor: (@scope, @rootscope, @repo, @confirm, @rs, @rs2, @params, @q, @location,
@appMetaService, @navUrls, @events, @analytics, @translate, @errorHandlingService,
@model, @kanbanUserstoriesService, @storage, @filterRemoteStorageService) ->
bindMethods(@) bindMethods(@)
@kanbanUserstoriesService.reset()
@.openFilter = false
return if @.applyStoredFilters(@params.pslug, "kanban-filters")
@scope.sectionName = @translate.instant("KANBAN.SECTION_NAME") @scope.sectionName = @translate.instant("KANBAN.SECTION_NAME")
@scope.statusViewModes = {}
@.initializeEventHandlers() @.initializeEventHandlers()
promise = @.loadInitialData() promise = @.loadInitialData()
@ -88,80 +90,106 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
# On Error # On Error
promise.then null, @.onInitialDataError.bind(@) promise.then null, @.onInitialDataError.bind(@)
taiga.defineImmutableProperty @.scope, "usByStatus", () =>
return @kanbanUserstoriesService.usByStatus
setZoom: (zoomLevel, zoom) ->
if @.zoomLevel != zoomLevel
@kanbanUserstoriesService.resetFolds()
@.zoomLevel = zoomLevel
@.zoom = zoom
filtersReloadContent: () ->
@.loadUserstories().then () =>
openArchived = _.difference(@kanbanUserstoriesService.archivedStatus, @kanbanUserstoriesService.statusHide)
if openArchived.length
for statusId in openArchived
@.loadUserStoriesForStatus({}, statusId)
initializeEventHandlers: -> initializeEventHandlers: ->
@scope.$on "usform:new:success", => @scope.$on "usform:new:success", (event, us) =>
@.loadUserstories() @.refreshTagsColors().then () =>
@.refreshTagsColors() @kanbanUserstoriesService.add(us)
@analytics.trackEvent("userstory", "create", "create userstory on kanban", 1) @analytics.trackEvent("userstory", "create", "create userstory on kanban", 1)
@scope.$on "usform:bulk:success", => @scope.$on "usform:bulk:success", (event, uss) =>
@.loadUserstories() @.refreshTagsColors().then () =>
@kanbanUserstoriesService.add(uss)
@analytics.trackEvent("userstory", "create", "bulk create userstory on kanban", 1) @analytics.trackEvent("userstory", "create", "bulk create userstory on kanban", 1)
@scope.$on "usform:edit:success", => @scope.$on "usform:edit:success", (event, us) =>
@.loadUserstories() @.refreshTagsColors().then () =>
@.refreshTagsColors() @kanbanUserstoriesService.replaceModel(us)
@scope.$on("assigned-to:added", @.onAssignedToChanged) @scope.$on("assigned-to:added", @.onAssignedToChanged)
@scope.$on("kanban:us:move", @.moveUs) @scope.$on("kanban:us:move", @.moveUs)
@scope.$on("kanban:show-userstories-for-status", @.loadUserStoriesForStatus) @scope.$on("kanban:show-userstories-for-status", @.loadUserStoriesForStatus)
@scope.$on("kanban:hide-userstories-for-status", @.hideUserStoriesForStatus) @scope.$on("kanban:hide-userstories-for-status", @.hideUserStoriesForStatus)
# Template actions
addNewUs: (type, statusId) -> addNewUs: (type, statusId) ->
switch type switch type
when "standard" then @rootscope.$broadcast("usform:new", @scope.projectId, statusId, @scope.usStatusList) when "standard" then @rootscope.$broadcast("usform:new", @scope.projectId, statusId, @scope.usStatusList)
when "bulk" then @rootscope.$broadcast("usform:bulk", @scope.projectId, statusId) when "bulk" then @rootscope.$broadcast("usform:bulk", @scope.projectId, statusId)
changeUsAssignedTo: (us) -> editUs: (id) ->
us = @kanbanUserstoriesService.getUs(id)
us = us.set('loading', true)
@kanbanUserstoriesService.replace(us)
@rs.userstories.getByRef(us.getIn(['model', 'project']), us.getIn(['model', 'ref']))
.then (editingUserStory) =>
@rs2.attachments.list("us", us.get('id'), us.getIn(['model', 'project'])).then (attachments) =>
@rootscope.$broadcast("usform:edit", editingUserStory, attachments.toJS())
us = us.set('loading', false)
@kanbanUserstoriesService.replace(us)
showPlaceHolder: (statusId) ->
if @scope.usStatusList[0].id == statusId &&
!@kanbanUserstoriesService.userstoriesRaw.length
return true
return false
toggleFold: (id) ->
@kanbanUserstoriesService.toggleFold(id)
isUsInArchivedHiddenStatus: (usId) ->
return @kanbanUserstoriesService.isUsInArchivedHiddenStatus(usId)
changeUsAssignedTo: (id) ->
us = @kanbanUserstoriesService.getUsModel(id)
@rootscope.$broadcast("assigned-to:add", us) @rootscope.$broadcast("assigned-to:add", us)
# Scope Events Handlers onAssignedToChanged: (ctx, userid, usModel) ->
usModel.assigned_to = userid
onAssignedToChanged: (ctx, userid, us) -> @kanbanUserstoriesService.replaceModel(usModel)
us.assigned_to = userid
promise = @repo.save(us) promise = @repo.save(usModel)
promise.then null, -> promise.then null, ->
console.log "FAIL" # TODO console.log "FAIL" # TODO
# Load data methods
refreshTagsColors: -> refreshTagsColors: ->
return @rs.projects.tagsColors(@scope.projectId).then (tags_colors) => return @rs.projects.tagsColors(@scope.projectId).then (tags_colors) =>
@scope.project.tags_colors = tags_colors @scope.project.tags_colors = tags_colors
loadUserstories: -> loadUserstories: ->
params = { params = {
status__is_archived: false status__is_archived: false,
include_attachments: true,
include_tasks: true
} }
params = _.merge params, @location.search()
promise = @rs.userstories.listAll(@scope.projectId, params).then (userstories) => promise = @rs.userstories.listAll(@scope.projectId, params).then (userstories) =>
@scope.userstories = userstories @kanbanUserstoriesService.init(@scope.project, @scope.usersById)
@kanbanUserstoriesService.set(userstories)
usByStatus = _.groupBy(userstories, "status")
us_archived = []
for status in @scope.usStatusList
if not usByStatus[status.id]?
usByStatus[status.id] = []
if @scope.usByStatus?
for us in @scope.usByStatus[status.id]
if us.status != status.id
us_archived.push(us)
# Must preserve the archived columns if loaded
if status.is_archived and @scope.usByStatus? and @scope.usByStatus[status.id].length != 0
for us in @scope.usByStatus[status.id].concat(us_archived)
if us.status == status.id
usByStatus[status.id].push(us)
usByStatus[status.id] = _.sortBy(usByStatus[status.id], "kanban_order")
if userstories.length == 0
status = @scope.usStatusList[0]
usByStatus[status.id].push({isPlaceholder: true})
@scope.usByStatus = usByStatus
# The broadcast must be executed when the DOM has been fully reloaded. # The broadcast must be executed when the DOM has been fully reloaded.
# We can't assure when this exactly happens so we need a defer # We can't assure when this exactly happens so we need a defer
@ -175,14 +203,28 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
return promise return promise
loadUserStoriesForStatus: (ctx, statusId) -> loadUserStoriesForStatus: (ctx, statusId) ->
params = { status: statusId } filteredStatus = @location.search().status
# if there are filters applied the action doesn't end if the statusId is not in the url
if filteredStatus
filteredStatus = filteredStatus.split(",").map (it) -> parseInt(it, 10)
return if filteredStatus.indexOf(statusId) == -1
params = {
status: statusId
include_attachments: true,
include_tasks: true
}
params = _.merge params, @location.search()
return @rs.userstories.listAll(@scope.projectId, params).then (userstories) => return @rs.userstories.listAll(@scope.projectId, params).then (userstories) =>
@scope.usByStatus[statusId] = _.sortBy(userstories, "kanban_order")
@scope.$broadcast("kanban:shown-userstories-for-status", statusId, userstories) @scope.$broadcast("kanban:shown-userstories-for-status", statusId, userstories)
return userstories return userstories
hideUserStoriesForStatus: (ctx, statusId) -> hideUserStoriesForStatus: (ctx, statusId) ->
@scope.usByStatus[statusId] = []
@scope.$broadcast("kanban:hidden-userstories-for-status", statusId) @scope.$broadcast("kanban:hidden-userstories-for-status", statusId)
loadKanban: -> loadKanban: ->
@ -204,8 +246,6 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
@scope.usStatusById = groupBy(project.us_statuses, (x) -> x.id) @scope.usStatusById = groupBy(project.us_statuses, (x) -> x.id)
@scope.usStatusList = _.sortBy(project.us_statuses, "order") @scope.usStatusList = _.sortBy(project.us_statuses, "order")
@.generateStatusViewModes()
@scope.$emit("project:loaded", project) @scope.$emit("project:loaded", project)
return project return project
@ -220,82 +260,40 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
@.fillUsersAndRoles(project.members, project.roles) @.fillUsersAndRoles(project.members, project.roles)
@.initializeSubscription() @.initializeSubscription()
@.loadKanban() @.loadKanban()
@.generateFilters()
## View Mode methods
generateStatusViewModes: ->
storedStatusViewModes = @rs.kanban.getStatusViewModes(@scope.projectId)
@scope.statusViewModes = {}
for status in @scope.usStatusList
mode = storedStatusViewModes[status.id] || defaultViewMode
@scope.statusViewModes[status.id] = mode
@.storeStatusViewModes()
storeStatusViewModes: ->
@rs.kanban.storeStatusViewModes(@scope.projectId, @scope.statusViewModes)
updateStatusViewMode: (statusId, newViewMode) ->
@scope.statusViewModes[statusId] = newViewMode
@.storeStatusViewModes()
isMaximized: (statusId) ->
mode = @scope.statusViewModes[statusId] or defaultViewMode
return mode == 'maximized'
isMinimized: (statusId) ->
mode = @scope.statusViewModes[statusId] or defaultViewMode
return mode == 'minimized'
# Utils methods # Utils methods
prepareBulkUpdateData: (uses, field="kanban_order") -> prepareBulkUpdateData: (uses, field="kanban_order") ->
return _.map(uses, (x) -> {"us_id": x.id, "order": x[field]}) return _.map(uses, (x) -> {"us_id": x.id, "order": x[field]})
resortUserStories: (uses) ->
items = []
for item, index in uses
item.kanban_order = index
if item.isModified()
items.push(item)
return items
moveUs: (ctx, us, oldStatusId, newStatusId, index) -> moveUs: (ctx, us, oldStatusId, newStatusId, index) ->
if oldStatusId != newStatusId us = @kanbanUserstoriesService.getUsModel(us.get('id'))
# Remove us from old status column
r = @scope.usByStatus[oldStatusId].indexOf(us)
@scope.usByStatus[oldStatusId].splice(r, 1)
# Add us to new status column. moveUpdateData = @kanbanUserstoriesService.move(us.id, newStatusId, index)
@scope.usByStatus[newStatusId].splice(index, 0, us)
us.status = newStatusId
else
r = @scope.usByStatus[newStatusId].indexOf(us)
@scope.usByStatus[newStatusId].splice(r, 1)
@scope.usByStatus[newStatusId].splice(index, 0, us)
itemsToSave = @.resortUserStories(@scope.usByStatus[newStatusId]) params = {
@scope.usByStatus[newStatusId] = _.sortBy(@scope.usByStatus[newStatusId], "kanban_order") include_attachments: true,
include_tasks: true
}
# Persist the userstory options = {
promise = @repo.save(us) headers: {
"set-orders": JSON.stringify(moveUpdateData.set_orders)
}
}
# Rehash userstories order field promise = @repo.save(us, true, params, options, true)
# and persist in bulk all changes.
promise = promise.then =>
itemsToSave = _.reject(itemsToSave, {"id": us.id})
data = @.prepareBulkUpdateData(itemsToSave)
return @rs.userstories.bulkUpdateKanbanOrder(us.project, data).then => promise = promise.then (result) =>
return itemsToSave headers = result[1]
if headers && headers['taiga-info-order-updated']
order = JSON.parse(headers['taiga-info-order-updated'])
@kanbanUserstoriesService.assignOrders(order)
return promise return promise
module.controller("KanbanController", KanbanController) module.controller("KanbanController", KanbanController)
############################################################################# #############################################################################
@ -322,7 +320,7 @@ module.directive("tgKanban", ["$tgRepo", "$rootScope", KanbanDirective])
## Kanban Archived Status Column Header Control ## Kanban Archived Status Column Header Control
############################################################################# #############################################################################
KanbanArchivedStatusHeaderDirective = ($rootscope, $translate) -> KanbanArchivedStatusHeaderDirective = ($rootscope, $translate, kanbanUserstoriesService) ->
showArchivedText = $translate.instant("KANBAN.ACTION_SHOW_ARCHIVED") showArchivedText = $translate.instant("KANBAN.ACTION_SHOW_ARCHIVED")
hideArchivedText = $translate.instant("KANBAN.ACTION_HIDE_ARCHIVED") hideArchivedText = $translate.instant("KANBAN.ACTION_HIDE_ARCHIVED")
@ -330,6 +328,9 @@ KanbanArchivedStatusHeaderDirective = ($rootscope, $translate) ->
status = $scope.$eval($attrs.tgKanbanArchivedStatusHeader) status = $scope.$eval($attrs.tgKanbanArchivedStatusHeader)
hidden = true hidden = true
kanbanUserstoriesService.addArchivedStatus(status.id)
kanbanUserstoriesService.hideStatus(status.id)
$scope.class = "icon-watch" $scope.class = "icon-watch"
$scope.title = showArchivedText $scope.title = showArchivedText
@ -342,24 +343,27 @@ KanbanArchivedStatusHeaderDirective = ($rootscope, $translate) ->
$scope.title = showArchivedText $scope.title = showArchivedText
$rootscope.$broadcast("kanban:hide-userstories-for-status", status.id) $rootscope.$broadcast("kanban:hide-userstories-for-status", status.id)
kanbanUserstoriesService.hideStatus(status.id)
else else
$scope.class = "icon-unwatch" $scope.class = "icon-unwatch"
$scope.title = hideArchivedText $scope.title = hideArchivedText
$rootscope.$broadcast("kanban:show-userstories-for-status", status.id) $rootscope.$broadcast("kanban:show-userstories-for-status", status.id)
kanbanUserstoriesService.showStatus(status.id)
$scope.$on "$destroy", -> $scope.$on "$destroy", ->
$el.off() $el.off()
return {link:link} return {link:link}
module.directive("tgKanbanArchivedStatusHeader", [ "$rootScope", "$translate", KanbanArchivedStatusHeaderDirective]) module.directive("tgKanbanArchivedStatusHeader", [ "$rootScope", "$translate", "tgKanbanUserstories", KanbanArchivedStatusHeaderDirective])
############################################################################# #############################################################################
## Kanban Archived Status Column Intro Directive ## Kanban Archived Status Column Intro Directive
############################################################################# #############################################################################
KanbanArchivedStatusIntroDirective = ($translate) -> KanbanArchivedStatusIntroDirective = ($translate, kanbanUserstoriesService) ->
userStories = [] userStories = []
link = ($scope, $el, $attrs) -> link = ($scope, $el, $attrs) ->
@ -367,105 +371,40 @@ KanbanArchivedStatusIntroDirective = ($translate) ->
status = $scope.$eval($attrs.tgKanbanArchivedStatusIntro) status = $scope.$eval($attrs.tgKanbanArchivedStatusIntro)
$el.text(hiddenUserStoriexText) $el.text(hiddenUserStoriexText)
updateIntroText = -> updateIntroText = (hasArchived) ->
if userStories.length > 0 if hasArchived
$el.text("") $el.text("")
else else
$el.text(hiddenUserStoriexText) $el.text(hiddenUserStoriexText)
$scope.$on "kanban:us:move", (ctx, itemUs, oldStatusId, newStatusId, itemIndex) -> $scope.$on "kanban:us:move", (ctx, itemUs, oldStatusId, newStatusId, itemIndex) ->
# The destination columnd is this one hasArchived = !!kanbanUserstoriesService.getStatus(newStatusId).length
if status.id == newStatusId updateIntroText(hasArchived)
# Reorder
if status.id == oldStatusId
r = userStories.indexOf(itemUs)
userStories.splice(r, 1)
userStories.splice(itemIndex, 0, itemUs)
# Archiving user story
else
itemUs.isArchived = true
userStories.splice(itemIndex, 0, itemUs)
# Unarchiving user story
else if status.id == oldStatusId
itemUs.isArchived = false
r = userStories.indexOf(itemUs)
userStories.splice(r, 1)
updateIntroText()
$scope.$on "kanban:shown-userstories-for-status", (ctx, statusId, userStoriesLoaded) -> $scope.$on "kanban:shown-userstories-for-status", (ctx, statusId, userStoriesLoaded) ->
if statusId == status.id if statusId == status.id
userStories = _.filter(userStoriesLoaded, (us) -> us.status == status.id) kanbanUserstoriesService.deleteStatus(statusId)
updateIntroText() kanbanUserstoriesService.add(userStoriesLoaded)
hasArchived = !!kanbanUserstoriesService.getStatus(statusId).length
updateIntroText(hasArchived)
$scope.$on "kanban:hidden-userstories-for-status", (ctx, statusId) -> $scope.$on "kanban:hidden-userstories-for-status", (ctx, statusId) ->
if statusId == status.id if statusId == status.id
userStories = [] updateIntroText(false)
updateIntroText()
$scope.$on "$destroy", -> $scope.$on "$destroy", ->
$el.off() $el.off()
return {link:link} return {link:link}
module.directive("tgKanbanArchivedStatusIntro", ["$translate", KanbanArchivedStatusIntroDirective]) module.directive("tgKanbanArchivedStatusIntro", ["$translate", "tgKanbanUserstories", KanbanArchivedStatusIntroDirective])
#############################################################################
## Kanban User Story Directive
#############################################################################
KanbanUserstoryDirective = ($rootscope, $loading, $rs, $rs2) ->
link = ($scope, $el, $attrs, $model) ->
$scope.$watch "us", (us) ->
if us.is_blocked and not $el.hasClass("blocked")
$el.addClass("blocked")
else if not us.is_blocked and $el.hasClass("blocked")
$el.removeClass("blocked")
$el.on 'click', '.edit-us', (event) ->
if $el.find(".icon-edit").hasClass("noclick")
return
target = $(event.target)
currentLoading = $loading()
.target(target)
.timeout(200)
.removeClasses("icon-edit")
.start()
us = $model.$modelValue
$rs.userstories.getByRef(us.project, us.ref).then (editingUserStory) =>
$rs2.attachments.list("us", us.id, us.project).then (attachments) =>
$rootscope.$broadcast("usform:edit", editingUserStory, attachments.toJS())
currentLoading.finish()
$scope.getTemplateUrl = () ->
if $scope.us.isPlaceholder
return "common/components/kanban-placeholder.html"
else
return "kanban/kanban-task.html"
$scope.$on "$destroy", ->
$el.off()
return {
template: '<ng-include src="getTemplateUrl()"/>',
link: link
require: "ngModel"
}
module.directive("tgKanbanUserstory", ["$rootScope", "$tgLoading", "$tgResources", "tgResources", KanbanUserstoryDirective])
############################################################################# #############################################################################
## Kanban Squish Column Directive ## Kanban Squish Column Directive
############################################################################# #############################################################################
KanbanSquishColumnDirective = (rs) -> KanbanSquishColumnDirective = (rs) ->
link = ($scope, $el, $attrs) -> link = ($scope, $el, $attrs) ->
$scope.$on "project:loaded", (event, project) -> $scope.$on "project:loaded", (event, project) ->
$scope.folds = rs.kanban.getStatusColumnModes(project.id) $scope.folds = rs.kanban.getStatusColumnModes(project.id)
@ -485,6 +424,7 @@ KanbanSquishColumnDirective = (rs) ->
return 310 return 310
totalWidth = _.reduce columnWidths, (total, width) -> totalWidth = _.reduce columnWidths, (total, width) ->
return total + width return total + width
$el.find('.kanban-table-inner').css("width", totalWidth) $el.find('.kanban-table-inner').css("width", totalWidth)
return {link: link} return {link: link}
@ -502,7 +442,7 @@ KanbanWipLimitDirective = ->
redrawWipLimit = => redrawWipLimit = =>
$el.find(".kanban-wip-limit").remove() $el.find(".kanban-wip-limit").remove()
timeout 200, => timeout 200, =>
element = $el.find(".kanban-task")[status.wip_limit] element = $el.find("tg-card")[status.wip_limit]
if element if element
angular.element(element).before("<div class='kanban-wip-limit'></div>") angular.element(element).before("<div class='kanban-wip-limit'></div>")
@ -518,83 +458,3 @@ KanbanWipLimitDirective = ->
return {link: link} return {link: link}
module.directive("tgKanbanWipLimit", KanbanWipLimitDirective) module.directive("tgKanbanWipLimit", KanbanWipLimitDirective)
#############################################################################
## Kanban User Directive
#############################################################################
KanbanUserDirective = ($log, $compile, $translate, avatarService) ->
template = _.template("""
<figure class="avatar">
<a href="#" title="{{'US.ASSIGN' | translate}}" <% if (!clickable) {%>class="not-clickable"<% } %>>
<img style="background-color: <%- bg %>" src="<%- imgurl %>" alt="<%- name %>" class="avatar">
</a>
</figure>
""")
clickable = false
link = ($scope, $el, $attrs, $model) ->
username_label = $el.parent().find("a.task-assigned")
username_label.addClass("not-clickable")
if not $attrs.tgKanbanUserAvatar
return $log.error "KanbanUserDirective: no attr is defined"
wtid = $scope.$watch $attrs.tgKanbanUserAvatar, (v) ->
if not $scope.usersById?
$log.error "KanbanUserDirective requires userById set in scope."
wtid()
else
user = $scope.usersById[v]
render(user)
render = (user) ->
avatar = avatarService.getAvatar(user)
if user is undefined
ctx = {
name: $translate.instant("COMMON.ASSIGNED_TO.NOT_ASSIGNED"),
imgurl: avatar.url,
clickable: clickable,
bg: null
}
else
ctx = {
name: user.full_name_display,
imgurl: avatar.url,
bg: avatar.bg,
clickable: clickable
}
html = $compile(template(ctx))($scope)
$el.html(html)
username_label.text(ctx.name)
bindOnce $scope, "project", (project) ->
if project.my_permissions.indexOf("modify_us") > -1
clickable = true
$el.on "click", (event) =>
if $el.find("a").hasClass("noclick")
return
us = $model.$modelValue
$ctrl = $el.controller()
$ctrl.changeUsAssignedTo(us)
username_label.removeClass("not-clickable")
username_label.on "click", (event) ->
if $el.find("a").hasClass("noclick")
return
us = $model.$modelValue
$ctrl = $el.controller()
$ctrl.changeUsAssignedTo(us)
$scope.$on "$destroy", ->
$el.off()
return {link: link, require:"ngModel"}
module.directive("tgKanbanUserAvatar", ["$log", "$compile", "$translate", "tgAvatarService", KanbanUserDirective])

View File

@ -40,8 +40,12 @@ module = angular.module("taigaKanban")
KanbanSortableDirective = ($repo, $rs, $rootscope) -> KanbanSortableDirective = ($repo, $rs, $rootscope) ->
link = ($scope, $el, $attrs) -> link = ($scope, $el, $attrs) ->
bindOnce $scope, "project", (project) -> unwatch = $scope.$watch "usByStatus", (usByStatus) ->
if not (project.my_permissions.indexOf("modify_us") > -1) return if !usByStatus || !usByStatus.size
unwatch()
if not ($scope.project.my_permissions.indexOf("modify_us") > -1)
return return
oldParentScope = null oldParentScope = null
@ -63,7 +67,7 @@ KanbanSortableDirective = ($repo, $rs, $rootscope) ->
copy: false, copy: false,
mirrorContainer: tdom[0], mirrorContainer: tdom[0],
moves: (item) -> moves: (item) ->
return $(item).hasClass('kanban-task') return $(item).is('tg-card')
}) })
drake.on 'drag', (item) -> drake.on 'drag', (item) ->
@ -83,7 +87,7 @@ KanbanSortableDirective = ($repo, $rs, $rootscope) ->
deleteElement(itemEl) deleteElement(itemEl)
$scope.$apply -> $scope.$apply ->
$rootscope.$broadcast("kanban:us:move", itemUs, itemUs.status, newStatusId, itemIndex) $rootscope.$broadcast("kanban:us:move", itemUs, itemUs.getIn(['model', 'status']), newStatusId, itemIndex)
scroll = autoScroll(containers, { scroll = autoScroll(containers, {
margin: 100, margin: 100,

View File

@ -96,7 +96,8 @@ urls = {
"userstories": "/userstories" "userstories": "/userstories"
"bulk-create-us": "/userstories/bulk_create" "bulk-create-us": "/userstories/bulk_create"
"bulk-update-us-backlog-order": "/userstories/bulk_update_backlog_order" "bulk-update-us-backlog-order": "/userstories/bulk_update_backlog_order"
"bulk-update-us-sprint-order": "/userstories/bulk_update_sprint_order" "bulk-update-us-milestone": "/userstories/bulk_update_milestone"
"bulk-update-us-miles-order": "/userstories/bulk_update_sprint_order"
"bulk-update-us-kanban-order": "/userstories/bulk_update_kanban_order" "bulk-update-us-kanban-order": "/userstories/bulk_update_kanban_order"
"userstories-filters": "/userstories/filters_data" "userstories-filters": "/userstories/filters_data"
"userstory-upvote": "/userstories/%s/upvote" "userstory-upvote": "/userstories/%s/upvote"
@ -112,6 +113,7 @@ urls = {
"task-downvote": "/tasks/%s/downvote" "task-downvote": "/tasks/%s/downvote"
"task-watch": "/tasks/%s/watch" "task-watch": "/tasks/%s/watch"
"task-unwatch": "/tasks/%s/unwatch" "task-unwatch": "/tasks/%s/unwatch"
"task-filters": "/tasks/filters_data"
# Issues # Issues
"issues": "/issues" "issues": "/issues"

View File

@ -30,8 +30,6 @@ generateHash = taiga.generateHash
resourceProvider = ($repo, $http, $urls, $storage, $q) -> resourceProvider = ($repo, $http, $urls, $storage, $q) ->
service = {} service = {}
hashSuffix = "issues-queryparams" hashSuffix = "issues-queryparams"
filtersHashSuffix = "issues-filters"
myFiltersHashSuffix = "issues-my-filters"
service.get = (projectId, issueId) -> service.get = (projectId, issueId) ->
params = service.getQueryParams(projectId) params = service.getQueryParams(projectId)
@ -95,53 +93,6 @@ resourceProvider = ($repo, $http, $urls, $storage, $q) ->
hash = generateHash([projectId, ns]) hash = generateHash([projectId, ns])
return $storage.get(hash) or {} return $storage.get(hash) or {}
service.storeFilters = (projectSlug, params) ->
ns = "#{projectSlug}:#{filtersHashSuffix}"
hash = generateHash([projectSlug, ns])
$storage.set(hash, params)
service.getFilters = (projectSlug) ->
ns = "#{projectSlug}:#{filtersHashSuffix}"
hash = generateHash([projectSlug, ns])
return $storage.get(hash) or {}
service.storeMyFilters = (projectId, myFilters) ->
deferred = $q.defer()
url = $urls.resolve("user-storage")
ns = "#{projectId}:#{myFiltersHashSuffix}"
hash = generateHash([projectId, ns])
if _.isEmpty(myFilters)
promise = $http.delete("#{url}/#{hash}", {key: hash, value:myFilters})
promise.then ->
deferred.resolve()
promise.then null, ->
deferred.reject()
else
promise = $http.put("#{url}/#{hash}", {key: hash, value:myFilters})
promise.then (data) ->
deferred.resolve()
promise.then null, (data) ->
innerPromise = $http.post("#{url}", {key: hash, value:myFilters})
innerPromise.then ->
deferred.resolve()
innerPromise.then null, ->
deferred.reject()
return deferred.promise
service.getMyFilters = (projectId) ->
deferred = $q.defer()
url = $urls.resolve("user-storage")
ns = "#{projectId}:#{myFiltersHashSuffix}"
hash = generateHash([projectId, ns])
promise = $http.get("#{url}/#{hash}")
promise.then (data) ->
deferred.resolve(data.data.value)
promise.then null, (data) ->
deferred.resolve({})
return deferred.promise
return (instance) -> return (instance) ->
instance.issues = service instance.issues = service

View File

@ -32,16 +32,6 @@ resourceProvider = ($storage) ->
hashSuffixStatusViewModes = "kanban-statusviewmodels" hashSuffixStatusViewModes = "kanban-statusviewmodels"
hashSuffixStatusColumnModes = "kanban-statuscolumnmodels" hashSuffixStatusColumnModes = "kanban-statuscolumnmodels"
service.storeStatusViewModes = (projectId, params) ->
ns = "#{projectId}:#{hashSuffixStatusViewModes}"
hash = generateHash([projectId, ns])
$storage.set(hash, params)
service.getStatusViewModes = (projectId) ->
ns = "#{projectId}:#{hashSuffixStatusViewModes}"
hash = generateHash([projectId, ns])
return $storage.get(hash) or {}
service.storeStatusColumnModes = (projectId, params) -> service.storeStatusColumnModes = (projectId, params) ->
ns = "#{projectId}:#{hashSuffixStatusColumnModes}" ns = "#{projectId}:#{hashSuffixStatusColumnModes}"
hash = generateHash([projectId, ns]) hash = generateHash([projectId, ns])

View File

@ -38,17 +38,23 @@ resourceProvider = ($repo, $http, $urls, $storage) ->
params.project = projectId params.project = projectId
return $repo.queryOne("tasks", taskId, params) return $repo.queryOne("tasks", taskId, params)
service.getByRef = (projectId, ref) -> service.getByRef = (projectId, ref, extraParams) ->
params = service.getQueryParams(projectId) params = service.getQueryParams(projectId)
params.project = projectId params.project = projectId
params.ref = ref params.ref = ref
params = _.extend({}, params, extraParams)
return $repo.queryOne("tasks", "by_ref", params) return $repo.queryOne("tasks", "by_ref", params)
service.listInAllProjects = (filters) -> service.listInAllProjects = (filters) ->
return $repo.queryMany("tasks", filters) return $repo.queryMany("tasks", filters)
service.list = (projectId, sprintId=null, userStoryId=null) -> service.filtersData = (params) ->
params = {project: projectId} return $repo.queryOneRaw("task-filters", null, params)
service.list = (projectId, sprintId=null, userStoryId=null, params) ->
params = _.merge(params, {project: projectId})
params.milestone = sprintId if sprintId params.milestone = sprintId if sprintId
params.user_story = userStoryId if userStoryId params.user_story = userStoryId if userStoryId
service.storeQueryParams(projectId, params) service.storeQueryParams(projectId, params)

View File

@ -26,7 +26,7 @@ taiga = @.taiga
generateHash = taiga.generateHash generateHash = taiga.generateHash
resourceProvider = ($repo, $http, $urls, $storage) -> resourceProvider = ($repo, $http, $urls, $storage, $q) ->
service = {} service = {}
hashSuffix = "userstories-queryparams" hashSuffix = "userstories-queryparams"
@ -35,10 +35,12 @@ resourceProvider = ($repo, $http, $urls, $storage) ->
params.project = projectId params.project = projectId
return $repo.queryOne("userstories", usId, params) return $repo.queryOne("userstories", usId, params)
service.getByRef = (projectId, ref) -> service.getByRef = (projectId, ref, extraParams = {}) ->
params = service.getQueryParams(projectId) params = service.getQueryParams(projectId)
params.project = projectId params.project = projectId
params.ref = ref params.ref = ref
params = _.extend({}, params, extraParams)
return $repo.queryOne("userstories", "by_ref", params) return $repo.queryOne("userstories", "by_ref", params)
service.listInAllProjects = (filters) -> service.listInAllProjects = (filters) ->
@ -96,9 +98,9 @@ resourceProvider = ($repo, $http, $urls, $storage) ->
params = {project_id: projectId, bulk_stories: data} params = {project_id: projectId, bulk_stories: data}
return $http.post(url, params) return $http.post(url, params)
service.bulkUpdateSprintOrder = (projectId, data) -> service.bulkUpdateMilestone = (projectId, milestoneId, data) ->
url = $urls.resolve("bulk-update-us-sprint-order") url = $urls.resolve("bulk-update-us-milestone")
params = {project_id: projectId, bulk_stories: data} params = {project_id: projectId, milestone_id: milestoneId, bulk_stories: data}
return $http.post(url, params) return $http.post(url, params)
service.bulkUpdateKanbanOrder = (projectId, data) -> service.bulkUpdateKanbanOrder = (projectId, data) ->
@ -133,4 +135,4 @@ resourceProvider = ($repo, $http, $urls, $storage) ->
instance.userstories = service instance.userstories = service
module = angular.module("taigaResources") module = angular.module("taigaResources")
module.factory("$tgUserstoriesResourcesProvider", ["$tgRepo", "$tgHttp", "$tgUrls", "$tgStorage", resourceProvider]) module.factory("$tgUserstoriesResourcesProvider", ["$tgRepo", "$tgHttp", "$tgUrls", "$tgStorage", "$q", resourceProvider])

View File

@ -108,6 +108,11 @@ CreateEditTaskDirective = ($repo, $model, $rs, $rootscope, $loading, lightboxSer
if not form.validate() if not form.validate()
return return
params = {
include_attachments: true,
include_tasks: true
}
if $scope.isNew if $scope.isNew
promise = $repo.create("tasks", $scope.task) promise = $repo.create("tasks", $scope.task)
broadcastEvent = "taskform:new:success" broadcastEvent = "taskform:new:success"
@ -116,20 +121,22 @@ CreateEditTaskDirective = ($repo, $model, $rs, $rootscope, $loading, lightboxSer
broadcastEvent = "taskform:edit:success" broadcastEvent = "taskform:edit:success"
promise.then (data) -> promise.then (data) ->
createAttachments(data)
deleteAttachments(data) deleteAttachments(data)
.then () => createAttachments(data)
.then () =>
currentLoading.finish()
lightboxService.close($el)
return data $rs.tasks.getByRef(data.project, data.ref, params).then (task) ->
$rootscope.$broadcast(broadcastEvent, task)
currentLoading = $loading() currentLoading = $loading()
.target(submitButton) .target(submitButton)
.start() .start()
# FIXME: error handling?
promise.then (data) -> promise.then (data) ->
currentLoading.finish() currentLoading.finish()
lightboxService.close($el) lightboxService.close($el)
$rootscope.$broadcast(broadcastEvent, data)
$el.on "submit", "form", submit $el.on "submit", "form", submit
@ -139,7 +146,7 @@ CreateEditTaskDirective = ($repo, $model, $rs, $rootscope, $loading, lightboxSer
return {link: link} return {link: link}
CreateBulkTasksDirective = ($repo, $rs, $rootscope, $loading, lightboxService) -> CreateBulkTasksDirective = ($repo, $rs, $rootscope, $loading, lightboxService, $model) ->
link = ($scope, $el, attrs) -> link = ($scope, $el, attrs) ->
$scope.form = {data: "", usId: null} $scope.form = {data: "", usId: null}
@ -161,6 +168,7 @@ CreateBulkTasksDirective = ($repo, $rs, $rootscope, $loading, lightboxService) -
promise = $rs.tasks.bulkCreate(projectId, sprintId, usId, data) promise = $rs.tasks.bulkCreate(projectId, sprintId, usId, data)
promise.then (result) -> promise.then (result) ->
result = _.map(result, (x) => $model.make_model('userstories', x))
currentLoading.finish() currentLoading.finish()
$rootscope.$broadcast("taskform:bulk:success", result) $rootscope.$broadcast("taskform:bulk:success", result)
lightboxService.close($el) lightboxService.close($el)
@ -205,5 +213,6 @@ module.directive("tgLbCreateBulkTasks", [
"$rootScope", "$rootScope",
"$tgLoading", "$tgLoading",
"lightboxService", "lightboxService",
"$tgModel",
CreateBulkTasksDirective CreateBulkTasksDirective
]) ])

View File

@ -38,13 +38,14 @@ module = angular.module("taigaTaskboard")
## Taskboard Controller ## Taskboard Controller
############################################################################# #############################################################################
class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin) class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin)
@.$inject = [ @.$inject = [
"$scope", "$scope",
"$rootScope", "$rootScope",
"$tgRepo", "$tgRepo",
"$tgConfirm", "$tgConfirm",
"$tgResources", "$tgResources",
"tgResources"
"$routeParams", "$routeParams",
"$q", "$q",
"tgAppMetaService", "tgAppMetaService",
@ -53,12 +54,20 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin)
"$tgEvents" "$tgEvents"
"$tgAnalytics", "$tgAnalytics",
"$translate", "$translate",
"tgErrorHandlingService" "tgErrorHandlingService",
"tgTaskboardTasks",
"$tgStorage",
"tgFilterRemoteStorageService"
] ]
constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @appMetaService, @location, @navUrls, constructor: (@scope, @rootscope, @repo, @confirm, @rs, @rs2, @params, @q, @appMetaService, @location, @navUrls,
@events, @analytics, @translate, @errorHandlingService) -> @events, @analytics, @translate, @errorHandlingService, @taskboardTasksService, @storage, @filterRemoteStorageService) ->
bindMethods(@) bindMethods(@)
@taskboardTasksService.reset()
@scope.userstories = []
@.openFilter = false
return if @.applyStoredFilters(@params.pslug, "tasks-filters")
@scope.sectionName = @translate.instant("TASKBOARD.SECTION_NAME") @scope.sectionName = @translate.instant("TASKBOARD.SECTION_NAME")
@.initializeEventHandlers() @.initializeEventHandlers()
@ -70,6 +79,150 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin)
# On Error # On Error
promise.then null, @.onInitialDataError.bind(@) promise.then null, @.onInitialDataError.bind(@)
taiga.defineImmutableProperty @.scope, "usTasks", () =>
return @taskboardTasksService.usTasks
setZoom: (zoomLevel, zoom) ->
if @.zoomLevel != zoomLevel
@taskboardTasksService.resetFolds()
@.zoomLevel = zoomLevel
@.zoom = zoom
if @.zoomLevel == '0'
@rootscope.$broadcast("sprint:zoom0")
changeQ: (q) ->
@.replaceFilter("q", q)
@.loadTasks()
@.generateFilters()
removeFilter: (filter) ->
@.unselectFilter(filter.dataType, filter.id)
@.loadTasks()
@.generateFilters()
addFilter: (newFilter) ->
@.selectFilter(newFilter.category.dataType, newFilter.filter.id)
@.loadTasks()
@.generateFilters()
selectCustomFilter: (customFilter) ->
@.replaceAllFilters(customFilter.filter)
@.loadTasks()
@.generateFilters()
removeCustomFilter: (customFilter) ->
@filterRemoteStorageService.getFilters(@scope.projectId, 'tasks-custom-filters').then (userFilters) =>
delete userFilters[customFilter.id]
@filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, 'tasks-custom-filters').then(@.generateFilters)
saveCustomFilter: (name) ->
filters = {}
urlfilters = @location.search()
filters.tags = urlfilters.tags
filters.status = urlfilters.status
filters.assigned_to = urlfilters.assigned_to
filters.owner = urlfilters.owner
@filterRemoteStorageService.getFilters(@scope.projectId, 'tasks-custom-filters').then (userFilters) =>
userFilters[name] = filters
@filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, 'tasks-custom-filters').then(@.generateFilters)
generateFilters: ->
@.storeFilters(@params.pslug, @location.search(), "tasks-filters")
urlfilters = @location.search()
loadFilters = {}
loadFilters.project = @scope.projectId
loadFilters.milestone = @scope.sprintId
loadFilters.tags = urlfilters.tags
loadFilters.status = urlfilters.status
loadFilters.assigned_to = urlfilters.assigned_to
loadFilters.owner = urlfilters.owner
loadFilters.q = urlfilters.q
return @q.all([
@rs.tasks.filtersData(loadFilters),
@filterRemoteStorageService.getFilters(@scope.projectId, 'tasks-custom-filters')
]).then (result) =>
data = result[0]
customFiltersRaw = result[1]
statuses = _.map data.statuses, (it) ->
it.id = it.id.toString()
return it
tags = _.map data.tags, (it) ->
it.id = it.name
return it
assignedTo = _.map data.assigned_to, (it) ->
if it.id
it.id = it.id.toString()
else
it.id = "null"
it.name = it.full_name || "Unassigned"
return it
owner = _.map data.owners, (it) ->
it.id = it.id.toString()
it.name = it.full_name
return it
@.selectedFilters = []
if loadFilters.status
selected = @.formatSelectedFilters("status", statuses, loadFilters.status)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.tags
selected = @.formatSelectedFilters("tags", tags, loadFilters.tags)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.assigned_to
selected = @.formatSelectedFilters("assigned_to", assignedTo, loadFilters.assigned_to)
@.selectedFilters = @.selectedFilters.concat(selected)
if loadFilters.owner
selected = @.formatSelectedFilters("owner", owner, loadFilters.owner)
@.selectedFilters = @.selectedFilters.concat(selected)
@.filterQ = loadFilters.q
@.filters = [
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.STATUS"),
dataType: "status",
content: statuses
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.TAGS"),
dataType: "tags",
content: tags,
hideEmpty: true
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.ASSIGNED_TO"),
dataType: "assigned_to",
content: assignedTo
},
{
title: @translate.instant("COMMON.FILTERS.CATEGORIES.CREATED_BY"),
dataType: "owner",
content: owner
}
];
@.customFilters = []
_.forOwn customFiltersRaw, (value, key) =>
@.customFilters.push({id: key, name: key, filter: value})
_setMeta: -> _setMeta: ->
prettyDate = @translate.instant("BACKLOG.SPRINTS.DATE") prettyDate = @translate.instant("BACKLOG.SPRINTS.DATE")
@ -92,22 +245,31 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin)
@appMetaService.setAll(title, description) @appMetaService.setAll(title, description)
initializeEventHandlers: -> initializeEventHandlers: ->
# TODO: Reload entire taskboard after create/edit tasks seems @scope.$on "taskform:bulk:success", (event, tasks) =>
# a big overhead. It should be optimized in near future. @.refreshTagsColors().then () =>
@scope.$on "taskform:bulk:success", => @taskboardTasksService.add(tasks)
@.loadTaskboard()
@analytics.trackEvent("task", "create", "bulk create task on taskboard", 1) @analytics.trackEvent("task", "create", "bulk create task on taskboard", 1)
@scope.$on "taskform:new:success", => @scope.$on "taskform:new:success", (event, task) =>
@.loadTaskboard() @.refreshTagsColors().then () =>
@taskboardTasksService.add(task)
@analytics.trackEvent("task", "create", "create task on taskboard", 1) @analytics.trackEvent("task", "create", "create task on taskboard", 1)
@scope.$on("taskform:edit:success", => @.loadTaskboard()) @scope.$on "taskform:edit:success", (event, task) =>
@scope.$on("taskboard:task:move", @.taskMove) @.refreshTagsColors().then () =>
@taskboardTasksService.replaceModel(task)
@scope.$on "assigned-to:added", (ctx, userId, task) => @scope.$on("taskboard:task:move", @.taskMove)
task.assigned_to = userId @scope.$on("assigned-to:added", @.onAssignedToChanged)
promise = @repo.save(task)
onAssignedToChanged: (ctx, userid, taskModel) ->
taskModel.assigned_to = userid
@taskboardTasksService.replaceModel(taskModel)
promise = @repo.save(taskModel)
promise.then null, -> promise.then null, ->
console.log "FAIL" # TODO console.log "FAIL" # TODO
@ -130,7 +292,6 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin)
@scope.project = project @scope.project = project
# Not used at this momment # Not used at this momment
@scope.pointsList = _.sortBy(project.points, "order") @scope.pointsList = _.sortBy(project.points, "order")
# @scope.roleList = _.sortBy(project.roles, "order")
@scope.pointsById = groupBy(project.points, (e) -> e.id) @scope.pointsById = groupBy(project.points, (e) -> e.id)
@scope.roleById = groupBy(project.roles, (e) -> e.id) @scope.roleById = groupBy(project.roles, (e) -> e.id)
@scope.taskStatusList = _.sortBy(project.task_statuses, "order") @scope.taskStatusList = _.sortBy(project.task_statuses, "order")
@ -170,34 +331,22 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin)
return @rs.sprints.get(@scope.projectId, @scope.sprintId).then (sprint) => return @rs.sprints.get(@scope.projectId, @scope.sprintId).then (sprint) =>
@scope.sprint = sprint @scope.sprint = sprint
@scope.userstories = _.sortBy(sprint.user_stories, "sprint_order") @scope.userstories = _.sortBy(sprint.user_stories, "sprint_order")
@taskboardTasksService.setUserstories(@scope.userstories)
return sprint return sprint
loadTasks: -> loadTasks: ->
return @rs.tasks.list(@scope.projectId, @scope.sprintId).then (tasks) => params = {
@scope.tasks = _.sortBy(tasks, 'taskboard_order') include_attachments: true,
@scope.usTasks = {} include_tasks: true
}
# Iterate over all userstories and params = _.merge params, @location.search()
# null userstory for unassigned tasks
for us in _.union(@scope.userstories, [{id:null}])
@scope.usTasks[us.id] = {}
for status in @scope.taskStatusList
@scope.usTasks[us.id][status.id] = []
for task in @scope.tasks return @rs.tasks.list(@scope.projectId, @scope.sprintId, null, params).then (tasks) =>
if @scope.usTasks[task.user_story]? and @scope.usTasks[task.user_story][task.status]? @taskboardTasksService.init(@scope.project, @scope.usersById)
@scope.usTasks[task.user_story][task.status].push(task) @taskboardTasksService.set(tasks)
if tasks.length == 0
if @scope.userstories.length > 0
usId = @scope.userstories[0].id
else
usId = null
@scope.usTasks[usId][@scope.taskStatusList[0].id].push({isPlaceholder: true})
return tasks
loadTaskboard: -> loadTaskboard: ->
return @q.all([ return @q.all([
@ -219,59 +368,69 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin)
return data return data
return promise.then(=> @.loadProject()) return promise.then(=> @.loadProject())
.then(=> @.loadTaskboard()) .then =>
.then(=> @.setRolePoints()) @.generateFilters()
refreshTasksOrder: (tasks) -> return @.loadTaskboard().then(=> @.setRolePoints())
items = @.resortTasks(tasks)
data = @.prepareBulkUpdateData(items)
return @rs.tasks.bulkUpdateTaskTaskboardOrder(@scope.project.id, data) showPlaceHolder: (statusId, usId) ->
if !@taskboardTasksService.tasksRaw.length
if @scope.taskStatusList[0].id == statusId &&
(!@scope.userstories.length || @scope.userstories[0].id == usId)
return true
resortTasks: (tasks) -> return false
items = []
for item, index in tasks editTask: (id) ->
item["taskboard_order"] = index task = @.taskboardTasksService.getTask(id)
if item.isModified()
items.push(item)
return items task = task.set('loading', true)
@taskboardTasksService.replace(task)
prepareBulkUpdateData: (uses) -> @rs.tasks.getByRef(task.getIn(['model', 'project']), task.getIn(['model', 'ref'])).then (editingTask) =>
return _.map(uses, (x) -> {"task_id": x.id, "order": x["taskboard_order"]}) @rs2.attachments.list("task", task.get('id'), task.getIn(['model', 'project'])).then (attachments) =>
@rootscope.$broadcast("taskform:edit", editingTask, attachments.toJS())
task = task.set('loading', false)
@taskboardTasksService.replace(task)
taskMove: (ctx, task, usId, statusId, order) -> taskMove: (ctx, task, oldStatusId, usId, statusId, order) ->
# Remove task from old position task = @taskboardTasksService.getTaskModel(task.get('id'))
r = @scope.usTasks[task.user_story][task.status].indexOf(task)
@scope.usTasks[task.user_story][task.status].splice(r, 1)
# Add task to new position moveUpdateData = @taskboardTasksService.move(task.id, usId, statusId, order)
tasks = @scope.usTasks[usId][statusId]
tasks.splice(order, 0, task)
task.user_story = usId params = {
task.status = statusId status__is_archived: false,
task.taskboard_order = order include_attachments: true,
include_tasks: true
}
promise = @repo.save(task) options = {
headers: {
"set-orders": JSON.stringify(moveUpdateData.set_orders)
}
}
@rootscope.$broadcast("sprint:task:moved", task) promise = @repo.save(task, true, params, options, true).then (result) =>
headers = result[1]
if headers && headers['taiga-info-order-updated']
order = JSON.parse(headers['taiga-info-order-updated'])
@taskboardTasksService.assignOrders(order)
promise.then =>
@.refreshTasksOrder(tasks)
@.loadSprintStats() @.loadSprintStats()
promise.then null, =>
console.log "FAIL TASK SAVE"
## Template actions ## Template actions
addNewTask: (type, us) -> addNewTask: (type, us) ->
switch type switch type
when "standard" then @rootscope.$broadcast("taskform:new", @scope.sprintId, us?.id) when "standard" then @rootscope.$broadcast("taskform:new", @scope.sprintId, us?.id)
when "bulk" then @rootscope.$broadcast("taskform:bulk", @scope.sprintId, us?.id) when "bulk" then @rootscope.$broadcast("taskform:bulk", @scope.sprintId, us?.id)
editTaskAssignedTo: (task) -> toggleFold: (id) ->
@taskboardTasksService.toggleFold(id)
changeTaskAssignedTo: (id) ->
task = @taskboardTasksService.getTaskModel(id)
@rootscope.$broadcast("assigned-to:add", task) @rootscope.$broadcast("assigned-to:add", task)
setRolePoints: () -> setRolePoints: () ->
@ -331,43 +490,6 @@ TaskboardDirective = ($rootscope) ->
module.directive("tgTaskboard", ["$rootScope", TaskboardDirective]) module.directive("tgTaskboard", ["$rootScope", TaskboardDirective])
#############################################################################
## Taskboard Task Directive
#############################################################################
TaskboardTaskDirective = ($rootscope, $loading, $rs, $rs2) ->
link = ($scope, $el, $attrs, $model) ->
$scope.$watch "task", (task) ->
if task.is_blocked and not $el.hasClass("blocked")
$el.addClass("blocked")
else if not task.is_blocked and $el.hasClass("blocked")
$el.removeClass("blocked")
$el.find(".edit-task").on "click", (event) ->
if $el.find('.edit-task').hasClass('noclick')
return
$scope.$apply ->
target = $(event.target)
currentLoading = $loading()
.target(target)
.timeout(200)
.start()
task = $scope.task
$rs.tasks.getByRef(task.project, task.ref).then (editingTask) =>
$rs2.attachments.list("task", editingTask.id, editingTask.project).then (attachments) =>
$rootscope.$broadcast("taskform:edit", editingTask, attachments.toJS())
currentLoading.finish()
return {link:link}
module.directive("tgTaskboardTask", ["$rootScope", "$tgLoading", "$tgResources", "tgResources", TaskboardTaskDirective])
############################################################################# #############################################################################
## Taskboard Squish Column Directive ## Taskboard Squish Column Directive
############################################################################# #############################################################################
@ -377,10 +499,14 @@ TaskboardSquishColumnDirective = (rs) ->
maxColumnWidth = 300 maxColumnWidth = 300
link = ($scope, $el, $attrs) -> link = ($scope, $el, $attrs) ->
$scope.$on "sprint:zoom0", () =>
recalculateTaskboardWidth()
$scope.$on "sprint:task:moved", () => $scope.$on "sprint:task:moved", () =>
recalculateTaskboardWidth() recalculateTaskboardWidth()
bindOnce $scope, "usTasks", (project) -> $scope.$watch "usTasks", () ->
if $scope.project
$scope.statusesFolded = rs.tasks.getStatusColumnModes($scope.project.id) $scope.statusesFolded = rs.tasks.getStatusColumnModes($scope.project.id)
$scope.usFolded = rs.tasks.getUsRowModes($scope.project.id, $scope.sprintId) $scope.usFolded = rs.tasks.getUsRowModes($scope.project.id, $scope.sprintId)
@ -403,7 +529,10 @@ TaskboardSquishColumnDirective = (rs) ->
recalculateTaskboardWidth() recalculateTaskboardWidth()
getCeilWidth = (usId, statusId) => getCeilWidth = (usId, statusId) =>
tasks = $scope.usTasks[usId][statusId].length if usId
tasks = $scope.usTasks.getIn([usId.toString(), statusId.toString()]).size
else
tasks = $scope.usTasks.getIn(['null', statusId.toString()]).size
if $scope.statusesFolded[statusId] if $scope.statusesFolded[statusId]
if tasks and $scope.usFolded[usId] if tasks and $scope.usFolded[usId]
@ -421,6 +550,9 @@ TaskboardSquishColumnDirective = (rs) ->
if width if width
column.css('max-width', width) column.css('max-width', width)
else
if $scope.ctrl.zoomLevel == '0'
column.css("max-width", 148)
else else
column.css("max-width", maxColumnWidth) column.css("max-width", maxColumnWidth)
@ -458,67 +590,3 @@ TaskboardSquishColumnDirective = (rs) ->
return {link: link} return {link: link}
module.directive("tgTaskboardSquishColumn", ["$tgResources", TaskboardSquishColumnDirective]) module.directive("tgTaskboardSquishColumn", ["$tgResources", TaskboardSquishColumnDirective])
#############################################################################
## Taskboard User Directive
#############################################################################
TaskboardUserDirective = ($log, $translate, avatarService) ->
clickable = false
link = ($scope, $el, $attrs) ->
username_label = $el.parent().find("a.task-assigned")
username_label.addClass("not-clickable")
$scope.$watch 'task.assigned_to', (assigned_to) ->
user = $scope.usersById[assigned_to]
avatar = avatarService.getAvatar(user)
if user is undefined
_.assign($scope, {
name: $translate.instant("COMMON.ASSIGNED_TO.NOT_ASSIGNED"),
avatar: avatar,
clickable: clickable
})
else
_.assign($scope, {
name: user.full_name_display,
avatar: avatar,
clickable: clickable
})
username_label.text($scope.name)
bindOnce $scope, "project", (project) ->
if project.my_permissions.indexOf("modify_task") > -1
clickable = true
$el.find(".avatar-assigned-to").on "click", (event) =>
if $el.find('a').hasClass('noclick')
return
$ctrl = $el.controller()
$ctrl.editTaskAssignedTo($scope.task)
username_label.removeClass("not-clickable")
username_label.on "click", (event) ->
if $el.find('a').hasClass('noclick')
return
$ctrl = $el.controller()
$ctrl.editTaskAssignedTo($scope.task)
return {
link: link,
templateUrl: "taskboard/taskboard-user.html",
scope: {
"usersById": "=users",
"project": "=",
"task": "=",
}
}
module.directive("tgTaskboardUserAvatar", ["$log", "$translate", "tgAvatarService", TaskboardUserDirective])

View File

@ -37,11 +37,14 @@ module = angular.module("taigaBacklog")
## Sortable Directive ## Sortable Directive
############################################################################# #############################################################################
TaskboardSortableDirective = ($repo, $rs, $rootscope) -> TaskboardSortableDirective = ($repo, $rs, $rootscope, $translate) ->
link = ($scope, $el, $attrs) -> link = ($scope, $el, $attrs) ->
bindOnce $scope, "tasks", (xx) -> unwatch = $scope.$watch "usTasks", (usTasks) ->
# If the user has not enough permissions we don't enable the sortable return if !usTasks || !usTasks.size
if not ($scope.project.my_permissions.indexOf("modify_us") > -1)
unwatch()
if not ($scope.project.my_permissions.indexOf("modify_task") > -1)
return return
oldParentScope = null oldParentScope = null
@ -49,6 +52,10 @@ TaskboardSortableDirective = ($repo, $rs, $rootscope) ->
itemEl = null itemEl = null
tdom = $el tdom = $el
filterError = ->
text = $translate.instant("BACKLOG.SORTABLE_FILTER_ERROR")
$tgConfirm.notify("error", text)
deleteElement = (itemEl) -> deleteElement = (itemEl) ->
# Completelly remove item and its scope from dom # Completelly remove item and its scope from dom
itemEl.scope().$destroy() itemEl.scope().$destroy()
@ -63,12 +70,22 @@ TaskboardSortableDirective = ($repo, $rs, $rootscope) ->
copy: false, copy: false,
mirrorContainer: $el[0], mirrorContainer: $el[0],
accepts: (el, target) -> return !$(target).hasClass('taskboard-userstory-box') accepts: (el, target) -> return !$(target).hasClass('taskboard-userstory-box')
moves: (item) -> return $(item).hasClass('taskboard-task') moves: (item) ->
return $(item).is('tg-card')
}) })
drake.on 'drag', (item) -> drake.on 'drag', (item) ->
oldParentScope = $(item).parent().scope() oldParentScope = $(item).parent().scope()
if $el.hasClass("active-filters")
filterError()
setTimeout (() ->
drake.cancel(true)
), 0
return false
drake.on 'dragend', (item) -> drake.on 'dragend', (item) ->
parentEl = $(item).parent() parentEl = $(item).parent()
itemEl = $(item) itemEl = $(item)
@ -85,7 +102,7 @@ TaskboardSortableDirective = ($repo, $rs, $rootscope) ->
deleteElement(itemEl) deleteElement(itemEl)
$scope.$apply -> $scope.$apply ->
$rootscope.$broadcast("taskboard:task:move", itemTask, newUsId, newStatusId, itemIndex) $rootscope.$broadcast("taskboard:task:move", itemTask, itemTask.getIn(['model', 'status']), newUsId, newStatusId, itemIndex)
scroll = autoScroll([$('.taskboard-table-body')[0]], { scroll = autoScroll([$('.taskboard-table-body')[0]], {
@ -107,5 +124,6 @@ module.directive("tgTaskboardSortable", [
"$tgRepo", "$tgRepo",
"$tgResources", "$tgResources",
"$rootScope", "$rootScope",
"$translate",
TaskboardSortableDirective TaskboardSortableDirective
]) ])

View File

@ -0,0 +1,173 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: home.service.coffee
###
groupBy = @.taiga.groupBy
class TaskboardTasksService extends taiga.Service
@.$inject = []
constructor: () ->
@.reset()
reset: () ->
@.tasksRaw = []
@.foldStatusChanged = {}
@.usTasks = Immutable.Map()
init: (project, usersById) ->
@.project = project
@.usersById = usersById
resetFolds: () ->
@.foldStatusChanged = {}
@.refresh()
toggleFold: (taskId) ->
@.foldStatusChanged[taskId] = !@.foldStatusChanged[taskId]
@.refresh()
add: (task) ->
@.tasksRaw = @.tasksRaw.concat(task)
@.refresh()
set: (tasks) ->
@.tasksRaw = tasks
@.refreshRawOrder()
@.refresh()
setUserstories: (userstories) ->
@.userstories = userstories
refreshRawOrder: () ->
@.order = {}
@.order[task.id] = task.taskboard_order for task in @.tasksRaw
assignOrders: (order) ->
order = _.invert(order)
@.order = _.assign(@.order, order)
@.refresh()
getTask: (id) ->
findedTask = null
@.usTasks.forEach (us) ->
us.forEach (status) ->
findedTask = status.find (task) -> return task.get('id') == id
return false if findedTask
return false if findedTask
return findedTask
replace: (task) ->
@.usTasks = @.usTasks.map (us) ->
return us.map (status) ->
findedIndex = status.findIndex (usItem) ->
return usItem.get('id') == us.get('id')
if findedIndex != -1
status = status.set(findedIndex, task)
return status
getTaskModel: (id) ->
return _.find @.tasksRaw, (task) -> return task.id == id
replaceModel: (task) ->
@.tasksRaw = _.map @.tasksRaw, (it) ->
if task.id == it.id
return task
else
return it
@.refresh()
move: (id, usId, statusId, index) ->
task = @.getTaskModel(id)
taskByUsStatus = _.filter @.tasksRaw, (task) =>
return task.status == statusId && task.user_story == usId
taskByUsStatus = _.sortBy taskByUsStatus, (it) => @.order[it.id]
taksWithoutMoved = _.filter taskByUsStatus, (it) => it.id != id
beforeDestination = _.slice(taksWithoutMoved, 0, index)
afterDestination = _.slice(taksWithoutMoved, index)
setOrders = {}
previous = beforeDestination[beforeDestination.length - 1]
previousWithTheSameOrder = _.filter beforeDestination, (it) =>
@.order[it.id] == @.order[previous.id]
if previousWithTheSameOrder.length > 1
for it in previousWithTheSameOrder
setOrders[it.id] = @.order[it.id]
if !previous
@.order[task.id] = 0
else if previous
@.order[task.id] = @.order[previous.id] + 1
for it, key in afterDestination
@.order[it.id] = @.order[task.id] + key + 1
task.status = statusId
task.user_story = usId
task.taskboard_order = @.order[task.id]
@.refresh()
return {"task_id": task.id, "order": @.order[task.id], "set_orders": setOrders}
refresh: ->
@.tasksRaw = _.sortBy @.tasksRaw, (it) => @.order[it.id]
tasks = @.tasksRaw
taskStatusList = _.sortBy(@.project.task_statuses, "order")
usTasks = {}
# Iterate over all userstories and
# null userstory for unassigned tasks
for us in _.union(@.userstories, [{id:null}])
usTasks[us.id] = {}
for status in taskStatusList
usTasks[us.id][status.id] = []
for taskModel in tasks
if usTasks[taskModel.user_story]? and usTasks[taskModel.user_story][taskModel.status]?
task = {}
task.foldStatusChanged = @.foldStatusChanged[taskModel.id]
task.model = taskModel.getAttrs()
task.images = _.filter taskModel.attachments, (it) -> return !!it.thumbnail_card_url
task.id = taskModel.id
task.assigned_to = @.usersById[taskModel.assigned_to]
task.colorized_tags = _.map task.model.tags, (tag) =>
color = @.project.tags_colors[tag]
return {name: tag, color: color}
usTasks[taskModel.user_story][taskModel.status].push(task)
@.usTasks = Immutable.fromJS(usTasks)
angular.module("taigaKanban").service("tgTaskboardTasks", TaskboardTasksService)

View File

@ -38,7 +38,7 @@ bindMethods = (object) =>
methods = [] methods = []
_.forIn object, (value, key) => _.forIn object, (value, key) =>
if key not in dependencies if key not in dependencies && _.isFunction(value)
methods.push(key) methods.push(key)
_.bindAll(object, methods) _.bindAll(object, methods)

View File

@ -45,6 +45,10 @@
"CAPSLOCK_WARNING": "Be careful! You are using capital letters in an input field that is case sensitive.", "CAPSLOCK_WARNING": "Be careful! You are using capital letters in an input field that is case sensitive.",
"CONFIRM_CLOSE_EDIT_MODE_TITLE": "Are you sure you want to close the edit mode?", "CONFIRM_CLOSE_EDIT_MODE_TITLE": "Are you sure you want to close the edit mode?",
"CONFIRM_CLOSE_EDIT_MODE_MESSAGE": "Remember that if you close the edit mode without saving all the changes will be lost", "CONFIRM_CLOSE_EDIT_MODE_MESSAGE": "Remember that if you close the edit mode without saving all the changes will be lost",
"CARD": {
"ASSIGN_TO": "Assign To",
"EDIT": "Edit card"
},
"FORM_ERRORS": { "FORM_ERRORS": {
"DEFAULT_MESSAGE": "This value seems to be invalid.", "DEFAULT_MESSAGE": "This value seems to be invalid.",
"TYPE_EMAIL": "This value should be a valid email.", "TYPE_EMAIL": "This value should be a valid email.",
@ -196,9 +200,25 @@
"TITLE": "filters", "TITLE": "filters",
"INPUT_PLACEHOLDER": "Subject or reference", "INPUT_PLACEHOLDER": "Subject or reference",
"TITLE_ACTION_FILTER_BUTTON": "search", "TITLE_ACTION_FILTER_BUTTON": "search",
"BREADCRUMB_TITLE": "back to categories", "TITLE": "Filters",
"BREADCRUMB_FILTERS": "Filters", "INPUT_SEARCH_PLACEHOLDER": "Subject or ref",
"BREADCRUMB_STATUS": "status" "TITLE_ACTION_SEARCH": "Search",
"ACTION_SAVE_CUSTOM_FILTER": "save as custom filter",
"PLACEHOLDER_FILTER_NAME": "Write the filter name and press enter",
"CATEGORIES": {
"TYPE": "Type",
"STATUS": "Status",
"SEVERITY": "Severity",
"PRIORITIES": "Priorities",
"TAGS": "Tags",
"ASSIGNED_TO": "Assigned to",
"CREATED_BY": "Created by",
"CUSTOM_FILTERS": "Custom filters"
},
"CONFIRM_DELETE": {
"TITLE": "Delete custom filter",
"MESSAGE": "the custom filter '{{customFilterName}}'"
}
}, },
"WYSIWYG": { "WYSIWYG": {
"H1_BUTTON": "First Level Heading", "H1_BUTTON": "First Level Heading",
@ -1169,9 +1189,7 @@
"TITLE": "Filters", "TITLE": "Filters",
"REMOVE": "Remove Filters", "REMOVE": "Remove Filters",
"HIDE": "Hide Filters", "HIDE": "Hide Filters",
"SHOW": "Show Filters", "SHOW": "Show Filters"
"FILTER_CATEGORY_STATUS": "Status",
"FILTER_CATEGORY_TAGS": "Tags"
}, },
"SPRINTS": { "SPRINTS": {
"TITLE": "SPRINTS", "TITLE": "SPRINTS",
@ -1278,7 +1296,6 @@
"SECTION_NAME": "Issue", "SECTION_NAME": "Issue",
"ACTION_NEW_ISSUE": "+ NEW ISSUE", "ACTION_NEW_ISSUE": "+ NEW ISSUE",
"ACTION_PROMOTE_TO_US": "Promote to User Story", "ACTION_PROMOTE_TO_US": "Promote to User Story",
"PLACEHOLDER_FILTER_NAME": "Write the filter name and press enter",
"PROMOTED": "This issue has been promoted to US:", "PROMOTED": "This issue has been promoted to US:",
"EXTERNAL_REFERENCE": "This issue has been created from", "EXTERNAL_REFERENCE": "This issue has been created from",
"GO_TO_EXTERNAL_REFERENCE": "Go to origin", "GO_TO_EXTERNAL_REFERENCE": "Go to origin",
@ -1296,28 +1313,6 @@
"TITLE": "Promote this issue to a new user story", "TITLE": "Promote this issue to a new user story",
"MESSAGE": "Are you sure you want to create a new US from this Issue?" "MESSAGE": "Are you sure you want to create a new US from this Issue?"
}, },
"FILTERS": {
"TITLE": "Filters",
"INPUT_SEARCH_PLACEHOLDER": "Subject or ref",
"TITLE_ACTION_SEARCH": "Search",
"ACTION_SAVE_CUSTOM_FILTER": "save as custom filter",
"BREADCRUMB": "Filters",
"TITLE_BREADCRUMB": "Filters",
"CATEGORIES": {
"TYPE": "Type",
"STATUS": "Status",
"SEVERITY": "Severity",
"PRIORITIES": "Priorities",
"TAGS": "Tags",
"ASSIGNED_TO": "Assigned to",
"CREATED_BY": "Created by",
"CUSTOM_FILTERS": "Custom filters"
},
"CONFIRM_DELETE": {
"TITLE": "Delete custom filter",
"MESSAGE": "the custom filter '{{customFilterName}}'"
}
},
"TABLE": { "TABLE": {
"COLUMNS": { "COLUMNS": {
"TYPE": "Type", "TYPE": "Type",

View File

@ -0,0 +1,29 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: board-zoom.directive.coffee
###
BoardZoomDirective = () ->
return {
scope: {
levels: "=",
value: "="
},
templateUrl: 'components/board-zoom/board-zoom.html'
}
angular.module('taigaComponents').directive("tgBoardZoom", [BoardZoomDirective])

View File

@ -0,0 +1,9 @@
input.range-slider(
type="range",
min="0",
max="{{levels - 1}}",
step="1"
ng-model="value"
ng-model-options="{ debounce: 200 }"
tg-bind-scope
)

View File

@ -0,0 +1,108 @@
$track-color: $whitish;
$thumb-color: $grayer;
$thumb-shadow: rgba($thumb-color, .3);
$thumb-radius: 50%;
$thumb-height: 14px;
$thumb-width: 14px;
$thumb-border-width: 0;
$thumb-border-color: transparent;
$track-width: 200px;
$track-height: 3px;
$track-border-width: 0;
$track-border-color: transparent;
$track-radius: 1px;
$contrast: 2;
@mixin track() {
width: $track-width;
height: $track-height;
cursor: pointer;
transition: all .2s ease;
}
@mixin thumb() {
border: $thumb-border-width solid $thumb-border-color;
height: $thumb-height;
width: $thumb-width;
border-radius: $thumb-radius;
background: $thumb-color;
cursor: pointer;
box-shadow: 0 0 0 2px $thumb-shadow;
transition: box-shadow .2s;
}
.range-slider {
-webkit-appearance: none;
margin: $thumb-height / 2 0;
width: $track-width;
&:focus {
&::-webkit-slider-runnable-track {
background: lighten($track-color, $contrast);
}
&::-webkit-slider-thumb {
box-shadow: 0 0 0 4px $thumb-shadow;
}
&::-moz-range-thumb {
box-shadow: 0 0 0 4px $thumb-shadow;
}
&::-ms-fill-lower {
background: $track-color;
}
&::-ms-fill-upper {
background: lighten($track-color, $contrast);
}
}
&::-webkit-slider-runnable-track {
@include track();
background: $track-color;
border: $track-border-width solid $track-border-color;
border-radius: $track-radius;
}
&::-webkit-slider-thumb {
@include thumb();
-webkit-appearance: none;
margin-top: ((-$track-border-width * 2 + $track-height) / 2) - ($thumb-height / 2);
}
&::-moz-range-track {
@include track();
background: $track-color;
border: $track-border-width solid $track-border-color;
border-radius: $track-radius;
}
&::-moz-range-thumb {
@include thumb();
}
&::-ms-track {
@include track();
background: transparent;
border-color: transparent;
border-width: $thumb-width 0;
color: transparent;
}
&::-ms-fill-lower {
background: darken($track-color, $contrast);
border: $track-border-width solid $track-border-color;
border-radius: $track-radius * 2;
}
&::-ms-fill-upper {
background: $track-color;
border: $track-border-width solid $track-border-color;
border-radius: $track-radius * 2;
}
&::-ms-thumb {
@include thumb();
}
}

View File

@ -0,0 +1,38 @@
###
# Copyright (C) 2014-2015 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: card-slideshow.controller.coffee
###
class CardSlideshowController
@.$inject = []
constructor: () ->
@.index = 0
next: () ->
@.index++
if @.index >= @.images.size
@.index = 0
previous: () ->
@.index--
if @.index < 0
@.index = @.images.size - 1
angular.module('taigaComponents').controller('CardSlideshow', CardSlideshowController)

View File

@ -0,0 +1,33 @@
###
# Copyright (C) 2014-2015 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: card.directive.coffee
###
module = angular.module("taigaComponents")
cardSlideshowDirective = () ->
return {
controller: "CardSlideshow",
templateUrl: "components/card-slideshow/card-slideshow.html",
bindToController: true,
controllerAs: "vm",
scope: {
images: "="
}
}
module.directive('tgCardSlideshow', cardSlideshowDirective)

View File

@ -0,0 +1,18 @@
.card-slideshow(ng-if="vm.images.size")
tg-svg.slideshow-icon.slideshow-left(
ng-click="vm.previous()"
ng-if="vm.images.size > 1"
svg-icon="icon-arrow-left"
)
tg-svg.slideshow-icon.slideshow-right(
ng-click="vm.next()"
ng-if="vm.images.size > 1"
svg-icon="icon-arrow-right"
)
.card-slideshow-wrapper(
ng-if="$index == vm.index"
tg-repeat="image in vm.images track by image.get('id')"
)
tg-preload-image(preload-src="{{image.get('thumbnail_card_url')}}")
img(ng-src="{{image.get('thumbnail_card_url')}}")

View File

@ -0,0 +1,4 @@
.card-completion(ng-if="vm.visible('extra_info') && vm.item.getIn(['model', 'tasks']).size")
.card-completion-bar
.card-completion-percentage(ng-style="{width: vm.closedTasksPercent() + '%'}" )
span.card-tooltip tasks {{vm.getClosedTasks().size}}/{{vm.item.getIn(['model', 'tasks']).size}}

View File

@ -0,0 +1,21 @@
.card-data(
ng-if="vm.visible('extra_info')"
ng-class="{'empty-tasks': !vm.item.getIn(['model', 'tasks']).size}"
)
span.card-estimation(
ng-if="vm.item.getIn(['model', 'total_points']) === null",
translate="US.NOT_ESTIMATED"
)
span.card-estimation(
ng-if="vm.item.getIn(['model', 'total_points'])"
) {{"COMMON.FIELDS.POINTS" | translate}} {{vm.item.getIn(['model', 'total_points'])}}
.card-statistics
.statistic.card-votes(ng-class="{'active': vm.item.getIn(['model', 'is_voter'])}")
tg-svg(svg-icon="icon-upvote")
span {{vm.item.getIn(['model', 'total_voters'])}}
.statistic.card-watchers
tg-svg(svg-icon="icon-watch")
span {{vm.item.getIn(['model', 'watchers']).size}}
.statistic.card-attachments(ng-if="vm.item.getIn(['model', 'attachments']).size")
tg-svg(svg-icon="icon-attachment")
span {{vm.item.getIn(['model', 'attachments']).size}}

View File

@ -0,0 +1,43 @@
.card-owner
.card-owner-info(ng-if="vm.item.get('assigned_to')")
.card-owner-avatar
img(
ng-class="{'is-iocaine': vm.item.getIn(['model', 'is_iocaine'])}"
tg-avatar="vm.item.get('assigned_to')"
)
tg-svg(
ng-if="vm.item.getIn(['model', 'is_iocaine'])"
svg-icon="icon-iocaine"
svg-title="COMMON.IOCAINE_TEXT"
)
span.card-owner-name(ng-if="vm.visible('owner')") {{vm.item.getIn(['assigned_to', 'full_name'])}}
div(ng-if="!vm.visible('owner')")
include card-title
.card-owner-info(ng-if="!vm.item.get('assigned_to')")
img(ng-src="/#{v}/images/unnamed.png")
span.card-owner-name(
ng-if="vm.visible('owner')",
translate="COMMON.ASSIGNED_TO.NOT_ASSIGNED"
)
div(ng-if="!vm.visible('owner')")
include card-title
.card-owner-actions(
ng-if="vm.visible('owner')"
tg-check-permission="{{vm.getPermissionsKey()}}"
)
a.e2e-assign.card-owner-assign(
ng-click="vm.onClickAssignedTo({id: vm.item.get('id')})"
href=""
)
tg-svg(svg-icon="icon-add-user")
span(translate="COMMON.CARD.ASSIGN_TO")
a.e2e-edit.card-edit(
href=""
ng-click="vm.onClickEdit({id: vm.item.get('id')})"
tg-loading="vm.item.get('loading')"
)
tg-svg(svg-icon="icon-edit")
span(translate="COMMON.CARD.EDIT")

View File

@ -0,0 +1,7 @@
.card-tags(ng-if="vm.visible('tags')")
span.card-tag(
tg-repeat="tag in vm.item.get('colorized_tags') track by tag.get('name')"
style="background-color: {{tag.get('color')}}"
title="{{tag.get('name')}}"
ng-if="tag.get('color')"
)

View File

@ -0,0 +1,7 @@
ul.card-tasks(ng-if="vm.isRelatedTasksVisible()")
li.card-task(tg-repeat="task in vm.item.getIn(['model', 'tasks'])")
a(
href="#"
tg-nav="project-tasks-detail:project=vm.project.slug,ref=task.get('ref')",
ng-class="{'closed-task': task.get('is_closed'), 'blocked-task': task.get('is_blocked')}"
) {{"#" + task.get('ref')}} {{task.get('subject')}}

View File

@ -0,0 +1,9 @@
h2.card-title
a(
href=""
tg-nav="{{vm.getNavKey()}}:project=vm.project.slug,ref=vm.item.getIn(['model', 'ref'])",
tg-nav-get-params="{\"kanban-status\": {{vm.item.getIn(['model', 'status'])}}}"
title="#{{ ::vm.item.getIn(['model', 'ref']) }} {{ vm.item.getIn(['model', 'subject'])}}"
)
span(ng-if="vm.visible('ref')") {{::"#" + vm.item.getIn(['model', 'ref'])}}
span.e2e-title(ng-if="vm.visible('subject')") {{vm.item.getIn(['model', 'subject'])}}

View File

@ -0,0 +1,6 @@
.card-unfold.ng-animate-disabled(
ng-click="vm.toggleFold()"
ng-if="vm.visible('unfold') && (vm.item.getIn(['model', 'tasks']).size || vm.item.get('images').size)"
role="button"
)
tg-svg(svg-icon="icon-view-more")

View File

@ -0,0 +1,82 @@
###
# Copyright (C) 2014-2015 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: card.controller.coffee
###
class CardController
@.$inject = []
visible: (name) ->
return @.zoom.indexOf(name) != -1
toggleFold: () ->
@.onToggleFold({id: @.item.get('id')})
getClosedTasks: () ->
return @.item.getIn(['model', 'tasks']).filter (task) -> return task.get('is_closed');
closedTasksPercent: () ->
return @.getClosedTasks().size * 100 / @.item.getIn(['model', 'tasks']).size
getPermissionsKey: () ->
if @.type == 'task'
return 'modify_task'
else
return 'modify_us'
_setVisibility: () ->
visibility = {
related: @.visible('related_tasks'),
slides: @.visible('attachments')
}
if!_.isUndefined(@.item.get('foldStatusChanged'))
if @.visible('related_tasks') && @.visible('attachments')
visibility.related = !@.item.get('foldStatusChanged')
visibility.slides = !@.item.get('foldStatusChanged')
else if @.visible('attachments')
visibility.related = @.item.get('foldStatusChanged')
visibility.slides = @.item.get('foldStatusChanged')
else if !@.visible('related_tasks') && !@.visible('attachments')
visibility.related = @.item.get('foldStatusChanged')
visibility.slides = @.item.get('foldStatusChanged')
if !@.item.getIn(['model', 'tasks']) || !@.item.getIn(['model', 'tasks']).size
visibility.related = false
if !@.item.get('images') || !@.item.get('images').size
visibility.slides = false
return visibility
isRelatedTasksVisible: () ->
visibility = @._setVisibility()
return visibility.related
isSlideshowVisible: () ->
visibility = @._setVisibility()
return visibility.slides
getNavKey: () ->
if @.type == 'task'
return 'project-tasks-detail'
else
return 'project-userstories-detail'
angular.module('taigaComponents').controller('Card', CardController)

View File

@ -0,0 +1,142 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: card.controller.spec.coffee
###
describe "Card", ->
$provide = null
$controller = null
mocks = {}
_inject = ->
inject (_$controller_) ->
$controller = _$controller_
_setup = ->
_inject()
beforeEach ->
module "taigaComponents"
_setup()
it "toggle fold callback", () ->
ctrl = $controller("Card")
ctrl.item = Immutable.fromJS({id: 2})
ctrl.onToggleFold = sinon.spy()
ctrl.toggleFold()
expect(ctrl.onToggleFold).to.have.been.calledWith({id: 2})
it "get closed tasks", () ->
ctrl = $controller("Card")
ctrl.item = Immutable.fromJS({
id: 2,
model: {
tasks: [
{is_closed: true},
{is_closed: false},
{is_closed: true}
]
}
})
tasks = ctrl.getClosedTasks()
expect(tasks.size).to.be.equal(2)
it "get closed percent", () ->
ctrl = $controller("Card")
ctrl.item = Immutable.fromJS({
id: 2,
model: {
tasks: [
{is_closed: true},
{is_closed: false},
{is_closed: false},
{is_closed: true}
]
}
})
percent = ctrl.closedTasksPercent()
expect(percent).to.be.equal(50)
describe "check if related task and slides visibility", () ->
it "no content", () ->
ctrl = $controller("Card")
ctrl.item = Immutable.fromJS({
id: 2,
images: [],
model: {
tasks: []
}
})
ctrl.visible = () => return true
visibility = ctrl._setVisibility()
expect(visibility).to.be.eql({
related: false,
slides: false
})
it "with content", () ->
ctrl = $controller("Card")
ctrl.item = Immutable.fromJS({
id: 2,
images: [3,4],
model: {
tasks: [1,2]
}
})
ctrl.visible = () => return true
visibility = ctrl._setVisibility()
expect(visibility).to.be.eql({
related: true,
slides: true
})
it "fold", () ->
ctrl = $controller("Card")
ctrl.item = Immutable.fromJS({
foldStatusChanged: true,
id: 2,
images: [3,4],
model: {
tasks: [1,2]
}
})
ctrl.visible = () => return true
visibility = ctrl._setVisibility()
expect(visibility).to.be.eql({
related: false,
slides: false
})

View File

@ -0,0 +1,43 @@
###
# Copyright (C) 2014-2015 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: card.directive.coffee
###
module = angular.module("taigaComponents")
cardDirective = () ->
return {
link: (scope) ->
controller: "Card",
controllerAs: "vm",
bindToController: true,
templateUrl: "components/card/card.html",
scope: {
onToggleFold: "&",
onClickAssignedTo: "&",
onClickEdit: "&",
project: "=",
item: "=",
zoom: "=",
zoomLevel: "=",
archived: "=",
type: "@"
}
}
module.directive('tgCard', cardDirective)

View File

@ -0,0 +1,16 @@
.card-inner(
class="{{'zoom-' + vm.zoomLevel}}"
ng-class="{'card-blocked': vm.item.getIn(['model', 'is_blocked']), 'archived': vm.archived}"
)
include card-templates/card-tags
include card-templates/card-owner
div(ng-if="vm.visible('owner')")
include card-templates/card-title
include card-templates/card-data
include card-templates/card-completion
include card-templates/card-tasks
tg-card-slideshow(
ng-if="vm.isSlideshowVisible()"
images="vm.item.get('images')"
)
include card-templates/card-unfold

View File

@ -0,0 +1,326 @@
.card {
box-shadow: 2px 2px 4px darken($whitish, 10%);
cursor: move;
display: block;
margin: 0 .6rem .6rem;
overflow: hidden;
transition: box-shadow .2s ease-in;
&:hover {
box-shadow: 3px 3px 6px darken($whitish, 10%);
}
}
.card-inner {
background: $white;
border-radius: .25rem;
&.zoom-0,
&.zoom-1 {
.card-title {
flex: 1;
margin: 0;
padding: .25rem;
}
}
&.zoom-1 {
.card-owner-info {
align-items: flex-start;
}
}
&.card-blocked {
background: $red-light;
.statistic,
.card-title a,
.card-owner-name,
.card-estimation {
color: $white;
}
.card-owner-actions {
background: rgba($red-light, .9);
}
svg {
fill: $white;
}
.statistic {
&.active {
color: $white;
}
}
.card-unfold {
&:hover {
background: rgba($red-light, .9);
}
}
&.zoom-0,
&.zoom-1 {
.card-title {
color: $white;
}
}
}
}
.card-tags {
display: flex;
.card-tag {
display: block;
flex: 1;
height: .5rem;
}
}
.card-owner {
position: relative;
.card-owner-info {
align-items: center;
display: flex;
}
.card-owner-avatar {
line-height: 0;
position: relative;
}
.icon-iocaine {
@include svg-size(1.2rem);
background: rgba($blackish, .8);
border-radius: 4px 0 0;
bottom: .25rem;
fill: $whitish;
padding: .25rem;
position: absolute;
right: .5rem;
}
.is-iocaine {
filter: hue-rotate(265deg) saturate(3);
}
&:hover {
.card-owner-actions {
opacity: 1;
}
}
img {
flex-shrink: 0;
height: 2.5rem;
margin-right: .5rem;
width: 2.5rem;
}
.card-owner-name {
color: $gray-light;
}
}
.card-owner-actions {
background: rgba($white, .9);
display: flex;
justify-content: space-between;
left: 0;
opacity: 0;
position: absolute;
top: 0;
transition: all .2s;
width: 100%;
&:hover {
color: $primary-light;
svg {
fill: currentColor;
}
}
.icon {
@include svg-size(1.2rem);
display: inline-block;
margin-right: .25rem;
padding: 0;
}
a {
align-items: center;
cursor: pointer;
display: flex;
padding: .6rem 1rem;
}
}
.card-title {
@include font-size(normal);
line-height: 1.25;
margin-bottom: .25rem;
padding: 1rem 1rem 0;
span {
padding-right: .25rem;
}
}
.card-data {
color: $gray-light;
display: flex;
font-size: 14px;
justify-content: space-between;
padding: 0 1rem .5rem;
}
.card-statistics {
@include font-size(small);
color: lighten($gray-light, 25%);
display: flex;
margin-left: auto;
.statistic {
align-content: center;
display: flex;
margin-left: .75rem;
&.active {
color: $primary-light;
svg {
fill: currentColor;
}
}
}
.icon {
@include svg-size(.75rem);
fill: lighten($gray-light, 25%);
margin-right: .2rem;
}
}
.card-completion {
margin: 0 1rem .5rem;
position: relative;
.card-completion-bar {
background: $whitish;
height: .4rem;
width: 100%;
}
.card-completion-percentage {
background: $primary-light;
cursor: pointer;
height: .4rem;
left: 0;
position: absolute;
top: 0;
&:hover {
+ .card-tooltip {
opacity: 1;
}
}
}
.card-tooltip {
background: $blackish;
border-radius: 5px;
color: $white;
font-size: 14px;
left: calc(25% - 50px);
opacity: 0;
padding: .25rem 1rem;
position: absolute;
text-align: center;
top: -2.25rem;
transition: opacity .2s;
width: 100px;
&::after {
background: $black;
content: '';
height: 10px;
left: 50%;
position: absolute;
top: 70%;
transform: rotate(45deg);
width: 10px;
}
}
}
.card-unfold {
align-items: center;
cursor: pointer;
display: flex;
justify-content: center;
margin: 0;
padding: .25rem;
&:hover {
background: linear-gradient(to bottom, $white, darken($white, 1%));
}
svg {
@include svg-size($width: 2rem, $height: .3rem);
fill: $whitish;
}
}
.card-tasks {
border-top: 1px solid $whitish;
margin: 0;
margin-top: .5rem;
padding: 0;
}
.card-task {
@include font-size(xsmall);
border-bottom: 1px solid $whitish;
list-style: none;
a {
color: $gray-light;
display: block;
overflow: hidden;
padding: .5rem .75rem;
text-overflow: ellipsis;
transition: color .2s;
white-space: nowrap;
&.blocked-task {
color: $red-light;
}
&.closed-task {
color: $gray-light;
text-decoration: line-through;
}
&:hover {
color: $primary;
}
}
}
.card-slideshow {
position: relative;
&:hover {
.slideshow-left,
.slideshow-right {
background: rgba($white, .2);
padding: .25rem;
transition: background .2s;
}
}
.slideshow-icon {
cursor: pointer;
position: absolute;
top: 35%;
&:hover {
background: rgba($primary-light, .5);
transition: background .2s;
}
}
svg {
@include svg-size(1.2rem);
transition: fill .2s;
}
.slideshow-left,
.slideshow-right {
background: transparent;
padding: .25rem;
}
.slideshow-left {
left: 0;
}
.slideshow-right {
right: 0;
}
img {
width: 100%;
}
}
.card-slideshow-wrapper {
align-items: center;
display: flex;
height: 120px;
justify-content: center;
overflow: hidden;
.loading-spinner {
min-height: 3rem;
min-width: 3rem;
}
}

View File

@ -0,0 +1,68 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: filter-utils.service.coffee
###
generateHash = taiga.generateHash
class FilterRemoteStorageService extends taiga.Service
@.$inject = [
"$q",
"$tgUrls",
"$tgHttp"
]
constructor: (@q, @urls, @http) ->
storeFilters: (projectId, myFilters, filtersHashSuffix) ->
deferred = @q.defer()
url = @urls.resolve("user-storage")
ns = "#{projectId}:#{filtersHashSuffix}"
hash = generateHash([projectId, ns])
if _.isEmpty(myFilters)
promise = @http.delete("#{url}/#{hash}", {key: hash, value:myFilters})
promise.then ->
deferred.resolve()
promise.then null, ->
deferred.reject()
else
promise = @http.put("#{url}/#{hash}", {key: hash, value:myFilters})
promise.then (data) ->
deferred.resolve()
promise.then null, (data) =>
innerPromise = @http.post("#{url}", {key: hash, value:myFilters})
innerPromise.then ->
deferred.resolve()
innerPromise.then null, ->
deferred.reject()
return deferred.promise
getFilters: (projectId, filtersHashSuffix) ->
deferred = @q.defer()
url = @urls.resolve("user-storage")
ns = "#{projectId}:#{filtersHashSuffix}"
hash = generateHash([projectId, ns])
promise = @http.get("#{url}/#{hash}")
promise.then (data) ->
deferred.resolve(data.data.value)
promise.then null, (data) ->
deferred.resolve({})
return deferred.promise
angular.module("taigaComponents").service("tgFilterRemoteStorageService", FilterRemoteStorageService)

View File

@ -0,0 +1,45 @@
###
# Copyright (C) 2014-2015 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: filter.-slide-down.controller.coffee
###
FilterSlideDownDirective = () ->
link = (scope, el, attrs, ctrl) ->
filter = $('tg-filter')
scope.$watch attrs.ngIf, (value) ->
if value
filter.find('.filter-list').hide()
wrapperHeight = filter.height()
contentHeight = 0
filter.children().each () ->
contentHeight += $(this).outerHeight(true)
$(el.context.nextSibling)
.css({
"max-height": wrapperHeight - contentHeight,
"display": "block"
})
return {
priority: 900,
link: link
}
angular.module('taigaComponents').directive("tgFilterSlideDown", [FilterSlideDownDirective])

View File

@ -0,0 +1,70 @@
###
# Copyright (C) 2014-2015 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: filter.controller.coffee
###
class FilterController
@.$inject = []
constructor: () ->
@.opened = null
@.customFilterForm = false
@.customFilterName = ''
toggleFilterCategory: (filterName) ->
if @.opened == filterName
@.opened = null
else
@.opened = filterName
isOpen: (filterName) ->
return @.opened == filterName
saveCustomFilter: () ->
@.onSaveCustomFilter({name: @.customFilterName})
@.customFilterForm = false
@.opened = 'custom-filter'
@.customFilterName = ''
changeQ: () ->
@.onChangeQ({q: @.q})
unselectFilter: (filter) ->
@.onRemoveFilter({filter: filter})
unselectFilter: (filter) ->
@.onRemoveFilter({filter: filter})
selectFilter: (filterCategory, filter) ->
filter = {
category: filterCategory
filter: filter
}
@.onAddFilter({filter: filter})
removeCustomFilter: (filter) ->
@.onRemoveCustomFilter({filter: filter})
selectCustomFilter: (filter) ->
@.onSelectCustomFilter({filter: filter})
isFilterSelected: (filterCategory, filter) ->
return !!_.find @.selectedFilters, (it) ->
return filter.id == it.id && filterCategory.dataType == it.dataType
angular.module('taigaComponents').controller('Filter', FilterController)

View File

@ -0,0 +1,87 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: filter.controller.spec.coffee
###
describe "Filter", ->
$provide = null
$controller = null
mocks = {}
_inject = ->
inject (_$controller_) ->
$controller = _$controller_
_setup = ->
_inject()
beforeEach ->
module "taigaComponents"
_setup()
it "toggle filter category", () ->
ctrl = $controller("Filter")
ctrl.toggleFilterCategory('filter1')
expect(ctrl.opened).to.be.equal('filter1')
ctrl.toggleFilterCategory('filter1')
expect(ctrl.opened).to.be.null
it "is filter open", () ->
ctrl = $controller("Filter")
ctrl.opened = 'filter1'
isOpen = ctrl.isOpen('filter1')
expect(isOpen).to.be.true;
it "save custom filter", () ->
ctrl = $controller("Filter")
ctrl.customFilterName = "custom-name"
ctrl.customFilterForm = true
ctrl.onSaveCustomFilter = sinon.spy()
ctrl.saveCustomFilter()
expect(ctrl.onSaveCustomFilter).to.have.been.calledWith({name: "custom-name"})
expect(ctrl.customFilterForm).to.be.false
expect(ctrl.opened).to.be.equal('custom-filter')
expect(ctrl.customFilterName).to.be.equal('')
it "is filter selected", () ->
ctrl = $controller("Filter")
ctrl.selectedFilters = [
{id: 1, dataType: "1"},
{id: 2, dataType: "2"},
{id: 3, dataType: "3"}
]
filterCategory = {dataType: "x"}
filter = {id: 1}
isFilterSelected = ctrl.isFilterSelected(filterCategory, filter)
expect(isFilterSelected).to.be.false
filterCategory = {dataType: "1"}
filter = {id: 1}
isFilterSelected = ctrl.isFilterSelected(filterCategory, filter)
expect(isFilterSelected).to.be.true

View File

@ -0,0 +1,44 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: filter.directive.coffee
###
FilterDirective = () ->
link = (scope, el, attrs, ctrl) ->
return {
scope: {
onChangeQ: "&",
onAddFilter: "&",
onSelectCustomFilter: "&",
onRemoveFilter: "&",
onRemoveCustomFilter: "&",
onSaveCustomFilter: "&",
customFilters: "<",
q: "<",
filters: "<"
customFilters: "<"
selectedFilters: "<"
},
bindToController: true,
controller: "Filter",
controllerAs: "vm",
templateUrl: 'components/filter/filter.html',
link: link
}
angular.module('taigaComponents').directive("tgFilter", [FilterDirective])

View File

@ -0,0 +1,110 @@
h1
span.title(translate="COMMON.FILTERS.TITLE")
form
fieldset
input.e2e-filter-q(
type="text",
placeholder="{{'COMMON.FILTERS.INPUT_PLACEHOLDER' | translate}}",
ng-model="vm.q"
ng-model-options="{ debounce: 200 }"
ng-change="vm.changeQ()"
)
tg-svg.search-action(
svg-icon="icon-search",
title="{{'COMMON.FILTERS.TITLE_ACTION_SEARCH' | translate}}"
)
.filters-step-cat
.filters-applied
.single-filter.ng-animate-disabled(ng-repeat="it in vm.selectedFilters track by it.key")
span.name(ng-attr-style="{{it.color ? 'border-left: 3px solid ' + it.color: ''}}") {{it.name}}
a.remove-filter.e2e-remove-filter(
ng-click="vm.unselectFilter(it)"
href=""
)
tg-svg(svg-icon="icon-close")
a.button.button-gray.save-filters.ng-animate-disabled.e2e-open-custom-filter-form(
ng-click="vm.customFilterForm = true"
ng-if="vm.selectedFilters.length && !vm.customFilterForm"
href="",
title="{{'COMMON.SAVE' | translate}}",
translate="COMMON.FILTERS.ACTION_SAVE_CUSTOM_FILTER"
)
form(
ng-if="vm.customFilterForm"
ng-submit="vm.saveCustomFilter()"
)
input.my-filter-name.e2e-filter-name-input(
tg-autofocus
ng-model="vm.customFilterName"
type="text"
placeholder="{{'COMMON.FILTERS.PLACEHOLDER_FILTER_NAME' | translate}}"
)
.filters-cats
ul
li(
ng-class="{selected: vm.isOpen(filter.dataType)}"
ng-repeat="filter in vm.filters track by filter.dataType"
)
a.filters-cat-single.e2e-category(
ng-class="{selected: vm.isOpen(filter.dataType)}"
ng-click="vm.toggleFilterCategory(filter.dataType)"
href=""
title="{{::filter.title}}"
)
span.title {{::filter.title}}
tg-svg.ng-animate-disabled(
ng-if="!vm.isOpen(filter.dataType)"
svg-icon="icon-arrow-right"
)
tg-svg.ng-animate-disabled(
ng-if="vm.isOpen(filter.dataType)"
svg-icon="icon-arrow-down"
)
.filter-list(
ng-if="vm.isOpen(filter.dataType)",
tg-filter-slide-down
)
.single-filter.ng-animate-disabled(
ng-repeat="it in filter.content"
ng-if="!vm.isFilterSelected(filter, it) && !(it.count == 0 && filter.hideEmpty)"
ng-click="vm.selectFilter(filter, it)"
)
span.name(ng-attr-style="{{it.color ? 'border-left: 3px solid ' + it.color: ''}}") {{it.name}}
span.number.e2e-filter-count(ng-if="it.count > 0") {{it.count}}
li.custom-filters.e2e-custom-filters(ng-class="{selected: vm.isOpen('custom-filter')}")
a.filters-cat-single(
ng-class="{selected: vm.isOpen('custom-filter')}"
ng-click="vm.toggleFilterCategory('custom-filter')"
href=""
title="{{'COMMON.FILTERS.CATEGORIES.CUSTOM_FILTERS' | translate}}"
)
span.title(translate="COMMON.FILTERS.CATEGORIES.CUSTOM_FILTERS")
tg-svg.ng-animate-disabled(
ng-if="!vm.isOpen('custom-filter')"
svg-icon="icon-arrow-right"
)
tg-svg.ng-animate-disabled(
ng-if="vm.isOpen('custom-filter')"
svg-icon="icon-arrow-down"
)
.filter-list(
ng-if="vm.isOpen('custom-filter')",
tg-filter-slide-down
)
.single-filter.ng-animate-disabled.e2e-custom-filter(
ng-repeat="it in vm.customFilters"
ng-click="vm.selectCustomFilter(it)"
)
span.name {{it.name}}
a.remove-filter.e2e-remove-custom-filter(
ng-click="vm.removeCustomFilter(it)"
href=""
)
tg-svg(svg-icon="icon-trash")

View File

@ -0,0 +1,150 @@
tg-filter {
background-color: $whitish;
display: block;
left: 0;
min-height: 100%;
padding: 1rem 0;
position: absolute;
top: 0;
width: 260px;
z-index: 1;
.filters-applied {
padding: 0 1rem 1rem;
}
h1,
form {
padding: 0 1rem;
}
input {
background: $grayer;
color: $white;
@include placeholder {
color: $gray-light;
}
}
.search-action {
position: absolute;
right: .7rem;
top: .7rem;
}
&.ng-hide-add {
transform: translateX(0);
transition-duration: .5s;
}
&.ng-hide-add-active {
transform: translateX(-260px);
}
&.ng-hide-remove {
transform: translateX(-260px);
transition-duration: .5s;
}
&.ng-hide-remove-active {
transform: translateX(0);
}
}
.filter-list {
display: none;
overflow-y: auto;
padding: 1rem;
}
.filters-step-cat {
margin-top: 2rem;
}
.filters-cats {
ul {
margin-bottom: 0;
}
li {
border-bottom: 1px solid $gray-light;
text-transform: uppercase;
&.selected {
border-bottom: 0;
}
}
.custom-filters {
.title {
color: $primary;
}
}
.filters-cat-single {
align-items: center;
color: $grayer;
display: flex;
justify-content: space-between;
padding: .5rem .5rem .5rem 1.5rem;
transition: color .2s ease-in;
&:hover,
&.selected {
background-color: rgba(darken($whitish, 20%), 1);
color: $grayer;
transition: background-color .2s ease-in;
.icon {
opacity: 1;
transition: opacity .2s ease-in;
}
}
}
.icon-arrow-down {
fill: currentColor;
float: right;
height: .9rem;
opacity: 0;
transition: opacity .2s ease-in;
width: .9rem;
}
}
.single-filter {
@include font-type(text);
@include clearfix;
align-items: center;
background: darken($whitish, 10%); // Fallback
cursor: pointer;
display: flex;
justify-content: space-between;
margin-bottom: .5rem;
opacity: .5;
padding-right: .5rem;
position: relative;
&:hover {
color: $grayer;
opacity: 1;
transition: opacity .2s linear;
}
&.selected,
&.active {
color: $grayer;
opacity: 1;
transition: opacity .2s linear;
}
.name,
.number {
padding: 8px 10px;
}
.name {
@include ellipsis(100%);
display: block;
width: 100%;
}
.number {
background: darken($whitish, 20%); // Fallback
position: absolute;
right: 0;
top: 0;
}
.remove-filter {
display: block;
svg {
fill: $gray;
transition: fill .2s linear;
}
&:hover {
svg {
fill: $red;
}
}
}
}

View File

@ -138,7 +138,7 @@ class JoyRideService extends taiga.Service
if @checkPermissionsService.check('add_us') if @checkPermissionsService.check('add_us')
steps.push({ steps.push({
element: '.icon-plus', element: '.add-action',
position: 'bottom', position: 'bottom',
joyride: { joyride: {
title: @translate.instant('JOYRIDE.KANBAN.STEP3.TITLE') title: @translate.instant('JOYRIDE.KANBAN.STEP3.TITLE')

View File

@ -0,0 +1,69 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: kanban-board-zoom.directive.coffee
###
KanbanBoardZoomDirective = (storage, projectService) ->
link = (scope, el, attrs, ctrl) ->
scope.zoomIndex = storage.get("kanban_zoom") or 2
scope.levels = 5
zooms = [
["ref"],
["subject"],
["owner", "tags", "extra_info", "unfold"],
["attachments"],
["related_tasks"]
]
getZoomView = (zoomIndex = 0) ->
if storage.get("kanban_zoom") != zoomIndex
storage.set("kanban_zoom", zoomIndex)
return _.reduce zooms, (result, value, key) ->
if key <= zoomIndex
result = result.concat(value)
return result
scope.$watch 'zoomIndex', (zoomLevel) ->
zoom = getZoomView(zoomLevel)
scope.onZoomChange({zoomLevel: zoomLevel, zoom: zoom})
unwatch = scope.$watch () ->
return projectService.project
, (project) ->
if project
if project.get('my_permissions').indexOf("view_tasks") == -1
scope.levels = 4
unwatch()
return {
scope: {
onZoomChange: "&"
},
template: """
<tg-board-zoom
class="board-zoom"
value="zoomIndex"
levels="levels"
></tg-board-zoom>
""",
link: link
}
angular.module('taigaComponents').directive("tgKanbanBoardZoom", ["$tgStorage", "tgProjectService", KanbanBoardZoomDirective])

View File

@ -0,0 +1,62 @@
###
# Copyright (C) 2014-2016 Taiga Agile LLC <taiga@taiga.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# File: taskboard-zoom.directive.coffee
###
TaskboardZoomDirective = (storage) ->
link = (scope, el, attrs, ctrl) ->
scope.zoomIndex = storage.get("taskboard_zoom") or 2
scope.levels = 4
zooms = [
["ref"],
["subject"],
["owner", "tags", "extra_info", "unfold"],
["attachments"],
["related_tasks"]
]
getZoomView = (zoomIndex = 0) ->
if storage.get("taskboard_zoom") != zoomIndex
storage.set("taskboard_zoom", zoomIndex)
return _.reduce zooms, (result, value, key) ->
if key <= zoomIndex
result = result.concat(value)
return result
scope.$watch 'zoomIndex', (zoomLevel) ->
zoom = getZoomView(zoomLevel)
scope.onZoomChange({zoomLevel: zoomLevel, zoom: zoom})
return {
scope: {
onZoomChange: "&"
},
template: """
<tg-board-zoom
levels="levels"
class="board-zoom"
value="zoomIndex"
></tg-board-zoom>
""",
link: link
}
angular.module('taigaComponents').directive("tgTaskboardZoom", ["$tgStorage", TaskboardZoomDirective])

View File

@ -3,8 +3,21 @@ doctype html
div.wrapper(tg-backlog, ng-controller="BacklogController as ctrl", div.wrapper(tg-backlog, ng-controller="BacklogController as ctrl",
ng-init="section='backlog'") ng-init="section='backlog'")
tg-project-menu tg-project-menu
sidebar.menu-secondary.extrabar.filters-bar(tg-backlog-filters)
include ../includes/modules/backlog-filters sidebar.backlog-filter
tg-filter(
q="ctrl.filterQ"
filters="ctrl.filters"
custom-filters="ctrl.customFilters"
selected-filters="ctrl.selectedFilters"
customFilters="ctl.customFilters"
on-save-custom-filter="ctrl.saveCustomFilter(name)"
on-add-filter="ctrl.addFilter(filter)"
on-select-custom-filter="ctrl.selectCustomFilter(filter)"
on-remove-custom-filter="ctrl.removeCustomFilter(filter)"
on-remove-filter="ctrl.removeFilter(filter)"
on-change-q="ctrl.changeQ(q)"
)
section.main.backlog section.main.backlog
include ../includes/components/mainTitle include ../includes/components/mainTitle
@ -39,13 +52,20 @@ div.wrapper(tg-backlog, ng-controller="BacklogController as ctrl",
) )
tg-svg(svg-icon="icon-move") tg-svg(svg-icon="icon-move")
span.text(translate="BACKLOG.MOVE_US_TO_LATEST_SPRINT") span.text(translate="BACKLOG.MOVE_US_TO_LATEST_SPRINT")
a.trans-button( a.trans-button.e2e-open-filter(
ng-if="userstories.length" ng-if="!ctrl.activeFilters"
href="" href=""
title="{{'BACKLOG.FILTERS.TOGGLE' | translate}}" title="{{'BACKLOG.FILTERS.TOGGLE' | translate}}"
id="show-filters-button" id="show-filters-button"
translate="BACKLOG.FILTERS.SHOW" translate="BACKLOG.FILTERS.SHOW"
) )
a.trans-button.active.e2e-open-filter(
ng-if="ctrl.activeFilters"
href=""
title="{{'BACKLOG.FILTERS.HIDE' | translate}}"
id="show-filters-button"
translate="BACKLOG.FILTERS.HIDE"
)
a.trans-button( a.trans-button(
ng-if="userstories.length" ng-if="userstories.length"
href="" href=""

View File

@ -1,9 +0,0 @@
<% _.each(filters, function(f) { %>
.single-filter.selected(
data-type!="<%- f.type %>"
data-id!="<%- f.id %>"
)
span.name(style!="<%- f.style %>") <%- f.name %>
a.remove-filter(href="")
tg-svg(svg-icon="icon-close")
<% }) %>

View File

@ -1,17 +0,0 @@
<% _.each(filters, function(f) { %>
<% if (f.selected) { %>
a.single-filter.active(data-type!="<%- f.type %>", data-id!="<%- f.id %>")
span.name(style!="<%- f.style %>")
| <%- f.name %>
<% if (f.count){ %>
span.number <%- f.count %>
<% } %>
<% } else { %>
a.single-filter(data-type!="<%- f.type %>", data-id!="<%- f.id %>")
span.name(style!="<%- f.style %>")
| <%- f.name %>
<% if (f.count){ %>
span.number <%- f.count %>
<% } %>
<% } %>
<% }) %>

View File

@ -1,18 +0,0 @@
div.taskboard-tagline(tg-colorize-tags="task.tags", tg-colorize-tags-type="taskboard")
div.taskboard-task-inner
div.taskboard-user-avatar(tg-taskboard-user-avatar, users="usersById", task="task", project="project")
tg-svg.iocaine(
ng-if="task.is_iocaine"
svg-icon="icon-iocaine",
svg-title="{{'COMMON.IOCAINE_TEXT' | translate}}"
)
p.taskboard-text
a.task-assigned(href="", title="{{'TASKBOARD.TITLE_ACTION_ASSIGN' | translate}}")
span.task-num(tg-bo-ref="task.ref")
a.task-name(href="", title="#{{ ::task.ref }} {{ ::task.subject }}", ng-bind="task.subject",
tg-nav="project-tasks-detail:project=project.slug,ref=task.ref")
tg-svg.edit-task(
tg-check-permission="modify_task"
svg-icon="icon-edit",
svg-title-translate="TASKBOARD.TITLE_ACTION_EDIT"
)

View File

@ -1,36 +0,0 @@
section.filters
div.filters-inner
h1
span.title(translate="COMMON.FILTERS.TITLE")
form
fieldset
input(type="text", placeholder="{{'COMMON.FILTERS.INPUT_PLACEHOLDER' | translate}}", ng-model="filtersQ")
tg-svg.search-action(
svg-icon="icon-search",
title="{{'COMMON.FILTERS.TITLE_ACTION_FILTER_BUTTON' | translate}}"
)
div.filters-step-cat
div.filters-applied
h2.hidden.breadcrumb
a.back(
href=""
title="{{'COMMON.FILTERS.BREADCRUMB_TITLE' | translate}}"
translate="BACKLOG.FILTERS.TITLE"
)
tg-svg(svg-icon="icon-arrow-right")
a.subfilter(href="")
span.title(translate="COMMON.FILTERS.BREADCRUMB_STATUS")
div.filters-cats
ul
li
a(href="", title="{{'BACKLOG.FILTERS.FILTER_CATEGORY_STATUS' | translate}}", data-type="status")
span.title(translate="BACKLOG.FILTERS.FILTER_CATEGORY_STATUS")
tg-svg(svg-icon="icon-arrow-right")
li
a(href="", title="{{'BACKLOG.FILTERS.FILTER_CATEGORY_TAGS' | translate}}", data-type="tags")
span.title(translate="BACKLOG.FILTERS.FILTER_CATEGORY_TAGS")
tg-svg(svg-icon="icon-arrow-right")
div.filter-list.hidden

View File

@ -1,84 +0,0 @@
section.filters
div.filters-inner
h1
span.title(translate="ISSUES.FILTERS.TITLE")
form
fieldset
input(type="text", placeholder="{{'ISSUES.FILTERS.INPUT_SEARCH_PLACEHOLDER' | translate}}",
ng-model="filtersQ")
tg-svg.search-action(svg-icon="icon-search", title="{{'ISSUES.FILTERS.TITLE_ACTION_SEARCH' | translate}}")
div.filters-step-cat
div.filters-applied
a.hide.button.button-gray.save-filters(href="", title="{{'COMMON.SAVE' | translate}}", ng-class="{hide: filters.length}", translate="ISSUES.FILTERS.ACTION_SAVE_CUSTOM_FILTER")
h2.hidden.breadcrumb
a.back(href="", title="{{'ISSUES.FILTERS.TITLE_BREADCRUMB' | translate}}", translate="ISSUES.FILTERS.BREADCRUMB")
tg-svg(svg-icon="icon-arrow-right")
a.subfilter(href="", title="cat-name")
span.title(translate="COMMON.FILTERS.BREADCRUMB_STATUS")
div.filters-cats
ul
li
a.filters-cat-single(
href=""
title="{{ 'ISSUES.FILTERS.CATEGORIES.TYPE' | translate}}"
data-type="types"
)
span.title(translate="ISSUES.FILTERS.CATEGORIES.TYPE")
tg-svg(svg-icon="icon-arrow-right")
li
a.filters-cat-single(
href=""
title="{{ 'ISSUES.FILTERS.CATEGORIES.STATUS' | translate}}"
data-type="status"
)
span.title(translate="ISSUES.FILTERS.CATEGORIES.STATUS")
tg-svg(svg-icon="icon-arrow-right")
li
a.filters-cat-single(
href=""
title="{{ 'ISSUES.FILTERS.CATEGORIES.SEVERITY' | translate}}"
data-type="severities"
)
span.title(translate="ISSUES.FILTERS.CATEGORIES.SEVERITY")
tg-svg(svg-icon="icon-arrow-right")
li
a.filters-cat-single(
href=""
title="{{ 'ISSUES.FILTERS.CATEGORIES.PRIORITIES' | translate}}"
data-type="priorities"
)
span.title(translate="ISSUES.FILTERS.CATEGORIES.PRIORITIES")
tg-svg(svg-icon="icon-arrow-right")
li
a.filters-cat-single(
href=""
title="{{ 'ISSUES.FILTERS.CATEGORIES.TAGS' | translate}}"
data-type="tags"
)
span.title(translate="ISSUES.FILTERS.CATEGORIES.TAGS")
tg-svg(svg-icon="icon-arrow-right")
li
a.filters-cat-single(href=""
title="{{ 'ISSUES.FILTERS.CATEGORIES.ASSIGNED_TO' | translate}}"
data-type="assignedTo"
)
span.title(translate="ISSUES.FILTERS.CATEGORIES.ASSIGNED_TO")
tg-svg(svg-icon="icon-arrow-right")
li
a.filters-cat-single(
href=""
title="{{ 'ISSUES.FILTERS.CATEGORIES.CREATED_BY' | translate}}"
data-type="createdBy"
)
span.title(translate="ISSUES.FILTERS.CATEGORIES.CREATED_BY")
tg-svg(svg-icon="icon-arrow-right")
li.custom-filters(ng-if="filters.myFilters.length")
a.filters-cat-single(
href=""
title="{{ 'ISSUES.FILTERS.CATEGORIES.CUSTOM_FILTERS' | translate}}"
data-type="myFilters"
)
span.title(translate="ISSUES.FILTERS.CATEGORIES.CUSTOM_FILTERS")
tg-svg(svg-icon="icon-arrow-right")
div.filter-list.hidden

View File

@ -1,13 +1,21 @@
section.issues-table.basic-table(ng-class="{empty: !issues.length}") section.issues-table.basic-table(ng-class="{empty: !issues.length}")
div.row.title div.row.title
div.level-field(data-fieldname="type", translate="ISSUES.TABLE.COLUMNS.TYPE") div.level-field(data-fieldname="type")
div.level-field(data-fieldname="severity", translate="ISSUES.TABLE.COLUMNS.SEVERITY") | {{"ISSUES.TABLE.COLUMNS.TYPE" | translate}}
div.level-field(data-fieldname="priority", translate="ISSUES.TABLE.COLUMNS.PRIORITY") div.level-field(data-fieldname="severity")
div.votes(data-fieldname="total_voters", translate="ISSUES.TABLE.COLUMNS.VOTES") | {{"ISSUES.TABLE.COLUMNS.SEVERITY" | translate}}
div.subject(data-fieldname="subject", translate="ISSUES.TABLE.COLUMNS.SUBJECT") div.level-field(data-fieldname="priority")
div.issue-field(data-fieldname="status", translate="ISSUES.TABLE.COLUMNS.STATUS") | {{"ISSUES.TABLE.COLUMNS.PRIORITY" | translate}}
div.created-field(data-fieldname="created_date", translate="ISSUES.TABLE.COLUMNS.CREATED") div.votes(data-fieldname="total_voters")
div.assigned-field(data-fieldname="assigned_to", translate="ISSUES.TABLE.COLUMNS.ASSIGNED_TO") | {{"ISSUES.TABLE.COLUMNS.VOTES" | translate}}
div.subject(data-fieldname="subject")
| {{"ISSUES.TABLE.COLUMNS.SUBJECT" | translate}}
div.issue-field(data-fieldname="status")
| {{"ISSUES.TABLE.COLUMNS.STATUS" | translate}}
div.created-field(data-fieldname="created_date")
| {{"ISSUES.TABLE.COLUMNS.CREATED" | translate}}
div.assigned-field(data-fieldname="assigned_to")
| {{"ISSUES.TABLE.COLUMNS.ASSIGNED_TO" | translate}}
div.row.table-main( div.row.table-main(
ng-repeat="issue in issues track by issue.id" ng-repeat="issue in issues track by issue.id"

View File

@ -1,8 +1,13 @@
div.kanban-table(tg-kanban-squish-column, tg-kanban-sortable) div.kanban-table(
tg-kanban-squish-column,
tg-kanban-sortable,
ng-class="{'zoom-0': ctrl.zoomLevel == 0}"
)
div.kanban-table-header div.kanban-table-header
div.kanban-table-inner div.kanban-table-inner
h2.task-colum-name(ng-repeat="s in usStatusList track by s.id", h2.task-colum-name(ng-repeat="s in usStatusList track by s.id",
ng-style="{'border-top-color':s.color}", tg-bo-title="s.name", ng-style="{'border-top-color':s.color}",
tg-bo-title="s.name",
ng-class='{vfold:folds[s.id]}', ng-class='{vfold:folds[s.id]}',
tg-class-permission="{'readonly': '!modify_task'}") tg-class-permission="{'readonly': '!modify_task'}")
span(tg-bo-bind="s.name") span(tg-bo-bind="s.name")
@ -21,21 +26,6 @@ div.kanban-table(tg-kanban-squish-column, tg-kanban-sortable)
ng-class='{hidden:!folds[s.id]}' ng-class='{hidden:!folds[s.id]}'
) )
tg-svg(svg-icon="icon-unfold-column") tg-svg(svg-icon="icon-unfold-column")
a.option(
href=""
title="{{'KANBAN.TITLE_ACTION_FOLD_CARDS' | translate}}"
ng-class="{hidden:statusViewModes[s.id] == 'minimized'}"
ng-click="ctrl.updateStatusViewMode(s.id, 'minimized')"
)
tg-svg.fold-action(svg-icon="icon-fold-row")
a.option(
href=""
title="{{'KANBAN.TITLE_ACTION_UNFOLD_CARDS' | translate}}"
ng-class="{hidden:statusViewModes[s.id] == 'maximized'}"
ng-click="ctrl.updateStatusViewMode(s.id, 'maximized')"
)
tg-svg.fold-action(svg-icon="icon-unfold-row")
a.option( a.option(
href="" href=""
title="{{'KANBAN.TITLE_ACTION_ADD_US' | translate}}" title="{{'KANBAN.TITLE_ACTION_ADD_US' | translate}}"
@ -65,18 +55,29 @@ div.kanban-table(tg-kanban-squish-column, tg-kanban-sortable)
div.kanban-table-body div.kanban-table-body
div.kanban-table-inner div.kanban-table-inner
div.kanban-uses-box.task-column(ng-class='{vfold:folds[s.id]}', div.kanban-uses-box.task-column(ng-class='{vfold:folds[s.id]}',
ng-repeat="s in usStatusList track by s.id", ng-repeat="s in ::usStatusList track by s.id",
tg-kanban-wip-limit="s", tg-kanban-wip-limit="s",
tg-kanban-column-height-fixer, tg-kanban-column-height-fixer,
tg-bind-scope tg-bind-scope
) )
div.kanban-task( .card-placeholder(
ng-repeat="us in usByStatus[s.id] track by us.id", ng-if="ctrl.showPlaceHolder(s.id)"
tg-kanban-userstory, ng-include="'common/components/kanban-placeholder.html'"
ng-model="us",
tg-bind-scope,
tg-class-permission="{'readonly': '!modify_task'}"
ng-class="{'kanban-task-maximized': ctrl.isMaximized(s.id), 'kanban-task-minimized': ctrl.isMinimized(s.id), 'card-placeholder': us.isPlaceholder}"
placeholder="{{us.isPlaceholder}}"
) )
tg-card.card.ng-animate-disabled(
tg-repeat="us in usByStatus.get(s.id.toString()) track by us.getIn(['model', 'id'])",
ng-class="{'kanban-task-maximized': ctrl.isMaximized(s.id), 'kanban-task-minimized': ctrl.isMinimized(s.id)}"
tg-class-permission="{'readonly': '!modify_task'}"
tg-bind-scope,
on-toggle-fold="ctrl.toggleFold(id)"
on-click-edit="ctrl.editUs(id)"
on-click-assigned-to="ctrl.changeUsAssignedTo(id)"
project="project"
item="us"
zoom="ctrl.zoom"
zoom-level="ctrl.zoomLevel"
archived="ctrl.isUsInArchivedHiddenStatus(us.get('id'))"
)
div.kanban-column-intro(ng-if="s.is_archived", tg-kanban-archived-status-intro="s") div.kanban-column-intro(ng-if="s.is_archived", tg-kanban-archived-status-intro="s")

View File

@ -1,8 +1,18 @@
div.taskboard-table(tg-taskboard-squish-column, tg-taskboard-sortable) div.taskboard-table(
tg-taskboard-squish-column,
tg-taskboard-sortable,
ng-class="{'zoom-0': ctrl.zoomLevel == 0}"
)
div.taskboard-table-header div.taskboard-table-header
div.taskboard-table-inner div.taskboard-table-inner
h2.task-colum-name(translate="TASKBOARD.TABLE.COLUMN") h2.task-colum-name(translate="TASKBOARD.TABLE.COLUMN")
h2.task-colum-name(ng-repeat="s in taskStatusList track by s.id", ng-style="{'border-top-color':s.color}", ng-class="{'column-fold':statusesFolded[s.id]}", class="squish-status-{{s.id}}", tg-bo-title="s.name") h2.task-colum-name(
ng-repeat="s in ::taskStatusList track by s.id"
ng-style="{'border-top-color':s.color}"
ng-class="{'column-fold':statusesFolded[s.id]}"
class="squish-status-{{s.id}}"
tg-bo-title="s.name"
)
span(tg-bo-bind="s.name") span(tg-bo-bind="s.name")
tg-svg.hfold.fold-action( tg-svg.hfold.fold-action(
@ -49,20 +59,31 @@ div.taskboard-table(tg-taskboard-squish-column, tg-taskboard-sortable)
span(translate="TASKBOARD.TABLE.FIELD_POINTS") span(translate="TASKBOARD.TABLE.FIELD_POINTS")
include ../components/addnewtask include ../components/addnewtask
div.taskboard-tasks-box.task-column(ng-repeat="st in taskStatusList track by st.id", class="squish-status-{{st.id}}", ng-class="{'column-fold':statusesFolded[st.id]}", tg-bind-scope)
div.taskboard-task( div.taskboard-tasks-box.task-column(
ng-repeat="task in usTasks[us.id][st.id] track by task.id" ng-repeat="st in ::taskStatusList track by st.id",
class="squish-status-{{st.id}}",
ng-class="{'column-fold':statusesFolded[st.id]}",
tg-bind-scope tg-bind-scope
tg-class-permission="{'readonly': '!modify_task'}"
ng-class="{'card-placeholder': task.isPlaceholder}"
) )
div(ng-if="!task.isPlaceholder", tg-taskboard-task) .card-placeholder(
include ../components/taskboard-task ng-if="ctrl.showPlaceHolder(st.id, us.id)"
ng-include="'common/components/taskboard-placeholder.html'"
div(ng-if="task.isPlaceholder") )
- var card = 'task' tg-card.card.ng-animate-disabled(
include ../../common/components/taskboard-placeholder tg-repeat="task in usTasks.getIn([us.id.toString(), st.id.toString()]) track by task.get('id')"
ng-class="{'kanban-task-maximized': ctrl.isMaximized(s.id), 'kanban-task-minimized': ctrl.isMinimized(s.id)}"
tg-class-permission="{'readonly': '!modify_task'}"
tg-bind-scope,
on-toggle-fold="ctrl.toggleFold(id)"
on-click-edit="ctrl.editTask(id)"
on-click-assigned-to="ctrl.changeTaskAssignedTo(id)"
project="project"
item="task"
zoom="ctrl.zoom"
zoom-level="ctrl.zoomLevel"
type="task"
)
div.task-row(ng-init="us = null", ng-class="{'row-fold':usFolded[null]}") div.task-row(ng-init="us = null", ng-class="{'row-fold':usFolded[null]}")
div.taskboard-userstory-box.task-column div.taskboard-userstory-box.task-column
a.vfold( a.vfold(
@ -82,15 +103,29 @@ div.taskboard-table(tg-taskboard-squish-column, tg-taskboard-sortable)
h3.us-title h3.us-title
span(translate="TASKBOARD.TABLE.ROW_UNASSIGED_TASKS_TITLE") span(translate="TASKBOARD.TABLE.ROW_UNASSIGED_TASKS_TITLE")
include ../components/addnewtask.jade include ../components/addnewtask.jade
div.taskboard-tasks-box.task-column(ng-repeat="st in taskStatusList track by st.id", class="squish-status-{{st.id}}", ng-class="{'column-fold':statusesFolded[st.id]}", tg-bind-scope)
div.taskboard-task(
ng-repeat="task in usTasks[null][st.id] track by task.id"
tg-bind-scope
tg-class-permission="{'readonly': '!modify_task'}"
ng-class="{'card-placeholder': task.isPlaceholder}"
)
div(ng-if="!task.isPlaceholder", tg-taskboard-task)
include ../components/taskboard-task
div(ng-if="task.isPlaceholder") div.taskboard-tasks-box.task-column(
include ../../common/components/taskboard-placeholder ng-repeat="st in ::taskStatusList track by st.id",
class="squish-status-{{st.id}}",
ng-class="{'column-fold':statusesFolded[st.id]}",
tg-bind-scope
)
.card-placeholder(
ng-if="ctrl.showPlaceHolder(st.id, us.id)"
ng-include="'common/components/taskboard-placeholder.html'"
)
tg-card.card.ng-animate-disabled(
tg-bind-scope,
tg-repeat="task in usTasks.getIn(['null', st.id.toString()]) track by task.get('id')"
ng-class="{'kanban-task-maximized': ctrl.isMaximized(s.id), 'kanban-task-minimized': ctrl.isMinimized(s.id)}"
tg-class-permission="{'readonly': '!modify_task'}"
on-toggle-fold="ctrl.toggleFold(id)"
on-click-edit="ctrl.editTask(id)"
on-click-assigned-to="ctrl.changeTaskAssignedTo(id)"
project="project"
item="task"
zoom="ctrl.zoom"
zoom-level="ctrl.zoomLevel"
type="task"
)

View File

@ -1,9 +0,0 @@
<% _.each(filters, function(f) { %>
.single-filter.selected(
data-type!="<%- f.type %>"
data-id!="<%- f.id %>"
)
span.name(style!="<%- f.style %>") <%- f.name %>
a.remove-filter(href="")
tg-svg(svg-icon="icon-close")
<% }) %>

View File

@ -1,21 +0,0 @@
<% _.each(filters, function(f) { %>
<% if (!f.selected) { %>
.single-filter(
data-type!="<%- f.type %>"
data-id!="<%- f.id %>"
)
span.name(style!="<%- f.style %>") <%- f.name %>
<% if (f.count){ %>
span.number <%- f.count %>
<% } %>
<% if (f.type == "myFilters"){ %>
a.remove-filter(href="")
tg-svg(svg-icon="icon-trash")
<% } %>
<% } %>
<% }) %>
span(class="new")
input.hidden.my-filter-name(
type="text"
placeholder="{{'ISSUES.PLACEHOLDER_FILTER_NAME' | translate}}"
)

View File

@ -6,8 +6,20 @@ div.wrapper.issues.lightbox-generic-form(
ng-init="section='issues'" ng-init="section='issues'"
) )
tg-project-menu tg-project-menu
sidebar.menu-secondary.extrabar.filters-bar(tg-issues-filters) sidebar.filters-bar
include ../includes/modules/issues-filters tg-filter(
q="ctrl.filterQ"
filters="ctrl.filters"
custom-filters="ctrl.customFilters"
selected-filters="ctrl.selectedFilters"
customFilters="ctl.customFilters"
on-save-custom-filter="ctrl.saveCustomFilter(name)"
on-add-filter="ctrl.addFilter(filter)"
on-select-custom-filter="ctrl.selectCustomFilter(filter)"
on-remove-custom-filter="ctrl.removeCustomFilter(filter)"
on-remove-filter="ctrl.removeFilter(filter)"
on-change-q="ctrl.changeQ(q)"
)
section.main.issues-page section.main.issues-page
header header

View File

@ -1,33 +0,0 @@
div.kanban-tagline(
tg-colorize-tags="us.tags"
tg-colorize-tags-type="kanban"
ng-hide="us.isArchived"
)
div.kanban-task-inner(ng-class="{'task-archived': us.isArchived}")
div.avatar-wrapper(tg-kanban-user-avatar="us.assigned_to", ng-model="us", ng-hide="us.isArchived")
div.task-text(ng-hide="us.isArchived")
a.task-assigned(href="", title="{{'US.ASSIGN' | translate}}")
span.task-num(tg-bo-ref="us.ref")
a.task-name(href="", title="#{{ ::us.ref }} {{ us.subject }}", ng-bind="us.subject",
tg-nav="project-userstories-detail:project=project.slug,ref=us.ref",
tg-nav-get-params="{\"kanban-status\": {{us.status}}}")
p.task-points(href="", title="{{'US.TOTAL_US_POINTS' | translate}}")
span(ng-if="us.total_points !== null", ng-bind="us.total_points")
span.points-text(ng-if="us.total_points !== null", translate="COMMON.FIELDS.POINTS")
span(ng-if="us.total_points === null", translate="US.NOT_ESTIMATED")
div.task-archived-text(ng-show="us.isArchived")
p(translate="KANBAN.ARCHIVED")
p
span.task-num(tg-bo-ref="us.ref")
span.task-name(ng-bind="us.subject")
p(translate="KANBAN.UNDO_ARCHIVED")
a.edit-us(
href="",
title="{{'COMMON.EDIT' | translate}}",
tg-check-permission="modify_us",
ng-hide="us.isArchived"
)
tg-svg(svg-icon="icon-edit")

View File

@ -5,7 +5,35 @@ div.wrapper(tg-kanban, ng-controller="KanbanController as ctrl"
tg-project-menu tg-project-menu
section.main.kanban section.main.kanban
tg-filter(
ng-show="ctrl.openFilter"
q="ctrl.filterQ"
filters="ctrl.filters"
custom-filters="ctrl.customFilters"
selected-filters="ctrl.selectedFilters"
customFilters="ctl.customFilters"
on-save-custom-filter="ctrl.saveCustomFilter(name)"
on-add-filter="ctrl.addFilter(filter)"
on-select-custom-filter="ctrl.selectCustomFilter(filter)"
on-remove-custom-filter="ctrl.removeCustomFilter(filter)"
on-remove-filter="ctrl.removeFilter(filter)"
on-change-q="ctrl.changeQ(q)"
)
.kanban-header
include ../includes/components/mainTitle include ../includes/components/mainTitle
.taskboard-actions
tg-kanban-board-zoom(
ng-if="usByStatus.size",
on-zoom-change="ctrl.setZoom(zoomLevel, zoom)"
)
button.button-filter.e2e-open-filter(
ng-class="{'button-filters-applied': !!ctrl.selectedFilters.length}"
ng-click="ctrl.openFilter = !ctrl.openFilter"
)
tg-svg(svg-icon="icon-filters")
include ../includes/modules/kanban-table include ../includes/modules/kanban-table
div.lightbox.lightbox-generic-form.lb-create-edit-userstory(tg-lb-create-edit-userstory) div.lightbox.lightbox-generic-form.lb-create-edit-userstory(tg-lb-create-edit-userstory)

View File

@ -4,11 +4,38 @@ div.wrapper(tg-taskboard, ng-controller="TaskboardController as ctrl",
ng-init="section='backlog'") ng-init="section='backlog'")
tg-project-menu tg-project-menu
section.main.taskboard section.main.taskboard
.taskboard-inner tg-filter(
ng-show="ctrl.openFilter"
q="ctrl.filterQ"
filters="ctrl.filters"
custom-filters="ctrl.customFilters"
selected-filters="ctrl.selectedFilters"
customFilters="ctl.customFilters"
on-save-custom-filter="ctrl.saveCustomFilter(name)"
on-add-filter="ctrl.addFilter(filter)"
on-select-custom-filter="ctrl.selectCustomFilter(filter)"
on-remove-custom-filter="ctrl.removeCustomFilter(filter)"
on-remove-filter="ctrl.removeFilter(filter)"
on-change-q="ctrl.changeQ(q)"
)
.taskboard-header
h1 h1
span(tg-bo-bind="project.name", class="project-name-short") span(tg-bo-bind="project.name", class="project-name-short")
span.green(tg-bo-bind="sprint.name") span.green(tg-bo-bind="sprint.name")
span.date(tg-date-range="sprint.estimated_start,sprint.estimated_finish") span.date(tg-date-range="sprint.estimated_start,sprint.estimated_finish")
.taskboard-actions
tg-taskboard-zoom(
ng-if="usTasks.size",
on-zoom-change="ctrl.setZoom(zoomLevel, zoom)"
)
button.button-filter.e2e-open-filter(
ng-class="{'button-filters-applied': !!ctrl.selectedFilters.length}"
ng-click="ctrl.openFilter = !ctrl.openFilter"
)
tg-svg(svg-icon="icon-filters")
.taskboard-inner
include ../includes/components/sprint-summary include ../includes/components/sprint-summary
div.graphics-container div.graphics-container

View File

@ -155,3 +155,14 @@ a.button-gray {
display: inline-block; display: inline-block;
margin-top: .5rem; margin-top: .5rem;
} }
.button-filter {
@extend %button;
background: $whitish;
margin-left: 1rem;
padding: .4rem .5rem;
&:hover {
background: $gray-light;
fill: $whitish;
}
}

View File

@ -1,50 +0,0 @@
.single-filter {
@include font-type(text);
@include clearfix;
align-items: center;
background: darken($whitish, 10%); // Fallback
display: flex;
justify-content: space-between;
margin-bottom: .5rem;
opacity: .5;
padding-right: .5rem;
position: relative;
&:hover {
color: $grayer;
opacity: 1;
transition: opacity .2s linear;
}
&.selected,
&.active {
color: $grayer;
opacity: 1;
transition: opacity .2s linear;
}
.name,
.number {
padding: 8px 10px;
}
.name {
@include ellipsis(100%);
display: block;
width: 100%;
}
.number {
background: darken($whitish, 20%); // Fallback
position: absolute;
right: 0;
top: 0;
}
.remove-filter {
display: block;
svg {
fill: $gray;
transition: fill .2s linear;
}
&:hover {
svg {
fill: $red;
}
}
}
}

View File

@ -1,224 +0,0 @@
.kanban-task {
background: $card;
border: 1px solid $card-hover;
box-shadow: none;
cursor: move;
margin: .2rem;
position: relative;
&:last-child {
margin-bottom: 0;
}
&:hover {
.edit-us {
display: block;
fill: $card-dark;
opacity: 1;
transition: color .3s linear, opacity .3s linear;
}
}
&.gu-mirror {
box-shadow: 1px 1px 15px rgba($black, .4);
opacity: 1;
transition: box-shadow .3s linear;
}
&.blocked {
background: $red;
border: 1px solid darken($red, 10%);
color: $white;
a,
span {
color: $white;
}
}
&.card-placeholder {
background: darken($whitish, 2%);
border: 3px dashed darken($whitish, 8%);
cursor: default;
}
.kanban-tagline {
border-color: $card-hover;
display: flex;
height: .6rem;
}
.kanban-tag {
border-top: .3rem solid $card-hover;
flex-basis: 0;
flex-grow: 1;
height: .6rem;
z-index: 90;
}
.kanban-task-inner {
display: flex;
padding: .5rem;
}
.avatar-wrapper {
flex-basis: 55px;
flex-grow: 0;
flex-shrink: 0;
width: 55px;
img {
width: 100%;
}
}
.avatar {
a {
@include font-size(small);
text-align: center;
}
img {
margin: 0 auto;
&:hover {
border: 2px solid $primary;
transition: border .3s linear;
}
}
}
.task-text {
@include font-size(small);
flex-grow: 1;
padding: 0 .5rem 0 .8rem;
}
.task-assigned {
color: $card-dark;
display: block;
}
.task-num {
color: $grayer;
margin-right: .3rem;
}
.task-name {
@include font-type(bold);
}
.loading {
bottom: .5rem;
position: absolute;
}
.edit-us {
display: block;
opacity: 0;
position: absolute;
svg {
@include svg-size(1.1rem);
fill: $card-hover;
}
&:hover {
cursor: pointer;
svg {
fill: darken($card-hover, 15%);
transition: color .3s linear;
}
}
}
}
.kanban-task-maximized {
.task-archived {
background: darken($whitish, 5%);
padding: .5rem;
text-align: left;
transition: background .3s linear;
&:hover {
background: darken($whitish, 8%);
transition: background .3s linear;
}
.task-archived-text {
flex: 1;
}
span {
color: $gray-light;
}
p {
@include font-size(small);
color: $gray-light;
margin: 0;
&:last-child {
color: $gray;
margin: .5rem 0;
text-align: center;
}
}
}
.task-name {
word-wrap: break-word;
}
.loading,
.edit-us {
bottom: .2rem;
right: .5rem;
}
.task-points {
@include font-size(small);
color: darken($card-hover, 15%);
margin: 0;
span {
display: inline-block;
&:first-child {
padding-right: .2rem;
}
}
.points-text {
text-transform: lowercase;
}
}
.kanban-tag {
border-top: .3rem solid;
}
}
.kanban-task-minimized {
.kanban-task-inner {
padding: 0 .3rem;
}
.task-archived {
@include font-size(small);
background: darken($whitish, 5%);
padding: .3rem;
text-align: left;
.task-archived-text {
flex: 1;
}
span {
color: $gray-light;
}
.task-name {
display: inline-block;
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
p {
color: $gray-light;
margin: 0;
&:last-child {
display: none;
}
}
}
.task-num {
vertical-align: top;
}
.task-name {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 135px;
}
.task-points {
display: none;
}
.icon-edit {
bottom: .2rem;
right: 1rem;
top: 1.4rem;
}
.kanban-tag {
border-top: .2rem solid;
}
.edit-us {
bottom: .2rem;
right: .5rem;
}
}

View File

@ -1,140 +0,0 @@
.taskboard-task {
background: $card;
border: 1px solid $card-hover;
box-shadow: none;
cursor: move;
margin: .2rem;
position: relative;
&:hover {
.icon-edit {
display: block;
fill: $card-dark;
opacity: 1;
transition: color .3s linear, opacity .3s linear;
}
}
&.gu-mirror {
box-shadow: 1px 1px 15px rgba($black, .4);
transition: box-shadow .3s linear;
}
.blocked {
background: $red;
border: 1px solid darken($red, 10%);
color: $white;
svg,
span {
color: $white;
fill: $white;
}
&:hover {
.icon-edit {
fill: currentColor;
}
}
}
&.card-placeholder {
background: darken($whitish, 2%);
border: 3px dashed darken($whitish, 8%);
cursor: default;
}
.taskboard-tagline {
border-color: $card-hover;
display: flex;
height: .6rem;
}
.taskboard-tag {
border-top: .3rem solid $card-hover;
flex-basis: 0;
flex-grow: 1;
height: .6rem;
z-index: 90;
}
.taskboard-task-inner {
display: flex;
padding: .5rem;
}
.taskboard-user-avatar {
flex-basis: 50px;
flex-grow: 1;
max-width: 55px;
a {
@include font-size(small);
display: block;
text-align: center;
}
img {
margin: 0 auto;
&:hover {
border: 2px solid $primary;
transition: border .3s linear;
}
}
}
.iocaine {
left: .2rem;
position: absolute;
top: 1rem;
img {
filter: hue-rotate(150deg) saturate(200%);
}
}
.icon-iocaine {
background: $black;
border-radius: 5px;
fill: $white;
height: 1.75rem;
padding: .25rem;
width: 1.75rem;
}
.task-assigned {
@include font-size(small);
color: $card-dark;
display: block;
&:hover {
color: $primary;
}
}
.task-num {
color: $grayer;
margin-right: .5em;
}
.task-name {
@include font-type(bold);
}
.taskboard-text {
@include font-size(small);
flex-basis: 50px;
flex-grow: 10;
padding: 0 .5rem 0 1rem;
word-wrap: break-word;
}
.icon {
transition: color .3s linear, opacity .3s linear;
}
.loading {
bottom: .5rem;
position: absolute;
}
.edit-task {
bottom: .5rem;
position: absolute;
top: auto;
}
.icon-edit {
@include svg-size(1.1rem);
cursor: pointer;
fill: $card-hover;
opacity: 0;
&:hover {
fill: $card-dark;
}
}
.icon-edit,
.loading {
right: 1rem;
}
}
.task-drag {
@include box-shadow();
}

View File

@ -56,21 +56,6 @@ body {
min-width: 0; min-width: 0;
padding: 1rem; padding: 1rem;
width: 320px; width: 320px;
&.filters-bar {
flex: 0 0 auto;
padding: 0;
transition: all .2s linear;
width: 0;
&.active {
padding: 2em 1em;
transition: all .2s linear;
width: 260px;
.filters-inner {
opacity: 1;
transition: all .4s ease-in;
}
}
}
.search-in { .search-in {
margin-top: .5rem; margin-top: .5rem;
} }

View File

@ -1,2 +1,2 @@
$navbar: 40px; $navbar: 40px;
$main-height: calc(100vh - 40px); $main-height: calc(100vh - #{$navbar});

View File

@ -1,3 +1,24 @@
.backlog-filter {
align-items: stretch;
display: flex;
opacity: 0;
overflow: hidden;
position: relative;
transition: all .2s linear;
width: 0;
tg-filter {
transform: translateX(-260px);
transition: all .2s linear;
}
&.active {
opacity: 1;
transition: all .2s linear;
width: 260px;
tg-filter {
transform: translateX(0);
}
}
}
.backlog-menu { .backlog-menu {
background: $mass-white; background: $mass-white;
color: $blackish; color: $blackish;

View File

@ -1,10 +1,6 @@
.issues { .issues {
.filters-bar { .filters-bar {
flex: 0 0 auto; position: relative;
width: 260px; width: 260px;
} }
.filters-inner {
opacity: 1;
padding: 1rem;
}
} }

View File

@ -4,6 +4,7 @@
height: $main-height; height: $main-height;
max-height: $main-height; max-height: $main-height;
max-width: calc(100vw - 50px); max-width: calc(100vw - 50px);
position: relative;
header { header {
min-height: 70px; min-height: 70px;
} }
@ -14,3 +15,12 @@
display: none; display: none;
} }
} }
.kanban-header {
align-items: center;
display: flex;
justify-content: space-between;
.options {
display: flex;
}
}

View File

@ -1,6 +1,7 @@
.taskboard { .taskboard {
height: $main-height; height: $main-height;
overflow: hidden; overflow: hidden;
position: relative;
h1, h1,
.graphics-container, .graphics-container,
.summary { .summary {
@ -11,6 +12,12 @@
} }
} }
.taskboard-header {
align-items: center;
display: flex;
justify-content: space-between;
}
.taskboard-inner { .taskboard-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -3,27 +3,29 @@
$column-width: 300px; $column-width: 300px;
$column-flex: 1; $column-flex: 1;
$column-shrink: 0; $column-shrink: 0;
$column-margin: 0 10px 0 0; $column-margin: 0 5px 0 0;
$column-padding: .5rem 1rem;
@mixin fold { @mixin fold {
.taskboard-task { .card {
background: none; align-self: flex-start;
border: 0; margin-top: .5rem;
margin: 0; tg-card-slideshow,
min-height: 0; .card-unfold,
.taskboard-task-inner { .card-tag,
padding: .1rem; .card-title,
} .card-owner-actions,
.taskboard-tagline, .card-data,
.taskboard-text { .card-statistics,
.card-owner-name {
display: none; display: none;
} }
.avatar { .card-owner {
height: 35px; img {
width: 35px; height: 1.3rem;
margin-right: 0;
width: 1.3rem;
} }
.icon {
display: none;
} }
} }
&.task-column, &.task-column,
@ -44,25 +46,20 @@ $column-margin: 0 10px 0 0;
.taskboard-table { .taskboard-table {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
.taskboard-task { &.zoom-0 {
&.readonly { .task-colum-name span {
cursor: auto; padding-right: 1rem;
}
&.gu-mirror {
opacity: 1;
.avatar-task-link {
display: none;
}
} }
} }
} }
.taskboard-table-header { .taskboard-table-header {
margin-bottom: .5rem; flex-basis: 38px;
min-height: 40px; flex-grow: 0;
flex-shrink: 0;
min-height: 38px;
position: relative; position: relative;
width: 100%; width: 100%;
.taskboard-table-inner { .taskboard-table-inner {
@ -83,7 +80,7 @@ $column-margin: 0 10px 0 0;
justify-content: space-between; justify-content: space-between;
margin: $column-margin; margin: $column-margin;
max-width: $column-width; max-width: $column-width;
padding: .5rem 1rem; padding: $column-padding;
position: relative; position: relative;
text-transform: uppercase; text-transform: uppercase;
width: $column-width; width: $column-width;
@ -102,6 +99,9 @@ $column-margin: 0 10px 0 0;
margin: 0; margin: 0;
} }
} }
span {
@include ellipsis(65%);
}
} }
tg-svg { tg-svg {
display: block; display: block;
@ -128,7 +128,8 @@ $column-margin: 0 10px 0 0;
} }
.taskboard-table-body { .taskboard-table-body {
height: 100%; flex: 1;
margin-bottom: 5rem;
overflow: auto; overflow: auto;
width: 100%; width: 100%;
.task-column { .task-column {
@ -147,14 +148,10 @@ $column-margin: 0 10px 0 0;
} }
.column-fold { .column-fold {
@include fold; @include fold;
.taskboard-task {
max-width: 40px;
width: 40px;
}
} }
.task-row { .task-row {
display: flex; display: flex;
margin-bottom: .5rem; margin-bottom: .25rem;
min-height: 10rem; min-height: 10rem;
width: 100%; width: 100%;
&.blocked { &.blocked {
@ -167,6 +164,7 @@ $column-margin: 0 10px 0 0;
.points-value, .points-value,
.points-value:hover { .points-value:hover {
color: $white; color: $white;
fill: $white;
transition: color .3s linear; transition: color .3s linear;
} }
.taskboard-tasks-box { .taskboard-tasks-box {
@ -185,18 +183,26 @@ $column-margin: 0 10px 0 0;
} }
} }
} }
.taskboard-userstory-box { .taskboard-userstory-box {
padding: .5rem .5rem .5rem 1.5rem; padding: .5rem .5rem .5rem 1.5rem;
} }
.avatar-task-link {
display: none;
} }
.avatar-assigned-to {
display: block; .taskboard-userstory-box {
position: relative;
.us-title {
@include font-size(normal);
@include font-type(text);
margin-bottom: 0;
margin-right: 3rem;
}
.points-value {
@include font-size(small);
color: $gray-light;
span {
margin-right: .1rem;
} }
.icon {
transition: fill .2s linear;
} }
tg-svg { tg-svg {
cursor: pointer; cursor: pointer;
@ -219,20 +225,3 @@ $column-margin: 0 10px 0 0;
} }
} }
} }
.taskboard-userstory-box {
position: relative;
.us-title {
@include font-size(normal);
@include font-type(text);
margin-bottom: 0;
margin-right: 3rem;
}
.points-value {
@include font-size(small);
color: $gray-light;
span {
margin-right: .1rem;
}
}
}

View File

@ -1,114 +0,0 @@
.filters {
h1 {
vertical-align: baseline;
.icon {
margin: 0;
}
a {
vertical-align: baseline;
}
}
.breadcrumb {
@include font-size(large);
margin-top: 1rem;
.icon-arrow-right {
@include svg-size(.7rem);
margin: 0 .25rem;
vertical-align: middle;
}
.back {
color: $gray-light;
}
}
input {
background: $grayer;
color: $white;
@include placeholder {
color: $gray-light;
}
}
.search-action {
position: absolute;
right: .7rem;
top: .7rem;
}
}
.filters-inner {
opacity: 0;
transition: all .1s ease-in;
.loading {
margin: 0;
padding: 8px;
text-align: center;
width: 100%;
.loading-spinner {
@include loading-spinner;
max-height: 1rem;
max-width: 1rem;
}
}
}
.filters-applied {
margin-top: .5rem;
}
.filters-step-cat {
.save-filters {
color: $white;
display: block;
text-align: center;
}
.my-filter-name {
background: $grayer;
color: $whitish;
width: 100%;
@include placeholder {
color: $gray-light;
}
}
}
.filter-list {
.single-filter {
cursor: pointer;
}
}
.filters-cats {
margin-top: 2rem;
li {
border-bottom: 1px solid $gray-light;
text-transform: uppercase;
}
.custom-filters {
.title {
color: $primary;
}
}
a {
align-items: center;
color: $grayer;
display: flex;
justify-content: space-between;
padding: .5rem 0 .5rem .5rem;
transition: color .2s ease-in;
&:hover {
color: $primary;
transition: color .2s ease-in;
.icon {
opacity: 1;
transition: opacity .2s ease-in;
}
}
}
.icon {
fill: currentColor;
float: right;
height: .9rem;
opacity: 0;
transition: opacity .2s ease-in;
width: .9rem;
}
}

View File

@ -1,10 +1,11 @@
//Table basic shared vars //Table basic shared vars
$column-width: 300px; $column-width: 296px;
$column-folded-width: 30px; $column-folded-width: 30px;
$column-flex: 0; $column-flex: 0;
$column-shrink: 0; $column-shrink: 0;
$column-margin: 0 10px 0 0; $column-margin: 0 5px 0 0;
$column-padding: .5rem 1rem;
.kanban-table { .kanban-table {
display: flex; display: flex;
@ -12,7 +13,19 @@ $column-margin: 0 10px 0 0;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
&.zoom-0 {
.task-column,
.task-colum-name {
max-width: $column-width / 2;
}
.task-colum-name span {
padding-right: 1rem;
}
}
.vfold { .vfold {
tg-card {
display: none;
}
&.task-colum-name { &.task-colum-name {
align-items: center; align-items: center;
display: flex; display: flex;
@ -36,9 +49,6 @@ $column-margin: 0 10px 0 0;
min-width: $column-folded-width; min-width: $column-folded-width;
width: $column-folded-width; width: $column-folded-width;
} }
.kanban-task {
display: none;
}
.kanban-column-intro { .kanban-column-intro {
display: none; display: none;
} }
@ -46,11 +56,11 @@ $column-margin: 0 10px 0 0;
.readonly { .readonly {
cursor: auto; cursor: auto;
} }
} }
.kanban-table-header { .kanban-table-header {
margin-bottom: .5rem; min-height: 38px;
min-height: 40px;
position: relative; position: relative;
width: 100%; width: 100%;
.kanban-table-inner { .kanban-table-inner {
@ -58,6 +68,9 @@ $column-margin: 0 10px 0 0;
overflow: hidden; overflow: hidden;
position: absolute; position: absolute;
} }
.options {
display: flex;
}
.task-colum-name { .task-colum-name {
@include font-size(medium); @include font-size(medium);
align-items: center; align-items: center;
@ -71,7 +84,7 @@ $column-margin: 0 10px 0 0;
justify-content: space-between; justify-content: space-between;
margin: $column-margin; margin: $column-margin;
max-width: $column-width; max-width: $column-width;
padding: .5rem .5rem .5rem 1rem; padding: $column-padding;
position: relative; position: relative;
text-transform: uppercase; text-transform: uppercase;
&:last-child { &:last-child {
@ -110,6 +123,7 @@ $column-margin: 0 10px 0 0;
max-width: $column-width; max-width: $column-width;
overflow-y: auto; overflow-y: auto;
widows: $column-width; widows: $column-width;
width: $column-width;
&:last-child { &:last-child {
margin-right: 0; margin-right: 0;
} }

View File

@ -28,14 +28,8 @@ a[ng-click] svg {
} }
// chrome url break // chrome url break
.kanban-task { tg-card {
.task-name { .card-title span:last-child {
word-break: break-word;
}
}
.taskboard-task {
.task-name {
word-break: break-word; word-break: break-word;
} }
} }

View File

@ -429,5 +429,14 @@
fill="#fff" fill="#fff"
d="M511.998 107.939c-222.856 0-404.061 181.204-404.061 404.061s181.205 404.061 404.061 404.061c222.856 0 404.061-181.203 404.061-404.061s-181.205-404.061-404.061-404.061zM511.998 158.447c88.671 0 169.621 32.484 231.616 86.222l-498.947 498.948c-53.74-61.998-86.223-142.945-86.223-231.617 0-195.561 157.992-353.553 353.553-353.553zM779.328 280.383c53.74 61.998 86.223 142.945 86.223 231.617 0 195.561-157.992 353.553-353.553 353.553-88.671 0-169.617-32.484-231.616-86.222l498.947-498.948z"></path> d="M511.998 107.939c-222.856 0-404.061 181.204-404.061 404.061s181.205 404.061 404.061 404.061c222.856 0 404.061-181.203 404.061-404.061s-181.205-404.061-404.061-404.061zM511.998 158.447c88.671 0 169.621 32.484 231.616 86.222l-498.947 498.948c-53.74-61.998-86.223-142.945-86.223-231.617 0-195.561 157.992-353.553 353.553-353.553zM779.328 280.383c53.74 61.998 86.223 142.945 86.223 231.617 0 195.561-157.992 353.553-353.553 353.553-88.671 0-169.617-32.484-231.616-86.222l498.947-498.948z"></path>
</symbol> </symbol>
<symbol id="icon-add-user" viewBox="0 0 470 350">
<title>Add user</title>
<path
d="M298.5 174.7c47.5 0 86-37.7 86-85.2 0-46.6-39.3-85-87-85-46.5 0-85 38.4-85 85 0 47.5 38.5 85.2 85 85.2zm-191-42V68H63v64.6H0v42h62.8v64.6h44.8v-64.5h62.7v-42h-62.7zm191 85c-56.4 0-170.3 28.7-170.3 85.2v43H469v-43c0-56.8-113-85.5-170.5-85.5z"/>
</symbol>
<symbol id="icon-view-more" viewBox="0 0 66.3 16">
<title>View more</title>
<path d="M16 8a8 8 0 0 1-8 8 8 8 0 0 1-8-8 8 8 0 0 1 8-8 8 8 0 0 1 8 8zM41.2 8a8 8 0 0 1-8 8 8 8 0 0 1-8-8 8 8 0 0 1 8-8 8 8 0 0 1 8 8zM66.3 8a8 8 0 0 1-8 8 8 8 0 0 1-8-8 8 8 0 0 1 8-8 8 8 0 0 1 8 8z"/>
</symbol>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -0,0 +1,81 @@
var utils = require('../utils');
var helper = module.exports;
helper.getFilter = function() {
return $('tg-filter');
};
helper.open = async function() {
let isPresent = await $('.e2e-open-filter').isPresent();
if(isPresent) {
$('.e2e-open-filter').click();
} else {
return;
}
var filter = helper.getFilter();
return utils.common.transitionend('.e2e-open-filter')
};
helper.byText = function(text) {
return $('.e2e-filter-q').sendKeys(text);
};
helper.clearByTextInput = function() {
return utils.common.clear($('.e2e-filter-q'));
};
helper.clearFilters = async function() {
let filters = $$('.e2e-remove-filter');
let filtersSize = await filters.count()
for(var i = 0; i < filtersSize; i++) {
filters.get(i).click();
}
await helper.clearByTextInput();
let isPresent = await $('.e2e-category.selected').isPresent();
if(isPresent) {
$('.e2e-category.selected').click();
}
};
helper.getFiltersCounters = function() {
return $$('.e2e-filter-count');
};
helper.getCustomFilters = function() {
return $$('.e2e-custom-filter');
};
helper.firterByLastCustomFilter = function() {
helper.openCustomFiltersCategory();
helper.getCustomFilters().last().click();
};
helper.openCustomFiltersCategory = function() {
$('.e2e-custom-filters').click();
};
helper.removeLastCustomFilter = function() {
$$('.e2e-remove-custom-filter').last().click();
}
helper.firterByCategoryWithContent = function() {
$$('.e2e-category').first().click();
let filter = helper.getFiltersCounters().first().element(by.xpath('..'));
return filter.click();
};
helper.saveFilter = async function(name) {
$('.e2e-open-custom-filter-form').click();
await $('.e2e-filter-name-input').sendKeys(name);
await $('.e2e-filter-name-input').sendKeys(protractor.Key.ENTER);
};

View File

@ -90,57 +90,3 @@ helper.parseIssue = async function(elm) {
return obj; return obj;
}; };
helper.getFilterInput = function() {
return $$('sidebar[tg-issues-filters] input').get(0);
};
helper.filtersCats = function() {
return $$('.filters-cats li');
};
helper.filtersList = function() {
return $$('.filter-list .single-filter');
};
helper.selectFilter = async function(index) {
helper.filtersList().get(index).click();
};
helper.saveFilter = async function(name) {
$('.filters-step-cat .save-filters').click();
await $('.filter-list input').sendKeys(name);
return browser.actions().sendKeys(protractor.Key.ENTER).perform();
};
helper.backToFilters = function() {
$$('.breadcrumb a').get(0).click();
};
helper.removeFilters = async function() {
let count = await $$('.filters-applied .single-filter.selected').count();
while(count) {
$$('.single-filter.selected').get(0).$('.remove-filter').click();
count = await $$('.single-filter.selected').count();
}
};
helper.getCustomFilters = function() {
return $$('.filter-list div[data-type="myFilters"]');
};
helper.removeCustomFilters = async function() {
let count = await $$('.filter-list .remove-filter').count();
while(count) {
$$('.filter-list .remove-filter').get(0).click();
await utils.lightbox.confirm.ok();
count = await $$('.filter-list .remove-filter').count();
}
};

View File

@ -15,15 +15,31 @@ helper.getColumns = function() {
}; };
helper.getColumnUssTitles = function(column) { helper.getColumnUssTitles = function(column) {
return helper.getColumns().$$('.task-name').getText(); return helper.getColumns().$$('.e2e-title').getText();
}; };
helper.getBoxUss = function(column) { helper.getBoxUss = function(column) {
return helper.getColumns().get(column).$$('.kanban-task'); return helper.getColumns().get(column).$$('tg-card');
}; };
helper.editUs = function(column, us) { helper.getUss = function() {
helper.getColumns().get(column).$$('.edit-us').get(us).click(); return $$('tg-card')
};
helper.editUs = async function(column, us) {
let editionZone = helper.getColumns().get(column).$$('.card-owner-actions').get(us);
await browser
.actions()
.mouseMove(editionZone)
.perform();
return browser
.actions()
.mouseMove(editionZone)
.mouseMove(editionZone.$('.e2e-edit'))
.click()
.perform();
}; };
helper.openBulkUsLb = function(column) { helper.openBulkUsLb = function(column) {
@ -59,5 +75,13 @@ helper.scrollRight = function() {
}; };
helper.watchersLinks = function() { helper.watchersLinks = function() {
return $$('.task-assigned'); return $$('.e2e-assign');
};
helper.zoom = async function(level) {
return browser
.actions()
.mouseMove($('tg-board-zoom'), {y: 14, x: level * 49})
.click()
.perform();
}; };

View File

@ -13,7 +13,11 @@ helper.getBox = function(row, column) {
helper.getBoxTasks = function(row, column) { helper.getBoxTasks = function(row, column) {
let box = helper.getBox(row, column); let box = helper.getBox(row, column);
return box.$$('.taskboard-task'); return box.$$('tg-card');
};
helper.getTasks = function() {
return $$('tg-card');
}; };
helper.openNewTaskLb = function(row) { helper.openNewTaskLb = function(row) {
@ -52,8 +56,20 @@ helper.unFoldColumn = function(row) {
icon.click(); icon.click();
}; };
helper.editTask = function(row, column, task) { helper.editTask = async function(row, column, task) {
helper.getBoxTasks(row, column).get(task).$('.edit-task').click(); let editionZone = helper.getBoxTasks(row, column).$$('.card-owner-actions').get(task);
await browser
.actions()
.mouseMove(editionZone)
.perform();
return browser
.actions()
.mouseMove(editionZone)
.mouseMove(editionZone.$('.e2e-edit'))
.click()
.perform();
}; };
helper.toggleGraph = function() { helper.toggleGraph = function() {
@ -114,5 +130,13 @@ helper.getBulkCreateTask = function() {
}; };
helper.watchersLinks = function() { helper.watchersLinks = function() {
return $$('.task-assigned'); return $$('.e2e-assign');
};
helper.zoom = async function(level) {
return browser
.actions()
.mouseMove($('tg-board-zoom'), {y: 10, x: level * 74})
.click()
.perform();
}; };

76
e2e/shared/filters.js Normal file
View File

@ -0,0 +1,76 @@
var filterHelper = require('../helpers/filters-helper');
var utils = require('../utils');
var chai = require('chai');
var chaiAsPromised = require('chai-as-promised');
chai.use(chaiAsPromised);
var expect = chai.expect;
module.exports = function(name, counter) {
before(async () => {
await filterHelper.open();
utils.common.takeScreenshot(name, 'filters');
});
it('filter by ref', async () => {
await filterHelper.byText('xxxxyy123123123');
let len = await counter();
len = await counter();
await filterHelper.clearFilters();
expect(len).to.be.equal(0);
});
it('filter by category', async () => {
let len = await counter();
await filterHelper.firterByCategoryWithContent();
let newLength = await counter();
expect(len).to.be.above(newLength);
await filterHelper.clearFilters();
newLength = await counter();
expect(len).to.be.equal(newLength);
});
it('save custom filters', async () => {
let len = await counter();
filterHelper.openCustomFiltersCategory();
let customFiltersSize = await filterHelper.getCustomFilters().count();
await filterHelper.firterByCategoryWithContent();
await filterHelper.saveFilter("custom-filter");
await filterHelper.clearFilters();
await filterHelper.firterByLastCustomFilter();
let newLength = await counter();
let newCustomFiltersSize = await filterHelper.getCustomFilters().count();
expect(newLength).to.be.below(len);
expect(newCustomFiltersSize).to.be.equal(customFiltersSize + 1);
await filterHelper.clearFilters();
});
it('remove custom filters', async () => {
filterHelper.openCustomFiltersCategory();
let customFiltersSize = await filterHelper.getCustomFilters().count();
filterHelper.removeLastCustomFilter();
let newCustomFiltersSize = await filterHelper.getCustomFilters().count();
expect(newCustomFiltersSize).to.be.equal(customFiltersSize - 1);
});
};

View File

@ -5,6 +5,8 @@ var commonHelper = require('../helpers').common;
var chai = require('chai'); var chai = require('chai');
var chaiAsPromised = require('chai-as-promised'); var chaiAsPromised = require('chai-as-promised');
var sharedFilters = require('../shared/filters');
chai.use(chaiAsPromised); chai.use(chaiAsPromised);
var expect = chai.expect; var expect = chai.expect;
@ -243,7 +245,7 @@ describe('backlog', function() {
expect(elementRef1).to.be.equal(draggedRefs[1]); expect(elementRef1).to.be.equal(draggedRefs[1]);
}); });
it.only('drag multiple us to milestone', async function() { it('drag multiple us to milestone', async function() {
let sprint = backlogHelper.sprints().get(0); let sprint = backlogHelper.sprints().get(0);
let initUssSprintCount = await backlogHelper.getSprintUsertories(sprint).count(); let initUssSprintCount = await backlogHelper.getSprintUsertories(sprint).count();
@ -453,143 +455,9 @@ describe('backlog', function() {
}); });
}); });
describe('filters', function() { describe('backlog filters', sharedFilters.bind(this, 'backlog', () => {
it('show filters', async function() { return backlogHelper.userStories().count();
let transition = utils.common.transitionend('.menu-secondary.filters-bar', 'opacity'); }));
$('#show-filters-button').click();
await transition();
utils.common.takeScreenshot('backlog', 'backlog-filters');
});
it('filter by subject', async function() {
let usCount = await backlogHelper.userStories().count();
let filterQ = element(by.model('filtersQ'));
let htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body');
await filterQ.sendKeys('add');
await htmlChanges();
let newUsCount = await backlogHelper.userStories().count();
expect(newUsCount).to.be.below(usCount);
htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body');
// clear status
await filterQ.clear();
await htmlChanges();
});
it('filter by ref', async function() {
let userstories = backlogHelper.userStories();
let filterQ = element(by.model('filtersQ'));
let htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body');
let ref = await backlogHelper.getTestingFilterRef();
ref = ref.replace('#', '');
await filterQ.sendKeys(ref);
await htmlChanges();
let newUsCount = await userstories.count();
expect(newUsCount).to.be.equal(1);
htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body');
// clear status
await filterQ.clear();
await htmlChanges();
});
it('filter by status', async function() {
let usCount = await backlogHelper.userStories().count();
let htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body');
$$('.filters-cats a').first().click();
$$('.filter-list a').first().click();
await htmlChanges();
let newUsCount = await backlogHelper.userStories().count();
expect(newUsCount).to.be.below(usCount);
//remove status
htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body');
$$('.filters-applied a').first().click();
await htmlChanges();
newUsCount = await backlogHelper.userStories().count();
expect(newUsCount).to.be.equal(usCount);
backlogHelper.goBackFilters();
});
it('filter by tags', async function() {
let usCount = await backlogHelper.userStories().count();
let htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body');
$$('.filters-cats a').get(1).click();
await browser.waitForAngular();
$$('.filter-list a').first().click();
await htmlChanges();
let newUsCount = await backlogHelper.userStories().count();
expect(newUsCount).to.be.below(usCount);
//remove tags
htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body');
$$('.filters-applied a').first().click();
await htmlChanges();
newUsCount = await backlogHelper.userStories().count();
expect(newUsCount).to.be.equal(usCount);
});
it('trying drag with filters open', async function() {
let dragableElements = backlogHelper.userStories();
let dragElement = dragableElements.get(5);
await utils.common.drag(dragElement, dragableElements.get(0));
let waitErrorOpen = await utils.notifications.error.open();
expect(waitErrorOpen).to.be.true;
await utils.notifications.error.close();
});
it('hide filters', async function() {
let menu = $('.menu-secondary.filters-bar');
let transition = utils.common.transitionend('.menu-secondary.filters-bar', 'width');
$('#show-filters-button').click();
await transition();
let waitWidth = await menu.getCssValue('width');
expect(waitWidth).to.be.equal('0px');
});
});
describe('closed sprints', function() { describe('closed sprints', function() {
async function createEmptyMilestone() { async function createEmptyMilestone() {

View File

@ -1,6 +1,7 @@
var utils = require('../../utils'); var utils = require('../../utils');
var issuesHelper = require('../../helpers').issues; var issuesHelper = require('../../helpers').issues;
var commonHelper = require('../../helpers').common; var commonHelper = require('../../helpers').common;
var sharedFilters = require('../../shared/filters');
var chai = require('chai'); var chai = require('chai');
var chaiAsPromised = require('chai-as-promised'); var chaiAsPromised = require('chai-as-promised');
@ -126,195 +127,7 @@ describe('issues list', function() {
expect(issueUserName).to.be.equal(newUserName); expect(issueUserName).to.be.equal(newUserName);
}); });
describe('filters', function() { describe('issues filters', sharedFilters.bind(this, 'issues', () => {
it('by ref', async function() { return issuesHelper.getIssues().count();
let table = issuesHelper.getTable(); }));
let issues = issuesHelper.getIssues();
let issue = issues.get(0);
issue = await issuesHelper.parseIssue(issue);
let filterInput = issuesHelper.getFilterInput();
let htmlChanges = await utils.common.outerHtmlChanges(table);
await filterInput.sendKeys(issue.ref);
await htmlChanges();
let newIssuesCount = await issues.count();
expect(newIssuesCount).to.be.equal(1);
htmlChanges = await utils.common.outerHtmlChanges(table);
await utils.common.clear(filterInput);
await htmlChanges();
});
it('by subject', async function() {
let table = issuesHelper.getTable();
let issues = issuesHelper.getIssues();
let issue = issues.get(0);
issue = await issuesHelper.parseIssue(issue);
let filterInput = issuesHelper.getFilterInput();
let oldIssuesCount = await $$('.row.table-main').count();
let htmlChanges = await utils.common.outerHtmlChanges(table);
await filterInput.sendKeys(issue.subject);
await htmlChanges();
let newIssuesCount = await issues.count();
expect(newIssuesCount).not.to.be.equal(oldIssuesCount);
expect(newIssuesCount).to.be.above(0);
htmlChanges = await utils.common.outerHtmlChanges(table);
await utils.common.clear(filterInput);
await htmlChanges();
});
it('by type', async function() {
let table = issuesHelper.getTable();
let htmlChanges = await utils.common.outerHtmlChanges(table);
issuesHelper.filtersCats().get(0).$('a').click();
issuesHelper.selectFilter(0);
await htmlChanges();
issuesHelper.backToFilters();
await issuesHelper.removeFilters();
});
it('by status', async function() {
let table = issuesHelper.getTable();
let htmlChanges = await utils.common.outerHtmlChanges(table);
issuesHelper.filtersCats().get(1).$('a').click();
issuesHelper.selectFilter(0);
await htmlChanges();
issuesHelper.backToFilters();
await issuesHelper.removeFilters();
});
it('by severity', async function() {
let table = issuesHelper.getTable();
let htmlChanges = await utils.common.outerHtmlChanges(table);
issuesHelper.filtersCats().get(2).$('a').click();
issuesHelper.selectFilter(0);
await htmlChanges();
issuesHelper.backToFilters();
await issuesHelper.removeFilters();
});
it('by priorities', async function() {
let table = issuesHelper.getTable();
let htmlChanges = await utils.common.outerHtmlChanges(table);
issuesHelper.filtersCats().get(3).$('a').click();
issuesHelper.selectFilter(0);
await htmlChanges();
issuesHelper.backToFilters();
await issuesHelper.removeFilters();
});
it('by tags', async function() {
let table = issuesHelper.getTable();
let htmlChanges = await utils.common.outerHtmlChanges(table);
issuesHelper.filtersCats().get(4).$('a').click();
issuesHelper.selectFilter(1);
await htmlChanges();
issuesHelper.backToFilters();
await issuesHelper.removeFilters();
});
it('by assigned to', async function() {
let table = issuesHelper.getTable();
let htmlChanges = await utils.common.outerHtmlChanges(table);
issuesHelper.filtersCats().get(5).$('a').click();
issuesHelper.selectFilter(0);
await htmlChanges();
issuesHelper.backToFilters();
await issuesHelper.removeFilters();
});
it('by created by', async function() {
let table = issuesHelper.getTable();
let htmlChanges = await utils.common.outerHtmlChanges(table);
issuesHelper.filtersCats().get(6).$('a').click();
issuesHelper.selectFilter(0);
await htmlChanges();
issuesHelper.backToFilters();
await issuesHelper.removeFilters();
});
it('empty', async function() {
let table = issuesHelper.getTable();
let htmlChanges = await utils.common.outerHtmlChanges(table);
let filterInput = issuesHelper.getFilterInput();
await filterInput.sendKeys(new Date().getTime());
await htmlChanges();
let newIssuesCount = await issuesHelper.getIssues().count();
expect(newIssuesCount).to.be.equal(0);
await utils.common.takeScreenshot('issues', 'empty-issues');
await utils.common.clear(filterInput);
});
it('save custom filter', async function() {
issuesHelper.filtersCats().get(1).$('a').click();
issuesHelper.selectFilter(0);
await browser.waitForAngular();
await issuesHelper.saveFilter('custom');
let customFilters = await issuesHelper.getCustomFilters().count();
expect(customFilters).to.be.equal(1);
await issuesHelper.removeFilters();
issuesHelper.backToFilters();
});
it('apply custom filter', async function() {
let table = issuesHelper.getTable();
let htmlChanges = await utils.common.outerHtmlChanges(table);
issuesHelper.filtersCats().get(7).$('a').click();
issuesHelper.selectFilter(0);
await htmlChanges();
await issuesHelper.removeFilters();
});
it('remove custom filter', async function() {
await issuesHelper.removeCustomFilters();
let customFilterCount = await issuesHelper.getCustomFilters().count();
expect(customFilterCount).to.be.equal(0);
});
});
}); });

View File

@ -2,6 +2,7 @@ var utils = require('../utils');
var kanbanHelper = require('../helpers').kanban; var kanbanHelper = require('../helpers').kanban;
var backlogHelper = require('../helpers').backlog; var backlogHelper = require('../helpers').backlog;
var commonHelper = require('../helpers').common; var commonHelper = require('../helpers').common;
var filterHelper = require('../helpers/filters-helper');
var chai = require('chai'); var chai = require('chai');
var chaiAsPromised = require('chai-as-promised'); var chaiAsPromised = require('chai-as-promised');
@ -18,6 +19,24 @@ describe('kanban', function() {
utils.common.takeScreenshot('kanban', 'kanban'); utils.common.takeScreenshot('kanban', 'kanban');
}); });
it('zoom', async function() {
kanbanHelper.zoom(1);
await browser.sleep(1000);
utils.common.takeScreenshot('kanban', 'zoom1');
kanbanHelper.zoom(2);
await browser.sleep(1000);
utils.common.takeScreenshot('kanban', 'zoom2');
kanbanHelper.zoom(3);
await browser.sleep(1000);
utils.common.takeScreenshot('kanban', 'zoom3');
kanbanHelper.zoom(4);
await browser.sleep(1000);
utils.common.takeScreenshot('kanban', 'zoom4');
});
describe('create us', function() { describe('create us', function() {
let createUSLightbox = null; let createUSLightbox = null;
let formFields = {}; let formFields = {};
@ -148,7 +167,6 @@ describe('kanban', function() {
await utils.lightbox.close(createUSLightbox.el); await utils.lightbox.close(createUSLightbox.el);
let ussTitles = await kanbanHelper.getColumnUssTitles(0); let ussTitles = await kanbanHelper.getColumnUssTitles(0);
let findSubject = ussTitles.indexOf(formFields.subject) !== -1; let findSubject = ussTitles.indexOf(formFields.subject) !== -1;
expect(findSubject).to.be.true; expect(findSubject).to.be.true;
@ -297,8 +315,12 @@ describe('kanban', function() {
await lightbox.waitClose(); await lightbox.waitClose();
let usAssignedTo = await kanbanHelper.getBoxUss(0).get(0).$('.task-assigned').getText(); let usAssignedTo = await kanbanHelper.getBoxUss(0).get(0).$('.card-owner-name').getText();
expect(assgnedToName).to.be.equal(usAssignedTo); expect(assgnedToName).to.be.equal(usAssignedTo);
}); });
describe('kanban filters', sharedFilters.bind(this, 'kanban', () => {
return kanbanHelper.getUss().count();
}));
}); });

View File

@ -2,6 +2,8 @@ var utils = require('../../utils');
var backlogHelper = require('../../helpers').backlog; var backlogHelper = require('../../helpers').backlog;
var taskboardHelper = require('../../helpers').taskboard; var taskboardHelper = require('../../helpers').taskboard;
var commonHelper = require('../../helpers').common; var commonHelper = require('../../helpers').common;
var filterHelper = require('../../helpers/filters-helper');
var sharedFilters = require('../../shared/filters');
var chai = require('chai'); var chai = require('chai');
var chaiAsPromised = require('chai-as-promised'); var chaiAsPromised = require('chai-as-promised');
@ -21,6 +23,24 @@ describe('taskboard', function() {
utils.common.takeScreenshot('taskboard', 'taskboard'); utils.common.takeScreenshot('taskboard', 'taskboard');
}); });
it('zoom', async function() {
taskboardHelper.zoom(1);
await browser.sleep(1000);
utils.common.takeScreenshot('taskboard', 'zoom1');
taskboardHelper.zoom(2);
await browser.sleep(1000);
utils.common.takeScreenshot('taskboard', 'zoom2');
taskboardHelper.zoom(3);
await browser.sleep(1000);
utils.common.takeScreenshot('taskboard', 'zoom3');
taskboardHelper.zoom(4);
await browser.sleep(1000);
utils.common.takeScreenshot('taskboard', 'zoom4');
});
describe('create task', function() { describe('create task', function() {
let createTaskLightbox = null; let createTaskLightbox = null;
let formFields = {}; let formFields = {};
@ -65,7 +85,7 @@ describe('taskboard', function() {
let tasks = taskboardHelper.getBoxTasks(0, 0); let tasks = taskboardHelper.getBoxTasks(0, 0);
let tasksSubject = await $$('.task-name').getText(); let tasksSubject = await $$('.e2e-title').getText();
let findSubject = tasksSubject.indexOf(formFields.subject) !== -1; let findSubject = tasksSubject.indexOf(formFields.subject) !== -1;
@ -111,7 +131,7 @@ describe('taskboard', function() {
let tasks = taskboardHelper.getBoxTasks(0, 0); let tasks = taskboardHelper.getBoxTasks(0, 0);
let tasksSubject = await $$('.task-name').getText(); let tasksSubject = await $$('.e2e-title').getText();
let findSubject = tasksSubject.indexOf(formFields.subject) !== 1; let findSubject = tasksSubject.indexOf(formFields.subject) !== 1;
@ -296,4 +316,8 @@ describe('taskboard', function() {
expect(open).to.be.false; expect(open).to.be.false;
}); });
}); });
describe('taskboard filters', sharedFilters.bind(this, 'taskboard', () => {
return taskboardHelper.getTasks().count();
}));
}); });

View File

@ -291,7 +291,11 @@ gulp.task("css-lint-app", function() {
return gulp.src(cssFiles) return gulp.src(cssFiles)
.pipe(gulpif(!isDeploy, cache(csslint("csslintrc.json"), { .pipe(gulpif(!isDeploy, cache(csslint("csslintrc.json"), {
success: function(csslintFile) { success: function(csslintFile) {
if (csslintFile.csslint) {
return csslintFile.csslint.success; return csslintFile.csslint.success;
} else {
return false;
}
}, },
value: function(csslintFile) { value: function(csslintFile) {
return { return {