Merge branch 'dev' into feature/api-work-package-description-formatting

pull/1550/head
Alex Coles 10 years ago
commit 0d40b49bf5
  1. 2
      .travis.yml
  2. 190
      app/assets/javascripts/angular/directives/components/selectable-title-directive.js
  3. 7
      app/assets/javascripts/angular/directives/components/with-dropdown.js
  4. 14
      app/assets/javascripts/angular/helpers/components/work-packages-helper.js
  5. 2
      app/assets/javascripts/angular/openproject-app.js
  6. 10
      app/assets/javascripts/angular/routing.js
  7. 81
      app/assets/javascripts/angular/work_packages/controllers/work-package-details-controller.js
  8. 7
      app/assets/javascripts/angular/work_packages/controllers/work-packages-list-controller.js
  9. 0
      app/assets/javascripts/angular/work_packages/directives/filter-clear-directive.js
  10. 0
      app/assets/javascripts/angular/work_packages/directives/lang-attribute-directive.js
  11. 0
      app/assets/javascripts/angular/work_packages/directives/options-dropdown-directive.js
  12. 0
      app/assets/javascripts/angular/work_packages/directives/query-columns-directive.js
  13. 0
      app/assets/javascripts/angular/work_packages/directives/query-filter-directive.js
  14. 0
      app/assets/javascripts/angular/work_packages/directives/query-filters-directive.js
  15. 0
      app/assets/javascripts/angular/work_packages/directives/query-form-directive.js
  16. 0
      app/assets/javascripts/angular/work_packages/directives/sort-header-directive.js
  17. 0
      app/assets/javascripts/angular/work_packages/directives/work-package-column-directive.js
  18. 0
      app/assets/javascripts/angular/work_packages/directives/work-package-group-header.js
  19. 0
      app/assets/javascripts/angular/work_packages/directives/work-package-group-sums-directive.js
  20. 0
      app/assets/javascripts/angular/work_packages/directives/work-package-row-directive.js
  21. 0
      app/assets/javascripts/angular/work_packages/directives/work-package-total-sums-directive.js
  22. 0
      app/assets/javascripts/angular/work_packages/directives/work-packages-table-directive.js
  23. 42
      app/assets/javascripts/angular/work_packages/tabs/panel-expander-directive.js
  24. 5
      app/assets/stylesheets/layout/_split_view.sass
  25. 11
      app/assets/stylesheets/layout/_toolbar.css.sass
  26. 27
      config/locales/js-de.yml
  27. 27
      config/locales/js-en.yml
  28. 18
      features/issues/issue_show.feature
  29. 4
      features/step_definitions/web_steps.rb
  30. 4
      karma/tests/controllers/work-packages-list-controller-test.js
  31. 56
      karma/tests/directives/components/selectable-title-test.js
  32. 47
      lib/api/v3/attachments/attachment_model.rb
  33. 75
      lib/api/v3/attachments/attachment_representer.rb
  34. 30
      lib/api/v3/attachments/attachments_api.rb
  35. 1
      lib/api/v3/root.rb
  36. 7
      lib/api/v3/work_packages/work_package_representer.rb
  37. 28
      public/templates/components/selectable_title.html
  38. 92
      public/templates/tabs/overview.html
  39. 3
      public/templates/work_packages.list.details.html
  40. 3
      public/templates/work_packages.list.html
  41. 2
      public/templates/work_packages/tabs/activity.html
  42. 0
      public/templates/work_packages/tabs/attachments.html
  43. 88
      public/templates/work_packages/tabs/overview.html
  44. 15
      public/templates/work_packages/tabs/panel_expander.html
  45. 0
      public/templates/work_packages/tabs/relations.html
  46. 2
      public/templates/work_packages/tabs/watchers.html
  47. 70
      spec/api/representers/attachment_representer_spec.rb

@ -29,6 +29,8 @@
language: ruby
rvm:
- 2.1
sudo: false
cache: bundler
branches:
only:
- dev

