Merge pull request #6834 from opf/feature/grid_my_page

Feature/grid my page

[ci skip]
pull/7011/head
Oliver Günther 6 years ago committed by GitHub
commit 9d22e7a457
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      Gemfile.lock
  2. 5
      Gemfile.modules
  3. 139
      app/assets/javascripts/my_page.js
  4. 127
      app/assets/stylesheets/content/_grid.sass
  5. 2
      app/assets/stylesheets/content/_index.sass
  6. 7
      app/assets/stylesheets/content/_resizer.sass
  7. 1
      app/assets/stylesheets/content/_table.sass
  8. 74
      app/assets/stylesheets/content/_widget_box.sass
  9. 6
      app/assets/stylesheets/layout/work_packages/_table_embedded.sass
  10. 47
      app/contracts/model_contract.rb
  11. 112
      app/controllers/my_controller.rb
  12. 68
      app/models/queries/filters/shared/me_value_filter.rb
  13. 11
      app/models/queries/time_entries/filters/user_filter.rb
  14. 36
      app/models/queries/work_packages/filter/me_value_filter_mixin.rb
  15. 17
      app/services/api/v3/parse_resource_params_service.rb
  16. 18
      app/services/params_to_query_service.rb
  17. 46
      app/views/my/_block.html.erb
  18. 12
      app/views/my/_block_container.html.erb
  19. 4
      app/views/my/add_block.html.erb
  20. 45
      app/views/my/blocks/_calendar.html.erb
  21. 7
      app/views/my/blocks/_embedded_work_package_table.html.erb
  22. 46
      app/views/my/blocks/_issuesassignedtome.html.erb
  23. 46
      app/views/my/blocks/_issuesreportedbyme.html.erb
  24. 39
      app/views/my/blocks/_issueswatched.html.erb
  25. 63
      app/views/my/blocks/_news.html.erb
  26. 142
      app/views/my/blocks/_timelog.html.erb
  27. 45
      app/views/my/blocks/_workpackagesresponsiblefor.html.erb
  28. 28
      app/views/my/page.html.erb
  29. 71
      app/views/my/page_layout.html.erb
  30. 46
      app/views/work_packages/_me_list_simple.html.erb
  31. 2
      config/initializers/assets.rb
  32. 4
      config/locales/en.yml
  33. 14
      config/locales/js-en.yml
  34. 4
      config/routes.rb
  35. 32
      db/migrate/20181118193730_create_grid.rb
  36. 800
      docs/api/apiv3/endpoints/grids.apib
  37. 2
      docs/api/apiv3/endpoints/time_entries.apib
  38. 1
      docs/api/apiv3/index.apib
  39. 3
      frontend/src/app/angular4-modules.ts
  40. 2
      frontend/src/app/components/api/api-v3/api-v3-filter-builder.ts
  41. 3
      frontend/src/app/components/routing/my-page/my-page.component.html
  42. 74
      frontend/src/app/components/routing/my-page/my-page.component.ts
  43. 1
      frontend/src/app/components/wp-table/embedded/wp-embedded-table.component.ts
  44. 4
      frontend/src/app/components/wp-table/embedded/wp-embedded-table.html
  45. 3
      frontend/src/app/modules/calendar/openproject-calendar.module.ts
  46. 15
      frontend/src/app/modules/calendar/wp-calendar/wp-calendar.component.ts
  47. 4
      frontend/src/app/modules/common/no-results/no-results.component.html
  48. 41
      frontend/src/app/modules/common/no-results/no-results.component.ts
  49. 5
      frontend/src/app/modules/common/openproject-common.module.ts
  50. 12
      frontend/src/app/modules/common/path-helper/apiv3/apiv3-paths.ts
  51. 40
      frontend/src/app/modules/common/path-helper/apiv3/grids/apiv3-grid-paths.ts
  52. 47
      frontend/src/app/modules/common/path-helper/apiv3/grids/apiv3-grids-paths.ts
  53. 37
      frontend/src/app/modules/common/path-helper/apiv3/news/apiv3-news-paths.ts
  54. 42
      frontend/src/app/modules/common/path-helper/apiv3/news/apiv3-newses-paths.ts
  55. 42
      frontend/src/app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entries-paths.ts
  56. 37
      frontend/src/app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entry-paths.ts
  57. 32
      frontend/src/app/modules/grids/areas/grid-area.ts
  58. 65
      frontend/src/app/modules/grids/areas/grid-widget-area.ts
  59. 107
      frontend/src/app/modules/grids/context_menus/column.directive.ts
  60. 106
      frontend/src/app/modules/grids/context_menus/row.directive.ts
  61. 104
      frontend/src/app/modules/grids/grid/add-widget.service.ts
  62. 220
      frontend/src/app/modules/grids/grid/area.service.ts
  63. 113
      frontend/src/app/modules/grids/grid/drag-and-drop.service.ts
  64. 110
      frontend/src/app/modules/grids/grid/grid.component.html
  65. 97
      frontend/src/app/modules/grids/grid/grid.component.ts
  66. 101
      frontend/src/app/modules/grids/grid/move.service.ts
  67. 25
      frontend/src/app/modules/grids/grid/remove-widget.service.ts
  68. 72
      frontend/src/app/modules/grids/grid/resize.service.ts
  69. 164
      frontend/src/app/modules/grids/openproject-grids.module.ts
  70. 14
      frontend/src/app/modules/grids/widgets/abstract-widget.component.ts
  71. 25
      frontend/src/app/modules/grids/widgets/add/add.modal.html
  72. 48
      frontend/src/app/modules/grids/widgets/add/add.modal.ts
  73. 24
      frontend/src/app/modules/grids/widgets/documents/documents.component.html
  74. 60
      frontend/src/app/modules/grids/widgets/documents/documents.component.ts
  75. 37
      frontend/src/app/modules/grids/widgets/news/news.component.html
  76. 83
      frontend/src/app/modules/grids/widgets/news/news.component.ts
  77. 100
      frontend/src/app/modules/grids/widgets/time-entries-current-user/time-entries-current-user.component.html
  78. 162
      frontend/src/app/modules/grids/widgets/time-entries-current-user/time-entries-current-user.component.ts
  79. 18
      frontend/src/app/modules/grids/widgets/widgets.service.ts
  80. 26
      frontend/src/app/modules/grids/widgets/wp-accountable/wp-accountable.component.ts
  81. 26
      frontend/src/app/modules/grids/widgets/wp-assigned/wp-assigned.component.ts
  82. 7
      frontend/src/app/modules/grids/widgets/wp-calendar/wp-calendar.component.html
  83. 37
      frontend/src/app/modules/grids/widgets/wp-calendar/wp-calendar.component.ts
  84. 25
      frontend/src/app/modules/grids/widgets/wp-created/wp-created.component.ts
  85. 25
      frontend/src/app/modules/grids/widgets/wp-watched/wp-watched.component.ts
  86. 5
      frontend/src/app/modules/grids/widgets/wp-widget/wp-widget.component.css
  87. 9
      frontend/src/app/modules/grids/widgets/wp-widget/wp-widget.component.html
  88. 4
      frontend/src/app/modules/grids/widgets/wp-widget/wp-widget.component.ts
  89. 81
      frontend/src/app/modules/hal/dm-services/abstract-dm.service.ts
  90. 40
      frontend/src/app/modules/hal/dm-services/dm.service.interface.ts
  91. 111
      frontend/src/app/modules/hal/dm-services/grid-dm.service.ts
  92. 42
      frontend/src/app/modules/hal/dm-services/news-dm.service.ts
  93. 42
      frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts
  94. 12
      frontend/src/app/modules/hal/openproject-hal.module.ts
  95. 49
      frontend/src/app/modules/hal/resources/grid-resource.ts
  96. 45
      frontend/src/app/modules/hal/resources/grid-widget-resource.ts
  97. 32
      frontend/src/app/modules/hal/resources/news-resource.ts
  98. 2
      frontend/src/app/modules/hal/resources/resources.ts
  99. 32
      frontend/src/app/modules/hal/resources/time-entry-resource.ts
  100. 1
      frontend/src/app/modules/hal/resources/wiki-page-resource.ts
  101. Some files were not shown because too many files have changed in this diff Show More

@ -130,6 +130,11 @@ PATH
specs:
openproject-global_roles (8.2.1)
PATH
remote: modules/grids
specs:
grids (8.2.1)
PATH
remote: modules/meeting
specs:
@ -899,6 +904,7 @@ DEPENDENCIES
fuubar (~> 2.3.2)
gon (~> 6.2.1)
grape (~> 1.1)
grids!
health_check
html-pipeline (~> 2.8.0)
htmldiff

@ -13,6 +13,9 @@ gem 'omniauth-openid-connect',
ref: '46f0c33bee2c885c89dd2866f5cf847da62b3482'
group :opf_plugins do
# included so that engines can reference OpenProject::Version
$:.push File.expand_path("../lib", __FILE__)
gem 'openproject-global_roles', path: 'modules/global_roles'
gem 'openproject-auth_plugins', path: 'modules/auth_plugins'
gem 'openproject-auth_saml', path: 'modules/auth_saml'
@ -30,4 +33,6 @@ group :opf_plugins do
gem 'openproject-two_factor_authentication', path: 'modules/two_factor_authentication'
gem 'openproject-webhooks', path: 'modules/webhooks'
gem 'openproject-github_integration', path: 'modules/github_integration'
gem 'grids', path: 'modules/grids'
end

@ -1,139 +0,0 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
//++
jQuery(document).ready(function($) {
// Add block
$('.my-page--block-form').submit(function (evt) {
var form = $(this);
var block = $('#block-options').val();
evt.preventDefault();
jQuery
.ajax(
{
url: form.attr('action'),
type: 'POST',
dataType: 'html',
data: {
block: block
}
})
.fail(function(error, text) {
jQuery(window).trigger(
'op:notifications:add',
{
type: 'error',
message: I18n.t('js.error.cannot_save_changes_with_message', { error: _.get(error, 'responseText', 'Internal error') })
}
);
})
.done(function(response) {
// Add partial to the top container.
jQuery("#top").prepend(response);
// Revert options selection back to the default one.
jQuery('#block-options option').first().prop('selected', true);
// Disable the option for this block in the blocks-to-add-to-page dropdown.
jQuery('#block-options option[value="' + block + '"]').attr("disabled", "true");
});
return false;
});
// Remove block
$('.my-page--container')
.on('click', '.my-page--remove-block', function (evt) {
evt.preventDefault();
var link = $(this);
var block = link.data('name');
var dasherized = link.data('dasherized');
jQuery
.ajax(
{
url: link.attr('href'),
type: 'POST',
dataType: 'html',
data: {
block: block
}
})
.fail(function(error) {
jQuery(window).trigger(
'op:notifications:add',
{
type: 'error',
message: I18n.t('js.error.cannot_save_changes_with_message', { error: _.get(error, 'responseText', 'Internal error') })
}
);
})
.done(function() {
jQuery("#block-" + dasherized).remove();
// Enable the option for this block in the blocks-to-add-to-page dropdown.
jQuery('#block-options option[value="' + block + '"]').removeAttr("disabled");
});
return false;
});
// Canonical list of containers that will exchange draggable elements.
var containers = jQuery('.dragula-container').toArray();
var drake = dragula(containers);
// On 'el' drop, we fire an Ajax request to persist the order chosen by
// the user. Actual ordering details are handled on the server.
drake.on('drop', function(el, target, source, sibling){
var url = window.gon.my_order_blocks_url;
// Array of target ordered children after this drop.
var target_ordered_children = jQuery(target).find('.block-wrapper').map(function(){
return jQuery(this).data('name');
}).get();
// Array of source ordered children after this drop.
var source_ordered_children = jQuery(source).find('.block-wrapper').map(function(){
return jQuery(this).data('name');
}).get();
// We send the source, target, and the new order of the children in both
// containers to the server.
jQuery.ajax({
url: url,
type: 'POST',
data: {
target: jQuery(target).attr('id'),
source: jQuery(source).attr('id'),
target_ordered_children: target_ordered_children,
source_ordered_children: source_ordered_children
}
});
});
});

@ -0,0 +1,127 @@
$grid--gap-width: 20px
$grid--header-width: 20px
@mixin grid--commons
display: grid
grid-column-gap: $grid--gap-width
grid-row-gap: $grid--gap-width
.grid--container
@include grid--commons
.grid--area
overflow: hidden
&.-drop-target
background-color: $gray-light
&.-resize-target
z-index: 1000
&.-widgeted
z-index: 10
@include widget-box--style
margin: 0
padding: 20px
.widget-box
height: 100%
&.-resizing
border: 1px solid $primary-color
z-index: 100
&.-addable
display: flex
flex-direction: column
justify-content: center
align-items: center
.resizer
margin-top: 0
margin-left: auto
margin-right: -20px
position: relative
.cdk-drag-handle
cursor: grab
.grid--area-content
height: 100%
ng-component
display: flex
flex-direction: column
height: 100%
.grid--widget-content
height: 100%
overflow-x: auto
overflow-y: auto
.grid--widget-limited-text
max-height: 5rem
position: relative
overflow: hidden
&:before
content: ''
width: 100%
height: 100%
position: absolute
left: 0
top: 0
background: linear-gradient(to bottom, rgba($body-background, 0) 60%, rgba($body-background, 1))
.grid--widget-add
padding: 15px
background-color: $gray
border-radius: 50%
&:before
@include icon-font-common
@include icon-mixin-add
.grid--widget-remove
float: right
margin-top: -10px
margin-right: -10px
cursor: pointer
&:before
@include icon-font-common
@include icon-mixin-remove
font-size: 0.75em
.grid--column-headers
@include grid--commons
grid-template-rows: $grid--header-width
.grid--row-headers
@include grid--commons
grid-template-columns: $grid--header-width
float: left
margin-left: -$grid--header-width
.grid--header
background-color: $gray-light
transition: background-color 4s ease
&:hover
background-color: $gray
transition: background-color 1s ease
.grid--addable-widget
min-width: 400px
padding: 20px 5px
border-bottom: 1px solid $gray
cursor: pointer
background: none
transition: background 4s ease
&:hover
background: $gray-light
transition: background 1s ease
&:last-of-type
border-bottom: none

@ -71,6 +71,8 @@
@import content/search
@import content/contextual
@import content/tooltip
@import content/grid
@import content/resizer
@import content/version
@import content/menus/_project_autocompletion

@ -0,0 +1,7 @@
.resizer
width: 0
height: 0
border-bottom: 20px solid $primary-color
border-left: 20px solid transparent
cursor: nwse-resize

@ -276,6 +276,7 @@ thead.-sticky th
border: 1px solid $gray
border-radius: $global-radius
padding: 14px 14px 14px 36px
display: block
> i,
.generic-table--no-results-title

