Merge branch 'dev' into feature/panel-toggles

Conflicts:
	app/assets/javascripts/angular/work_packages/controllers/work-package-details-controller.js
	public/templates/tabs/overview.html
pull/1551/head
Till Breuer 10 years ago
commit 92bea3f2df
  1. 1
      Gemfile
  2. 2
      Gemfile.lock
  3. 190
      app/assets/javascripts/angular/directives/components/selectable-title-directive.js
  4. 7
      app/assets/javascripts/angular/directives/components/with-dropdown.js
  5. 6
      app/assets/javascripts/angular/work_packages/controllers/work-package-details-controller.js
  6. 7
      app/assets/javascripts/angular/work_packages/controllers/work-packages-list-controller.js
  7. 11
      app/assets/stylesheets/layout/_toolbar.css.sass
  8. 10
      bower.json
  9. 18
      features/issues/issue_show.feature
  10. 4
      features/step_definitions/web_steps.rb
  11. 4
      karma/tests/controllers/work-packages-list-controller-test.js
  12. 56
      karma/tests/directives/components/selectable-title-test.js
  13. 1
      lib/api/v3/activities/activity_model.rb
  14. 15
      lib/api/v3/activities/activity_representer.rb
  15. 4
      lib/api/v3/queries/query_representer.rb
  16. 10
      lib/api/v3/users/user_representer.rb
  17. 1
      lib/api/v3/work_packages/work_package_model.rb
  18. 17
      lib/api/v3/work_packages/work_package_representer.rb
  19. 28
      public/templates/components/selectable_title.html
  20. 3
      public/templates/work_packages.list.details.html
  21. 3
      public/templates/work_packages.list.html
  22. 2
      public/templates/work_packages/tabs/activity.html
  23. 48
      public/templates/work_packages/tabs/overview.html
  24. 2
      public/templates/work_packages/tabs/watchers.html
  25. 111
      spec/representers/work_package_representer_spec.rb

@ -52,6 +52,7 @@ gem "rdoc", ">= 2.4.2"
gem 'globalize' gem 'globalize'
gem 'omniauth' gem 'omniauth'
gem 'request_store' gem 'request_store'
gem 'gravatar_image_tag'
# TODO: adds #auto_link which was deprecated in rails 3.1 # TODO: adds #auto_link which was deprecated in rails 3.1
gem 'rails_autolink' gem 'rails_autolink'

@ -212,6 +212,7 @@ GEM
rack-accept rack-accept
rack-mount rack-mount
virtus (>= 1.0.0) virtus (>= 1.0.0)
gravatar_image_tag (1.2.0)
hashie (2.1.1) hashie (2.1.1)
hike (1.2.3) hike (1.2.3)
hooks (0.3.3) hooks (0.3.3)
@ -461,6 +462,7 @@ DEPENDENCIES
globalize globalize
gon (~> 4.0) gon (~> 4.0)
grape (~> 0.7.0) grape (~> 0.7.0)
gravatar_image_tag
htmldiff htmldiff
i18n (>= 0.6.8) i18n (>= 0.6.8)
i18n-js! i18n-js!