@ -29,27 +29,187 @@
// TODO move to UI components
angular.module('openproject.uiComponents')
.directive('selectableTitle', [function() {
.constant('LABEL_MAX_CHARS', 40)
.constant('KEY_CODES', {
enter: 13,
up: 38,
down: 40
})
.directive('selectableTitle', ['$sce', 'LABEL_MAX_CHARS', 'KEY_CODES', function($sce, LABEL_MAX_CHARS, KEY_CODES) {
return {
restrict: 'E',
replace: true,
scope: {
selectedTitle: '=',
reloadMethod: '=',
groups: '='
groups: '=',
transitionMethod: '='
},
templateUrl: '/templates/components/selectable_title.html',
link: function(scope) {
scope.$watch('groups', refreshFilteredGroups);
scope.$watch('selectedId', selectTitle);
function refreshFilteredGroups() {
if(scope.groups){
initFilteredModels();
}
}
function selectTitle() {
angular.forEach(scope.filteredGroups, function(group) {
if(group.models.length) {
angular.forEach(group.models, function(model){
model.highlighted = model.id == scope.selectedId;
});
}
});
}
function initFilteredModels() {
scope.filteredGroups = angular.copy(scope.groups);
angular.forEach(scope.filteredGroups, function(group) {
group.models = group.models.map(function(model){
return {
label: model[0],
labelHtml: $sce.trustAsHtml(truncate(model[0], LABEL_MAX_CHARS)),
id: model[1],
highlighted: false
}
});
});
}
angular.element('#title-filter').bind('click', function(event) {
function labelHtml(label, filterBy) {
filterBy = filterBy.toLowerCase();
label = truncate(label, LABEL_MAX_CHARS);
if(label.toLowerCase().indexOf(filterBy) >= 0) {
var labelHtml = label.substr(0, label.toLowerCase().indexOf(filterBy))
+ "<span class='filter-selection'>" + label.substr(label.toLowerCase().indexOf(filterBy), filterBy.length) + "</span>"
+ label.substr(label.toLowerCase().indexOf(filterBy) + filterBy.length);
} else {
var labelHtml = label;
}
return $sce.trustAsHtml(labelHtml);
}
function truncate(text, chars) {
if (text.length > chars) {
return text.substr(0, chars) + "...";
}
return text;
}
function modelIndex(models) {
return models.map(function(model){
return model.id;
}).indexOf(scope.selectedId);
}
function performSelect() {
scope.transitionMethod(scope.selectedId);
}
function nextNonEmptyGroup(groups, currentGroupIndex) {
currentGroupIndex = (currentGroupIndex == undefined) ? -1 : currentGroupIndex;
while(currentGroupIndex < groups.length - 1) {
if(groups[currentGroupIndex + 1].models.length) {
return groups[currentGroupIndex + 1];
}
currentGroupIndex = currentGroupIndex + 1;
}
return null;
}
function previousNonEmptyGroup(groups, currentGroupIndex) {
while(currentGroupIndex > 0) {
if(groups[currentGroupIndex - 1].models.length) {
return groups[currentGroupIndex - 1];
}
currentGroupIndex = currentGroupIndex - 1;
}
return null;
}
function getModelPosition(groups, selectedId) {
for(var group_index = 0; group_index < groups.length; group_index++) {
var models = groups[group_index].models;
var model_index = modelIndex(models);
if(model_index >= 0) {
return {
group: group_index,
model: model_index
};
}
}
return false;
}
function selectNext() {
var groups = scope.filteredGroups;
if(!scope.selectedId) {
var nextGroup = nextNonEmptyGroup(groups);
scope.selectedId = nextGroup ? nextGroup.models[0].id : 0;
} else {
var position = getModelPosition(groups, scope.selectedId);
if (!position) return;
var models = groups[position.group].models;
if(position.model == models.length - 1){ // It is the last in the group
var nextGroup = nextNonEmptyGroup(groups, position.group);
if(nextGroup) {
scope.selectedId = nextGroup.models[0].id;
}
} else {
scope.selectedId = models[position.model + 1].id;
}
}
}
function selectPrevious() {
var groups = scope.filteredGroups;
if(scope.selectedId) {
var position = getModelPosition(groups, scope.selectedId);
if (!position) return;
var models = groups[position.group].models;
if(position.model == 0){ // It is the last in the group
var previousGroup = previousNonEmptyGroup(groups, position.group);
if(previousGroup) {
scope.selectedId = previousGroup.models[previousGroup.models.length - 1].id;
}
} else {
scope.selectedId = models[position.model - 1].id;
}
}
}
function preventDefault(event) {
event.preventDefault();
event.stopPropagation();
}
angular.element('#title-filter').bind('click', function(event) {
preventDefault(event);
});
scope.handleSelection = function(event) {
switch(event.which) {
case KEY_CODES.enter:
performSelect();
preventDefault(event);
break;
case KEY_CODES.down:
selectNext();
preventDefault(event);
break;
case KEY_CODES.up:
selectPrevious();
preventDefault(event);
break;
default:
break;
}
};
scope.reload = function(modelId, newTitle) {
scope.selectedTitle = newTitle;
@ -58,12 +218,26 @@ angular.module('openproject.uiComponents')
};
scope.filterModels = function(filterBy) {
refreshFilteredGroups();
initFilteredModels();
scope.selectedId = 0;
angular.forEach(scope.filteredGroups, function(group) {
group.models = group.models.filter(function(model){
return model[0].toLowerCase().indexOf(filterBy.toLowerCase()) >= 0;
});
if(filterBy.length) {
group.filterBy = filterBy;
group.models = group.models.filter(function(model){
return model.label.toLowerCase().indexOf(filterBy.toLowerCase()) >= 0;
});
if(group.models.length) {
angular.forEach(group.models, function(model){
model['labelHtml'] = labelHtml(model.label, filterBy);
});
if(!scope.selectedId) {
group.models[0].highlighted = true;
scope.selectedId = group.models[0].id;
}
}
}
});
};
}

@ -60,7 +60,8 @@ angular.module('openproject.uiComponents')
return {
restrict: 'EA',
scope: {
dropdownId: '@'
dropdownId: '@',
focusElementId: '@'
},
link: function (scope, element, attributes) {
var dropdown = jQuery("#" + attributes.dropdownId),
@ -88,6 +89,10 @@ angular.module('openproject.uiComponents')
if (showDropdown) dropdown.show();
position(dropdown, trigger);
if(attributes.focusElementId) {
angular.element('#' + attributes.focusElementId).focus();
}
});
}
};

@ -106,6 +106,20 @@ angular.module('openproject.workPackages.helpers')
return value;
}
},
formatWorkPackageProperty: function(value, propertyName) {
var mappings = {
dueDate: 'date',
startDate: 'date',
createdAt: 'datetime',
updatedAt: 'datetime'
};
if (propertyName === 'estimatedTime') {
return value && value.value ? value.value + ' ' + value.units : null;
} else {
return this.formatValue(value, mappings[propertyName]);
}
},
parseDateTime: function(value) {
return new Date(Date.parse(value.replace(/(A|P)M$/, '')));

@ -67,6 +67,7 @@ angular.module('openproject.workPackages', [
'openproject.workPackages.controllers',
'openproject.workPackages.filters',
'openproject.workPackages.directives',
'openproject.workPackages.tabs',
'openproject.uiComponents',
'ng-context-menu'
]);
@ -92,6 +93,7 @@ angular.module('openproject.workPackages.directives', [
'openproject.services',
'openproject.workPackages.services'
]);
angular.module('openproject.workPackages.tabs', []);
// messages
angular.module('openproject.messages', ['openproject.messages.controllers']);

@ -57,23 +57,23 @@ angular.module('openproject')
})
.state('work-packages.list.details.overview', {
url: "/overview",
templateUrl: "/templates/tabs/overview.html",
templateUrl: "/templates/work_packages/tabs/overview.html",
})
.state('work-packages.list.details.activity', {
url: "/activity",
templateUrl: "/templates/tabs/activity.html",
templateUrl: "/templates/work_packages/tabs/activity.html",
})
.state('work-packages.list.details.relations', {
url: "/relations",
templateUrl: "/templates/tabs/relations.html",
templateUrl: "/templates/work_packages/tabs/relations.html",
})
.state('work-packages.list.details.watchers', {
url: "/watchers",
templateUrl: "/templates/tabs/watchers.html",
templateUrl: "/templates/work_packages/tabs/watchers.html",
})
.state('work-packages.list.details.attachments', {
url: "/attachments",
templateUrl: "/templates/tabs/attachments.html",
templateUrl: "/templates/work_packages/tabs/attachments.html",
});
}])

@ -28,16 +28,91 @@
angular.module('openproject.workPackages.controllers')
.constant('DEFAULT_WORK_PACKAGE_PROPERTIES', [
'status', 'assigneeName', 'responsibleName',
'date', 'percentageDone', 'priority',
'authorName', 'createdAt', 'dueDate',
'estimatedTime', 'startDate', 'updatedAt',
'versionName'
])
.controller('WorkPackageDetailsController', [
'$scope',
'workPackage',
function($scope, workPackage) {
'I18n',
'DEFAULT_WORK_PACKAGE_PROPERTIES',
'WorkPackagesHelper',
function($scope, workPackage, I18n, DEFAULT_WORK_PACKAGE_PROPERTIES, WorkPackagesHelper) {
// initialization
$scope.workPackage = workPackage;
$scope.$parent.preselectedWorkPackageId = $scope.workPackage.props.id;
$scope.maxDescriptionLength = 800;
// resources for tabs
$scope.activities = workPackage.embedded.activities;
$scope.latestActitivies = $scope.activities.reverse().slice(0, 3);
$scope.watchers = workPackage.embedded.watchers;
$scope.$parent.preselectedWorkPackageId = $scope.workPackage.props.id;
// work package properties
$scope.presentWorkPackageProperties = [];
$scope.emptyWorkPackageProperties = [];
var workPackageProperties = DEFAULT_WORK_PACKAGE_PROPERTIES;
function getFormattedPropertyValue(property) {
if (property === 'date') {
if (workPackage.props.startDate && workPackage.props.dueDate) {
return WorkPackagesHelper.formatWorkPackageProperty(workPackage.props['startDate'], 'startDate') +
' - ' +
WorkPackagesHelper.formatWorkPackageProperty(workPackage.props['dueDate'], 'dueDate');
}
} else {
return WorkPackagesHelper.formatWorkPackageProperty(workPackage.props[property], property);
}
}
function addFormattedValueToPresentProperties(property, label, value) {
$scope.presentWorkPackageProperties.push({
property: property,
label: label,
value: value || '-'
});
}
function secondRowToBeDisplayed() {
return !!workPackageProperties
.slice(3, 6)
.map(function(property) {
return workPackage.props[property];
})
.reduce(function(a, b) {
return a || b;
});
}
angular.forEach(workPackageProperties, function(property, index) {
var label = I18n.t('js.work_packages.properties.' + property),
value = getFormattedPropertyValue(property);
if (!!value ||
index < 3 ||
index < 6 && secondRowToBeDisplayed()) {
addFormattedValueToPresentProperties(property, label, value);
} else {
$scope.emptyWorkPackageProperties.push(label);
}
});
// toggles
$scope.toggleStates = {
hideFullDescription: true,
hideAllAttributes: true
};
$scope.toggleStates = {};
$scope.editWorkPackage = function() {
// TODO: Temporarily going to the old edit dialog until we get in-place editing done
window.location = "/work_packages/" + $scope.workPackage.props.id;
};
}
]);

@ -34,6 +34,7 @@ angular.module('openproject.workPackages.controllers')
'$q',
'$location',
'$stateParams',
'$state',
'I18n',
'WorkPackagesTableService',
'WorkPackageService',
@ -45,7 +46,7 @@ angular.module('openproject.workPackages.controllers')
'HALAPIResource',
'INITIALLY_SELECTED_COLUMNS',
'OPERATORS_AND_LABELS_BY_FILTER_TYPE',
function($scope, $rootScope, $q, $location, $stateParams,
function($scope, $rootScope, $q, $location, $stateParams, $state,
I18n, WorkPackagesTableService,
WorkPackageService, ProjectService, QueryService, PaginationService,
AuthorisationService, WorkPackageLoadingHelper, HALAPIResource, INITIALLY_SELECTED_COLUMNS,
@ -183,6 +184,10 @@ angular.module('openproject.workPackages.controllers')
return $scope.refreshWorkPackages;
};
$scope.setQueryState = function(query_id) {
$state.go('work-packages.list', { query_id: query_id });
}
// More
function serviceErrorHandler(data) {

@ -0,0 +1,42 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2014 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
angular.module('openproject.workPackages.tabs')
.directive('panelExpander', [function() {
return {
restrict: 'E',
replace: true,
templateUrl: '/templates/work_packages/tabs/panel_expander.html',
scope: {
collapsed: '=',
expandText: '@',
collapseText: '@'
}
};
}]);

@ -76,16 +76,15 @@ div
background: #fff598
&.panel-toggler
float: left
margin: 5px 0 0 0
width: 100%
color: #777777
fieldset
margin: 10px 0 0 0
border-top: 1px solid #ddd
border-left: 0px
border-right: 0px
border-bottom: 0px
cursor: pointer
width: 720px
legend
padding: 0 20px
span
@ -114,7 +113,7 @@ div
float: left
margin: 15px 0 0 0
width: 100%
height: 172px
ul
margin: 10px 0 0 0
padding: 0

@ -61,6 +61,14 @@
li
float: none
li.first
border-radius: 5px
background-color: #24B3E7
span.filter-selection
text-decoration: underline
color: #000000
.dropdown-scrollable
overflow-y: auto
max-height: 500px
@ -114,6 +122,9 @@
color: #999999
font-size: 12px
input[type="search"]::-webkit-search-cancel-button
display: none
.text
cursor: pointer
i

@ -207,15 +207,34 @@ de:
message: "Sie verwenden einen veralteten Browser. OpenProject unterstützt diesen Browser nicht länger. Bitte aktualisieren Sie Ihren Browser."
learn_more: "Mehr erfahren"
work_packages:
description_filter: "Filter"
description_enter_text: "Text eingeben"
description_options_hide: "Optionen ausblenden"
description_options_show: "Optionen einblenden"
label_enable_multi_select: "Mehrfachauswahl umschalten"
label_filter_add: "Filter hinzufügen"
label_options: "Optionen"
message_error_during_bulk_delete: Fehler beim Löschen der Arbeitspakete.
message_successful_bulk_delete: Arbeitspakete erfolgreich gelöscht.
description_filter: "Filter"
description_enter_text: "Text eingeben"
description_options_hide: "Optionen ausblenden"
description_options_show: "Optionen einblenden"
properties:
assigneeName: "Zugewiesen an"
authorName: "Autor"
createdAt: "Angelegt"
description: "Beschreibung"
date: "Datum"
dueDate: "Abgabedatum"
estimatedTime: "Geschätzter Aufwand"
percentageDone: "% erledigt"
priority: "Priorität"
projectName: "Projekt"
responsibleName: "Verantwortlicher"
startDate: "Startdatum"
status: "Status"
subject: "Thema"
title: "Titel"
type: "Typ"
updatedAt: "Aktualisiert"
versionName: "Version"
time_relative:
days: "Tagen"
weeks: "Wochen"

@ -209,16 +209,35 @@ en:
message: "The browser you are using is no longer supported by OpenProject. Please update your browser."
learn_more: "Learn more"
work_packages:
description_filter: "Filter"
description_enter_text: "Enter text"
description_options_hide: "Hide options"
description_options_show: "Show options"
error: "An error has occured."
label_enable_multi_select: "Toggle multiselect"
label_filter_add: "Add filter"
label_options: "Options"
message_error_during_bulk_delete: An error occurred while trying to delete work packages.
message_successful_bulk_delete: Successfully deleted work packages.
description_filter: "Filter"
description_enter_text: "Enter text"
description_options_hide: "Hide options"
description_options_show: "Show options"
properties:
assigneeName: "Assignee"
authorName: "Author"
createdAt: "Created on"
description: "Description"
date: "Datum"
dueDate: "Due date"
estimatedTime: "Estimated time"
percentageDone: "Percentage done"
priority: "Priority"
projectName: "Project"
responsibleName: "Responsible"
startDate: "Start date"
status: "Status"
subject: "Subject"
title: "Title"
type: "Type"
updatedAt: "Updated on"
versionName: "Version"
time_relative:
days: "days"
weeks: "weeks"

@ -57,7 +57,11 @@ Feature: Watch issues
Then I should see "Watch" within "#content > .action_menu_specific"
When I click on "Watch" within "#content > .action_menu_specific"
Then I should see "Unwatch" within "#content > .action_menu_specific"
Then the issue "issue1" should have 1 watchers
# The space before and after 'Watch' is important as 'Unwatch' includes the
# string 'watch' if matched case insenstivive.
And I should not see " Watch " within "#content > .action_menu_specific"
And I should see "Bob Bobbit" within "#watchers > ul"
And the issue "issue1" should have 1 watchers
@javascript
Scenario: Unwatch an issue
@ -66,8 +70,12 @@ Feature: Watch issues
When I go to the page of the issue "issue1"
Then I should see "Unwatch" within "#content > .action_menu_specific"
When I click on "Unwatch" within "#content > .action_menu_specific"
Then I should see "Watch" within "#content > .action_menu_specific"
Then the issue "issue1" should have 0 watchers
# The space before and after 'Watch' is important as 'Unwatch' includes the
# string 'watch' if matched case insenstivive.
Then I should see " Watch " within "#content >.action_menu_specific"
And I should not see "Unwatch" within "#content >.action_menu_specific"
And I should not see "Bob Bobbit" within "#watchers"
And the issue "issue1" should have 0 watchers
@javascript
Scenario: Add a watcher to an issue
@ -77,7 +85,7 @@ Feature: Watch issues
And I select "Bob Bobbit" from "watcher_user_id" within "#watchers"
And I press "Add" within "#watchers"
Then I should see "Bob Bobbit" within "#watchers > ul"
Then the issue "issue1" should have 1 watchers
And the issue "issue1" should have 1 watchers
@javascript
Scenario: Remove a watcher from an issue
@ -87,4 +95,4 @@ Feature: Watch issues
Then I should see "Bob Bobbit" within "#watchers > ul"
When I click on "Delete" within "#watchers > ul"
Then I should not see "Bob Bobbit" within "#watchers"
Then the issue "issue1" should have 0 watchers
And the issue "issue1" should have 0 watchers

@ -183,7 +183,7 @@ end
Then /^(?:|I )should see \/([^\/]*)\/$/ do |regexp|
regexp = Regexp.new(regexp)
page.should have_xpath('//*', :text => regexp)
should have_content(regexp)
end
Then /^(?:|I )should not see "([^"]*)"$/ do |text|
@ -194,7 +194,7 @@ end
Then /^(?:|I )should not see \/([^\/]*)\/$/ do |regexp|
regexp = Regexp.new(regexp)
page.should have_no_xpath('//*', :text => regexp)
should have_no_content(regexp)
end
Then /^the "([^"]*)" field(?: within (.*))? should contain "([^"]*)"$/ do |field, parent, value|

@ -125,6 +125,7 @@ describe('WorkPackagesListController', function() {
};
testParams = {};
testState = {};
buildController = function() {
scope.projectIdentifier = 'test';
@ -135,7 +136,8 @@ describe('WorkPackagesListController', function() {
QueryService: testQueryService,
PaginationService: testPaginationService,
WorkPackageService: testWorkPackageService,
$stateParams: testParams
$stateParams: testParams,
$state: testState
});
};

@ -103,7 +103,7 @@ describe('selectableTitle Directive', function() {
it('should truncate long text for models', function() {
var models = element.find('a');
expect(jQuery(models[4]).text()).to.equal('Misunderstood anthropomorphic puppet pig');
expect(jQuery(models[5]).text()).to.equal('Badly misunderstood anthropomorphic...');
expect(jQuery(models[5]).text()).to.equal('Badly misunderstood anthropomorphic pupp...');
});
it('should show a title (tooltip) for models', function() {
@ -128,5 +128,59 @@ describe('selectableTitle Directive', function() {
element.find('a').first().click();
expect(title.text()).to.equal('pinky1');
});
it('highlight the first element on key down pressed', function() {
var title = element.find('span').first();
expect(title.text().replace(/(\n|\s)/gm,"")).to.equal('Title1');
element.find('h2 span').first().click();
var listElements = element.find('li');
expect(jQuery(listElements[0]).hasClass('first')).to.be.false;
var e = jQuery.Event('keydown');
e.which = 40;
element.find('#title-filter').first().trigger(e);
expect(jQuery(listElements[0]).hasClass('first')).to.be.true;
});
it('highlight the second element on key down/up pressing group transitioning bonanza', function() {
var title = element.find('span').first();
expect(title.text().replace(/(\n|\s)/gm,"")).to.equal('Title1');
element.find('h2 span').first().click();
var listElements = element.find('li');
expect(jQuery(listElements[1]).hasClass('first')).to.be.false;
for(i = 0; i < 3; i++){
var e = jQuery.Event('keydown');
e.which = 40;
element.find('#title-filter').first().trigger(e);
}
var e = jQuery.Event('keydown');
e.which = 38;
element.find('#title-filter').first().trigger(e);
expect(jQuery(listElements[1]).hasClass('first')).to.be.true;
});
xit('should change the title when a model is selected with enter key', function() {
var title = element.find('span').first();
expect(title.text()).to.equal('Title1');
element.find('h2 span').first().click();
var listElements = element.find('li');
var e = jQuery.Event('keydown');
e.which = 40;
element.find('#title-filter').first().trigger(e);
var e = jQuery.Event('keydown');
e.which = 13;
element.find('#title-filter').first().trigger(e);
expect(title.text()).to.equal('pinky1');
});
});
});

@ -0,0 +1,47 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'reform'
require 'reform/form/coercion'
module API
module V3
module Attachments
class AttachmentModel < Reform::Form
include Coercion
property :filename, type: String
property :disk_filename, type: String
property :description, type: String
property :content_type, type: String
property :digest, type: String
end
end
end
end

@ -0,0 +1,75 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'roar/decorator'
require 'roar/representer/json/hal'
module API
module V3
module Attachments
class AttachmentRepresenter < Roar::Decorator
include Roar::Representer::JSON::HAL
include Roar::Representer::Feature::Hypermedia
include OpenProject::StaticRouting::UrlHelpers
self.as_strategy = API::Utilities::CamelCasingStrategy.new
property :_type, exec_context: :decorator
link :self do
{ href: "#{root_url}api/v3/attachments/#{represented.model.id}", title: "#{represented.model.filename}" }
end
link :work_package do
work_package = represented.model.container
{ href: "#{root_url}api/v3/work_packages/#{work_package.id}", title: "#{work_package.subject}" } unless work_package.nil?
end
link :author do
author = represented.model.author
{ href: "#{root_url}api/v3/users/#{author.id}", title: "#{author.name} - #{author.login}" } unless author.nil?
end
property :id, getter: -> (*) { model.id }, render_nil: true
property :filename, as: :fileName, render_nil: true
property :disk_filename, as: :diskFileName, render_nil: true
property :description, render_nil: true
property :file_size, getter: -> (*) { model.filesize }, render_nil: true
property :content_type, getter: -> (*) { model.content_type }, render_nil: true
property :digest, render_nil: true
property :downloads, getter: -> (*) { model.downloads }, render_nil: true
property :created_at, getter: -> (*) { model.created_on.utc.iso8601 }, render_nil: true
def _type
'Attachment'
end
end
end
end
end

@ -0,0 +1,30 @@
module API
module V3
module Attachments
class AttachmentsAPI < Grape::API
resources :attachments do
params do
requires :id, desc: 'Attachment id'
end
namespace ':id' do
before do
@attachment = Attachment.find(params[:id])
model = ::API::V3::Attachments::AttachmentModel.new(@attachment)
@representer = ::API::V3::Attachments::AttachmentRepresenter.new(model)
end
get do
@representer.to_json
end
end
end
end
end
end
end

@ -38,6 +38,7 @@ module API
mount API::V3::WorkPackages::WorkPackagesAPI
mount API::V3::Queries::QueriesAPI
mount API::V3::Attachments::AttachmentsAPI
end
end
end

@ -83,6 +83,8 @@ module API
property :created_at, getter: -> (*) { work_package.created_at.utc.iso8601}, render_nil: true
property :updated_at, getter: -> (*) { work_package.updated_at.utc.iso8601}, render_nil: true
collection :custom_properties, exec_context: :decorator, render_nil: true
collection :activities, embedded: true, class: ::API::V3::Activities::ActivityModel, decorator: ::API::V3::Activities::ActivityRepresenter
collection :watchers, embedded: true, class: ::API::V3::Users::UserModel, decorator: ::API::V3::Users::UserRepresenter
@ -90,6 +92,11 @@ module API
'WorkPackage'
end
def custom_properties
values = represented.work_package.custom_field_values
values.map { |v| { name: v.custom_field.name, format: v.custom_field.field_format, value: v.value }}
end
end
end
end

@ -1,25 +1,31 @@
<div class="title-container">
<div class="text">
<h2 title="{{ selectedTitle }}">
<span with-dropdown dropdown-id="querySelectDropdown">
{{ selectedTitle | characters:50 }}<i class="icon-pulldown-arrow1 icon-button"></i>
</span>
</h2>
</div>
<div class="text">
<h2 title="{{ selectedTitle }}">
<span with-dropdown dropdown-id="querySelectDropdown" focus-element-id="title-filter">
{{ selectedTitle | characters:50 }}<i class="icon-pulldown-arrow1 icon-button"></i>
</span>
</h2>
</div>
<div class="dropdown dropdown-relative" id="querySelectDropdown">
<div class="search-query-wrapper">
<input type="search" ng-model="filterBy" ng-change="filterModels(filterBy)" id="title-filter"><i id="magnifier" class="icon-search"></i>
<input type="search"
ng-model="filterBy"
ng-change="filterModels(filterBy)"
ng-keydown="handleSelection($event)"
id="title-filter"><i id="magnifier" class="icon-search"></i>
</input>
</div>
<div class="dropdown-scrollable">
<div class="query-menu-container" ng-if="group.models" ng-repeat="group in filteredGroups">
<ul class="query-menu">
<div class="title-group-header">{{ group.name }}</div>
<li ng-repeat="model in group.models">
<li ng-repeat="model in group.models" ng-class="{'first': model.highlighted }">
<a href
ui-sref="work-packages.list({ query_id: model[1] })"
title="{{ model[0] }}">{{ model[0] | characters:40 }}</a>
ui-sref="work-packages.list({ query_id: model.id })"
title="{{ model.label }}"
ng-bind-html="model.labelHtml"></a>
</li>
</ul>
</div>

@ -1,92 +0,0 @@
<div class="detail-panel-description">
<h3>Description</h3>
<div class="detail-panel-description-content">
{{ workPackage.props.description }}
</div>
</div>
<div class="panel-toggler" ng-click="toggleStates.hideFullDescription = !toggleStates.hideFullDescription">
<fieldset>
<legend align="center">
<span ng-if="!toggleStates.hideFullDescription">
<i class="icon-arrow-right5-2"></i>
Hide full description
</span>
<span ng-if="toggleStates.hideFullDescription">
<i class="icon-arrow-right5-3"></i>
Show full description
</span>
</legend>
</fieldset>
</div>
<div class="detail-panel-attributes" slide-toggle collapsed="toggleStates.hideFullDescription">
<ul>
<li><label>Status</label>{{ workPackage.props.status }}</li>
<li><label>Priortiy</label>{{ workPackage.props.priority }}</li>
<li><label>Date</label>{{ workPackage.props.startDate }} - {{ workPackage.props.dueDate }}</li>
<li><label>Responsible</label><img class="avatar" src="images/avatar_logout.png" /><span class="user"><a href="#">{{ workPackage.props.responsibleName }}</a></span>
<span class="role">{{ workPackage.props.responsibleRole }}</span>
</li>
<li><label>Assignee</label><img class="avatar" src="images/avatar_logout.png" /><span class="user"><a href="#">{{ workPackage.props.assigneeName }}</a></span>
<span class="role">{{ workPackage.props.assigneeRole }}</span>
</li>
<li><label>% Done</label>{{ workPackage.props.percentageDone }} %</li>
</ul>
</div>
<div class="panel-toggler" ng-click="toggleStates.hideAllAttributes = !toggleStates.hideAllAttributes">
<fieldset>
<legend align="center">
<span ng-if="!toggleStates.hideAllAttributes">
<i class="icon-arrow-right5-2"></i>
Hide all attributes
</span>
<span ng-if="toggleStates.hideAllAttributes">
<i class="icon-arrow-right5-3"></i>
Show all attributes
</span>
</legend>
</fieldset>
</div>
<div class="detail-panel-latest-activity" slide-toggle collapsed="toggleStates.hideAllAttributes">
<h3>Latest activity</h3>
<ul>
<li>
<div class="comments-number"><a href="#1">#1</a>
<div class="comments-icons"><i class="icon-quote"></i><i class="icon-edit"></i></div>
</div>
<img class="avatar" src="images/avatar_logout.png" />
<span class="user"><a href="#">Christoph Zierz</a></span>
<span class="date">commented on 06/05/2014 16:42 Uhr</span>
<span class="comment">Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis . ..
</span>
</li>
<li>
<div class="comments-number"><a href="#1">#2</a>
<div class="comments-icons"><i class="icon-quote"></i><i class="icon-edit"></i></div>
</div>
<img class="avatar" src="images/avatar_logout.png" />
<span class="user"><a href="#">Niels Lindenthal</a></span>
<span class="date">commented on 08/05/2014 16:42 Uhr</span>
<span class="comment">
<ul>
<li>Status changed from scheduled to closed</li>
<li>Assignee deleted (Michael Frister)</li>
<li>% done changed from 0 to 100</li>
</ul>
</span>
</li>
</ul>
</div>
<div class="comments-form">
<h3>Add your comments here</h3>
<form>
<textarea placeholder="Add comments here" rows="4"></textarea>
</form>
<button class="button">Add comment</button>
</div>

@ -29,6 +29,5 @@
</div>
<div class="bottom-toolbar">
<button class="button"><i class="icon-left icon-edit"></i>Edit</button>
<button class="button">More<i class="icon-right icon-pulldown-arrow1"></i></button>
<button class="button" ng-click="editWorkPackage()"><i class="icon-left icon-edit"></i>Edit</button>
<div>

@ -1,7 +1,8 @@
<div class="toolbar-container">
<div toolbar id="toolbar">
<selectable-title selected-title="selectedTitle"
groups="groups">
groups="groups"
transition-method="setQueryState">
</selectable-title>
<ul id="toolbar-items">

@ -17,7 +17,7 @@
</div>
-->
</div>
<img class="avatar" src="images/avatar_logout.png" />
<img class="avatar" ng-src="{{ activity.props.userAvatar }}" />
<span class="user"><a href name="{{currentNote}}" ng-bind="activity.props.userName"></a></span>
<span class="date">commented on <span ng-bind="activity.props.createdAt | date:'short'"/></span>
<span class="comment">

@ -0,0 +1,88 @@
<div class="detail-panel-description">
<h3>Description</h3>
<div class="detail-panel-description-content">
<p>
<span ng-bind="workPackage.props.description | characters: maxDescriptionLength"
ng-show="toggleStates.hideFullDescription"/>
<span ng-bind="workPackage.props.description"
ng-hide="toggleStates.hideFullDescription"/>
</p>
</div>
<panel-expander ng-if="workPackage.props.description.length > maxDescriptionLength"
collapsed="toggleStates.hideFullDescription"
expand-text="Show full description"
collapse-text="Hide full description">
</panel-expander>
</div>
<div class="detail-panel-attributes">
<!-- present attributes -->
<ul>
<li ng-repeat="propertyData in presentWorkPackageProperties">
<label ng-bind="propertyData.label"/>
<span ng-switch="propertyData.property">
<p ng-switch-when="assigneeName">
<img class="avatar"
ng-if="workPackage.props.assigneeName && workPackage.props.assigneeAvatar"
ng-src="{{ workPackage.props.assigneeAvatar }}" />
<span class="user"><a href ng-bind="propertyData.value"/></span>
<span class="role" ng-bind="workPackage.props.assigneeRole"/>
</p>
<p ng-switch-when="authorName">
<img class="avatar"
ng-if="workPackage.props.authorName && workPackage.props.authorAvatar"
ng-src="{{ workPackage.props.authorAvatar }}" />
<span class="user"><a href ng-bind="propertyData.value"/></span>
</p>
<p ng-switch-when="responsibleName">
<img class="avatar"
ng-if="workPackage.props.responsibleName && workPackage.props.responsibleAvatar"
ng-src="{{ workPackage.props.responsibleAvatar }}" />
<span class="user"><a href ng-bind="propertyData.value"/></span>
<span class="role" ng-bind="workPackage.props.responsibleRole"/>
</p>
<span ng-switch-default ng-bind="propertyData.value"/>
</span>
</li>
</ul>
<!-- empty attributes -->
<ul class="work-package-details-overview-tab-empty-attributes"
slide-toggle collapsed="toggleStates.hideAllAttributes">
<li ng-repeat="property in emptyWorkPackageProperties">
<label ng-bind="property"></label>
<span> - </span>
</li>
</ul>
<panel-expander ng-if="emptyWorkPackageProperties.length"
collapsed="toggleStates.hideAllAttributes"
expand-text="Show all attributes"
collapse-text="Hide empty attributes">
</panel-expander>
</div>
<div class="detail-panel-latest-activity">
<h3>Latest activity</h3>
<ul>
<li ng-repeat="activity in latestActitivies"
class="work-package-details-activities-activity"
ng-init="currentNote = 'note-' + ($index+1)">
<div class="work-package-details-activities-activity-contents">
<div class="comments-number"><a ng-href="#{{currentNote}}" ng-bind="'#' + ($index+1)"></a>
</div>
<img class="avatar" ng-src="{{ activity.props.userAvatar }}" />
<span class="user"><a href name="{{currentNote}}" ng-bind="activity.props.userName"></a></span>
<span class="date">commented on <span ng-bind="activity.props.createdAt | date:'short'"/></span>
<span class="comment">
<ul class="work-package-details-activities-messages">
<li ng-repeat="message in activity.props.messages track by $index">
<span class="message" ng-bind="message"/>
</li>
</ul>
</span>
</div>
</li>
</ul>
</div>

@ -0,0 +1,15 @@
<div class="panel-toggler"
ng-click="collapsed = !collapsed">
<fieldset>
<legend align="center">
<span ng-if="!collapsed">
<i class="icon-arrow-right5-3"></i>
<span ng-bind="collapseText"/>
</span>
<span ng-if="collapsed">
<i class="icon-arrow-right5-2"></i>
<span ng-bind="expandText"/>
</span>
</legend>
</fieldset>
</div>

@ -2,7 +2,7 @@
<div class="detail-panel-watchers">
<ul>
<li ng-repeat="user in watchers">
<img class="avatar" ng-src="{{ user.props.image_url || '/images/avatar_logout.png' }}" />
<img class="avatar" ng-src="{{ user.props.userAvatar }}" />
<span class="user"><a ng-href="/users/{{ user.props.id }}" ng-bind="user.props.firstname + user.props.lastname"></a></span>
</li>
</ul>

@ -0,0 +1,70 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::API::V3::Attachments::AttachmentRepresenter do
let(:attachment) { FactoryGirl.create(:attachment) }
let(:model) { ::API::V3::Attachments::AttachmentModel.new(attachment) }
let(:representer) { ::API::V3::Attachments::AttachmentRepresenter.new(model) }
context 'generation' do
subject(:generated) { representer.to_json }
it { should include_json('Attachment'.to_json).at_path('_type') }
describe 'attachment' do
it { should have_json_path('id') }
it { should have_json_path('fileName') }
it { should have_json_path('diskFileName') }
it { should have_json_path('description') }
it { should have_json_path('fileSize') }
it { should have_json_path('contentType') }
it { should have_json_path('digest') }
it { should have_json_path('downloads') }
it { should have_json_path('createdAt') }
end
describe '_links' do
it { should have_json_type(Object).at_path('_links') }
it 'should link to self' do
expect(subject).to have_json_path('_links/self/href')
end
it 'should link to a work package' do
expect(subject).to have_json_path('_links/work_package/href')
end
it 'should link to an author' do
expect(subject).to have_json_path('_links/author/href')
end
end
end
end
Loading…
Cancel
Save