@ -92,47 +92,10 @@ $widget-box--enumeration-width: 20px
padding-top: 0
vertical-align: middle
.widget-box--additional-info
margin: 0
font-size: 0.9rem
font-style: italic
.widget-box--enumeration
margin-left: 1.5rem
margin-top: 0.5rem
.widget-box--arrow-links
list-style: none
margin: 0.5rem 0 1rem 0
&:last-child
margin-bottom: 0
li:before
@include icon-font-common
@include icon-mixin-arrow-right2
@extend .icon-context
display: inline-block
font-size: 0.6rem
color: $content-icon-link-color
width: $widget-box--enumeration-width
.-widget-box--arrow-multiline
&:before
float: left
&:after
clear: both
content: ""
display: table
> div
float: left
margin-bottom: 10px
//necessary for correct alignment even with long texts
width: calc(100% - #{$widget-box--enumeration-width})
.widget-box--feature-list
list-style: none
margin: 0.5rem 0 1rem 0
@ -157,6 +120,43 @@ $widget-box--enumeration-width: 20px
margin-left: auto
margin-right: auto
.widget-box--additional-info
margin: 0
font-size: 0.9rem
font-style: italic
.widget-box--arrow-links
list-style: none
margin: 0.5rem 0 1rem 0
&:last-child
margin-bottom: 0
li:before
@include icon-font-common
@include icon-mixin-arrow-right2
@extend .icon-context
display: inline-block
font-size: 0.6rem
color: $content-icon-link-color
width: $widget-box--enumeration-width
.-widget-box--arrow-multiline
&:before
float: left
&:after
clear: both
content: ""
display: table
> div
float: left
margin-bottom: 10px
//necessary for correct alignment even with long texts
width: calc(100% - #{$widget-box--enumeration-width})
@include breakpoint(680px down)
.widget-boxes
&.-flex

@ -32,6 +32,12 @@ $table-timeline--compact-row-height: 28px
// unless we're setting an external height with overflow, containment will not work.
.work-packages-embedded-view--container
&.-external-height
display: flex
flex-direction: column
overflow: hidden
width: 100%
// Align with section header
.wp-table--table-header:first-child .generic-table--sort-header-outer,
.wp-table--cell-td:first-child .wp-edit-field--display-field,

@ -45,6 +45,14 @@ class ModelContract < Reform::Contract
@attribute_validations ||= []
end
def attribute_aliases
@attribute_aliases ||= {}
end
def attribute_alias(db, outside)
attribute_aliases[db] = outside
end
def attribute(attribute, options = {}, &block)
property attribute
@ -90,13 +98,19 @@ class ModelContract < Reform::Contract
end
def writable_attributes
writable = collect_ancestor_attributes(:writable_attributes)
@writable_attributes ||= begin
writable = collect_ancestor_attributes(:writable_attributes)
collect_ancestor_attributes(:writable_conditions).each do |attribute, condition|
writable -= [attribute, "#{attribute}_id"] unless instance_exec(&condition)
collect_ancestor_attributes(:writable_conditions).each do |attribute, condition|
writable -= [attribute, "#{attribute}_id"] unless instance_exec(&condition)
end
writable
end
end
writable
def writable?(attribute)
writable_attributes.include?(attribute.to_s)
end
def validate
@ -139,7 +153,9 @@ class ModelContract < Reform::Contract
invalid_changes = model.changed - writable_attributes
invalid_changes.each do |attribute|
errors.add attribute, :error_readonly
outside_attribute = collect_ancestor_attributes(:attribute_aliases)[attribute] || attribute
errors.add outside_attribute, :error_readonly
end
end
@ -154,13 +170,26 @@ class ModelContract < Reform::Contract
# Traverse ancestor hierarchy to collect contract information.
# This allows to define attributes on a common base class of two or more contracts.
def collect_ancestor_attributes(attribute_to_collect)
attributes = []
combination_method, cleanup_method = if self.class.send(attribute_to_collect).is_a?(Hash)
%i[merge with_indifferent_access]
else
%i[concat uniq]
end
collect_ancestor_attributes_by(attribute_to_collect, combination_method, cleanup_method)
end
def collect_ancestor_attributes_by(attribute_to_collect, combination_method, cleanup_method)
klass = self.class
while klass != ModelContract
attributes = klass.send(attribute_to_collect)
while klass.superclass != ModelContract
# Collect all the attribute_to_collect from ancestors
attributes += klass.send(attribute_to_collect)
klass = klass.superclass
attributes = attributes.send(combination_method, klass.send(attribute_to_collect))
end
attributes.uniq
attributes.send(cleanup_method)
end
end

@ -37,7 +37,7 @@ class MyController < ApplicationController
before_action :require_login
before_action :check_password_confirmation,
only: [:account],
if: ->() { request.patch? }
if: -> { request.patch? }
menu_item :account, only: [:account]
menu_item :settings, only: [:settings]
@ -45,32 +45,8 @@ class MyController < ApplicationController
menu_item :access_token, only: [:access_token]
menu_item :mail_notifications, only: [:mail_notifications]
DEFAULT_BLOCKS = { 'issuesassignedtome' => :label_assigned_to_me_work_packages,
'workpackagesresponsiblefor' => :label_responsible_for_work_packages,
'issuesreportedbyme' => :label_reported_work_packages,
'issueswatched' => :label_watched_work_packages,
'news' => :label_news_latest,
'calendar' => :label_calendar,
'timelog' => :label_spent_time
}.freeze
DEFAULT_LAYOUT = { 'left' => ['issuesassignedtome'],
'right' => ['issuesreportedbyme']
}.freeze
DRAG_AND_DROP_CONTAINERS = ['top', 'left', 'right']
verify xhr: true,
only: [:add_block, :remove_block, :order_blocks]
def self.available_blocks
@available_blocks ||= DEFAULT_BLOCKS.merge(Redmine::Views::MyPage::Block.additional_blocks)
end
# Show user's page
def index
@user = User.current
@blocks = get_current_layout
render action: 'page', layout: 'no_menu'
end
alias :page :index
@ -89,7 +65,7 @@ class MyController < ApplicationController
# Manage user's password
def password
@user = User.current # required by "my" layout
@user = User.current # required by "my" layout
@username = @user.login
redirect_if_password_change_not_allowed_for(@user)
end
@ -98,7 +74,7 @@ class MyController < ApplicationController
def change_password
return render_404 if OpenProject::Configuration.disable_password_login?
@user = User.current # required by "my" layout
@user = User.current # required by "my" layout
@username = @user.login
return if redirect_if_password_change_not_allowed_for(@user)
if @user.check_password?(params[:password], update_legacy: false)
@ -162,88 +138,6 @@ class MyController < ApplicationController
redirect_to action: 'access_token'
end
# User's page layout configuration
def page_layout
@user = User.current
@blocks = get_current_layout
@block_options = []
# Pass block url to frontend
gon.my_order_blocks_url = my_order_blocks_url;
# We track blocks that will show up on the page. This is in order to have
# them disabled in the blocks-to-add-to-page dropdown.
blocks_on_page = get_current_layout.values.flatten
MyController.available_blocks.each do |block, value|
@block_options << [t("my.blocks.#{value}", default: [value, value.to_s.humanize]), block.dasherize, disabled: blocks_on_page.include?(block)]
end
end
# Add a block to the user's page at the top.
# params[:block] : id of the block to add
#
# Responds with a HTML block.
def add_block
@block = params[:block].to_s.underscore
unless MyController.available_blocks.keys.include? @block
render plain: I18n.t(:error_invalid_selected_value), status: 400
return
end
@user = User.current
layout = get_current_layout
# Remove if already present in a group.
DRAG_AND_DROP_CONTAINERS.each { |f| (layout[f] ||= []).delete @block }
# Add it on top.
layout['top'].unshift @block
# Save user preference.
@user.pref[:my_page_layout] = layout
@user.pref.save
render layout: false
end
# Remove a block from the user's `my` page.
# params[:block] : id of the block to remove
#
# Responds with a JS layout.
def remove_block
@block = params[:block].to_s.underscore
@user = User.current
# Remove block in all groups.
layout = get_current_layout
DRAG_AND_DROP_CONTAINERS.each { |f| (layout[f] ||= []).delete @block }
# Save user preference.
@user.pref[:my_page_layout] = layout
@user.pref.save
head 200, content_type: "text/html"
end
def order_blocks
@user = User.current
layout = get_current_layout
# A nil +params[source_ordered_children]+ means all elements within
# +params['source']+ were dragged out elsewhere.
layout[params['source']] = params['source_ordered_children'] || []
layout[params['target']] = params['target_ordered_children']
@user.pref[:my_page_layout] = layout
@user.pref.save
head :ok
end
def default_breadcrumb
l(:label_my_account)
end

@ -0,0 +1,68 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module Queries::Filters::Shared::MeValueFilter
##
# Return the values object with the me value
# mapped to the current user.
def values_replaced
vals = values.clone
if vals.delete(me_value_key)
if User.current.logged?
vals.push(User.current.id.to_s)
else
vals.push('0')
end
end
vals
end
protected
##
# Returns the me value if the user is logged
def me_allowed_value
values = []
if User.current.logged?
values << [me_label, me_value_key]
end
values
end
def me_label
I18n.t(:label_me)
end
def me_value_key
::Queries::Filters::MeValue::KEY
end
end

@ -29,13 +29,22 @@
#++
class Queries::TimeEntries::Filters::UserFilter < Queries::TimeEntries::Filters::TimeEntryFilter
include Queries::Filters::Shared::MeValueFilter
def allowed_values
@allowed_values ||= begin
# We don't care for the first value as we do not display the values visibly
::Principal.in_visible_project.pluck(:id).map { |id| [id, id.to_s] }
me_allowed_value + ::Principal
.in_visible_project
.pluck(:id)
.map { |id| [id, id.to_s] }
end
end
def where
operator_strategy.sql_for_field(values_replaced, self.class.model.table_name, self.class.key)
end
def type
:list_optional
end

@ -31,6 +31,7 @@
##
# Mixin to a filter or strategy
module Queries::WorkPackages::Filter::MeValueFilterMixin
include Queries::Filters::Shared::MeValueFilter
##
# Return whether the current values object has a me value
def has_me_value?
@ -47,47 +48,12 @@ module Queries::WorkPackages::Filter::MeValueFilterMixin
principals
end
##
# Return the values object with the me value
# mapped to the current user.
def values_replaced
vals = values.clone
if vals.delete(me_value_key)
if User.current.logged?
vals.push(User.current.id.to_s)
else
vals.push('0')
end
end
vals
end
protected
def me_label
I18n.t(:label_me)
end
##
# Returns the me value if the user is logged
def me_allowed_value
values = []
if User.current.logged?
values << [me_label, me_value_key]
end
values
end
def no_me_values
sanitized_values = values.reject { |v| v == me_value_key }
sanitized_values = sanitized_values.reject { |v| v == User.current.id.to_s } if has_me_value?
sanitized_values
end
def me_value_key
::Queries::Filters::MeValue::KEY
end
end

@ -33,10 +33,17 @@ module API
:representer,
:current_user
def initialize(user, model, representer)
def initialize(user, model: nil, representer: nil)
self.current_user = user
self.model = model
self.representer = representer
self.representer = if !representer && model
"API::V3::#{model.to_s.pluralize}::#{model}Representer".constantize
elsif representer
representer
else
raise 'Representer not defined'
end
end
def call(request_body)
@ -61,7 +68,11 @@ module API
end
def struct
OpenStruct.new available_custom_fields: model.new.available_custom_fields
if model
OpenStruct.new available_custom_fields: model.new.available_custom_fields
else
OpenStruct.new
end
end
end
end

@ -27,11 +27,11 @@
#++
class ParamsToQueryService
attr_accessor :model,
:user
attr_accessor :user,
:query_class
def initialize(model, user)
self.model = model
def initialize(model, user, query_class: nil)
set_query_class(query_class, model)
self.user = user
end
@ -120,9 +120,13 @@ class ParamsToQueryService
@conversion_model ||= ::API::Utilities::QueryFiltersNameConverterContext.new(query_class)
end
def query_class
model_name = model.name
def set_query_class(query_class, model)
self.query_class = if query_class
query_class
else
model_name = model.name
"::Queries::#{model_name.pluralize}::#{model_name}Query".constantize
"::Queries::#{model_name.pluralize}::#{model_name}Query".constantize
end
end
end

@ -1,46 +0,0 @@
<%#-- copyright
OpenProject is a project management system.
Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<div id="block-<%= block_name.dasherize %>" class="widget-box block-wrapper" data-name='<%= block_name %>'>
<% content_for "#{block_name}-remove-block" do %>
<div class="box-actions">
<%= link_to '',
{ action: "remove_block", block: block_name },
class: "icon icon-close close-icon my-page--remove-block",
data: { name: block_name, dasherized: block_name.dasherize },
title: l(:button_remove_widget)
%>
</div>
<% end %>
<div class='handle'>
<%= render partial: "my/blocks/#{block_name}", locals: { user: user, edit: true, block_name: block_name } %>
</div>
</div>

@ -1,12 +0,0 @@
<% unless blocks.nil? || blocks.empty? %>
<% blocks.each do |block_name| %>
<% next unless MyController.available_blocks.keys.include? block_name %>
<% if edit %>
<%= render partial: "my/block", locals: { block_name: block_name, user: @user } %>
<% else %>
<div class="widget-box">
<%= render partial: "my/blocks/#{block_name}", locals: { edit: false, user: @user } %>
</div>
<% end %>
<% end %>
<% end %>

@ -1,4 +0,0 @@
<%= render partial: 'block',
locals: { user: @user,
block_name: @block
} %>

@ -1,45 +0,0 @@
<%#-- copyright
OpenProject is a project management system.
Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% if defined? block_name %>
<%= content_for "#{block_name}-remove-block" %>
<% end %>
<h3 class="widget-box--header">
<%= op_icon('icon-context icon-calendar') %>
<span class="widget-box--header-title"><%=l(:label_calendar)%></span>
</h3>
<% if edit %>
<div class="macro -embedded-table ck-widget">
<%= t('js.editor.macro.embedded_calendar.text') %>
</div>
<% else %>
<wp-embedded-calendar></wp-embedded-calendar>
<% end %>

@ -1,7 +0,0 @@
<% if edit %>
<div class="macro -embedded-table ck-widget">
<%= t('js.editor.macro.embedded_table.text') %>
</div>
<% else %>
<%= render partial: 'work_packages/me_list_simple', locals: { user_filter: user_filter } %>
<% end %>

@ -1,46 +0,0 @@
<%#-- copyright
OpenProject is a project management system.
Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% if defined? block_name %>
<%= content_for "#{block_name}-remove-block" %>
<% end %>
<h3 class="widget-box--header">
<%= op_icon('icon-context icon-assigned-to-me') %>
<span class="widget-box--header-title"><%=l(:label_assigned_to_me_work_packages)%></span>
</h3>
<%= render partial: 'my/blocks/embedded_work_package_table',
locals: { edit: edit, user_filter: :assignee } %>
<% content_for :header_tags do %>
<%= auto_discovery_link_tag(:atom,
work_packages_assigned_to_me_path({format: 'atom', key: User.current.rss_key}),
{title: l(:label_assigned_to_me_work_packages)}) %>
<% end %>

@ -1,46 +0,0 @@
<%#-- copyright
OpenProject is a project management system.
Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% if defined? block_name %>
<%= content_for "#{block_name}-remove-block" %>
<% end %>
<h3 class="widget-box--header">
<%= op_icon('icon-context icon-reported-by-me') %>
<span class="widget-box--header-title"><%=l(:label_reported_work_packages)%></span>
</h3>
<%= render partial: 'my/blocks/embedded_work_package_table',
locals: { edit: edit, user_filter: :author } %>
<% content_for :header_tags do %>
<%= auto_discovery_link_tag(:atom,
work_packages_reported_by_me_path({format: 'atom', key: User.current.rss_key}),
{title: l(:label_reported_work_packages)}) %>
<% end %>

@ -1,39 +0,0 @@
<%#-- copyright
OpenProject is a project management system.
Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% if defined? block_name %>
<%= content_for "#{block_name}-remove-block" %>
<% end %>
<h3 class="widget-box--header">
<%= op_icon('icon-context icon-preview') %>
<span class="widget-box--header-title"><%=l(:label_watched_work_packages)%></span>
</h3>
<%= render partial: 'my/blocks/embedded_work_package_table',
locals: { edit: edit, user_filter: :watcher } %>

@ -1,63 +0,0 @@
<%#-- copyright
OpenProject is a project management system.
Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% news = News.limit(10)
.order("#{News.table_name}.created_on DESC")
.where("#{News.table_name}.project_id in (#{@user.projects.collect{|m| m.id}.join(',')})")
.includes(:project, :author) unless @user.projects.empty? %>
<% if defined? block_name %>
<%= content_for "#{block_name}-remove-block" %>
<% end %>
<h3 class="widget-box--header">
<%= op_icon('icon-context icon-news') %>
<span class="widget-box--header-title"><%= l(:label_news_latest) %></span>
</h3>
<% news = News.latest(count: 3) %>
<% if news.empty? %>
<%= no_results_box(custom_title: t('news.my_page.no_results_title_text')) %>
<% end %>
<% unless news.empty? %>
<ul class="widget-box--arrow-links">
<% news.each do |news| %>
<li class="-widget-box--arrow-multiline">
<div>
<%= avatar(news.author, {class: 'avatar-mini'}) %>
<%= link_to_project(news.project) + ': ' %>
<%= link_to h(news.title), news_path(news) %>
<br/>
<p class="widget-box--additional-info"><%= authoring news.created_on, news.author %></p>
</div>
</li>
<% end %>
</ul>
<% end %>

@ -1,142 +0,0 @@
<%#-- copyright
OpenProject is a project management system.
Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% if defined? block_name %>
<%= content_for "#{block_name}-remove-block" %>
<% end %>
<h3 class="widget-box--header">
<%= op_icon('icon-context icon-time') %>
<span class="widget-box--header-title"><%=l(:label_spent_time)%> (<%= l(:label_last_n_days, 7) %>)</span>
</h3>
<%
entries = TimeEntry.where(["#{TimeEntry.table_name}.user_id = ? AND #{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", @user.id, Date.today - 6, Date.today])
.includes(:activity, :project, { work_package: [:type, :status] })
.references(:projects)
.order("#{TimeEntry.table_name}.spent_on DESC, #{Project.table_name}.name ASC, #{::Type.table_name}.position ASC, #{WorkPackage.table_name}.id ASC")
entries_by_day = entries.group_by(&:spent_on)
%>
<div class="total-hours">
<p><%= l(:label_total) %>: <%= html_hours("%.2f" % entries.sum(:hours).to_f) %></p>
</div>
<% if entries.any? %>
<div class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table time-entries">
<colgroup>
<col highlight-col>
<col highlight-col>
<col highlight-col>
<col highlight-col>
<col>
</colgroup>
<thead>
<tr>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= l(:label_activity) %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= Project.model_name.human %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= TimeEntry.human_attribute_name(:comments) %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= TimeEntry.human_attribute_name(:hours) %>
</span>
</div>
</div>
</th>
<th><div class="generic-table--empty-header"></div></th>
</tr>
</thead>
<tbody>
<% entries_by_day.keys.sort.reverse.each do |day| %>
<tr>
<td><strong><%= day == Date.today ? l(:label_today).titleize : format_date(day) %></strong></td>
<td></td>
<td></td>
<td class="hours"><em><%= html_hours("%.2f" % entries_by_day[day].sum(&:hours).to_f) %></em></td>
<td></td>
</tr>
<% entries_by_day[day].each do |entry| -%>
<tr class="time-entry" style="border-bottom: 1px solid #f5f5f5;">
<td class="activity"><%=h entry.activity %></td>
<td class="subject"><%=h entry.project %> <%= ' - '.html_safe + link_to_work_package(entry.work_package, truncate: 50) if entry.work_package%></td>
<td class="comments"><%=h entry.comments %></td>
<td class="hours"><%= html_hours("%.2f" % entry.hours) %></td>
<td class="buttons">
<% if entry.editable_by?(@user) -%>
<%= link_to icon_wrapper('icon-context icon-edit', ''),
{controller: '/timelog', action: 'edit', id: entry},
alt: l(:button_edit),
class: 'no-decoration-on-hover',
title: l(:button_edit) %>
<%= link_to icon_wrapper('icon-context icon-delete', ''),
{controller: '/timelog', action: 'destroy', id: entry},
data: { confirm: l(:text_are_you_sure) },
class: 'no-decoration-on-hover',
method: :delete,
alt: l(:button_delete),
title: l(:button_delete) %>
<% end -%>
</td>
</tr>
<% end -%>
<% end -%>
</tbody>
</table>
</div>
</div>
<% end %>

@ -1,45 +0,0 @@
<%#-- copyright
OpenProject is a project management system.
Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% if defined? block_name %>
<%= content_for "#{block_name}-remove-block" %>
<% end %>
<h3 class="widget-box--header">
<%= op_icon('icon-context icon-assigned-to-me') %>
<span class="widget-box--header-title"><%=l(:label_responsible_for_work_packages)%></span>
</h3>
<%= render partial: 'my/blocks/embedded_work_package_table',
locals: { edit: edit, user_filter: :responsible } %>
<% content_for :header_tags do %>
<%= auto_discovery_link_tag(:atom,
work_packages_responsible_for_path({format: 'atom', key: User.current.rss_key}),
{title: l(:label_responsible_for_work_packages)}) %>
<% end %>

@ -26,27 +26,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<% breadcrumb_paths(l(:label_my_page)) %>
<%= toolbar title: l(:label_my_page) do %>
<li class="toolbar-item" title="<%= l(:label_personalize_page) %>">
<%= link_to({ action: 'page_layout' }, accesskey: accesskey(:edit), class: 'button my-page--personalize-button') do %>
<%= op_icon('button--icon icon-settings') %>
<span class="hidden-for-sighted"><%= l(:label_personalize_page) %></span>
<% end %>
</li>
<% end %>
<div class="my-page--container">
<div id="top" class="widget-boxes">
<%= render partial: 'block_container', locals: { edit: false, blocks: @blocks['top'] } %>
</div>
<div class="grid-block widget-boxes">
<% %w(left right).each do |position| %>
<div id="<%= position %>">
<%= render partial: 'block_container', locals: { edit: false, blocks: @blocks[position] } %>
</div>
<% end %>
</div>
</div>
<% html_title(l(:label_my_page)) -%>
<% html_title(t(:label_my_page)) -%>
<openproject-base></openproject-base>

@ -1,71 +0,0 @@
<%#-- copyright
OpenProject is a project management system.
Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<%= javascript_include_tag 'my_page' %>
<%= nonced_javascript_tag do %>
<%= include_gon(need_tag: false) -%>
<% end %>
<%= toolbar title: l(:label_my_page) do %>
<%= styled_form_tag({ action: "add_block" }, class: 'my-page--block-form') do %>
<% options = "<option disabled selected>--#{t(:button_add)}--</option>"
.html_safe
.concat(options_for_select(@block_options)) %>
<li class="toolbar-item">
<%= styled_select_tag 'block', options, id: 'block-options',class: '-small' %>
</li>
<li class="toolbar-item">
<%= submit_tag l(:button_add), class: 'button' %>
</li>
<li class="toolbar-item">
<%= link_to({action: 'page'}, class: 'button') do %>
<%= op_icon('button--icon icon-cancel') %>
<span class="button--text"><%= l(:button_save_back) %></span>
<% end %>
</li>
<% end %>
<% end %>
<h4><%=l(:label_visible_elements) %></h4>
<div id='visible-grid' class="my-page--container">
<div id="top" class="dragula-container grid-content block-receiver">
<%= render partial: 'block_container', locals: { edit: true, blocks: @blocks['top'] } %>
</div>
<div class="grid-block widget-boxes">
<% %w(left right).each do |position| %>
<div id="<%= position %>" class="dragula-container grid-content block-receiver">
<%= render partial: 'block_container', locals: { edit: true, blocks: @blocks[position] } %>
</div>
<% end %>
</div>
</div>
<% html_title(l(:label_my_page)) -%>

@ -1,46 +0,0 @@
<%#-- copyright
OpenProject is a project management system.
Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<%
query_properties = {
'columns[]': %w(id project type subject),
filters:
JSON.dump([{ user_filter => { operator: '=', values: ['me'] } }, { status: { operator: 'o', values: []}}]).to_s
}
configuration = {
actionsColumnEnabled: false,
columnMenuEnabled: false,
contextMenuEnabled: false
}
%>
<wp-embedded-table-entry query-props="<%= query_properties.to_json %>"
configuration="<%= configuration.to_json %>">
</wp-embedded-table-entry>

@ -7,14 +7,12 @@ OpenProject::Application.configure do
accessibility.css
admin_users.js
autocompleter.js
calendar/lang/*.js
copy_issue_actions.js
date-de-DE.js
date-en-US.js
locales/*.js
members_form.js
members_select_boxes.js
my_page.js
new_user.js
project/responsible_attribute.js
project/description_handling.js

@ -755,7 +755,6 @@ en:
button_print: "Print"
button_quote: "Quote"
button_remove: Remove
button_remove_widget: "Remove widget"
button_rename: "Rename"
button_replace: "Replace"
button_revoke: "Revoke"
@ -1378,7 +1377,6 @@ en:
label_my_account: "My account"
label_my_account_data: "My account data"
label_my_page: "My page"
label_my_page_block: "My page block"
label_my_projects: "My projects"
label_my_queries: "My custom queries"
label_never: "Never"
@ -1485,7 +1483,6 @@ en:
label_repository_plural: "Repositories"
label_required: 'required'
label_requires: 'requires'
label_responsible_for_work_packages: "Work packages I am accountable for"
label_result_plural: "Results"
label_reverse_chronological_order: "In reverse chronological order"
label_revision: "Revision"
@ -1672,7 +1669,6 @@ en:
label_keyboard_shortcut_go_preview: "Go to preview the current edit (on edit pages only)"
label_keyboard_shortcut_focus_previous_item: "Focus previous list element (on some lists only)"
label_keyboard_shortcut_focus_next_item: "Focus next list element (on some lists only)"
label_visible_elements: Visible elements
auth_source:
using_abstract_auth_source: "Can't use an abstract authentication source."

@ -171,12 +171,17 @@ en:
general_text_yes: "yes"
general_text_No: "No"
general_text_Yes: "Yes"
label_activate: "Activate"
label_activity_no: "Activity entry number %{activityNo}"
label_activity_with_comment_no: "Activity entry number %{activityNo}. Has a user comment."
label_add_column_after: "Add column after"
label_add_column_before: "Add column before"
label_add_columns: "Add columns"
label_add_comment: "Add comment"
label_add_comment_title: "Comment and type @ to notify other people"
label_add_row_after: "Add row after"
label_add_row_before: "Add row before"
label_add_selected_columns: "Add selected columns"
label_added_by: "added by"
label_added_time_by: "Added by %{author} %{age}"
@ -233,6 +238,7 @@ en:
label_menu_collapse: "collapse"
label_menu_expand: "expand"
label_more_than_ago: "more than days ago"
label_my_page: "My page"
label_next: "Next"
label_no_data: "No data to display"
label_no_due_date: "no end date"
@ -251,7 +257,9 @@ en:
label_visibility_settings: "Visibility settings"
label_quote_comment: "Quote this comment"
label_reset: "Reset"
label_remove_column: "Remove column"
label_remove_columns: "Remove selected columns"
label_remove_row: "Remove row"
label_save_as: "Save as"
label_select_watcher: "Select a watcher..."
label_selected_filter_list: "Selected filters"
@ -273,6 +281,7 @@ en:
label_activity_show_only_comments: "Show activities with comments only"
label_activity_show_all: "Show all activities"
label_total_progress: "%{percent}% Total progress"
label_total_amount: "Total: %{amount}"
label_updated_on: "updated on"
label_warning: "Warning"
label_work_package: "Work package"
@ -389,6 +398,11 @@ en:
requires: "requiring"
required: "required by"
time_entry:
activity: 'Activity'
comment: 'Comment'
hours: 'Hours'
watchers:
label_loading: loading watchers...
label_error_loading: An error occurred while loading the watchers

@ -523,10 +523,6 @@ OpenProject::Application.routes.draw do
end
scope controller: 'my' do
post '/my/add_block', action: 'add_block'
post '/my/remove_block', action: 'remove_block'
post '/my/order_blocks', action: 'order_blocks'
get '/my/page_layout', action: 'page_layout'
get '/my/password', action: 'password'
post '/my/change_password', action: 'change_password'
get '/my/page', action: 'page'

@ -0,0 +1,32 @@
class CreateGrid < ActiveRecord::Migration[5.1]
def change
create_grids
create_grid_widgets
end
private
def create_grids
create_table :grids do |t|
t.integer :row_count, null: false
t.integer :column_count, null: false
t.string :type
t.references :user
t.timestamps
end
end
def create_grid_widgets
create_table :grid_widgets do |t|
t.integer :start_row, null: false
t.integer :end_row, null: false
t.integer :start_column, null: false
t.integer :end_column, null: false
t.string :identifier
t.text :options
t.references :grid
end
end
end

@ -0,0 +1,800 @@
# Group Grids
A grid is a layout for a page or a part of the page of the OpenProject application. It defines the structure (number of rows and number of columns) as well as the contents of the page.
The contents is defined by `GridWidget`s. While a `GridWidget` is it's own type, it is not a resource in it's own right as it is an intrinsic part of a `Grid`.
Depending on what page a grid is defined for, different widgets may be eligible to be placed on the grid. The page might also define the permissions needed for accessing, creating or modifiying the grid.
Currently, the following pages employ grids:
+ /my/page: The my page every user has. Only a user can access or modify their "my page".
*The delete action is not yet supported*
## Actions
| Link | Description | Condition |
|:-------------------:| -------------------------------------------------------------------- | ---------------------------------------------------------------- |
| updateImmediately | Directly perform edits on this grid | **Permission**: depends on the page the grid is defined for |
| update | Validate edits on the grid via a form resource before commiting | **Permission**: depends on the page the grid is defined for |
## Linked Properties
| Link | Description | Type | Constraints | Supported operations | Condition |
| :-----------: | -------------------------------------------------------------- | ------------- | --------------------- | -------------------- | ----------------------------------------- |
| self | This grid | Grid | not null | READ | |
| page | The url of the page the grid is defined for | url | not null | READ / WRITE | The page cannot be changed after the creation |
## Local Properties
| Property | Description | Type | Constraints | Supported operations | Condition |
| :----------: | --------------------------------------------------------- | -------- | ---------------------------------------------------- | -------------------- | -------------- |
| id | Grid's id | Integer | x > 0 | READ | |
| rowCount | The number of rows the grid has | Integer | x > 0 | READ/WRITE | |
| columnCount | The number of columns the grid has | Integer | x > 0 | READ/WRITE | |
| widgets | The set of `GridWidget`s selected for the grid | []GridWidget | | READ/WRITE | The widgets cannot overlap |
| createdAt | The time the grid was created | DateTime | | READ |   |
| updatedAt | The time the grid was last updated | DateTime | | READ |   |
## GridWidget Properties
| Property | Description | Type | Constraints | Supported operations | Condition |
| :----------: | --------------------------------------------------------- | -------- | ---------------------------------------------------- | -------------------- | -------------- |
| identifier | The kind of widget | String | not null | READ/WRITE | |
| startRow | The row the widget starts at (1 based) | Integer | x > 0, x < rowCount of the grid, x < endRow | READ/WRITE | |
| endRow | The row the widget ends. The widget's area does not include the row itself. | Integer | x > 0, x <= rowCount of the grid, x > startRow | READ/WRITE | |
| startColumn | The column the widget starts at (1 based) | Integer | x > 0, x < columnCount of the grid, x < endColumn | READ/WRITE | |
| endColumn | The column the widget ends. The widget's area does not include the column itself. | Integer | x > 0, x <= columnCount of the grid, x > startColumn | READ/WRITE | |
## Grid [/api/v3/grids/{id}]
+ Model
+ Body
{
"_type": "Grid",
"id": 2,
"rowCount": 8,
"columnCount": 5,
"widgets": [
{
"_type": "GridWidget",
"identifier": "time_entries_current_user",
"startRow": 1,
"endRow": 8,
"startColumn": 1,
"endColumn": 3
},
{
"_type": "GridWidget",
"identifier": "news",
"startRow": 3,
"endRow": 8,
"startColumn": 4,
"endColumn": 5
},
{
"_type": "GridWidget",
"identifier": "documents",
"startRow": 1,
"endRow": 3,
"startColumn": 3,
"endColumn": 6
}
],
"createdAt": "2018-12-03T16:58:30Z",
"updatedAt": "2018-12-13T19:36:40Z",
"_links": {
"page": {
"href": "/my/page",
"type": "text/html"
},
"updateImmediately": {
"href": "/api/v3/grids/2",
"method": "patch"
},
"update": {
"href": "/api/v3/grids/2/form",
"method": "post"
},
"self": {
"href": "/api/v3/grids/2"
}
}
}
## View grid [GET]
+ Parameters
+ id (required, integer, `1`) ... grid id
+ Response 200 (application/hal+json)
[Grid][]
+ Response 404 (application/hal+json)
Returned if the Grid does not exist or if the user does not have permission to view it.
**Required permission** depends on the page the grid is defined for
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:NotFound",
"message": "The requested resource could not be found."
}
## Grids [/api/v3/grids{?offset,pageSize,filters,sortBy}]
+ Model
+ Body
{
"_type": "Collection",
"total": 1,
"count": 1,
"pageSize": 30,
"offset": 1,
"_embedded": {
"elements": [
{
"_type": "Grid",
"id": 2,
"rowCount": 8,
"columnCount": 5,
"widgets": [
{
"_type": "GridWidget",
"identifier": "time_entries_current_user",
"startRow": 1,
"endRow": 8,
"startColumn": 1,
"endColumn": 3
},
{
"_type": "GridWidget",
"identifier": "news",
"startRow": 3,
"endRow": 8,
"startColumn": 4,
"endColumn": 5
},
{
"_type": "GridWidget",
"identifier": "documents",
"startRow": 1,
"endRow": 3,
"startColumn": 3,
"endColumn": 6
}
],
"createdAt": "2018-12-03T16:58:30Z",
"updatedAt": "2018-12-13T19:36:40Z",
"_links": {
"page": {
"href": "/my/page",
"type": "text/html"
},
"updateImmediately": {
"href": "/api/v3/grids/2",
"method": "patch"
},
"update": {
"href": "/api/v3/grids/2/form",
"method": "post"
},
"self": {
"href": "/api/v3/grids/2"
}
}
}
]
},
"_links": {
"self": {
"href": "/api/v3/time_entries?offset=1&pageSize=30"
},
"jumpTo": {
"href": "/api/v3/time_entries?offset=%7Boffset%7D&pageSize=30",
"templated": true
},
"changeSize": {
"href": "/api/v3/time_entries?offset=1&pageSize=%7Bsize%7D",
"templated": true
}
}
}
## List Grids [GET]
Lists all grids matching the provided filters and being part of the selected query page. The grids returned will also depend on the permissions of the requesting user.
+ Parameters
+ offset = `1` (optional, integer, `25`) ... Page number inside the requested collection.
+ pageSize (optional, integer, `25`) ... Number of elements to display per page.
+ filters (optional, string, `[{ "page": { "operator": "=", "values": ["/my/page"] } }]`) ... JSON specifying filter conditions.
Accepts the same format as returned by the [queries](#queries) endpoint. Currently supported filters are:
+ page: Filter grid by work package
+ Response 200 (application/hal+json)
[Grid][]
+ Response 400 (application/hal+json)
Returned if the client sends invalid request parameters e.g. filters
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:InvalidQuery",
"message": [
"Filters Invalid filter does not exist."
]
}
+ Response 403 (application/hal+json)
Returned if the client is not logged in and login is required.
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:MissingPermission",
"message": "You are not authorized to view this resource."
}
## Create Grid [POST]
Creates a new grid applying the attributes provided in the body. The constraints applied to the grid depend on the page the grid is placed in which is why the create form end point should be used to be guided when wanting to create a grid.
+ Request Create grid
+ Body
{
"rowCount": 8,
"columnCount": 5,
"widgets": [
{
"identifier": "time_entries_current_user",
"startRow": 1,
"endRow": 8,
"startColumn": 1,
"endColumn": 3
},
{
"identifier": "news",
"startRow": 3,
"endRow": 8,
"startColumn": 4,
"endColumn": 5
},
{
"identifier": "documents",
"startRow": 1,
"endRow": 3,
"startColumn": 3,
"endColumn": 6
}
],
"_links": {
"page": {
"href": "/my/page"
}
}
}
+ Response 201
[Grid][]
+ Response 400 (application/hal+json)
Occurs when the client did not send a valid JSON object in the request body.
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:InvalidRequestBody",
"message": "The request body was not a single JSON object."
}
+ Response 403 (application/hal+json)
Returned if the client does not have sufficient permissions.
**Required permission:** Depends on the page the grid is defined for.
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:MissingPermission",
"message": "You are not authorized to access this resource."
}
+ Response 422 (application/hal+json)
Returned if:
* a constraint for a property was violated (`PropertyConstraintViolation`)
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:PropertyConstraintViolation",
"message": "Grid is invalid.",
"_embedded": {
"details": {
"attribute"=>"rowCount",
"message"=>"Number of rows must be greater than 0."
}
}
}
## Update Grid [PATCH]
Updates the given grid by applying the attributes provided in the body. The constraints applied to the grid depend on the page the grid is placed in which is why the create form end point should be used to be guided when wanting to update a grid.
+ Request Update grid
+ Body
{
"rowCount": 8,
"columnCount": 5,
"widgets": [
{
"identifier": "time_entries_current_user",
"startRow": 1,
"endRow": 8,
"startColumn": 1,
"endColumn": 3
},
{
"identifier": "news",
"startRow": 3,
"endRow": 8,
"startColumn": 4,
"endColumn": 5
},
{
"identifier": "documents",
"startRow": 1,
"endRow": 3,
"startColumn": 3,
"endColumn": 6
}
]
}
+ Response 200
[Grid][]
+ Response 400 (application/hal+json)
Occurs when the client did not send a valid JSON object in the request body.
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:InvalidRequestBody",
"message": "The request body was not a single JSON object."
}
+ Response 403 (application/hal+json)
Returned if the client does not have sufficient permissions.
**Required permission:** The permission depends on the page the grid is placed in.
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:MissingPermission",
"message": "You are not authorized to access this resource."
}
+ Response 422 (application/hal+json)
Returned if:
* a constraint for a property was violated (`PropertyConstraintViolation`)
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:PropertyConstraintViolation",
"message": "Grid is invalid.",
"_embedded": {
"details": {
"attribute"=>"rowCount",
"message"=>"Number of rows must be greater than 0."
}
}
}
## Grid Create Form [/api/v3/grids/form]
This endpoint returns a form to allow a guided creation of a new grids.
The returned form will be pre-filled with default values for every property, if available.
For more details and all possible responses see the general specification of [Forms](#forms).
A page link must be provided in the body when calling this end point.
## Grid Create Form [POST]
+ Response 200 (application/hal+json)
+ Body
{
"_type": "Form",
"_embedded": {
"payload": {
"rowCount": 6,
"columnCount": 4,
"widgets": [
{
"_type": "GridWidget",
"identifier": "work_packages_assigned",
"startRow": 1,
"endRow": 7,
"startColumn": 1,
"endColumn": 3
},
{
"_type": "GridWidget",
"identifier": "work_packages_created",
"startRow": 1,
"endRow": 7,
"startColumn": 3,
"endColumn": 5
}
],
"_links": {
"page": {
"href": "/my/page",
"type": "text/html"
}
}
},
"schema": {
"_type": "Schema",
"id": {
"type": "Integer",
"name": "ID",
"required": true,
"hasDefault": false,
"writable": false
},
"createdAt": {
"type": "DateTime",
"name": "Created on",
"required": true,
"hasDefault": false,
"writable": false
},
"updatedAt": {
"type": "DateTime",
"name": "Updated on",
"required": true,
"hasDefault": false,
"writable": false
},
"rowCount": {
"type": "Integer",
"name": "Number of rows",
"required": true,
"hasDefault": false,
"writable": true
},
"columnCount": {
"type": "Integer",
"name": "Number of columns",
"required": true,
"hasDefault": false,
"writable": true
},
"page": {
"type": "Href",
"name": "Page",
"required": true,
"hasDefault": false,
"writable": true,
"_links": {
"allowedValues": [
{
"href": "/my/page",
"title": "My page"
}
]
}
},
"widgets": {
"type": "[]GridWidget",
"name": "Widgets",
"required": true,
"hasDefault": false,
"writable": true,
"_links": {}
},
"_links": {}
},
"validationErrors": {
"widgets": {
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:MultipleErrors",
"message": "Multiple field constraints have been violated.",
"_embedded": {
"errors": [
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:PropertyConstraintViolation",
"message": "Widgets is outside of the grid.",
"_embedded": {
"details": {
"attribute": "widgets"
}
}
},
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:PropertyConstraintViolation",
"message": "Widgets is outside of the grid.",
"_embedded": {
"details": {
"attribute": "widgets"
}
}
}
]
}
},
"rowCount": {
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:PropertyConstraintViolation",
"message": "Number of rows must be greater than 0.",
"_embedded": {
"details": {
"attribute": "rowCount"
}
}
}
}
},
"_links": {
"self": {
"href": "/api/v3/grids/form",
"method": "post"
},
"validate": {
"href": "/api/v3/grids/form",
"method": "post"
}
}
}
## Grid Update Form [/api/v3/grids/{id}/form]
This endpoint returns a form to allow a guided modification of an existing grid.
For more details and all possible responses see the general specification of [Forms](#forms).
## Grid Update Form [POST]
+ Parameters
+ id (required, integer, `1`) ... ID of the grid being modified
+ Request Update grid form
+ Body
{
"rowCount": 8,
"columnCount": 5,
"widgets": [
{
"identifier": "time_entries_current_user",
"startRow": 1,
"endRow": 8,
"startColumn": 1,
"endColumn": 3
},
{
"identifier": "news",
"startRow": 3,
"endRow": 8,
"startColumn": 4,
"endColumn": 5
},
{
"identifier": "documents",
"startRow": 1,
"endRow": 3,
"startColumn": 3,
"endColumn": 6
}
]
}
+ Response 200 (application/hal+json)
+ Body
{
"_type": "Form",
"_embedded": {
"payload": {
"rowCount": 6,
"columnCount": 5,
"widgets": [
{
"_type": "GridWidget",
"identifier": "time_entries_current_user",
"startRow": 1,
"endRow": 8,
"startColumn": 1,
"endColumn": 3
},
{
"_type": "GridWidget",
"identifier": "news",
"startRow": 3,
"endRow": 8,
"startColumn": 4,
"endColumn": 5
},
{
"_type": "GridWidget",
"identifier": "documents",
"startRow": 1,
"endRow": 3,
"startColumn": 3,
"endColumn": 6
}
]
},
"schema": {
"_type": "Schema",
"id": {
"type": "Integer",
"name": "ID",
"required": true,
"hasDefault": false,
"writable": false
},
"createdAt": {
"type": "DateTime",
"name": "Created on",
"required": true,
"hasDefault": false,
"writable": false
},
"updatedAt": {
"type": "DateTime",
"name": "Updated on",
"required": true,
"hasDefault": false,
"writable": false
},
"rowCount": {
"type": "Integer",
"name": "Number of rows",
"required": true,
"hasDefault": false,
"writable": true
},
"columnCount": {
"type": "Integer",
"name": "Number of columns",
"required": true,
"hasDefault": false,
"writable": true
},
"page": {
"type": "Href",
"name": "Page",
"required": true,
"hasDefault": false,
"writable": false,
"_links": {}
},
"widgets": {
"type": "[]GridWidget",
"name": "Widgets",
"required": true,
"hasDefault": false,
"writable": true,
"_links": {}
},
"_links": {}
},
"validationErrors": {
"widgets": {
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:MultipleErrors",
"message": "Multiple field constraints have been violated.",
"_embedded": {
"errors": [
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:PropertyConstraintViolation",
"message": "Widgets is outside of the grid.",
"_embedded": {
"details": {
"attribute": "widgets"
}
}
},
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:PropertyConstraintViolation",
"message": "Widgets is outside of the grid.",
"_embedded": {
"details": {
"attribute": "widgets"
}
}
}
]
}
}
}
},
"_links": {
"self": {
"href": "/api/v3/grids/2/form",
"method": "post"
},
"validate": {
"href": "/api/v3/grids/2/form",
"method": "post"
}
}
}
+ Response 403 (application/hal+json)
Returned if the client does not have sufficient permissions.
**Required permission:** depends on the page the grid is defined for.
*Note that you will only receive this error, if you are at least allowed to see the corresponding grid.*
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:MissingPermission",
"message": "You are not authorized to access this resource."
}
+ Response 404 (application/hal+json)
Returned if the grid does not exist or the client does not have sufficient permissions to see it.
**Required permission:** view work package
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:NotFound",
"message": "The requested resource could not be found."
}

@ -6,8 +6,6 @@
| updateImmediately | Directly perform edits on this time entry | **Permission**: 'edit time entries' or 'edit own time entries if the time entry belongs to the user |
| delete | Delete this time entry | **Permission**: 'edit time entries' or 'edit own time entries if the time entry belongs to the user |
None yet
## Linked Properties
| Link | Description | Type | Constraints | Supported operations | Condition |
| :-----------: | -------------------------------------------------------------- | ------------- | --------------------- | -------------------- | ----------------------------------------- |

@ -10,6 +10,7 @@
<!-- include(endpoints/custom-options.apib) -->
<!-- include(endpoints/documents.apib) -->
<!-- include(endpoints/forms.apib) -->
<!-- include(endpoints/grids.apib) -->
<!-- include(endpoints/groups.apib) -->
<!-- include(endpoints/help_texts.apib) -->
<!-- include(endpoints/news.apib) -->

@ -74,6 +74,7 @@ import {CurrentUserService} from 'core-components/user/current-user.service';
import {OpenprojectWorkPackagesModule} from 'core-app/modules/work_packages/openproject-work-packages.module';
import {OpenprojectAttachmentsModule} from 'core-app/modules/attachments/openproject-attachments.module';
import {OpenprojectEditorModule} from 'core-app/modules/editor/openproject-editor.module';
import {OpenprojectGridsModule} from "core-app/modules/grids/openproject-grids.module";
import {OpenprojectRouterModule} from "core-app/modules/router/openproject-router.module";
import {OpenprojectWorkPackageRoutesModule} from "core-app/modules/work_packages/openproject-work-package-routes.module";
import {BrowserModule} from "@angular/platform-browser";
@ -95,7 +96,7 @@ import {FullCalendarModule} from "ng-fullcalendar";
OpenprojectEditorModule,
// Display + Edit field functionality
OpenprojectFieldsModule,
OpenprojectGridsModule,
OpenprojectAttachmentsModule,
// Work packages and their routes

@ -26,7 +26,7 @@
// See doc/COPYRIGHT.rdoc for more details.
//++
export type FilterOperator = '=' | '!*' | '!' | '~' | '**';
export type FilterOperator = '=' | '!*' | '!' | '~' | 'o' | '>t-' | '**' ;
export interface ApiV3Filter {
[filter:string]:{ operator:FilterOperator, values:any };

@ -0,0 +1,3 @@
<h2 [textContent]="text.title"></h2>
<grid *ngIf="grid" [grid]="grid"></grid>

@ -0,0 +1,74 @@
import {Component, OnInit} from "@angular/core";
import {GridDmService} from "core-app/modules/hal/dm-services/grid-dm.service";
import {GridResource} from "core-app/modules/hal/resources/grid-resource";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
templateUrl: './my-page.component.html'
})
export class MyPageComponent implements OnInit {
public text = { title: this.i18n.t('js.label_my_page') };
constructor(readonly gridDm:GridDmService,
readonly pathHelper:PathHelperService,
readonly halResourceService:HalResourceService,
readonly i18n:I18nService) {}
public grid:GridResource;
ngOnInit() {
this
.loadMyPage()
.then((grid) => {
this.grid = grid;
});
}
// If a page with the current page exists (scoped to the current user by the backend)
// that page will be used to initialized the grid.
// If it does not exist, fetch the form and then create a new resource.
// The created resource is then used to initialize the grid.
private loadMyPage():Promise<GridResource> {
return this
.gridDm
.list({ filters: [['page', '=', [this.pathHelper.myPagePath()]]] })
.then(collection => {
if (collection.total === 0) {
return this.myPageForm();
} else {
return (collection.elements[0] as GridResource);
}
});
}
private myPageForm():Promise<GridResource> {
return new Promise<GridResource>((resolve, reject) => {
let payload = {
'_links': {
'page': {
'href': this.pathHelper.myPagePath()
}
}
};
this
.gridDm
.createForm(payload)
.then((form) => {
let source = form.payload.$source;
let resource = this.halResourceService.createHalResource(source) as GridResource;
this.gridDm.create(resource, form.schema)
.then((resource) => {
resolve(resource);
})
.catch(() => {
reject();
});
});
});
}
}

@ -62,6 +62,7 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo
@Input('queryProps') public queryProps:any = {};
@Input() public tableActions:OpTableActionFactory[] = [];
@Input() public compactTableStyle:boolean = false;
@Input() public externalHeight:boolean = false;
public tableInformationLoaded = false;
public showTablePagination = false;

@ -1,5 +1,7 @@
<div class="work-packages-embedded-view--container loading-indicator--location"
[ngClass]="{ '-hierarchy-disabled': !configuration.hierarchyToggleEnabled, '-compact-tables': compactTableStyle }"
[ngClass]="{ '-hierarchy-disabled': !configuration.hierarchyToggleEnabled,
'-compact-tables': compactTableStyle,
'-external-height': externalHeight }"
[attr.data-indicator-name]="uniqueEmbeddedTableName">
<ng-container *ngIf="tableInformationLoaded">
<!-- TABLE + TIMELINE horizontal split -->

@ -71,6 +71,9 @@ export const CALENDAR_ROUTES:Ng2StateDeclaration[] = [
WorkPackagesCalendarController,
WorkPackagesCalendarEntryComponent,
],
exports: [
WorkPackagesCalendarController
]
})
export class OpenprojectCalendarModule {
}

@ -210,9 +210,20 @@ export class WorkPackagesCalendarController implements OnInit, OnDestroy {
private get staticOptions() {
return {
editable: false,
eventLimit: 18,
eventLimit: false,
locale: this.i18n.locale,
height: 400,
height: () => {
let heightElement = jQuery(this.element.nativeElement);
while (!heightElement.height() && heightElement.parent()) {
heightElement = heightElement.parent();
}
let topOfCalendar = jQuery(this.element.nativeElement).position().top;
let topOfHeightElement = heightElement.position().top;
return heightElement.height()! - (topOfCalendar - topOfHeightElement);
},
header: false,
defaultView: 'basicWeek'
};

@ -0,0 +1,4 @@
<i class="icon-info1" aria-hidden="true"></i>
<span class="generic-table--no-results-title"
[textContent]="title">
</span>

@ -0,0 +1,41 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {Component, Input, HostBinding} from '@angular/core';
@Component({
templateUrl: './no-results.component.html',
selector: 'no-results'
})
export class NoResultsComponent {
@Input()
title:string;
@HostBinding('class.generic-table--no-results-container') setHostClass = true;
}

@ -70,6 +70,7 @@ import {UIRouterModule} from "@uirouter/angular";
import {PortalModule} from "@angular/cdk/portal";
import {CommonModule} from "@angular/common";
import {CollapsibleSectionComponent} from "core-app/modules/common/collapsible-section/collapsible-section.component";
import {NoResultsComponent} from "core-app/modules/common/no-results/no-results.component";
import {NgSelectModule} from "@ng-select/ng-select";
export function bootstrapModule(injector:Injector) {
@ -140,6 +141,8 @@ export function bootstrapModule(injector:Injector) {
OPContextMenuComponent,
NoResultsComponent,
// Autocompleter Component
NgSelectModule,
],
@ -184,6 +187,8 @@ export function bootstrapModule(injector:Injector) {
// Zen mode button
ZenModeButtonComponent,
NoResultsComponent
],
entryComponents: [
OpDateTimeComponent,

@ -37,6 +37,9 @@ import {Apiv3QueriesPaths} from 'core-app/modules/common/path-helper/apiv3/queri
import {Apiv3ProjectPaths} from 'core-app/modules/common/path-helper/apiv3/projects/apiv3-project-paths';
import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder";
import {Apiv3TypesPaths} from "core-app/modules/common/path-helper/apiv3/types/apiv3-types-paths";
import {Apiv3GridsPaths} from "core-app/modules/common/path-helper/apiv3/grids/apiv3-grids-paths";
import {Apiv3NewsesPaths} from "core-app/modules/common/path-helper/apiv3/news/apiv3-newses-paths";
import {Apiv3TimeEntriesPaths} from "core-app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entries-paths";
export class ApiV3Paths {
// Base path
@ -60,6 +63,12 @@ export class ApiV3Paths {
// /api/v3/priorities
public readonly priorities = new SimpleResourceCollection(this.apiV3Base, 'priorities');
// /api/v3/time_entries
public readonly time_entries = new Apiv3TimeEntriesPaths(this.apiV3Base);
// /api/v3/news
public readonly news = new Apiv3NewsesPaths(this.apiV3Base);
// /api/v3/types
public readonly types = new Apiv3TypesPaths(this.apiV3Base);
@ -78,6 +87,9 @@ export class ApiV3Paths {
// /api/v3/help_texts
public readonly help_texts = new SimpleResourceCollection(this.apiV3Base, 'help_texts');
// /api/v3/grids
public readonly grids = new Apiv3GridsPaths(this.apiV3Base);
constructor(readonly appBasePath:string) {
}

@ -0,0 +1,40 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
// ++
import {
SimpleResource
} from 'core-app/modules/common/path-helper/apiv3/path-resources';
export class Apiv3GridPaths extends SimpleResource {
constructor(basePath:string, gridId:string|number) {
super(basePath, gridId);
}
// Static paths
readonly form = new SimpleResource(this.path, 'form');
}

@ -0,0 +1,47 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
// ++
import {
SimpleResource,
SimpleResourceCollection
} from 'core-app/modules/common/path-helper/apiv3/path-resources';
import {Apiv3GridPaths} from "core-app/modules/common/path-helper/apiv3/grids/apiv3-grid-paths";
export class Apiv3GridsPaths extends SimpleResourceCollection {
constructor(basePath:string) {
super(basePath, 'grids');
}
public id(gridId:string|number) {
return new Apiv3GridPaths(this.path, gridId);
}
public form() {
return new SimpleResource(this.path, 'form');
}
}

@ -0,0 +1,37 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
// ++
import {
SimpleResource
} from 'core-app/modules/common/path-helper/apiv3/path-resources';
export class Apiv3NewsPaths extends SimpleResource {
constructor(basePath:string, newsId:string|number) {
super(basePath, newsId);
}
}

@ -0,0 +1,42 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
// ++
import {
SimpleResourceCollection
} from 'core-app/modules/common/path-helper/apiv3/path-resources';
import {Apiv3NewsPaths} from "core-app/modules/common/path-helper/apiv3/news/apiv3-news-paths";
export class Apiv3NewsesPaths extends SimpleResourceCollection {
constructor(basePath:string) {
super(basePath, 'news');
}
public id(gridId:string|number) {
return new Apiv3NewsPaths(this.path, gridId);
}
}

@ -0,0 +1,42 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
// ++
import {
SimpleResourceCollection
} from 'core-app/modules/common/path-helper/apiv3/path-resources';
import {Apiv3NewsPaths} from "core-app/modules/common/path-helper/apiv3/news/apiv3-news-paths";
export class Apiv3TimeEntriesPaths extends SimpleResourceCollection {
constructor(basePath:string) {
super(basePath, 'time_entries');
}
public id(gridId:string|number) {
return new Apiv3NewsPaths(this.path, gridId);
}
}

@ -0,0 +1,37 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
// ++
import {
SimpleResource
} from 'core-app/modules/common/path-helper/apiv3/path-resources';
export class Apiv3TimeEntryPaths extends SimpleResource {
constructor(basePath:string, newsId:string|number) {
super(basePath, newsId);
}
}

@ -0,0 +1,32 @@
export class GridArea {
private storedGuid:string;
public startRow:number;
public endRow:number;
public startColumn:number;
public endColumn:number;
constructor(startRow:number, endRow:number, startColumn:number, endColumn:number) {
this.startRow = startRow;
this.endRow = endRow;
this.startColumn = startColumn;
this.endColumn = endColumn;
}
public get guid():string {
if (!this.storedGuid) {
this.storedGuid = this.newGuid();
}
return this.storedGuid;
}
private newGuid() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
}
}

@ -0,0 +1,65 @@
import {GridWidgetResource} from "app/modules/hal/resources/grid-widget-resource";
import {GridArea} from "app/modules/grids/areas/grid-area";
export class GridWidgetArea extends GridArea {
public widget:GridWidgetResource;
constructor(widget:GridWidgetResource) {
super(widget.startRow,
widget.endRow,
widget.startColumn,
widget.endColumn);
this.widget = widget;
}
public moveRight() {
this.startColumn++;
this.endColumn++;
}
public moveLeft() {
this.startColumn--;
this.endColumn--;
}
public growColumn() {
this.endColumn++;
}
public overlaps(otherArea:GridWidgetArea) {
return this.rowOverlaps(otherArea) &&
this.columnOverlaps(otherArea);
}
public rowOverlaps(otherArea:GridWidgetArea) {
return this.startRow < otherArea.endRow &&
this.endRow >= otherArea.endRow ||
this.startRow <= otherArea.startRow &&
this.endRow > otherArea.startRow ||
this.startRow > otherArea.startRow &&
this.endRow < otherArea.endRow;
}
public columnOverlaps(otherArea:GridWidgetArea) {
return this.startColumn < otherArea.endColumn &&
this.endColumn >= otherArea.endColumn ||
this.startColumn <= otherArea.startColumn &&
this.endColumn > otherArea.startColumn ||
this.startColumn > otherArea.startColumn &&
this.endColumn < otherArea.endColumn;
}
public startColumnOverlaps(otherArea:GridWidgetArea) {
return this.startColumn < otherArea.startColumn &&
this.endColumn > otherArea.startColumn &&
this.rowOverlaps(otherArea);
}
public writeAreaChangeToWidget() {
this.widget.startRow = this.startRow;
this.widget.endRow = this.endRow;
this.widget.startColumn = this.startColumn;
this.widget.endColumn = this.endColumn;
}
}

@ -0,0 +1,107 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {Directive, ElementRef, Input} from '@angular/core';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {OpContextMenuTrigger} from 'core-components/op-context-menu/handlers/op-context-menu-trigger.directive';
import {OPContextMenuService} from 'core-components/op-context-menu/op-context-menu.service';
import {GridComponent} from "core-app/modules/grids/grid/grid.component";
import {GridAreaService} from "core-app/modules/grids/grid/area.service";
@Directive({
selector: '[gridColumnContextMenu]'
})
export class GridColumnContextMenu extends OpContextMenuTrigger {
@Input('gridColumnContextMenu-columnNumber') public columnNumber:number;
constructor(readonly elementRef:ElementRef,
readonly opContextMenu:OPContextMenuService,
readonly I18n:I18nService,
readonly layout:GridAreaService) {
super(elementRef, opContextMenu);
}
protected open(evt:JQueryEventObject) {
this.buildItems();
this.opContextMenu.show(this, evt);
}
public get locals() {
return {
items: this.items
};
}
public positionArgs(openerEvent:JQueryEventObject):any {
return {
my: 'right top',
at: 'right bottom',
of: this.$element,
collision: 'flipfit'
};
}
private buildItems() {
let grid = this.layout;
let columnNumber = this.columnNumber;
let items = [
{
linkText: I18n.t('js.label_add_column_before'),
onClick: () => {
grid.addColumn(columnNumber - 1);
return true;
}
},
{
linkText: I18n.t('js.label_add_column_after'),
onClick: () => {
grid.addColumn(columnNumber);
return true;
}
}
];
if (grid.numColumns > 1) {
items.push(
{
linkText: I18n.t('js.label_remove_column'),
onClick: () => {
grid.removeColumn(columnNumber);
return true;
}
}
);
}
this.items = items;
}
}

@ -0,0 +1,106 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {Directive, ElementRef, Input} from '@angular/core';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {OpContextMenuTrigger} from 'core-components/op-context-menu/handlers/op-context-menu-trigger.directive';
import {OPContextMenuService} from 'core-components/op-context-menu/op-context-menu.service';
import {GridAreaService} from "core-app/modules/grids/grid/area.service";
@Directive({
selector: '[gridRowContextMenu]'
})
export class GridRowContextMenu extends OpContextMenuTrigger {
@Input('gridRowContextMenu-rowNumber') public rowNumber:number;
constructor(readonly elementRef:ElementRef,
readonly opContextMenu:OPContextMenuService,
readonly I18n:I18nService,
readonly layout:GridAreaService) {
super(elementRef, opContextMenu);
}
protected open(evt:JQueryEventObject) {
this.buildItems();
this.opContextMenu.show(this, evt);
}
public get locals() {
return {
items: this.items
};
}
public positionArgs(openerEvent:JQueryEventObject):any {
return {
my: 'left top',
at: 'right top',
of: this.$element,
collision: 'flipfit'
};
}
private buildItems() {
let grid = this.layout;
let rowNumber = this.rowNumber;
let items = [
{
linkText: I18n.t('js.label_add_row_before'),
onClick: () => {
grid.addRow(rowNumber - 1);
return true;
}
},
{
linkText: I18n.t('js.label_add_row_after'),
onClick: () => {
grid.addRow(rowNumber);
return true;
}
}
];
if (grid.numRows > 1) {
items.push(
{
linkText: I18n.t('js.label_remove_row'),
onClick: () => {
grid.removeRow(rowNumber);
return true;
}
}
);
}
this.items = items;
}
}

@ -0,0 +1,104 @@
import {Injectable, Injector} from "@angular/core";
import {OpModalService} from "app/components/op-modals/op-modal.service";
import {AddGridWidgetModal} from "app/modules/grids/widgets/add/add.modal";
import {GridWidgetResource} from "app/modules/hal/resources/grid-widget-resource";
import {GridArea} from "app/modules/grids/areas/grid-area";
import {HalResourceService} from "app/modules/hal/services/hal-resource.service";
import {GridWidgetArea} from "core-app/modules/grids/areas/grid-widget-area";
import {GridAreaService} from "core-app/modules/grids/grid/area.service";
import {GridDragAndDropService} from "core-app/modules/grids/grid/drag-and-drop.service";
import {GridResizeService} from "core-app/modules/grids/grid/resize.service";
@Injectable()
export class GridAddWidgetService {
constructor(readonly opModalService:OpModalService,
readonly injector:Injector,
readonly halResource:HalResourceService,
readonly layout:GridAreaService,
readonly drag:GridDragAndDropService,
readonly resize:GridResizeService) {
}
public isAddable(area:GridArea) {
return !this.drag.currentlyDragging &&
!this.resize.currentlyResizing &&
this.layout.mousedOverArea === area &&
this.layout.widgetAreaIds.includes(area.guid);
}
public widget(area:GridArea) {
this
.select(area)
.then((widgetResource) => {
// try to set it to a 2 x 3 layout
// but shrink if that is outside the grid or
// overlaps any other widget
let newArea = new GridWidgetArea(widgetResource);
newArea.endColumn = newArea.endColumn + 1;
newArea.endRow = newArea.endRow + 2;
let maxRow:number = this.layout.numRows + 1;
let maxColumn:number = this.layout.numColumns + 1;
this.layout.widgetAreas.forEach((existingArea) => {
if (newArea.startColumnOverlaps(existingArea) &&
maxColumn > existingArea.startColumn) {
maxColumn = existingArea.startColumn;
}
});
if (maxColumn < newArea.endColumn) {
newArea.endColumn = maxColumn;
}
this.layout.widgetAreas.forEach((existingArea) => {
if (newArea.overlaps(existingArea) &&
maxRow > existingArea.startRow) {
maxRow = existingArea.startRow;
}
});
if (maxRow < newArea.endRow) {
newArea.endRow = maxRow;
}
newArea.writeAreaChangeToWidget();
this.layout.widgetResources.push(newArea.widget);
this.layout.buildAreas();
})
.catch(() => {
// user didn't select a widget
});
}
private select(area:GridArea) {
return new Promise<GridWidgetResource>((resolve, reject) => {
const modal = this.opModalService.show(AddGridWidgetModal, { });
modal.closingEvent.subscribe((modal:AddGridWidgetModal) => {
let registered = modal.chosenWidget;
if (!registered) {
reject();
return;
}
let source = {
_type: 'GridWidget',
identifier: registered.identifier,
startRow: area.startRow,
endRow: area.endRow,
startColumn: area.startColumn,
endColumn: area.endColumn
};
let resource = this.halResource.createHalResource(source) as GridWidgetResource;
resolve(resource);
});
});
}
}

@ -0,0 +1,220 @@
import {Injectable} from '@angular/core';
import {GridWidgetArea} from "app/modules/grids/areas/grid-widget-area";
import {GridArea} from "core-app/modules/grids/areas/grid-area";
import {GridDmService} from "core-app/modules/hal/dm-services/grid-dm.service";
import {GridResource} from "core-app/modules/hal/resources/grid-resource";
import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource";
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
@Injectable()
export class GridAreaService {
private resource:GridResource;
private schema:SchemaResource;
public numColumns:number = 0;
public numRows:number = 0;
public gridAreas:GridArea[];
public widgetAreas:GridWidgetArea[];
public widgetAreaIds:string[];
public widgetResources:GridWidgetResource[] = [];
public mousedOverArea:GridArea|null;
constructor (private gridDm:GridDmService) {
}
public set gridResource(value:GridResource) {
this.resource = value;
this.fetchSchema();
this.numRows = this.resource.rowCount;
this.numColumns = this.resource.columnCount;
this.widgetResources = this.resource.widgets;
this.buildAreas(false);
}
public setMousedOverArea(area:GridArea) {
this.mousedOverArea = area;
}
public buildAreas(save = true) {
this.gridAreas = this.buildGridAreas();
this.widgetAreaIds = this.buildWidgetAreaIds();
this.widgetAreas = this.buildGridWidgetAreas();
this.resource.widgets = this.widgetResources;
this.resource.rowCount = this.numRows;
this.resource.columnCount = this.numColumns;
if (save) {
this.gridDm.update(this.resource, this.schema);
}
}
private buildGridAreas() {
let cells:GridArea[] = [];
for (let row = 1; row <= this.numRows; row++) {
cells.push(...this.buildGridAreasRow(row));
}
return cells;
}
private buildGridAreasColumn(column:number) {
let cells:GridArea[] = [];
for (let row = 1; row <= this.numRows; row++) {
let cell = new GridArea(row,
row + 1,
column,
column + 1);
cells.push(cell);
}
return cells;
}
private buildGridAreasRow(row:number) {
let cells:GridArea[] = [];
for (let column = 1; column <= this.numColumns; column++) {
let cell = new GridArea(row,
row + 1,
column,
column + 1);
cells.push(cell);
}
return cells;
}
private buildGridWidgetAreas() {
return this.widgetResources.map((widget) => {
return new GridWidgetArea(widget);
});
}
// persist all changes to the areas caused by dragging/resizing
// to the widget
public writeAreaChangesToWidgets() {
this.widgetAreas.forEach((area) => {
area.writeAreaChangeToWidget();
});
}
public addColumn(column:number) {
this.numColumns++;
this.widgetResources.filter((widget) => {
return widget.startColumn > column;
}).forEach((widget) => {
widget.startColumn++;
widget.endColumn++;
});
this.buildAreas();
}
public addRow(row:number) {
this.numRows++;
this.widgetResources.filter((widget) => {
return widget.startRow > row;
}).forEach((widget) => {
widget.startRow++;
widget.endRow++;
});
this.buildAreas();
}
public removeColumn(column:number) {
this.numColumns--;
// remove widgets that only span the removed column
this.widgetResources = this.widgetResources.filter((widget) => {
return !(widget.startColumn === column && widget.endColumn === column + 1);
});
//shrink widgets that span more than the removed column
this.widgetResources.forEach((widget) => {
if (widget.startColumn <= column && widget.endColumn >= column + 1) {
//shrink widgets that span more than the removed column
widget.endColumn--;
}
});
// move all widgets that are after the removed column
// so that they appear to keep their place.
this.widgetResources.filter((widget) => {
return widget.startColumn > column;
}).forEach((widget) => {
widget.startColumn--;
widget.endColumn--;
});
this.buildAreas();
}
public removeRow(row:number) {
this.numRows--;
// remove widgets that only span the removed row
this.widgetResources = this.widgetResources.filter((widget) => {
return !(widget.startRow === row && widget.endRow === row + 1);
});
//shrink widgets that span more than the removed row
this.widgetResources.forEach((widget) => {
if (widget.startRow <= row && widget.endRow >= row + 1) {
//shrink widgets that span more than the removed row
widget.endRow--;
}
});
// move all widgets that are after the removed row
// so that they appear to keep their place.
this.widgetResources.filter((widget) => {
return widget.startRow > row;
}).forEach((widget) => {
widget.startRow--;
widget.endRow--;
});
this.buildAreas();
}
public resetAreas(ignoredArea:GridWidgetArea|null = null) {
this.widgetAreas.filter((area) => {
return !ignoredArea || area.guid !== ignoredArea.guid;
}).forEach((area) => {
area.startRow = area.widget.startRow;
area.endRow = area.widget.endRow;
area.startColumn = area.widget.startColumn;
area.endColumn = area.widget.endColumn;
});
this.numRows = this.resource.rowCount;
this.numColumns = this.resource.columnCount;
}
private buildWidgetAreaIds() {
return this.gridAreas.map((area) => {
return area.guid;
});
}
private fetchSchema() {
this.gridDm.updateForm(this.resource)
.then((form) => {
this.schema = form.schema;
});
}
}

@ -0,0 +1,113 @@
import {Injectable} from '@angular/core';
import {GridWidgetArea} from "core-app/modules/grids/areas/grid-widget-area";
import {CdkDragEnd, CdkDragEnter, CdkDragExit, CdkDragDrop} from '@angular/cdk/drag-drop';
import {GridArea} from "core-app/modules/grids/areas/grid-area";
import {GridAreaService} from "core-app/modules/grids/grid/area.service";
import {GridMoveService} from "core-app/modules/grids/grid/move.service";
@Injectable()
export class GridDragAndDropService {
public draggedArea:GridWidgetArea|null;
public placeholderArea:GridWidgetArea|null;
constructor(readonly layout:GridAreaService,
readonly move:GridMoveService) {
}
public entered(event:CdkDragEnter<GridArea>) {
if (this.draggedArea) {
let dropArea = event.container.data;
this.layout.resetAreas(this.draggedArea);
this.moveAreasOnDragging(dropArea);
}
}
public exited(event:CdkDragExit<GridArea>) {
// prevent flickering when dragging within the area spanned
// by the dragged element. Otherwise, cdk drag fires an entered event on every
// moved pixel.
if (this.draggedArea) {
this.draggedArea.endRow = this.draggedArea.startRow + 1;
this.draggedArea.endColumn = this.draggedArea.startColumn + 1;
}
}
private moveAreasOnDragging(dropArea:GridArea) {
if (!this.placeholderArea) {
return;
}
let widgetArea = this.draggedArea!;
// we cannot use the widget's original area as moving it while dragging confuses cdkDrag
this.placeholderArea.startRow = dropArea.startRow;
if (this.placeholderArea.startRow + this.placeholderArea.widget.height > this.layout.numRows + 1) {
this.placeholderArea.endRow = this.layout.numRows + 1;
} else {
this.placeholderArea.endRow = dropArea.startRow + this.placeholderArea.widget.height;
}
this.placeholderArea.startColumn = dropArea.startColumn;
if (this.placeholderArea.startColumn + this.placeholderArea.widget.width > this.layout.numColumns + 1) {
this.placeholderArea.endColumn = this.layout.numColumns + 1;
} else {
this.placeholderArea.endColumn = dropArea.startColumn + this.placeholderArea.widget.width;
}
this.move.down(this.placeholderArea, widgetArea);
}
public get currentlyDragging() {
return !!this.draggedArea;
}
public start(area:GridWidgetArea) {
this.draggedArea = area;
this.placeholderArea = new GridWidgetArea(area.widget);
}
public stop(area:GridWidgetArea, event:CdkDragEnd) {
if (!this.draggedArea) {
return;
}
let dropArea = event.source.dropContainer.data;
// Handle special case of user starting to move the widget but then deciding to
// move it back to the original area.
if (this.draggedArea.startColumn === dropArea.startColumn &&
this.draggedArea.startRow === dropArea.startRow) {
this.layout.resetAreas();
}
this.draggedArea = null;
this.placeholderArea = null;
}
public drop(event:CdkDragDrop<GridArea>) {
// this.draggedArea is already reset to null at this point
let dropArea = event.container.data;
let draggedArea = event.previousContainer.data as GridWidgetArea;
// Set the draggedArea's startRow/startColumn properties
// to the drop zone ones.
// The dragged Area should keep it's height and width normally but will
// shrink if the area would otherwise end outside the grid.
draggedArea.startRow = dropArea.startRow;
if (dropArea.startRow + draggedArea.widget.height > this.layout.numRows + 1) {
draggedArea.endRow = this.layout.numRows + 1;
} else {
draggedArea.endRow = dropArea.startRow + draggedArea.widget.height;
}
draggedArea.startColumn = dropArea.startColumn;
if (dropArea.startColumn + draggedArea.widget.width > this.layout.numColumns + 1) {
draggedArea.endColumn = this.layout.numColumns + 1;
} else {
draggedArea.endColumn = dropArea.startColumn + draggedArea.widget.width;
}
this.layout.writeAreaChangesToWidgets();
this.layout.buildAreas();
}
}

@ -0,0 +1,110 @@
<!-- Column headers -->
<div class="grid--column-headers"
[style.grid-template-columns]="gridColumnStyle">
<div *ngFor="let column of columnNumbers"
[style.grid-row-start]="1"
[style.grid-row-end]="1"
[style.grid-column-start]="column"
[style.grid-column-end]="column + 1"
class="grid--header"
gridColumnContextMenu
[gridColumnContextMenu-columnNumber]="column">
</div>
</div>
<!-- Row headers -->
<div class="grid--row-headers"
[style.grid-template-rows]="gridRowStyle">
<div *ngFor="let row of rowNumbers"
[style.grid-row-start]="row"
[style.grid-row-end]="row + 1"
[style.grid-column-start]="1"
[style.grid-column-end]="2"
class="grid--header"
gridRowContextMenu
[gridRowContextMenu-rowNumber]="row">
</div>
</div>
<div class="grid--container"
[style.grid-template-columns]="gridColumnStyle"
[style.grid-template-rows]="gridRowStyle">
<!-- Grid areas that have a widget in them -->
<div *ngFor="let area of layout.widgetAreas; trackBy: identifyGridArea;"
class="grid--area -widgeted"
[style.grid-row-start]="area.startRow"
[style.grid-row-end]="area.endRow"
[style.grid-column-start]="area.startColumn"
[style.grid-column-end]="area.endColumn"
cdkDropList
[id]="area.guid"
[cdkDropListData]="area"
[cdkDropListConnectedTo]="layout.widgetAreaIds">
<div class="grid--area-content widget-box"
cdkDrag
(cdkDragStarted)="drag.start(area)"
(cdkDragEnded)="drag.stop(area, $event)">
<div *cdkDragPlaceholder></div>
<div class="grid--widget-remove"
(click)="remove.widget(area)">
</div>
<ndc-dynamic [ndcDynamicComponent]="widgetComponent(area.widget)"
[ndcDynamicOutputs]="{}">
</ndc-dynamic>
</div>
<resizer *ngIf="!drag.currentlyDragging"
(end)="resize.end(area, $event)"
(start)="resize.start(area)"
(move)="resize.moving($event)">
</resizer>
</div>
<!-- One grid area per cell (row x columns) -->
<div *ngFor="let area of layout.gridAreas; trackBy: identifyGridArea;"
class="grid--area"
[ngClass] = "{'-drop-target': drag.currentlyDragging,
'-resize-target': resize.isTarget(area),
'-addable': add.isAddable(area) }"
[style.grid-row-start]="area.startRow"
[style.grid-row-end]="area.endRow"
[style.grid-column-start]="area.startColumn"
[style.grid-column-end]="area.endColumn"
cdkDropList
[id]="area.guid"
(mouseover)="layout.setMousedOverArea(area)"
[cdkDropListData]="area"
(cdkDropListDropped)="drag.drop($event)"
(cdkDropListEntered)="drag.entered($event)"
(cdkDropListExited)="drag.exited($event)"
[cdkDropListConnectedTo]="layout.widgetAreaIds">
<div class="grid--widget-add"
*ngIf="add.isAddable(area)"
(click)="add.widget(area)">
</div>
</div>
<!-- Grid area to visualize resizing -->
<div class="grid--area -resizing"
*ngIf="resize.placeholderArea"
[style.grid-row-start]="resize.placeholderArea.startRow"
[style.grid-row-end]="resize.placeholderArea.endRow"
[style.grid-column-start]="resize.placeholderArea.startColumn"
[style.grid-column-end]="resize.placeholderArea.endColumn">
</div>
<!-- Grid area used as a placeholder while dragging -->
<div *ngIf="drag.placeholderArea"
class="grid--area -widgeted"
[style.grid-row-start]="drag.placeholderArea.startRow"
[style.grid-row-end]="drag.placeholderArea.endRow"
[style.grid-column-start]="drag.placeholderArea.startColumn"
[style.grid-column-end]="drag.placeholderArea.endColumn">
<div class="widget-box">
<ndc-dynamic [ndcDynamicComponent]="widgetComponent(drag.placeholderArea.widget)"
[ndcDynamicOutputs]="{}">
</ndc-dynamic>
</div>
</div>
</div>

@ -0,0 +1,97 @@
import {Component,
ComponentRef,
OnDestroy,
OnInit,
Input} from "@angular/core";
import {GridResource} from "app/modules/hal/resources/grid-resource";
import {GridWidgetResource} from "app/modules/hal/resources/grid-widget-resource";
import {debugLog} from "app/helpers/debug_output";
import {DomSanitizer} from "@angular/platform-browser";
import {GridWidgetsService} from "app/modules/grids/widgets/widgets.service";
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component";
import {GridArea} from "app/modules/grids/areas/grid-area";
import {GridWidgetArea} from "app/modules/grids/areas/grid-widget-area";
import {GridMoveService} from "app/modules/grids/grid/move.service";
import {GridDragAndDropService} from "core-app/modules/grids/grid/drag-and-drop.service";
import {GridResizeService} from "core-app/modules/grids/grid/resize.service";
import {GridAreaService} from "core-app/modules/grids/grid/area.service";
import {GridAddWidgetService} from "core-app/modules/grids/grid/add-widget.service";
import {GridRemoveWidgetService} from "core-app/modules/grids/grid/remove-widget.service";
export interface WidgetRegistration {
identifier:string;
component:{ new (...args:any[]):AbstractWidgetComponent };
}
@Component({
templateUrl: './grid.component.html',
selector: 'grid',
providers: [
GridAreaService,
GridMoveService,
GridDragAndDropService,
GridResizeService,
GridAddWidgetService,
GridRemoveWidgetService
]
})
export class GridComponent implements OnDestroy, OnInit {
public uiWidgets:ComponentRef<any>[] = [];
public GRID_AREA_HEIGHT = 100;
@Input() grid:GridResource;
constructor(private sanitization:DomSanitizer,
private widgetsService:GridWidgetsService,
public drag:GridDragAndDropService,
public resize:GridResizeService,
public layout:GridAreaService,
public add:GridAddWidgetService,
public remove:GridRemoveWidgetService) {
}
ngOnInit() {
this.layout.gridResource = this.grid;
}
ngOnDestroy() {
this.uiWidgets.forEach((widget) => widget.destroy());
}
public widgetComponent(widget:GridWidgetResource|null) {
if (!widget) {
return null;
}
let registration = this.widgetsService.registered.find((reg) => reg.identifier === widget.identifier);
if (!registration) {
debugLog(`No widget registered with identifier ${widget.identifier}`);
return null;
} else {
return registration.component;
}
}
public get gridColumnStyle() {
return this.sanitization.bypassSecurityTrustStyle(`repeat(${this.layout.numColumns}, 1fr)`);
}
// array containing Numbers from 1 to this.numColumns
public get columnNumbers() {
return Array.from(Array(this.layout.numColumns + 1).keys()).slice(1);
}
public get gridRowStyle() {
return this.sanitization.bypassSecurityTrustStyle(`repeat(${this.layout.numRows}, ${this.GRID_AREA_HEIGHT}px)`);
}
public get rowNumbers() {
return Array.from(Array(this.layout.numRows + 1).keys()).slice(1);
}
public identifyGridArea(index:number, area:GridArea) {
return area.guid;
}
}

@ -0,0 +1,101 @@
import {Injectable} from '@angular/core';
import {GridWidgetArea} from "app/modules/grids/areas/grid-widget-area";
import {GridAreaService} from "core-app/modules/grids/grid/area.service";
@Injectable()
export class GridMoveService {
constructor(private layout:GridAreaService) {}
public down(movedArea:GridWidgetArea|null, ignoreArea:GridWidgetArea) {
let movedAreas:GridWidgetArea[] = [];
let remainingAreas:GridWidgetArea[] = this.layout.widgetAreas.slice(0);
if (ignoreArea) {
remainingAreas = remainingAreas.filter((area) => {
return area.guid !== ignoreArea.guid;
});
}
remainingAreas.sort((a, b) => {
return b.startRow - a.startRow;
});
while (movedArea !== null) {
movedAreas.push(movedArea!);
remainingAreas = remainingAreas.filter((area) => {
return area.guid !== movedArea!.guid;
});
movedArea = this.moveOneDown(movedAreas, remainingAreas);
}
}
private moveOneDown(anchorAreas:GridWidgetArea[], movableAreas:GridWidgetArea[]) {
let moveSpecification = this.firstAreaToMove(anchorAreas, movableAreas);
if (moveSpecification) {
let toMoveArea = moveSpecification[0] as GridWidgetArea;
let anchorArea = moveSpecification[1] as GridWidgetArea;
let areaHeight = toMoveArea.widget.height;
toMoveArea.startRow = anchorArea.endRow;
toMoveArea.endRow = toMoveArea.startRow + areaHeight;
if (this.layout.numRows < toMoveArea.endRow - 1) {
this.layout.numRows = toMoveArea.endRow - 1;
}
return toMoveArea;
} else {
return null;
}
}
// Return first area that needs to move as it overlaps another area.
// There are two groups of areas here. The first (anchorAreas) is considered stable
// and as such not fit for being moved. This happens e.g. when the user explicitly
// moved a widget or if the area has already been moved in a previous run of this method.
// The second group (movableAreas) consists of all areas that are movable.
// Once an area out of the second group has been identified that overlaps an area of the first
// group, the appropriate reference area for later moving is selected out of the group of all
// unmovable areas. The reference area is the bottommost area within the unmovable areas which's
// column values (start/end) include the to move area's start column value and which's end row is larger
// than the area overlapping the area to move. Unmovable areas which's column values do not include the
// start column are to the left or right of the area to move and can thus be ignored.
private firstAreaToMove(anchorAreas:GridWidgetArea[], movableAreas:GridWidgetArea[]) {
let overlappingArea:GridWidgetArea|null = null;
let toMoveArea:GridWidgetArea|null = null;
movableAreas.forEach((movableArea) => {
anchorAreas.forEach((anchorArea) => {
if (anchorArea.overlaps(movableArea)) {
overlappingArea = anchorArea;
toMoveArea = movableArea;
return;
}
});
if (toMoveArea) {
return;
}
});
if (toMoveArea !== null) {
let referenceArea = overlappingArea!;
anchorAreas.forEach((anchorArea) => {
if (anchorArea.endRow > referenceArea.endRow &&
toMoveArea!.startColumn >= anchorArea.startColumn && toMoveArea!.startColumn < anchorArea.endColumn) {
referenceArea = anchorArea;
}
});
return [toMoveArea, referenceArea];
} else {
return null;
}
}
}

@ -0,0 +1,25 @@
import {Injectable} from "@angular/core";
import {GridWidgetArea} from "core-app/modules/grids/areas/grid-widget-area";
import {GridAreaService} from "core-app/modules/grids/grid/area.service";
@Injectable()
export class GridRemoveWidgetService {
constructor(readonly layout:GridAreaService) {
}
public widget(area:GridWidgetArea) {
let removedWidget = area.widget;
this.layout.widgetResources = this.layout.widgetResources.filter((widget) => {
return widget.identifier !== removedWidget.identifier ||
widget.startColumn !== removedWidget.startColumn ||
widget.endColumn !== removedWidget.endColumn ||
widget.startRow !== removedWidget.startRow ||
widget.endRow !== removedWidget.endRow;
});
this.layout.buildAreas();
}
}

@ -0,0 +1,72 @@
import {Injectable} from '@angular/core';
import {GridWidgetArea} from "core-app/modules/grids/areas/grid-widget-area";
import {GridArea} from "core-app/modules/grids/areas/grid-area";
import {ResizeDelta} from "core-app/modules/common/resizer/resizer.component";
import {GridAreaService} from "core-app/modules/grids/grid/area.service";
import {GridMoveService} from "core-app/modules/grids/grid/move.service";
@Injectable()
export class GridResizeService {
public placeholderArea:GridWidgetArea|null;
private resizedArea:GridWidgetArea|null;
private targetIds:string[];
constructor(readonly layout:GridAreaService,
readonly move:GridMoveService) { }
public end(area:GridWidgetArea, deltas:ResizeDelta) {
if (!this.placeholderArea ||
!this.resizedArea) {
return;
}
this.resizedArea.endRow = this.placeholderArea.endRow;
this.resizedArea.endColumn = this.placeholderArea.endColumn;
this.layout.writeAreaChangesToWidgets();
this.layout.buildAreas();
this.resizedArea = null;
this.placeholderArea = null;
}
public start(resizedArea:GridWidgetArea) {
this.placeholderArea = new GridWidgetArea(resizedArea.widget);
this.resizedArea = resizedArea;
let resizeTargets = this.layout.gridAreas.filter((area) => {
return area.startRow >= this.placeholderArea!.startRow &&
area.startColumn >= this.placeholderArea!.startColumn;
});
this.targetIds = resizeTargets.map((area) => {
return area.guid;
});
}
public moving(deltas:ResizeDelta) {
if (!this.placeholderArea ||
!this.resizedArea ||
!this.layout.mousedOverArea ||
!this.targetIds.includes(this.layout.mousedOverArea.guid)) {
return;
}
this.layout.resetAreas();
this.placeholderArea.endRow = this.layout.mousedOverArea.endRow;
this.placeholderArea.endColumn = this.layout.mousedOverArea.endColumn;
this.move.down(this.placeholderArea, this.resizedArea);
}
public isTarget(area:GridArea) {
let areaId = area.guid;
return this.placeholderArea && this.targetIds.includes(areaId);
}
public get currentlyResizing() {
return this.placeholderArea;
}
}

@ -0,0 +1,164 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
// ++
import {NgModule, APP_INITIALIZER, Injector} from '@angular/core';
import {DynamicModule} from 'ng-dynamic-component';
import {HookService} from "core-app/modules/plugins/hook-service";
import {MyPageComponent} from "core-components/routing/my-page/my-page.component";
import {OpenprojectCommonModule} from "core-app/modules/common/openproject-common.module";
import {BrowserModule} from '@angular/platform-browser';
import {FormsModule} from '@angular/forms';
import {DragDropModule} from '@angular/cdk/drag-drop';
import {OpenprojectWorkPackagesModule} from "core-app/modules/work_packages/openproject-work-packages.module";
import {WidgetWpAssignedComponent} from "core-app/modules/grids/widgets/wp-assigned/wp-assigned.component.ts";
import {WidgetWpCreatedComponent} from "core-app/modules/grids/widgets/wp-created/wp-created.component.ts";
import {WidgetWpWatchedComponent} from "core-app/modules/grids/widgets/wp-watched/wp-watched.component.ts";
import {WidgetWpCalendarComponent} from "core-app/modules/grids/widgets/wp-calendar/wp-calendar.component.ts";
import {WidgetTimeEntriesCurrentUserComponent} from "core-app/modules/grids/widgets/time-entries-current-user/time-entries-current-user.component";
import {GridWidgetsService} from "core-app/modules/grids/widgets/widgets.service";
import {GridComponent} from "core-app/modules/grids/grid/grid.component";
import {AddGridWidgetModal} from "core-app/modules/grids/widgets/add/add.modal";
import {GridColumnContextMenu} from "core-app/modules/grids/context_menus/column.directive";
import {GridRowContextMenu} from "core-app/modules/grids/context_menus/row.directive";
import {OpenprojectCalendarModule} from "core-app/modules/calendar/openproject-calendar.module";
import {Ng2StateDeclaration, UIRouterModule} from '@uirouter/angular';
import {WidgetDocumentsComponent} from "core-app/modules/grids/widgets/documents/documents.component";
import {WidgetNewsComponent} from "core-app/modules/grids/widgets/news/news.component";
import {WidgetWpAccountableComponent} from './widgets/wp-accountable/wp-accountable.component';
export const GRID_ROUTES:Ng2StateDeclaration[] = [
{
name: 'my_page',
url: '/my/page',
component: MyPageComponent,
},
];
@NgModule({
imports: [
BrowserModule,
FormsModule,
DragDropModule,
OpenprojectCommonModule,
OpenprojectWorkPackagesModule,
OpenprojectCalendarModule,
DynamicModule.withComponents([WidgetDocumentsComponent,
WidgetNewsComponent,
WidgetWpAssignedComponent,
WidgetWpAccountableComponent,
WidgetWpCreatedComponent,
WidgetWpWatchedComponent,
WidgetWpCalendarComponent,
WidgetTimeEntriesCurrentUserComponent]),
// Routes for grid pages
UIRouterModule.forChild({ states: GRID_ROUTES }),
],
providers: [
{
provide: APP_INITIALIZER,
useFactory: registerWidgets,
deps: [Injector],
multi: true
},
GridWidgetsService,
],
declarations: [
GridComponent,
WidgetDocumentsComponent,
WidgetNewsComponent,
WidgetWpAssignedComponent,
WidgetWpAccountableComponent,
WidgetWpCreatedComponent,
WidgetWpWatchedComponent,
WidgetWpCalendarComponent,
WidgetTimeEntriesCurrentUserComponent,
AddGridWidgetModal,
GridColumnContextMenu,
GridRowContextMenu,
// MyPage
MyPageComponent,
],
entryComponents: [
AddGridWidgetModal,
// MyPage
MyPageComponent,
],
exports: [
]
})
export class OpenprojectGridsModule {
}
export function registerWidgets(injector:Injector) {
return () => {
const hookService = injector.get(HookService);
hookService.register('gridWidgets', () => {
return [
{
identifier: 'work_packages_assigned',
component: WidgetWpAssignedComponent
},
{
identifier: 'work_packages_accountable',
component: WidgetWpAccountableComponent
},
{
identifier: 'work_packages_created',
component: WidgetWpCreatedComponent
},
{
identifier: 'work_packages_watched',
component: WidgetWpWatchedComponent
},
{
identifier: 'work_packages_calendar',
component: WidgetWpCalendarComponent
},
{
identifier: 'time_entries_current_user',
component: WidgetTimeEntriesCurrentUserComponent
},
{
identifier: 'documents',
component: WidgetDocumentsComponent
},
{
identifier: 'news',
component: WidgetNewsComponent
}
];
});
};
}

@ -0,0 +1,14 @@
import {HostBinding, Input} from "@angular/core";
import {GridWidgetResource} from "app/modules/hal/resources/grid-widget-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
export abstract class AbstractWidgetComponent {
@HostBinding('style.grid-column-start') gridColumnStart:number;
@HostBinding('style.grid-column-end') gridColumnEnd:number;
@HostBinding('style.grid-row-start') gridRowStart:number;
@HostBinding('style.grid-row-end') gridRowEnd:number;
@Input() resource:GridWidgetResource;
constructor(protected i18n:I18nService) { }
}

@ -0,0 +1,25 @@
<div class="op-modal--portal ngdialog-theme-openproject grid--add-modal">
<div class="op-modal--modal-container confirm-dialog--modal loading-indicator--location"
data-indicator-name="modal"
tabindex="0">
<div class="op-modal--modal-header">
<a class="op-modal--modal-close-button">
<i
class="icon-close"
(click)="closeMe($event)"
[attr.title]="text.close_popup">
</i>
</a>
<h3 class="icon-context icon-attention" [textContent]="text.title"></h3>
</div>
<div class="ngdialog-body op-modal--modal-body">
<div *ngFor="let widget of selectable;trackBy: trackWidgetBy"
(click)="select($event, widget)"
[id]="widget.identifier"
[textContent]="widget.title"
class="grid--addable-widget" >
</div>
</div>
</div>
</div>

@ -0,0 +1,48 @@
import {Component, ElementRef, Inject, ChangeDetectorRef} from "@angular/core";
import {OpModalComponent} from "app/components/op-modals/op-modal.component";
import {WidgetRegistration} from "app/modules/grids/grid/grid.component";
import {OpModalLocalsToken} from "app/components/op-modals/op-modal.service";
import {OpModalLocalsMap} from "app/components/op-modals/op-modal.types";
import {GridWidgetsService} from "app/modules/grids/widgets/widgets.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
templateUrl: './add.modal.html'
})
export class AddGridWidgetModal extends OpModalComponent {
text = { title: this.i18n.t('js.grid.add_modal.choose_widget'),
close_popup: this.i18n.t('js.button_close') };
public chosenWidget:WidgetRegistration;
constructor(readonly elementRef:ElementRef,
@Inject(OpModalLocalsToken) readonly locals:OpModalLocalsMap,
readonly cdRef:ChangeDetectorRef,
readonly widgetsService:GridWidgetsService,
readonly i18n:I18nService) {
super(locals, cdRef, elementRef);
}
public get selectable() {
return this.widgetsService.registered.map((widget) => {
return {
identifier: widget.identifier,
title: this.i18n.t(`js.grid.widgets.${widget.identifier}.title`),
component: widget.component
};
}).sort((a, b) => {
return a.title.localeCompare(b.title);
});
}
public select($event:any, widget:WidgetRegistration) {
this.chosenWidget = widget;
this.closeMe($event);
}
public trackWidgetBy(widget:WidgetRegistration) {
return widget.identifier;
}
}

@ -0,0 +1,24 @@
<h3 class="widget-box--header" cdkDragHandle>
<i class="icon-context icon-notes" aria-hidden="true"></i>
<span class="widget-box--header-title" [textContent]="text.title"></span>
</h3>
<div class="grid--widget-content">
<no-results *ngIf="noEntries"
[title]="text.noResults">
</no-results>
<ng-container *ngFor="let document of entries">
<h4 class="document-category-elements--header">
<a [href]="documentPath(document)"
[textContent]="document.title">
</a>
</h4>
<p class="document-category-elements--date">
<em [textContent]="documentCreated(document)"></em>
</p>
<div class="wiki grid--widget-limited-text"
[innerHtml]="documentDescription(document)">
</div>
</ng-container>
</div>

@ -0,0 +1,60 @@
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component";
import {Component, OnInit, SecurityContext} from '@angular/core';
import {DocumentResource} from "../../../../../../../modules/documents/frontend/module/hal/resources/document-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {TimezoneService} from "core-components/datetime/timezone.service";
import {DomSanitizer} from '@angular/platform-browser';
@Component({
templateUrl: './documents.component.html',
})
export class WidgetDocumentsComponent extends AbstractWidgetComponent implements OnInit {
public text = {
title: this.i18n.t('js.grid.widgets.documents.title'),
noResults: this.i18n.t('js.grid.widgets.documents.no_results'),
};
public entries:DocumentResource[] = [];
private entriesLoaded = false;
constructor(readonly halResource:HalResourceService,
readonly pathHelper:PathHelperService,
readonly i18n:I18nService,
readonly timezone:TimezoneService,
readonly domSanitizer:DomSanitizer) {
super(i18n);
}
ngOnInit() {
let orders = JSON.stringify([['created_on', 'desc']]);
let url = `${this.pathHelper.api.v3.apiV3Base}/documents?sortBy=${orders}&pageSize=10`;
this.halResource
.get<CollectionResource>(url)
.toPromise()
.then((collection) => {
this.entries = collection.elements as DocumentResource[];
this.entriesLoaded = true;
});
}
public documentPath(document:DocumentResource) {
return `${this.pathHelper.appBasePath}/documents/${document.id}`;
}
public documentCreated(document:DocumentResource) {
return this.timezone.formattedDatetime(document.createdAt);
}
public documentDescription(document:DocumentResource) {
return this.domSanitizer.sanitize(SecurityContext.HTML, document.description.html);
}
public get noEntries() {
return !this.entries.length && this.entriesLoaded;
}
}

@ -0,0 +1,37 @@
<h3 class="widget-box--header" cdkDragHandle>
<i class="icon-context icon-news" aria-hidden="true"></i>
<span class="widget-box--header-title" [textContent]="text.title"></span>
</h3>
<div class="grid--widget-content">
<no-results *ngIf="noEntries"
[title]="text.noResults">
</no-results>
<ul class="widget-box--arrow-links">
<li class="-widget-box--arrow-multiline" *ngFor="let news of entries">
<div>
<img class="avatar avatar-mini avatar--fallback"
data-avatar-fallback-remove="true"
*ngIf="newsAuthorAvatar(news)"
[attr.src]="newsAuthorAvatar(news)"
[attr.title]="newsAuthorName(news)"
[attr.alt]="newsAuthorName(news)" />
<a [href]="newsProjectPath(news)"
[textContent]="newsProjectName(news)">
</a>:
<a [href]="newsPath(news)"
[textContent]="news.title">
</a>
<p class="widget-box--additional-info">
{{text.createdBy}}
<a [href]="newsAuthorPath(news)"
[textContent]="newsAuthorName(news)">
</a>
{{text.at}}
{{newsCreated(news)}}
</p>
</div>
</li>
</ul>
</div>

@ -0,0 +1,83 @@
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component";
import {Component, OnInit} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {TimezoneService} from "core-components/datetime/timezone.service";
import {NewsResource} from "core-app/modules/hal/resources/news-resource";
import {UserCacheService} from "core-components/user/user-cache.service";
import {UserResource} from "core-app/modules/hal/resources/user-resource";
import {NewsDmService} from "core-app/modules/hal/dm-services/news-dm.service";
@Component({
templateUrl: './news.component.html',
})
export class WidgetNewsComponent extends AbstractWidgetComponent implements OnInit {
public text = {
title: this.i18n.t('js.grid.widgets.news.title'),
createdBy: this.i18n.t('js.label_created_by'),
at: this.i18n.t('js.grid.widgets.news.at'),
noResults: this.i18n.t('js.grid.widgets.news.no_results'),
};
public entries:NewsResource[] = [];
private entriesLoaded = false;
constructor(readonly halResource:HalResourceService,
readonly pathHelper:PathHelperService,
readonly i18n:I18nService,
readonly timezone:TimezoneService,
readonly userCache:UserCacheService,
readonly newsDm:NewsDmService) {
super(i18n);
}
ngOnInit() {
this.newsDm
.list({ sortBy: [['created_on', 'desc']], pageSize: 3 })
.then((collection) => {
this.entries = collection.elements as NewsResource[];
this.entriesLoaded = true;
this.entries.forEach((entry) => {
this.userCache
.require(entry.author.idFromLink)
.then((user:UserResource) => {
entry.author = user;
});
});
});
}
public newsPath(news:NewsResource) {
return `${this.pathHelper.appBasePath}/news/${news.id}`;
}
public newsProjectPath(news:NewsResource) {
return this.pathHelper.projectPath(news.project.idFromLink);
}
public newsProjectName(news:NewsResource) {
return news.project.name;
}
public newsAuthorName(news:NewsResource) {
return news.author.name;
}
public newsAuthorPath(news:NewsResource) {
return this.pathHelper.userPath(news.author.id);
}
public newsAuthorAvatar(news:NewsResource) {
return news.author.avatar;
}
public newsCreated(news:NewsResource) {
return this.timezone.formattedDatetime(news.createdAt);
}
public get noEntries() {
return !this.entries.length && this.entriesLoaded;
}
}

@ -0,0 +1,100 @@
<h3 class="widget-box--header" cdkDragHandle>
<i class="icon-context icon-time" aria-hidden="true"></i>
<span class="widget-box--header-title" [textContent]="text.title"></span>
</h3>
<no-results *ngIf="noEntries"
[title]="text.noResults">
</no-results>
<ng-container *ngIf="!noEntries">
<div class="total-hours">
<p>Total: <span [textContent]="total"></span></p>
</div>
<div class="generic-table--results-container" *ngIf="anyEntries">
<table class="generic-table time-entries">
<colgroup>
<col highlight-col>
<col highlight-col>
<col highlight-col>
<col highlight-col>
<col>
</colgroup>
<thead class="-sticky">
<tr>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.activity"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.workPackage"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.comment"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.hour"></span>
</div>
</div>
</th>
<th><div class="generic-table--empty-header"></div></th>
</tr>
</thead>
<tbody>
<tr class="time-entry" *ngFor="let item of rows">
<td class="activity"
*ngIf="item.entry"
[textContent]="activityName(item.entry)">
</td>
<td colspan="3"
*ngIf="item.sum">
<strong [textContent]="item.date"></strong>
</td>
<td class="subject"
*ngIf="item.entry">
{{projectName(item.entry)}} - <a [href]="workPackagePath(item.entry)" [textContent]="workPackageName(item.entry)"></a>
</td>
<td class="comments"
*ngIf="item.entry"
[textContent]="comment(item.entry)">
</td>
<td class="hours"
*ngIf="item.entry"
[textContent]="hours(item.entry)">
</td>
<td class="hours"
*ngIf="item.sum">
<em [textContent]="item.sum | number : '1.2-2'"></em>
</td>
<td class="buttons">
<a [href]="editPath(item.entry)"
*ngIf="item.entry && item.entry.updateImmediately"
[title]="text.edit">
<op-icon icon-classes="icon-context icon-edit"></op-icon>
</a>
<a [href]="deletePath(item.entry)"
*ngIf="item.entry && item.entry.delete"
(click)="deleteIfConfirmed($event, item.entry)"
[title]="text.delete" >
<op-icon icon-classes="icon-context icon-delete"></op-icon>
</a>
</td>
</tr>
</tbody>
</table>
</div>
</ng-container>

@ -0,0 +1,162 @@
import {Component, OnInit} from "@angular/core";
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service";
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource";
import {TimezoneService} from "core-components/datetime/timezone.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {ConfirmDialogService} from "core-components/modals/confirm-dialog/confirm-dialog.service";
import {formatNumber} from "@angular/common";
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
@Component({
templateUrl: './time-entries-current-user.component.html',
})
export class WidgetTimeEntriesCurrentUserComponent extends AbstractWidgetComponent implements OnInit {
public text = {
title: this.i18n.t('js.grid.widgets.time_entries_current_user.title'),
activity: this.i18n.t('js.time_entry.activity'),
comment: this.i18n.t('js.time_entry.comment'),
hour: this.i18n.t('js.time_entry.hours'),
workPackage: this.i18n.t('js.label_work_package'),
edit: this.i18n.t('js.button_edit'),
delete: this.i18n.t('js.button_delete'),
confirmDelete: {
text: this.i18n.t('js.text_are_you_sure'),
title: this.i18n.t('js.modals.form_submit.title')
},
noResults: this.i18n.t('js.grid.widgets.time_entries_current_user.no_results'),
};
public entries:TimeEntryResource[] = [];
private entriesLoaded = false;
public rows:{ date:string, sum?:string, entry?:TimeEntryResource}[] = [];
constructor(readonly timeEntryDm:TimeEntryDmService,
readonly timezone:TimezoneService,
readonly i18n:I18nService,
readonly pathHelper:PathHelperService,
readonly confirmDialog:ConfirmDialogService) {
super(i18n);
}
ngOnInit() {
let filters = [['spentOn', '>t-', ['7']] as [string, FilterOperator, [string]],
['user_id', '=', ['me']] as [string, FilterOperator, [string]]];
this.timeEntryDm.list({ filters: filters })
.then((collection) => {
this.buildEntries(collection.elements);
this.entriesLoaded = true;
});
}
public get total() {
let duration = this.entries.reduce((current, entry) => {
return current + this.timezone.toHours(entry.hours);
}, 0);
return this.i18n.t('js.units.hour', { count: this.formatNumber(duration) });
}
public get anyEntries() {
return !!this.entries.length;
}
public activityName(entry:TimeEntryResource) {
return entry.activity.name;
}
public projectName(entry:TimeEntryResource) {
return entry.project.name;
}
public workPackageName(entry:TimeEntryResource) {
return `#${entry.workPackage.idFromLink}: ${entry.workPackage.name}`;
}
public workPackageId(entry:TimeEntryResource) {
return entry.workPackage.idFromLink;
}
public comment(entry:TimeEntryResource) {
return entry.comment;
}
public hours(entry:TimeEntryResource) {
return this.formatNumber(this.timezone.toHours(entry.hours));
}
public editPath(entry:TimeEntryResource) {
return this.pathHelper.timeEntryEditPath(entry.id);
}
public deletePath(entry:TimeEntryResource) {
return this.pathHelper.timeEntryPath(entry.id);
}
public workPackagePath(entry:TimeEntryResource) {
return this.pathHelper.workPackagePath(entry.workPackage.idFromLink);
}
public deleteIfConfirmed(event:Event, entry:TimeEntryResource) {
event.preventDefault();
this.confirmDialog.confirm({
text: this.text.confirmDelete,
closeByEscape: true,
showClose: true,
closeByDocument: true
}).then(() => {
entry.delete().then(() => {
let newEntries = this.entries.filter((anEntry) => {
return entry.id !== anEntry.id;
});
this.buildEntries(newEntries);
});
})
.catch(() => {
// nothing
});
}
private buildEntries(entries:TimeEntryResource[]) {
this.entries = entries;
let sumsByDateSpent:{[key:string]:number} = {};
entries.forEach((entry) => {
let date = entry.spentOn;
if (!sumsByDateSpent[date]) {
sumsByDateSpent[date] = 0;
}
sumsByDateSpent[date] = sumsByDateSpent[date] + this.timezone.toHours(entry.hours);
});
let sortedEntries = entries.sort((a, b) => {
return b.spentOn.localeCompare(a.spentOn);
});
this.rows = [];
let currentDate:string|null = null;
sortedEntries.forEach((entry) => {
if (entry.spentOn !== currentDate) {
currentDate = entry.spentOn;
this.rows.push({date: this.timezone.formattedDate(currentDate!), sum: this.formatNumber(sumsByDateSpent[currentDate!])});
}
this.rows.push({date: currentDate!, entry: entry});
});
//entries
}
private formatNumber(value:number) {
return formatNumber(value, this.i18n.locale, '1.2-2');
}
public get noEntries() {
return !this.entries.length && this.entriesLoaded;
}
}

@ -0,0 +1,18 @@
import {Injectable} from "@angular/core";
import {WidgetRegistration} from "app/modules/grids/grid/grid.component";
import {HookService} from "app/modules/plugins/hook-service";
@Injectable()
export class GridWidgetsService {
constructor(private Hook:HookService) {}
public get registered() {
let registeredWidgets:WidgetRegistration[] = [];
_.each(this.Hook.call('gridWidgets'), (registration:WidgetRegistration[]) => {
registeredWidgets = registeredWidgets.concat(registration);
});
return registeredWidgets;
}
}

@ -0,0 +1,26 @@
import {Component, OnInit} from "@angular/core";
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component";
import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder";
@Component({
templateUrl: '../wp-widget/wp-widget.component.html',
styleUrls: ['../wp-widget/wp-widget.component.css']
})
export class WidgetWpAccountableComponent extends AbstractWidgetComponent implements OnInit {
public text = { title: this.i18n.t('js.grid.widgets.work_packages_accountable.title') };
public queryProps:any;
public configuration = { "actionsColumnEnabled": false,
"columnMenuEnabled": false,
"contextMenuEnabled": false };
ngOnInit() {
let filters = new ApiV3FilterBuilder();
filters.add('responsible', '=', ["me"]);
filters.add('status', 'o', []);
this.queryProps = {"columns[]":["id", "project", "type", "subject"],
"filters":filters.toJson()};
}
}

@ -0,0 +1,26 @@
import {Component, OnInit} from "@angular/core";
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component";
import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder";
@Component({
templateUrl: '../wp-widget/wp-widget.component.html',
styleUrls: ['../wp-widget/wp-widget.component.css']
})
export class WidgetWpAssignedComponent extends AbstractWidgetComponent implements OnInit {
public text = { title: this.i18n.t('js.grid.widgets.work_packages_assigned.title') };
public queryProps:any;
public configuration = { "actionsColumnEnabled": false,
"columnMenuEnabled": false,
"contextMenuEnabled": false };
ngOnInit() {
let filters = new ApiV3FilterBuilder();
filters.add('assignee', '=', ["me"]);
filters.add('status', 'o', []);
this.queryProps = {"columns[]":["id", "project", "type", "subject"],
"filters":filters.toJson()};
}
}

@ -0,0 +1,7 @@
<h3 class="widget-box--header" cdkDragHandle>
<i class="icon-context icon-calendar" aria-hidden="true"></i>
<span class="widget-box--header-title" [textContent]="text.title"></span>
</h3>
<wp-calendar [static]="true">
</wp-calendar>

@ -0,0 +1,37 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
// ++
import {Component} from '@angular/core';
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component";
@Component({
templateUrl: './wp-calendar.component.html',
})
export class WidgetWpCalendarComponent extends AbstractWidgetComponent {
public text = { title: this.i18n.t('js.grid.widgets.work_packages_calendar.title') };
}

@ -0,0 +1,25 @@
import {Component, OnInit} from "@angular/core";
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component";
import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder";
@Component({
templateUrl: '../wp-widget/wp-widget.component.html',
styleUrls: ['../wp-widget/wp-widget.component.css']
})
export class WidgetWpCreatedComponent extends AbstractWidgetComponent implements OnInit {
public text = { title: this.i18n.t('js.grid.widgets.work_packages_created.title') };
public queryProps:any;
public configuration = { "actionsColumnEnabled": false,
"columnMenuEnabled": false,
"contextMenuEnabled": false };
ngOnInit() {
let filters = new ApiV3FilterBuilder();
filters.add('author', '=', ["me"]);
filters.add('status', 'o', []);
this.queryProps = {"columns[]":["id", "project", "type", "subject"],
"filters":filters.toJson()};
}
}

@ -0,0 +1,25 @@
import {Component, OnInit} from "@angular/core";
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component";
import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder";
@Component({
templateUrl: '../wp-widget/wp-widget.component.html',
styleUrls: ['../wp-widget/wp-widget.component.css']
})
export class WidgetWpWatchedComponent extends AbstractWidgetComponent implements OnInit {
public text = { title: this.i18n.t('js.grid.widgets.work_packages_watched.title') };
public queryProps:any;
public configuration = { "actionsColumnEnabled": false,
"columnMenuEnabled": false,
"contextMenuEnabled": false };
ngOnInit() {
let filters = new ApiV3FilterBuilder();
filters.add('watcher', '=', ["me"]);
filters.add('status', 'o', []);
this.queryProps = {"columns[]":["id", "project", "type", "subject"],
"filters":filters.toJson()};
}
}

@ -0,0 +1,5 @@
wp-embedded-table {
display: flex;
flex: 1 1 auto;
overflow: hidden;
}

@ -0,0 +1,9 @@
<h3 class="widget-box--header" cdkDragHandle>
<i class="icon-context icon-assigned-to-me" aria-hidden="true"></i>
<span class="widget-box--header-title" [textContent]="text.title"></span>
</h3>
<wp-embedded-table [queryProps]="queryProps"
[configuration]="configuration"
[externalHeight]="true">
</wp-embedded-table>

@ -0,0 +1,4 @@
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component";
export class WidgetWpListComponent extends AbstractWidgetComponent {
}

@ -0,0 +1,81 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {DmListParameter, DmServiceInterface} from "core-app/modules/hal/dm-services/dm.service.interface";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {Injectable} from '@angular/core';
// This only needs to be Injectable for the tests to work
@Injectable()
export abstract class AbstractDmService<T extends HalResource> implements DmServiceInterface {
constructor(protected halResourceService:HalResourceService,
protected pathHelper:PathHelperService) {
}
public list(params:DmListParameter|null):Promise<CollectionResource> {
let queryProps = [];
if (params && params.sortBy) {
queryProps.push(`sortBy=${JSON.stringify(params.sortBy)}`);
}
if (params && params.pageSize) {
queryProps.push(`pageSize=${params.pageSize}`);
}
if (params && params.filters) {
let filters = new ApiV3FilterBuilder();
params.filters.forEach((filterParam) => {
filters.add(...filterParam);
});
queryProps.push(filters.toParams());
}
let queryPropsString = '';
if (queryProps.length) {
queryPropsString = `?${queryProps.join('&')}`;
}
return this.halResourceService.get<CollectionResource<T>>(this.listUrl() + queryPropsString).toPromise();
}
public one(id:number):Promise<T> {
return this.halResourceService.get<T>(this.oneUrl(id).toString()).toPromise();
}
protected abstract listUrl():string;
protected abstract oneUrl(id:number|string):string;
}

@ -0,0 +1,40 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
export interface DmListParameter {
filters?:[string, FilterOperator, [string]][];
sortBy?:[string, string][];
pageSize?:number;
}
export interface DmServiceInterface {
list(params:DmListParameter):Promise<CollectionResource>;
}

@ -0,0 +1,111 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {Injectable} from '@angular/core';
import {HalResourceService} from 'core-app/modules/hal/services/hal-resource.service';
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
import {GridResource} from "core-app/modules/hal/resources/grid-resource";
import {FormResource} from "core-app/modules/hal/resources/form-resource";
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
import {ApiV3FilterBuilder, FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
import {PayloadDmService} from "core-app/modules/hal/dm-services/payload-dm.service";
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {AbstractDmService} from "core-app/modules/hal/dm-services/abstract-dm.service";
@Injectable()
export class GridDmService extends AbstractDmService<GridResource> {
constructor(protected halResourceService:HalResourceService,
protected pathHelper:PathHelperService,
protected payloadDm:PayloadDmService) {
super(halResourceService,
pathHelper);
}
public createForm(resource:GridResource|null|any = null, schema:SchemaResource|null = null) {
let payload = this.extractPayload(resource, schema);
return this.halResourceService.post<FormResource>(this.pathHelper.api.v3.grids.form().toString(),
payload).toPromise();
}
public create(resource:GridResource, schema:SchemaResource|null = null):Promise<GridResource> {
let payload = this.extractPayload(resource, schema);
return this.halResourceService.post<GridResource>(this.pathHelper.api.v3.grids.path,
payload).toPromise();
}
public update(resource:GridResource, schema:SchemaResource):Promise<GridResource> {
let payload = this.extractPayload(resource, schema);
return this.halResourceService.patch<GridResource>(this.pathHelper.api.v3.grids.id(resource.idFromLink).toString(),
payload).toPromise();
}
public updateForm(resource:GridResource, schema:SchemaResource|null = null) {
let payload = this.extractPayload(resource, schema);
return this.halResourceService.post<FormResource>(this.pathHelper.api.v3.grids.id(resource.idFromLink).form.toString(),
payload).toPromise();
}
public extractPayload(resource:GridResource|null = null, schema:SchemaResource|null = null) {
if (resource && schema) {
let payload = this.payloadDm.extract(resource, schema);
// The widget only states the type of the widget resource but does not explain
// the widget itself. We therefore have to do that by hand.
if (payload.widgets) {
payload.widgets = resource.widgets.map((widget) => {
return {
startRow: widget.startRow,
endRow: widget.endRow,
startColumn: widget.startColumn,
endColumn: widget.endColumn,
identifier: widget.identifier
};
});
}
return payload;
} else if (!(resource instanceof HalResource)) {
return resource;
} else {
return {};
}
}
protected listUrl() {
return this.pathHelper.api.v3.grids.toString();
}
protected oneUrl(id:number|string) {
return this.pathHelper.api.v3.grids.id(id).toString();
}
}

@ -0,0 +1,42 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {Injectable} from '@angular/core';
import {AbstractDmService} from "core-app/modules/hal/dm-services/abstract-dm.service";
import {NewsResource} from "core-app/modules/hal/resources/news-resource";
@Injectable()
export class NewsDmService extends AbstractDmService<NewsResource> {
protected listUrl() {
return this.pathHelper.api.v3.news.toString();
}
protected oneUrl(id:number|string) {
return this.pathHelper.api.v3.news.id(id).toString();
}
}

@ -0,0 +1,42 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {Injectable} from '@angular/core';
import {AbstractDmService} from "core-app/modules/hal/dm-services/abstract-dm.service";
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource";
@Injectable()
export class TimeEntryDmService extends AbstractDmService<TimeEntryResource> {
protected listUrl() {
return this.pathHelper.api.v3.time_entries.toString();
}
protected oneUrl(id:number|string) {
return this.pathHelper.api.v3.time_entries.id(id).toString();
}
}

@ -45,7 +45,10 @@ import {UserDmService} from 'core-app/modules/hal/dm-services/user-dm.service';
import {ProjectDmService} from 'core-app/modules/hal/dm-services/project-dm.service';
import {HalResourceSortingService} from "core-app/modules/hal/services/hal-resource-sorting.service";
import {HalAwareErrorHandler} from "core-app/modules/hal/services/hal-aware-error-handler";
import {GridDmService} from "core-app/modules/hal/dm-services/grid-dm.service";
import {TimeEntryDmService} from './dm-services/time-entry-dm.service';
import {CommonModule} from "@angular/common";
import {NewsDmService} from './dm-services/news-dm.service';
@NgModule({
imports: [
@ -59,15 +62,18 @@ import {CommonModule} from "@angular/common";
{ provide: HTTP_INTERCEPTORS, useClass: OpenProjectHeaderInterceptor, multi: true },
{ provide: APP_INITIALIZER, useFactory: initializeHalResourceConfig, deps: [HalResourceService], multi: true },
ConfigurationDmService,
GridDmService,
HelpTextDmService,
NewsDmService,
PayloadDmService,
ProjectDmService,
QueryDmService,
UserDmService,
QueryFormDmService,
RelationsDmService,
ProjectDmService,
RootDmService,
TypeDmService
TimeEntryDmService,
TypeDmService,
UserDmService,
]
})
export class OpenprojectHalModule { }

@ -0,0 +1,49 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource";
export class GridResource extends HalResource {
public widgets:GridWidgetResource[];
public $initialize(source:any) {
super.$initialize(source);
this.widgets = this
.widgets
.map((widget:Object) => new GridWidgetResource(
this.injector,
widget,
true,
this.halInitializer,
'GridWidget'
)
);
}
}

@ -0,0 +1,45 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
export class GridWidgetResource extends HalResource {
public identifier:string;
public startRow:number;
public endRow:number;
public startColumn:number;
public endColumn:number;
public get height() {
return this.endRow - this.startRow;
}
public get width() {
return this.endColumn - this.startColumn;
}
}

@ -0,0 +1,32 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
export class NewsResource extends HalResource {
}

@ -23,6 +23,7 @@ import {TypeResource} from 'core-app/modules/hal/resources/type-resource';
import {UserResource} from 'core-app/modules/hal/resources/user-resource';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {WorkPackageCollectionResource} from 'core-app/modules/hal/resources/wp-collection-resource';
import {GridResource} from "core-app/modules/hal/resources/grid-resource";
export const coreHalResources = [
AttachmentCollectionResource,
@ -50,4 +51,5 @@ export const coreHalResources = [
UserResource,
WorkPackageResource,
WorkPackageCollectionResource,
GridResource
];

@ -0,0 +1,32 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 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.
//++
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
export class TimeEntryResource extends HalResource {
}

@ -29,6 +29,7 @@
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
import {Attachable} from 'core-app/modules/hal/resources/mixins/attachable-mixin';
export interface WikiPageResourceLinks {
addAttachment(attachment:HalResource):Promise<any>;
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save