@ -29,27 +29,187 @@
// TODO move to UI components // TODO move to UI components
angular.module('openproject.uiComponents') 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 { return {
restrict: 'E', restrict: 'E',
replace: true, replace: true,
scope: { scope: {
selectedTitle: '=', selectedTitle: '=',
reloadMethod: '=', groups: '=',
groups: '=' transitionMethod: '='
}, },
templateUrl: '/templates/components/selectable_title.html', templateUrl: '/templates/components/selectable_title.html',
link: function(scope) { link: function(scope) {
scope.$watch('groups', refreshFilteredGroups); scope.$watch('groups', refreshFilteredGroups);
scope.$watch('selectedId', selectTitle);
function refreshFilteredGroups() { 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); 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.preventDefault();
event.stopPropagation(); 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.reload = function(modelId, newTitle) {
scope.selectedTitle = newTitle; scope.selectedTitle = newTitle;
@ -58,12 +218,26 @@ angular.module('openproject.uiComponents')
}; };
scope.filterModels = function(filterBy) { scope.filterModels = function(filterBy) {
refreshFilteredGroups(); initFilteredModels();
scope.selectedId = 0;
angular.forEach(scope.filteredGroups, function(group) { angular.forEach(scope.filteredGroups, function(group) {
group.models = group.models.filter(function(model){ if(filterBy.length) {
return model[0].toLowerCase().indexOf(filterBy.toLowerCase()) >= 0; 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 { return {
restrict: 'EA', restrict: 'EA',
scope: { scope: {
dropdownId: '@' dropdownId: '@',
focusElementId: '@'
}, },
link: function (scope, element, attributes) { link: function (scope, element, attributes) {
var dropdown = jQuery("#" + attributes.dropdownId), var dropdown = jQuery("#" + attributes.dropdownId),
@ -88,6 +89,10 @@ angular.module('openproject.uiComponents')
if (showDropdown) dropdown.show(); if (showDropdown) dropdown.show();
position(dropdown, trigger); position(dropdown, trigger);
if(attributes.focusElementId) {
angular.element('#' + attributes.focusElementId).focus();
}
}); });
} }
}; };

@ -50,6 +50,7 @@ angular.module('openproject.workPackages.controllers')
// resources for tabs // resources for tabs
$scope.activities = workPackage.embedded.activities; $scope.activities = workPackage.embedded.activities;
$scope.latestActitivies = $scope.activities.reverse().slice(0, 3);
$scope.watchers = workPackage.embedded.watchers; $scope.watchers = workPackage.embedded.watchers;
// work package properties // work package properties
@ -87,5 +88,10 @@ angular.module('openproject.workPackages.controllers')
hideFullDescription: true, hideFullDescription: true,
hideAllAttributes: true hideAllAttributes: true
}; };
$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', '$q',
'$location', '$location',
'$stateParams', '$stateParams',
'$state',
'I18n', 'I18n',
'WorkPackagesTableService', 'WorkPackagesTableService',
'WorkPackageService', 'WorkPackageService',
@ -45,7 +46,7 @@ angular.module('openproject.workPackages.controllers')
'HALAPIResource', 'HALAPIResource',
'INITIALLY_SELECTED_COLUMNS', 'INITIALLY_SELECTED_COLUMNS',
'OPERATORS_AND_LABELS_BY_FILTER_TYPE', 'OPERATORS_AND_LABELS_BY_FILTER_TYPE',
function($scope, $rootScope, $q, $location, $stateParams, function($scope, $rootScope, $q, $location, $stateParams, $state,
I18n, WorkPackagesTableService, I18n, WorkPackagesTableService,
WorkPackageService, ProjectService, QueryService, PaginationService, WorkPackageService, ProjectService, QueryService, PaginationService,
AuthorisationService, WorkPackageLoadingHelper, HALAPIResource, INITIALLY_SELECTED_COLUMNS, AuthorisationService, WorkPackageLoadingHelper, HALAPIResource, INITIALLY_SELECTED_COLUMNS,
@ -183,6 +184,10 @@ angular.module('openproject.workPackages.controllers')
return $scope.refreshWorkPackages; return $scope.refreshWorkPackages;
}; };
$scope.setQueryState = function(query_id) {
$state.go('work-packages.list', { query_id: query_id });
}
// More // More
function serviceErrorHandler(data) { function serviceErrorHandler(data) {

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

@ -3,7 +3,7 @@
"version": "3.0", "version": "3.0",
"dependencies": { "dependencies": {
"jquery": "1.11.0", "jquery": "1.11.0",
"jquery-ujs": "latest", "jquery-ujs": "1.0.0",
"jquery-ui": "~1.10.4", "jquery-ui": "~1.10.4",
"select2": "3.3.2", "select2": "3.3.2",
"jquery.atwho": "finnlabs/At.js#0025862f7600c8dddec98caab13a76b11cfaba18", "jquery.atwho": "finnlabs/At.js#0025862f7600c8dddec98caab13a76b11cfaba18",
@ -11,14 +11,14 @@
"openproject-ui_components": "opf/openproject-ui_components#with-bower", "openproject-ui_components": "opf/openproject-ui_components#with-bower",
"angular": "~1.2.14", "angular": "~1.2.14",
"angular-animate": "~1.2.14", "angular-animate": "~1.2.14",
"angular-ui-select2": "latest", "angular-ui-select2": "0.0.5",
"angular-ui-select2-sortable": "latest", "angular-ui-select2-sortable": "0.0.1",
"angular-ui-date": "latest", "angular-ui-date": "0.0.3",
"angular-ui-router": "~0.2.10", "angular-ui-router": "~0.2.10",
"angular-i18n": "~1.3.0", "angular-i18n": "~1.3.0",
"angular-modal": "~0.4.0", "angular-modal": "~0.4.0",
"angular-sanitize": "~1.2.14", "angular-sanitize": "~1.2.14",
"angular-truncate": "sparkalow/angular-truncate", "angular-truncate": "sparkalow/angular-truncate#fdf60fda265042d12e9414b5354b2cc52f1419de",
"angular-feature-flags": "mjt01/angular-feature-flags#ca40201e2279777dc3820c783f60f845cefff731", "angular-feature-flags": "mjt01/angular-feature-flags#ca40201e2279777dc3820c783f60f845cefff731",
"jquery-migrate": "~1.2.1", "jquery-migrate": "~1.2.1",
"momentjs": "~2.6.0", "momentjs": "~2.6.0",

@ -57,7 +57,11 @@ Feature: Watch issues
Then I should see "Watch" within "#content > .action_menu_specific" Then I should see "Watch" within "#content > .action_menu_specific"
When I click on "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 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 @javascript
Scenario: Unwatch an issue Scenario: Unwatch an issue
@ -66,8 +70,12 @@ Feature: Watch issues
When I go to the page of the issue "issue1" When I go to the page of the issue "issue1"
Then I should see "Unwatch" within "#content > .action_menu_specific" Then I should see "Unwatch" within "#content > .action_menu_specific"
When I click on "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" # The space before and after 'Watch' is important as 'Unwatch' includes the
Then the issue "issue1" should have 0 watchers # 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 @javascript
Scenario: Add a watcher to an issue 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 select "Bob Bobbit" from "watcher_user_id" within "#watchers"
And I press "Add" within "#watchers" And I press "Add" within "#watchers"
Then I should see "Bob Bobbit" within "#watchers > ul" 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 @javascript
Scenario: Remove a watcher from an issue Scenario: Remove a watcher from an issue
@ -87,4 +95,4 @@ Feature: Watch issues
Then I should see "Bob Bobbit" within "#watchers > ul" Then I should see "Bob Bobbit" within "#watchers > ul"
When I click on "Delete" within "#watchers > ul" When I click on "Delete" within "#watchers > ul"
Then I should not see "Bob Bobbit" within "#watchers" 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| Then /^(?:|I )should see \/([^\/]*)\/$/ do |regexp|
regexp = Regexp.new(regexp) regexp = Regexp.new(regexp)
page.should have_xpath('//*', :text => regexp) should have_content(regexp)
end end
Then /^(?:|I )should not see "([^"]*)"$/ do |text| Then /^(?:|I )should not see "([^"]*)"$/ do |text|
@ -194,7 +194,7 @@ end
Then /^(?:|I )should not see \/([^\/]*)\/$/ do |regexp| Then /^(?:|I )should not see \/([^\/]*)\/$/ do |regexp|
regexp = Regexp.new(regexp) regexp = Regexp.new(regexp)
page.should have_no_xpath('//*', :text => regexp) should have_no_content(regexp)
end end
Then /^the "([^"]*)" field(?: within (.*))? should contain "([^"]*)"$/ do |field, parent, value| Then /^the "([^"]*)" field(?: within (.*))? should contain "([^"]*)"$/ do |field, parent, value|

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

@ -103,7 +103,7 @@ describe('selectableTitle Directive', function() {
it('should truncate long text for models', function() { it('should truncate long text for models', function() {
var models = element.find('a'); var models = element.find('a');
expect(jQuery(models[4]).text()).to.equal('Misunderstood anthropomorphic puppet pig'); 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() { it('should show a title (tooltip) for models', function() {
@ -128,5 +128,59 @@ describe('selectableTitle Directive', function() {
element.find('a').first().click(); element.find('a').first().click();
expect(title.text()).to.equal('pinky1'); 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');
});
}); });
}); });

@ -36,6 +36,7 @@ module API
class ActivityModel < Reform::Form class ActivityModel < Reform::Form
include Composition include Composition
include Coercion include Coercion
include GravatarImageTag
model :journal model :journal

@ -36,22 +36,22 @@ module API
class ActivityRepresenter < Roar::Decorator class ActivityRepresenter < Roar::Decorator
include Roar::Representer::JSON::HAL include Roar::Representer::JSON::HAL
include Roar::Representer::Feature::Hypermedia include Roar::Representer::Feature::Hypermedia
include Rails.application.routes.url_helpers include OpenProject::StaticRouting::UrlHelpers
self.as_strategy = API::Utilities::CamelCasingStrategy.new self.as_strategy = API::Utilities::CamelCasingStrategy.new
property :_type, exec_context: :decorator property :_type, exec_context: :decorator
link :self do link :self do
{ href: "#{root_url}/api/v3/activities/#{represented.journal.id}", title: "#{represented.journal.id}" } { href: "#{root_url}api/v3/activities/#{represented.journal.id}", title: "#{represented.journal.id}" }
end end
link :work_package do link :work_package do
{ href: "#{root_url}/api/v3/work_packages/#{represented.journal.journable.id}", title: "#{represented.journal.journable.subject}" } { href: "#{root_url}api/v3/work_packages/#{represented.journal.journable.id}", title: "#{represented.journal.journable.subject}" }
end end
link :user do link :user do
{ href: "#{root_url}/api/v3/users/#{represented.journal.user.id}", title: "#{represented.journal.user.name} - #{represented.journal.user.login}" } { href: "#{root_url}api/v3/users/#{represented.journal.user.id}", title: "#{represented.journal.user.name} - #{represented.journal.user.login}" }
end end
property :id, getter: -> (*) { journal.id }, render_nil: true property :id, getter: -> (*) { journal.id }, render_nil: true
@ -59,6 +59,7 @@ module API
property :user_name, getter: -> (*) { journal.user.try(:name) }, render_nil: true property :user_name, getter: -> (*) { journal.user.try(:name) }, render_nil: true
property :user_login, getter: -> (*) { journal.user.try(:login) }, render_nil: true property :user_login, getter: -> (*) { journal.user.try(:login) }, render_nil: true
property :user_mail, getter: -> (*) { journal.user.try(:mail) }, render_nil: true property :user_mail, getter: -> (*) { journal.user.try(:mail) }, render_nil: true
property :user_avatar, getter: -> (*) { gravatar_image_url(journal.user.try(:mail)) }, render_nil: true
property :messages, exec_context: :decorator, render_nil: true property :messages, exec_context: :decorator, render_nil: true
property :version, getter: -> (*) { journal.version }, render_nil: true property :version, getter: -> (*) { journal.version }, render_nil: true
property :created_at, getter: -> (*) { journal.created_at.utc.iso8601 }, render_nil: true property :created_at, getter: -> (*) { journal.created_at.utc.iso8601 }, render_nil: true
@ -79,12 +80,6 @@ module API
[journal.notes] [journal.notes]
end end
end end
private
def default_url_options
ActionController::Base.default_url_options
end
end end
end end
end end

@ -36,14 +36,14 @@ module API
class QueryRepresenter < Roar::Decorator class QueryRepresenter < Roar::Decorator
include Roar::Representer::JSON::HAL include Roar::Representer::JSON::HAL
include Roar::Representer::Feature::Hypermedia include Roar::Representer::Feature::Hypermedia
include Rails.application.routes.url_helpers include OpenProject::StaticRouting::UrlHelpers
self.as_strategy = API::Utilities::CamelCasingStrategy.new self.as_strategy = API::Utilities::CamelCasingStrategy.new
property :_type, exec_context: :decorator property :_type, exec_context: :decorator
link :self do link :self do
{ href: "http://localhost:3000/api/v3/queries/#{represented.query.id}", title: "#{represented.name}" } { href: "#{root_url}api/v3/queries/#{represented.query.id}", title: "#{represented.name}" }
end end
property :id, getter: -> (*) { query.id }, render_nil: true property :id, getter: -> (*) { query.id }, render_nil: true

@ -36,14 +36,14 @@ module API
class UserRepresenter < Roar::Decorator class UserRepresenter < Roar::Decorator
include Roar::Representer::JSON::HAL include Roar::Representer::JSON::HAL
include Roar::Representer::Feature::Hypermedia include Roar::Representer::Feature::Hypermedia
include Rails.application.routes.url_helpers include OpenProject::StaticRouting::UrlHelpers
self.as_strategy = API::Utilities::CamelCasingStrategy.new self.as_strategy = API::Utilities::CamelCasingStrategy.new
property :_type, exec_context: :decorator property :_type, exec_context: :decorator
link :self do link :self do
{ href: "#{root_url}/api/v3/users/#{represented.model.id}", title: "#{represented.model.name} - #{represented.model.login}" } { href: "#{root_url}api/v3/users/#{represented.model.id}", title: "#{represented.model.name} - #{represented.model.login}" }
end end
# will need array of links for work packages the user is watching # will need array of links for work packages the user is watching
@ -59,12 +59,6 @@ module API
def _type def _type
'User' 'User'
end end
private
def default_url_options
ActionController::Base.default_url_options
end
end end
end end
end end

@ -36,6 +36,7 @@ module API
class WorkPackageModel < Reform::Form class WorkPackageModel < Reform::Form
include Composition include Composition
include Coercion include Coercion
include GravatarImageTag
model :work_package model :work_package

@ -36,7 +36,7 @@ module API
class WorkPackageRepresenter < Roar::Decorator class WorkPackageRepresenter < Roar::Decorator
include Roar::Representer::JSON::HAL include Roar::Representer::JSON::HAL
include Roar::Representer::Feature::Hypermedia include Roar::Representer::Feature::Hypermedia
include Rails.application.routes.url_helpers include OpenProject::StaticRouting::UrlHelpers
self.as_strategy = ::API::Utilities::CamelCasingStrategy.new self.as_strategy = ::API::Utilities::CamelCasingStrategy.new
@ -48,7 +48,7 @@ module API
property :_type, exec_context: :decorator property :_type, exec_context: :decorator
link :self do link :self do
{ href: "#{root_url}/api/v3/work_packages/#{represented.work_package.id}", title: "#{represented.subject}" } { href: "#{root_url}api/v3/work_packages/#{represented.work_package.id}", title: "#{represented.subject}" }
end end
property :id, getter: -> (*) { work_package.id }, render_nil: true property :id, getter: -> (*) { work_package.id }, render_nil: true
@ -69,16 +69,21 @@ module API
property :responsible_name, getter: -> (*) { work_package.responsible.try(:name) }, render_nil: true property :responsible_name, getter: -> (*) { work_package.responsible.try(:name) }, render_nil: true
property :responsible_login, getter: -> (*) { work_package.responsible.try(:login) }, render_nil: true property :responsible_login, getter: -> (*) { work_package.responsible.try(:login) }, render_nil: true
property :responsible_mail, getter: -> (*) { work_package.responsible.try(:mail) }, render_nil: true property :responsible_mail, getter: -> (*) { work_package.responsible.try(:mail) }, render_nil: true
property :responsible_avatar, getter: -> (*) { gravatar_image_url(work_package.responsible.try(:mail)) }, render_nil: true
property :assigned_to_id, as: :assigneeId, getter: -> (*) { work_package.assigned_to.try(:id) }, render_nil: true property :assigned_to_id, as: :assigneeId, getter: -> (*) { work_package.assigned_to.try(:id) }, render_nil: true
property :assignee_name, getter: -> (*) { work_package.assigned_to.try(:name) }, render_nil: true property :assignee_name, getter: -> (*) { work_package.assigned_to.try(:name) }, render_nil: true
property :assignee_login, getter: -> (*) { work_package.assigned_to.try(:login) }, render_nil: true property :assignee_login, getter: -> (*) { work_package.assigned_to.try(:login) }, render_nil: true
property :assignee_mail, getter: -> (*) { work_package.assigned_to.try(:mail) }, render_nil: true property :assignee_mail, getter: -> (*) { work_package.assigned_to.try(:mail) }, render_nil: true
property :assignee_avatar, getter: -> (*) { gravatar_image_url(work_package.assigned_to.try(:mail)) }, render_nil: true
property :author_name, getter: -> (*) { work_package.author.name }, render_nil: true property :author_name, getter: -> (*) { work_package.author.name }, render_nil: true
property :author_login, getter: -> (*) { work_package.author.login }, render_nil: true property :author_login, getter: -> (*) { work_package.author.login }, render_nil: true
property :author_mail, getter: -> (*) { work_package.author.mail }, render_nil: true property :author_mail, getter: -> (*) { work_package.author.mail }, render_nil: true
property :author_avatar, getter: -> (*) { gravatar_image_url(work_package.author.try(:mail)) }, render_nil: true
property :created_at, getter: -> (*) { work_package.created_at.utc.iso8601}, render_nil: true 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 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 :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 collection :watchers, embedded: true, class: ::API::V3::Users::UserModel, decorator: ::API::V3::Users::UserRepresenter
@ -86,11 +91,11 @@ module API
'WorkPackage' 'WorkPackage'
end end
private 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
def default_url_options
ActionController::Base.default_url_options
end
end end
end end
end end

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

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

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

@ -17,7 +17,7 @@
</div> </div>
--> -->
</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="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="date">commented on <span ng-bind="activity.props.createdAt | date:'short'"/></span>
<span class="comment"> <span class="comment">

@ -43,39 +43,23 @@
<div class="detail-panel-latest-activity"> <div class="detail-panel-latest-activity">
<h3>Latest activity</h3> <h3>Latest activity</h3>
<ul> <ul>
<li> <li ng-repeat="activity in latestActitivies"
<div class="comments-number"><a href="#1">#1</a> class="work-package-details-activities-activity"
<div class="comments-icons"><i class="icon-quote"></i><i class="icon-edit"></i></div> 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> </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> </li>
</ul> </ul>
</div> </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>

@ -2,7 +2,7 @@
<div class="detail-panel-watchers"> <div class="detail-panel-watchers">
<ul> <ul>
<li ng-repeat="user in watchers"> <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> <span class="user"><a ng-href="/users/{{ user.props.id }}" ng-bind="user.props.firstname + user.props.lastname"></a></span>
</li> </li>
</ul> </ul>

@ -0,0 +1,111 @@
#-- 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::WorkPackages::WorkPackageRepresenter do
let(:representer) { described_class.new(model) }
let(:model) { ::API::V3::WorkPackages::WorkPackageModel.new(
work_package: work_package
)
}
let(:work_package) { FactoryGirl.build(:work_package,
created_at: DateTime.now,
updated_at: DateTime.now
)
}
context 'generation' do
subject(:generated) { representer.to_json }
it { should include_json('WorkPackage'.to_json).at_path('_type') }
describe 'work_package' do
it { should have_json_path('id') }
it { should have_json_path('assigneeId') }
it { should have_json_path('assigneeLogin') }
it { should have_json_path('assigneeMail') }
it { should have_json_path('assigneeName') }
it { should have_json_path('authorLogin') }
it { should have_json_path('authorMail') }
it { should have_json_path('authorName') }
it { should have_json_path('description') }
it { should have_json_path('dueDate') }
it { should have_json_path('percentageDone') }
it { should have_json_path('priority') }
it { should have_json_path('projectId') }
it { should have_json_path('projectName') }
it { should have_json_path('responsibleId') }
it { should have_json_path('responsibleLogin') }
it { should have_json_path('responsibleMail') }
it { should have_json_path('responsibleName') }
it { should have_json_path('startDate') }
it { should have_json_path('status') }
it { should have_json_path('subject') }
it { should have_json_path('type') }
it { should have_json_path('versionId') }
it { should have_json_path('versionName') }
it { should have_json_path('createdAt') }
it { should have_json_path('updatedAt') }
end
describe 'estimatedTime' do
it { should have_json_type(Object).at_path('estimatedTime') }
it { should have_json_path('estimatedTime/units') }
it { should have_json_path('estimatedTime/value') }
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')
expect(subject).to have_json_path('_links/self/title')
end
end
describe '_embedded' do
it { should have_json_type(Object).at_path('_embedded') }
describe 'activities' do
it { should have_json_type(Array).at_path('_embedded/activities') }
it { should have_json_size(0).at_path('_embedded/activities') }
end
end
end
end
Loading…
Cancel
Save