Merge remote-tracking branch 'origin/wp-edit-sync' into wp-edit-sync

Conflicts:
	frontend/app/components/wp-panels/overview-panel/overview-panel.directive.ts
pull/4338/head
Roman Roelofsen 9 years ago
commit fe8007fa19
  1. 2
      Gemfile.lock
  2. 6
      app/assets/stylesheets/content/_context_menu.sass
  3. 6
      app/assets/stylesheets/content/_forms.lsg
  4. 4
      app/assets/stylesheets/content/_forms.sass
  5. 2
      app/assets/stylesheets/content/_work_packages_table_edit.sass
  6. 1
      app/assets/stylesheets/default.css.sass
  7. 1
      app/assets/stylesheets/layout/_toolbar.sass
  8. 6
      app/assets/stylesheets/specific/announcements.sass
  9. 30
      app/controllers/announcements_controller.rb
  10. 1
      app/controllers/homescreen_controller.rb
  11. 9
      app/helpers/announcements_helper.rb
  12. 11
      app/helpers/types_helper.rb
  13. 26
      app/models/announcement.rb
  14. 2
      app/views/account/exit.html.erb
  15. 2
      app/views/account/login.html.erb
  16. 10
      app/views/announcements/_show.html.erb
  17. 22
      app/views/announcements/edit.html.erb
  18. 2
      app/views/boards/_form.html.erb
  19. 2
      app/views/boards/show.html.erb
  20. 2
      app/views/categories/_form.html.erb
  21. 2
      app/views/homescreen/index.html.erb
  22. 2
      app/views/messages/edit.html.erb
  23. 2
      app/views/messages/new.html.erb
  24. 4
      app/views/messages/show.html.erb
  25. 2
      app/views/news/edit.html.erb
  26. 2
      app/views/news/new.html.erb
  27. 2
      app/views/project_associations/new.html.erb
  28. 2
      app/views/reportings/edit.html.erb
  29. 2
      app/views/reportings/new.html.erb
  30. 2
      app/views/repositories/committers.html.erb
  31. 2
      app/views/timelines/filter/_projects.html.erb
  32. 66
      app/views/types/_form.html.erb
  33. 2
      app/views/versions/edit.html.erb
  34. 2
      app/views/versions/new.html.erb
  35. 2
      app/views/wiki/edit.html.erb
  36. 2
      app/views/wiki/new.html.erb
  37. 9
      codecov.yml
  38. 9
      config/initializers/menus.rb
  39. 14
      config/locales/en.yml
  40. 7
      config/locales/js-en.yml
  41. 3
      config/routes.rb
  42. 28
      db/migrate/20121114100641_aggregated_announcements_migrations.rb
  43. 2
      doc/operation_guides/manual/installation-guide.md
  44. 53
      frontend/app/angular-modules.ts
  45. 71
      frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts
  46. 5
      frontend/app/components/common/notification-box/notification-box.directive.html
  47. 23
      frontend/app/components/common/notification-box/notification-box.directive.js
  48. 0
      frontend/app/components/common/notification-box/notification-box.directive.test.js
  49. 132
      frontend/app/components/common/notifications/notifications.service.js
  50. 84
      frontend/app/components/common/notifications/notifications.service.test.js
  51. 2
      frontend/app/components/context-menus/wp-context-menu/wp-context-menu.service.html
  52. 13
      frontend/app/components/context-menus/wp-context-menu/wp-context-menu.service.test.ts
  53. 18
      frontend/app/components/inplace-edit/wp-field/wp-field.service.js
  54. 14
      frontend/app/components/routing/ui-router.config.ts
  55. 1
      frontend/app/components/routing/wp-list/wp.list.html
  56. 115
      frontend/app/components/routing/wp-show/wp-show.controller.js
  57. 56
      frontend/app/components/routing/wp-show/wp.show.html
  58. 16
      frontend/app/components/work-packages/wp-attachments/wp-attachments.service.test.ts
  59. 109
      frontend/app/components/work-packages/wp-attachments/wp-attachments.service.ts
  60. 18
      frontend/app/components/work-packages/wp-display-attr/wp-display-attr.directive.html
  61. 2
      frontend/app/components/work-packages/wp-display-attr/wp-display-attr.directive.test.ts
  62. 20
      frontend/app/components/work-packages/wp-display-attr/wp-display-attr.directive.ts
  63. 13
      frontend/app/components/work-packages/wp-new.controller.js
  64. 299
      frontend/app/components/work-packages/wp-single-view/wp-single-view-field.service.ts
  65. 63
      frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.html
  66. 125
      frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.ts
  67. 140
      frontend/app/components/work-packages/wp-single-view/wp-single-view.service.ts
  68. 15
      frontend/app/components/wp-buttons/wp-inline-create-button/wp-inline-create-button.controller.ts
  69. 6
      frontend/app/components/wp-edit/wp-edit-field/wp-edit-field.directive.ts
  70. 18
      frontend/app/components/wp-edit/wp-edit-form.directive.ts
  71. 11
      frontend/app/components/wp-panels/overview-panel/overview-panel.directive.html
  72. 39
      frontend/app/components/wp-panels/overview-panel/overview-panel.directive.ts
  73. 17
      frontend/app/components/wp-panels/overview-panel/wp-overview-panel.directive.html
  74. 48
      frontend/app/components/wp-table/context-menu-helper/wp-context-menu-helper.service.js
  75. 35
      frontend/app/components/wp-table/wp-group-header/wp-group-header.directive.js
  76. 33
      frontend/app/components/wp-table/wp-table.directive.html
  77. 44
      frontend/app/components/wp-table/wp-table.directive.js
  78. 7
      frontend/app/components/wp-table/wp-table.service.js
  79. 5
      frontend/app/services/index.js
  80. 130
      frontend/app/services/notifications-service.js
  81. 73
      frontend/app/templates/work_packages/tabs/overview.html
  82. 8
      frontend/app/ui_components/index.js
  83. 104
      frontend/app/work_packages/controllers/details-tab-overview-controller.js
  84. 11
      frontend/app/work_packages/controllers/index.js
  85. 2
      frontend/app/work_packages/directives/work-package-attachments-directive.js
  86. 4
      frontend/app/work_packages/helpers/index.js
  87. 14
      frontend/app/work_packages/helpers/work-package-display-helper.js
  88. 10
      frontend/app/work_packages/services/index.js
  89. 105
      frontend/app/work_packages/services/work-package-attachments-service.js
  90. 22
      frontend/tests/unit/tests/work_packages/helpers/work-package-context-menu-helper-test.js
  91. 2
      frontend/webpack.config.js
  92. 2
      lib/api/decorators/aggregation_group.rb
  93. 2
      lib/api/decorators/allowed_values_by_collection_representer.rb
  94. 5
      lib/api/decorators/property_schema_representer.rb
  95. 70
      lib/api/decorators/schema.rb
  96. 2
      lib/api/v3/projects/project_collection_representer.rb
  97. 2
      lib/api/v3/projects/project_representer.rb
  98. 1
      lib/api/v3/utilities/custom_field_injector.rb
  99. 4
      lib/api/v3/work_packages/available_projects_on_create_api.rb
  100. 4
      lib/api/v3/work_packages/available_projects_on_edit_api.rb
  101. Some files were not shown because too many files have changed in this diff Show More

@ -53,7 +53,7 @@ GIT
GIT GIT
remote: https://github.com/opf/openproject-translations.git remote: https://github.com/opf/openproject-translations.git
revision: 87a7df42600c7edc084007c2c147361ea94933c4 revision: af89ef0a50fe76c243fdee253f1b1fa439488a46
branch: dev branch: dev
specs: specs:
openproject-translations (5.1.0) openproject-translations (5.1.0)

@ -26,12 +26,6 @@
// See doc/COPYRIGHT.rdoc for more details. // See doc/COPYRIGHT.rdoc for more details.
//++ //++
@mixin context-menu-defaults
display:block
margin: 0
padding: 0
border: 0
#work-package-context-menu, #column-context-menu #work-package-context-menu, #column-context-menu
&.action-menu &.action-menu
position: absolute position: absolute

@ -13,7 +13,7 @@
</div> </div>
</div> </div>
<hr class="form--separator"> <hr class="form--separator" />
<button class="button -highlight">Save</button> <button class="button -highlight">Save</button>
<button class="button">Cancel</button> <button class="button">Cancel</button>
</form> </form>
@ -32,7 +32,7 @@
</div> </div>
</div> </div>
<hr class="form--separator"> <hr class="form--separator" />
<button class="button -highlight">Save</button> <button class="button -highlight">Save</button>
<button class="button">Cancel</button> <button class="button">Cancel</button>
</form> </form>
@ -51,7 +51,7 @@
</div> </div>
</div> </div>
<hr class="form--separator"> <hr class="form--separator" />
<button class="button -highlight">Save</button> <button class="button -highlight">Save</button>
<button class="button">Cancel</button> <button class="button">Cancel</button>
</form> </form>

@ -178,8 +178,6 @@ $form--field-types: (text-field, text-area, select, check-box, radio-button, ran
\:checked + .styled-checkbox:after \:checked + .styled-checkbox:after
opacity: 1 opacity: 1
\:focus + .styled-checkbox:before
.styled-checkbox .styled-checkbox
box-sizing: content-box box-sizing: content-box
display: inline-block display: inline-block
@ -436,7 +434,7 @@ fieldset.form--fieldset
&.add_locale.icon &.add_locale.icon
margin-top: 5px margin-top: 5px
&:before &:before
padding-left: 0px padding-left: 0px
%form--field-element-container %form--field-element-container
display: block display: block

@ -66,7 +66,6 @@
// Editable fields cursor // Editable fields cursor
.-editable .wp-table--cell-span .-editable .wp-table--cell-span
padding: 0 5px 0 5px
cursor: text cursor: text
border-color: transparent border-color: transparent
border-style: solid border-style: solid
@ -85,6 +84,7 @@
// Default cursor on non-editable and id fields // Default cursor on non-editable and id fields
.wp-table--cell-span .wp-table--cell-span
padding: 0 5px 0 5px
cursor: not-allowed cursor: not-allowed
&.id &.id

@ -112,6 +112,7 @@
@import content/select2 @import content/select2
@import specific/homescreen @import specific/homescreen
@import specific/announcements
@import misc_legacy @import misc_legacy
@import jstoolbar @import jstoolbar

@ -67,7 +67,6 @@
display: table display: table
.toolbar-items .toolbar-items
> li
.toolbar-item, .toolbar-item,
.toolbar-button-group > li .toolbar-button-group > li
float: left float: left

@ -0,0 +1,6 @@
#announcement
margin-left: auto
margin-right: auto
margin-top: 1rem
width: 511px

@ -0,0 +1,30 @@
class AnnouncementsController < ApplicationController
layout 'admin'
before_filter :require_admin
def edit
@announcement = Announcement.only_one
end
def update
@announcement = Announcement.only_one
@announcement.attributes = announcement_params
if @announcement.save
flash[:success] = t(:notice_successful_update)
end
render action: 'edit'
end
private
def default_breadcrumb
t(:label_announcement)
end
def announcement_params
params.require(:announcement).permit('text', 'show_until', 'active')
end
end

@ -32,6 +32,7 @@ class HomescreenController < ApplicationController
@newest_projects = Project.visible.newest.take(3) @newest_projects = Project.visible.newest.take(3)
@newest_users = User.newest.take(3) @newest_users = User.newest.take(3)
@news = News.latest(count: 3) @news = News.latest(count: 3)
@announcement = Announcement.active_and_current
@homescreen = OpenProject::Homescreen @homescreen = OpenProject::Homescreen
end end

@ -0,0 +1,9 @@
module AnnouncementsHelper
def notice_annoucement_active
if @announcement.active_and_current?
l(:'announcements.is_active')
else
l(:'announcements.is_inactive')
end
end
end

@ -69,7 +69,7 @@ module ::TypesHelper
# within the form date is shown as a single entry including start and due # within the form date is shown as a single entry including start and due
if merge_date if merge_date
attributes['date'] = { required: false } attributes['date'] = { required: false, has_default: false }
attributes.delete 'due_date' attributes.delete 'due_date'
attributes.delete 'start_date' attributes.delete 'start_date'
end end
@ -77,6 +77,7 @@ module ::TypesHelper
WorkPackageCustomField.all.each do |field| WorkPackageCustomField.all.each do |field|
attributes["custom_field_#{field.id}"] = { attributes["custom_field_#{field.id}"] = {
required: field.is_required, required: field.is_required,
has_default: field.default_value,
display_name: field.name display_name: field.name
} }
end end
@ -102,6 +103,14 @@ module ::TypesHelper
end end
end end
def translated_attribute_name(name, attr)
if attr[:name_source]
attr[:name_source].call
else
attr[:display_name] || attr_translate(name)
end
end
## ##
# There isn't actually a 'date' field for work packages. # There isn't actually a 'date' field for work packages.
# There are two fields: 'start_date' and 'due_date' # There are two fields: 'start_date' and 'due_date'

@ -0,0 +1,26 @@
class Announcement < ActiveRecord::Base
scope :active, -> { where(active: true) }
scope :current, -> { where('show_until >= ?', Date.today) }
validates :show_until, presence: true
def self.active_and_current
active.current.first
end
def self.only_one
a = first
a = create_default_announcement if a.nil?
a
end
def active_and_current?
active? && show_until && show_until >= Date.today
end
def self.create_default_announcement
Announcement.create text: 'Announcement',
show_until: Date.today + 14.days,
active: false
end
end

@ -44,7 +44,7 @@ See doc/COPYRIGHT.rdoc for more details.
<div id="login-form" class="form -bordered"> <div id="login-form" class="form -bordered">
<h1><%= I18n.t(:label_login) %></h1> <h1><%= I18n.t(:label_login) %></h1>
<hr class="form--separator"> <hr class="form--separator" />
<p><%= instruction_text.html_safe %></p> <p><%= instruction_text.html_safe %></p>
</div> </div>
<%= call_hook :view_account_login_bottom %> <%= call_hook :view_account_login_bottom %>

@ -35,7 +35,7 @@ See doc/COPYRIGHT.rdoc for more details.
<div id="login-form" class="form -bordered"> <div id="login-form" class="form -bordered">
<h1><%= I18n.t(:label_login) %></h1> <h1><%= I18n.t(:label_login) %></h1>
<hr class="form--separator"> <hr class="form--separator" />
<% unless OpenProject::Configuration.disable_password_login? %> <% unless OpenProject::Configuration.disable_password_login? %>
<%= render partial: 'password_login_form' %> <%= render partial: 'password_login_form' %>
<% end %> <% end %>

@ -0,0 +1,10 @@
<% announcement = Announcement.active_and_current %>
<% if announcement.present? %>
<div id="announcement">
<div class="notification-box -info">
<div class="notification-box--content">
<%= format_text announcement.text %>
</div>
</div>
</div>
<% end %>

@ -0,0 +1,22 @@
<% html_title t(:label_administration), t(:label_announcement) %>
<%= error_messages_for 'announcement' %>
<%= toolbar title: "#{t(:label_announcement)} (#{notice_annoucement_active})" %>
<%= labelled_tabular_form_for @announcement,
:url => {:action => :update},
:html => {:method => :put} do |f|%>
<div class="form--field">
<%= f.text_area :text, :cols => 80, :rows => 5, label: t(:label_text) %>
</div>
<div class="form--field">
<%= f.text_field :show_until, label: t('announcements.show_until') %>
<%= calendar_for("announcement_show_until") %>
</div>
<div class="form--field">
<%= f.check_box :active, label: t(:label_active) %>
</div>
<hr class="form-separator">
<%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %>
<% end %>

@ -34,5 +34,5 @@ See doc/COPYRIGHT.rdoc for more details.
<div class="form--field"> <div class="form--field">
<%= f.text_field :description, required: true %> <%= f.text_field :description, required: true %>
</div> </div>
<hr class="form--separator"> <hr class="form--separator" />
<!--[eoform:board]--> <!--[eoform:board]-->

@ -39,7 +39,7 @@ See doc/COPYRIGHT.rdoc for more details.
class: 'form' } do |f| %> class: 'form' } do |f| %>
<%= render partial: 'messages/form', locals: {f: f} %> <%= render partial: 'messages/form', locals: {f: f} %>
<hr class="form--separator"> <hr class="form--separator" />
<%= styled_button_tag l(:button_create), class: '-highlight -with-icon icon-checkmark' %> <%= styled_button_tag l(:button_create), class: '-highlight -with-icon icon-checkmark' %>
<%= preview_link preview_board_topics_path(@board), 'message-form-preview' %> <%= preview_link preview_board_topics_path(@board), 'message-form-preview' %>
<%= link_to l(:button_cancel), '', onclick: 'Element.hide("add-message")', class: 'button' %> <%= link_to l(:button_cancel), '', onclick: 'Element.hide("add-message")', class: 'button' %>

@ -38,4 +38,4 @@ See doc/COPYRIGHT.rdoc for more details.
include_blank: true %> include_blank: true %>
</div> </div>
<hr class="form--separator"> <hr class="form--separator" />

@ -34,6 +34,8 @@ See doc/COPYRIGHT.rdoc for more details.
</h2> </h2>
</div> </div>
<%= render partial: 'announcements/show' %>
<% if @homescreen[:blocks].any? %> <% if @homescreen[:blocks].any? %>
<section class="widget-boxes -flex"> <section class="widget-boxes -flex">
<% @homescreen[:blocks].each do |block| %> <% @homescreen[:blocks].each do |block| %>

@ -37,7 +37,7 @@ See doc/COPYRIGHT.rdoc for more details.
class: 'form' } do |f| %> class: 'form' } do |f| %>
<%= render partial: 'form', locals: { f: f, replying: !@message.parent.nil? } %> <%= render partial: 'form', locals: { f: f, replying: !@message.parent.nil? } %>
<hr class="form--separator"> <hr class="form--separator" />
<%= f.button l(:button_save), class: 'button -highlight -with-icon icon-checkmark' %> <%= f.button l(:button_save), class: 'button -highlight -with-icon icon-checkmark' %>
<%= preview_link preview_topic_path(@message), 'message-form-preview' %> <%= preview_link preview_topic_path(@message), 'message-form-preview' %>

@ -36,7 +36,7 @@ See doc/COPYRIGHT.rdoc for more details.
class: 'form' } do |f| %> class: 'form' } do |f| %>
<%= render partial: 'messages/form', locals: { f: f } %> <%= render partial: 'messages/form', locals: { f: f } %>
<hr class="form--separator"> <hr class="form--separator" />
<%= f.button l(:button_create), class: 'button -highlight -with-icon icon-checkmark' %> <%= f.button l(:button_create), class: 'button -highlight -with-icon icon-checkmark' %>
<%= preview_link preview_board_topics_path(@board), 'message-form-preview' %> <%= preview_link preview_board_topics_path(@board), 'message-form-preview' %>

@ -123,7 +123,7 @@ See doc/COPYRIGHT.rdoc for more details.
<div id="reply" style="display:none;"> <div id="reply" style="display:none;">
<hr class="form--separator"> <hr class="form--separator" />
<%= labelled_tabular_form_for @reply, <%= labelled_tabular_form_for @reply,
as: :reply, as: :reply,
@ -133,7 +133,7 @@ See doc/COPYRIGHT.rdoc for more details.
class: 'form' } do |f| %> class: 'form' } do |f| %>
<%= render partial: 'form', locals: {f: f, replying: true} %> <%= render partial: 'form', locals: {f: f, replying: true} %>
<hr class="form--separator"> <hr class="form--separator" />
<%= f.button l(:button_submit), class: 'button -highlight -highlight -with-icon icon-checkmark' %> <%= f.button l(:button_submit), class: 'button -highlight -highlight -with-icon icon-checkmark' %>
<%= preview_link preview_topic_path(@message), 'message-form-preview' %> <%= preview_link preview_topic_path(@message), 'message-form-preview' %>

@ -32,7 +32,7 @@ See doc/COPYRIGHT.rdoc for more details.
<%= labelled_tabular_form_for @news, html: { id: 'news-form' } do |f| %> <%= labelled_tabular_form_for @news, html: { id: 'news-form' } do |f| %>
<%= render partial: 'form', locals: { f: f } %> <%= render partial: 'form', locals: { f: f } %>
<hr class="form--separator"> <hr class="form--separator" />
<%= styled_button_tag l(:button_save), class: '-highlight -with-icon icon-checkmark' %> <%= styled_button_tag l(:button_save), class: '-highlight -with-icon icon-checkmark' %>
<%= preview_link preview_news_path(@news), 'news-form-preview' %> <%= preview_link preview_news_path(@news), 'news-form-preview' %>
<% end %> <% end %>

@ -33,7 +33,7 @@ See doc/COPYRIGHT.rdoc for more details.
<%= labelled_tabular_form_for [@project, @news], html: { id: 'news-form' } do |f| %> <%= labelled_tabular_form_for [@project, @news], html: { id: 'news-form' } do |f| %>
<%= render partial: 'news/form', locals: { f: f } %> <%= render partial: 'news/form', locals: { f: f } %>
<hr class="form--separator"> <hr class="form--separator" />
<%= styled_button_tag l(:button_create), class: '-highlight -with-icon icon-checkmark' %> <%= styled_button_tag l(:button_create), class: '-highlight -with-icon icon-checkmark' %>
<%= preview_link preview_news_index_path, 'news-form-preview' %> <%= preview_link preview_news_index_path, 'news-form-preview' %>
<% end %> <% end %>

@ -72,7 +72,7 @@ See doc/COPYRIGHT.rdoc for more details.
</span> </span>
<%= render partial: 'form', locals: {f: f, project: @project} %> <%= render partial: 'form', locals: {f: f, project: @project} %>
<hr class="form--separator"> <hr class="form--separator" />
<%= f.button l("timelines.add_project_association"), name: nil, <%= f.button l("timelines.add_project_association"), name: nil,
class: 'button -highlight -with-icon icon-checkmark'%> class: 'button -highlight -with-icon icon-checkmark'%>
<%= link_to l(:button_cancel), project_project_associations_path(@project), <%= link_to l(:button_cancel), project_project_associations_path(@project),

@ -62,7 +62,7 @@ See doc/COPYRIGHT.rdoc for more details.
</div> </div>
<%= wikitoolbar_for 'reporting_reported_project_status_comment' %> <%= wikitoolbar_for 'reporting_reported_project_status_comment' %>
</fieldset> </fieldset>
<hr class="form--separator"> <hr class="form--separator" />
<%= f.button l(:button_save), name: nil, class: 'button -highlight -with-icon icon-checkmark' %> <%= f.button l(:button_save), name: nil, class: 'button -highlight -with-icon icon-checkmark' %>
<%= link_to l(:button_cancel), project_reportings_path(@project), class: 'button' %> <%= link_to l(:button_cancel), project_reportings_path(@project), class: 'button' %>
<% end %> <% end %>

@ -72,7 +72,7 @@ See doc/COPYRIGHT.rdoc for more details.
</span> </span>
</fieldset> </fieldset>
<hr class="form--separator"> <hr class="form--separator" />
<%= f.button l(:button_create), name: nil, class: 'button -highlight -with-icon icon-checkmark' %> <%= f.button l(:button_create), name: nil, class: 'button -highlight -with-icon icon-checkmark' %>
<%= link_to l(:button_cancel), project_reportings_path(@project), class: 'button' %> <%= link_to l(:button_cancel), project_reportings_path(@project), class: 'button' %>
<% end %> <% end %>

@ -78,7 +78,7 @@ See doc/COPYRIGHT.rdoc for more details.
<div class="generic-table--header-background"></div> <div class="generic-table--header-background"></div>
</div> </div>
</div> </div>
<hr class="form--separator"> <hr class="form--separator" />
<p> <p>
<%= submit_tag l(:button_update), class: 'button -highlight' %> <%= submit_tag l(:button_update), class: 'button -highlight' %>
<%= link_to settings_project_path(id: @project.id, tab: 'repository'), class: 'button' do %> <%= link_to settings_project_path(id: @project.id, tab: 'repository'), class: 'button' do %>

@ -178,7 +178,7 @@ See doc/COPYRIGHT.rdoc for more details.
</div> </div>
<div class="form--grouping-row"> <div class="form--grouping-row">
<hr class="form--separator"> <hr class="form--separator" />
</div> </div>
<div class="form--grouping-row"> <div class="form--grouping-row">

@ -50,37 +50,21 @@ See doc/COPYRIGHT.rdoc for more details.
<!--[eoform:type]--> <!--[eoform:type]-->
</section> </section>
</div> </div>
<div class="grid-content medium-6">
<% if @projects.any? %>
<fieldset class="form--fieldset" id="type_project_ids">
<legend class="form--fieldset-legend"><%= l(:label_project_plural) %></legend>
<div class="form--fieldset-control">
<span class="form--fieldset-control-container">
(<%= check_all_links 'type_project_ids' %>)
</span>
</div>
<%= project_nested_ul(@projects) do |p|
content_tag('label',
check_box_tag('type[project_ids][]', p.id, controller.type.projects.include?(p), id: nil) +
' ' + h(p), class: 'form--label-with-check-box')
end %>
<%= hidden_field_tag('type[project_ids][]', '', id: nil) %>
</fieldset>
<% end %>
</div>
</div> </div>
<div class="grid-block"> <div class="grid-block">
<div class="grid-content medium-6"> <div class="grid-content medium-6">
<fieldset class="form--fieldset -collapsible collapsed" id="type_attribute_visibility"> <fieldset class="form--fieldset -collapsible" id="type_attribute_visibility">
<legend class="form--fieldset-legend" onclick="toggleFieldset(this);"><%= I18n.t('label_form_configuration')%></legend> <legend class="form--fieldset-legend" onclick="toggleFieldset(this);">
<div style="display: none;"> <%= I18n.t('label_form_configuration')%>
</legend>
<div>
<p><%= I18n.t('text_form_configuration') %></p> <p><%= I18n.t('text_form_configuration') %></p>
<table class="attributes-table"> <table class="attributes-table">
<thead> <thead>
<tr> <tr>
<td><%= I18n.t('label_attribute') %></td> <td><%= I18n.t('label_attribute') %></td>
<td><%= I18n.t('label_default') %></td> <td><%= I18n.t('label_active') %></td>
<td><%= I18n.t('label_always_visible') %></td> <td><%= I18n.t('label_always_visible') %></td>
</tr> </tr>
</thead> </thead>
@ -88,13 +72,20 @@ See doc/COPYRIGHT.rdoc for more details.
<% <%
attributes = ::TypesHelper attributes = ::TypesHelper
.work_package_form_attributes(merge_date: true) .work_package_form_attributes(merge_date: true)
.reject { |name, attr| not name =~ /custom_field_/ and attr[:required] } .reject { |name, attr|
# display all custom fields don't display required fields without a default
not name =~ /custom_field_/ and (attr[:required] and not attr[:has_default])
}
keys = attributes.keys.sort_by do |name|
translated_attribute_name(name, attributes[name])
end
%> %>
<% attributes.each do |name, attr| %> <% keys.each do |name| %>
<% attr = attributes[name] %>
<tr> <tr>
<td> <td>
<%= label "type_attribute_visibility_#{name}", <%= label "type_attribute_visibility_#{name}",
attr[:display_name] || attr_translate(name), translated_attribute_name(name, attr),
value: "type_attribute_visibility[#{name}]", value: "type_attribute_visibility[#{name}]",
class: 'form--label' %> class: 'form--label' %>
</td> </td>
@ -124,6 +115,31 @@ See doc/COPYRIGHT.rdoc for more details.
</div> </div>
</div> </div>
<div class="grid-block">
<div class="grid-content medium-6">
<% if @projects.any? %>
<fieldset class="form--fieldset -collapsible" id="type_project_ids">
<legend class="form--fieldset-legend" onclick="toggleFieldset(this);">
<%= l(:label_project_plural) %>
</legend>
<div>
<div class="form--fieldset-control">
<span class="form--fieldset-control-container">
(<%= check_all_links 'type_project_ids' %>)
</span>
</div>
<%= project_nested_ul(@projects) do |p|
content_tag('label',
check_box_tag('type[project_ids][]', p.id, controller.type.projects.include?(p), id: nil) +
' ' + h(p), class: 'form--label-with-check-box')
end %>
<%= hidden_field_tag('type[project_ids][]', '', id: nil) %>
</div>
</fieldset>
<% end %>
</div>
</div>
<div class="grid-block"> <div class="grid-block">
<%= styled_button_tag l(controller.type.new_record? ? :button_create : :button_save), <%= styled_button_tag l(controller.type.new_record? ? :button_create : :button_save),
class: '-highlight -with-icon icon-checkmark' %> class: '-highlight -with-icon icon-checkmark' %>

@ -34,7 +34,7 @@ See doc/COPYRIGHT.rdoc for more details.
<%= render partial: 'form', locals: { f: f, <%= render partial: 'form', locals: { f: f,
project: @project } %> project: @project } %>
<hr class="form--separator"> <hr class="form--separator" />
<%= styled_button_tag l(:button_save), class: '-highlight -with-icon icon-checkmark' %> <%= styled_button_tag l(:button_save), class: '-highlight -with-icon icon-checkmark' %>
<% end %> <% end %>

@ -35,7 +35,7 @@ See doc/COPYRIGHT.rdoc for more details.
<%= render partial: 'versions/form', locals: { f: f, <%= render partial: 'versions/form', locals: { f: f,
project: @project } %> project: @project } %>
<hr class="form--separator"> <hr class="form--separator" />
<%= styled_button_tag l(:button_create), class: '-highlight -with-icon icon-checkmark' %> <%= styled_button_tag l(:button_create), class: '-highlight -with-icon icon-checkmark' %>
<% end %> <% end %>

@ -47,7 +47,7 @@ See doc/COPYRIGHT.rdoc for more details.
<%= render partial: 'attachments/form' %> <%= render partial: 'attachments/form' %>
<hr class="form--separator"> <hr class="form--separator" />
<%= f.button l(:button_save), class: 'button -highlight -with-icon icon-checkmark' %> <%= f.button l(:button_save), class: 'button -highlight -with-icon icon-checkmark' %>
<%= preview_link preview_project_wiki_path(@project, @page), 'wiki_form-preview' %> <%= preview_link preview_project_wiki_path(@project, @page), 'wiki_form-preview' %>
<%= wikitoolbar_for 'content_text' %> <%= wikitoolbar_for 'content_text' %>

@ -73,7 +73,7 @@ See doc/COPYRIGHT.rdoc for more details.
<%= render partial: 'attachments/form', f: f %> <%= render partial: 'attachments/form', f: f %>
<hr class="form--separator"> <hr class="form--separator" />
<%= f.button l(:button_save), class: 'button -highlight -with-icon icon-checkmark' %> <%= f.button l(:button_save), class: 'button -highlight -with-icon icon-checkmark' %>
<%= preview_link preview_project_wiki_index_path(@project), 'wiki_form-preview' %> <%= preview_link preview_project_wiki_index_path(@project), 'wiki_form-preview' %>

@ -0,0 +1,9 @@
coverage:
ignore:
- spec/factories/.*
- vendor/bundle/.*
status:
patch: false
project:
default: {}
comment: off

@ -68,11 +68,11 @@ end
Redmine::MenuManager.map :account_menu do |menu| Redmine::MenuManager.map :account_menu do |menu|
menu.push :administration, menu.push :administration,
{ controller: '/admin', action: 'projects' }, { controller: '/admin', action: 'projects' },
html: { class: 'hidden-for-mobile'}, html: { class: 'hidden-for-mobile' },
if: Proc.new { User.current.admin? } if: Proc.new { User.current.admin? }
menu.push :my_account, menu.push :my_account,
{ controller: '/my', action: 'account' }, { controller: '/my', action: 'account' },
html: { class: 'hidden-for-mobile'}, html: { class: 'hidden-for-mobile' },
if: Proc.new { User.current.logged? } if: Proc.new { User.current.logged? }
menu.push :logout, :signout_path, menu.push :logout, :signout_path,
if: Proc.new { User.current.logged? } if: Proc.new { User.current.logged? }
@ -167,6 +167,11 @@ Redmine::MenuManager.map :admin_menu do |menu|
html: { class: 'server_authentication icon2 icon-flag' }, html: { class: 'server_authentication icon2 icon-flag' },
if: proc { !OpenProject::Configuration.disable_password_login? } if: proc { !OpenProject::Configuration.disable_password_login? }
menu.push :announcements,
{ controller: '/announcements', action: 'edit' },
caption: 'Announcement',
html: { class: 'icon2 icon-news' }
menu.push :plugins, menu.push :plugins,
{ controller: '/admin', action: 'plugins' }, { controller: '/admin', action: 'plugins' },
last: true, last: true,

@ -40,6 +40,11 @@ en:
plugins: plugins:
no_results_title_text: There are currently no plugins available. no_results_title_text: There are currently no plugins available.
announcements:
show_until: Show until
is_active: currently displayed
is_inactive: currently not displayed
auth_sources: auth_sources:
index: index:
no_results_content_title: There are currently no authentication modes. no_results_content_title: There are currently no authentication modes.
@ -209,6 +214,8 @@ en:
activerecord: activerecord:
attributes: attributes:
announcements:
show_until: "Display until"
attachment: attachment:
downloads: "Downloads" downloads: "Downloads"
file: "File" file: "File"
@ -834,6 +841,7 @@ en:
indefinite_expiration: "Never" indefinite_expiration: "Never"
label_account: "Account" label_account: "Account"
label_active: "Active"
label_activity: "Activity" label_activity: "Activity"
label_add_edit_translations: "Add and edit translations" label_add_edit_translations: "Add and edit translations"
label_add_another_file: "Add another file" label_add_another_file: "Add another file"
@ -853,6 +861,7 @@ en:
label_all_time: "all time" label_all_time: "all time"
label_all_words: "All words" label_all_words: "All words"
label_always_visible: "Always displayed" label_always_visible: "Always displayed"
label_announcement: "Announcement"
label_and_its_subprojects: "%{value} and its subprojects" label_and_its_subprojects: "%{value} and its subprojects"
label_api_access_key: "API access key" label_api_access_key: "API access key"
label_api_access_key_created_on: "API access key created %{value} ago" label_api_access_key_created_on: "API access key created %{value} ago"
@ -1769,7 +1778,8 @@ en:
text_form_configuration: > text_form_configuration: >
You can customize which attributes will be displayed by default You can customize which attributes will be displayed by default
in forms for work packages of this type. in forms for work packages of this type.
Fields that are required cannot be customized and will always be shown. Fields that are required but don't have a default value cannot be customized.
They will always be shown.
text_caracters_maximum: "%{count} characters maximum." text_caracters_maximum: "%{count} characters maximum."
text_caracters_minimum: "Must be at least %{count} characters long." text_caracters_minimum: "Must be at least %{count} characters long."
text_comma_separated: "Multiple values allowed (comma separated)." text_comma_separated: "Multiple values allowed (comma separated)."
@ -2050,7 +2060,7 @@ en:
tooltip: tooltip:
attribute_visibility: attribute_visibility:
default: Show if not empty default: Show if not empty
visible: Always show visible: Show even if empty
hidden: Hide by default hidden: Hide by default
queries: queries:

@ -374,7 +374,7 @@ en:
label_column_multiselect: "Combined dropdown field: Select with arrow keys, confirm selection with enter, delete with backspace" label_column_multiselect: "Combined dropdown field: Select with arrow keys, confirm selection with enter, delete with backspace"
message_error_during_bulk_delete: An error occurred while trying to delete work packages. message_error_during_bulk_delete: An error occurred while trying to delete work packages.
message_successful_bulk_delete: Successfully deleted work packages. message_successful_bulk_delete: Successfully deleted work packages.
message_successful_show_in_fullscreen: "Click here to open this work package in fullscreen view" message_successful_show_in_fullscreen: "Click here to open this work package in fullscreen view."
no_value: "No value" no_value: "No value"
inline_create: inline_create:
title: 'Click here to add a new work package to this list' title: 'Click here to add a new work package to this list'
@ -382,9 +382,8 @@ en:
header: 'New Work Package' header: 'New Work Package'
header_with_parent: 'New work package (Child of %{type} #%{id})' header_with_parent: 'New work package (Child of %{type} #%{id})'
no_results: no_results:
title: No work packages to display title: No work packages to display.
description_html: | description: Either none have been created or all work packages are filtered out.
<p>Either none have been created or all work packages are filtered out.</p>
property_groups: property_groups:
details: "Details" details: "Details"
people: "People" people: "People"

@ -208,7 +208,6 @@ OpenProject::Application.routes.draw do
get 'identifier', action: 'identifier' get 'identifier', action: 'identifier'
patch 'identifier', action: 'update_identifier' patch 'identifier', action: 'update_identifier'
match 'copy_project_from_(:coming_from)' => 'copy_projects#copy_project', via: :get, as: :copy_from, match 'copy_project_from_(:coming_from)' => 'copy_projects#copy_project', via: :get, as: :copy_from,
constraints: { coming_from: /(admin|settings)/ } constraints: { coming_from: /(admin|settings)/ }
match 'copy_from_(:coming_from)' => 'copy_projects#copy', via: :post, as: :copy, match 'copy_from_(:coming_from)' => 'copy_projects#copy', via: :post, as: :copy,
@ -288,7 +287,6 @@ OpenProject::Application.routes.draw do
end end
resources :work_packages, only: [] do resources :work_packages, only: [] do
collection do collection do
get '/report/:detail' => 'work_packages/reports#report_details' get '/report/:detail' => 'work_packages/reports#report_details'
get '/report' => 'work_packages/reports#report' get '/report' => 'work_packages/reports#report'
@ -357,6 +355,7 @@ OpenProject::Application.routes.draw do
scope 'admin' do scope 'admin' do
match '/projects' => 'admin#projects', via: :get, as: :admin_projects match '/projects' => 'admin#projects', via: :get, as: :admin_projects
resource :announcements, only: [:edit, :update]
resources :enumerations resources :enumerations
resources :groups do resources :groups do

@ -0,0 +1,28 @@
require Rails.root.join('db', 'migrate', 'migration_utils', 'migration_squasher').to_s
require 'open_project/plugins/migration_mapping'
# This migration aggregates the migrations detailed in the MIGRATION_FILES
class AggregatedAnnouncementsMigrations < ActiveRecord::Migration
MIGRATION_FILES = <<-MIGRATIONS
001_create_announcements.rb
20121114100640_index_on_announcements.rb
MIGRATIONS
OLD_PLUGIN_NAME = 'redmine_announcements'
def up
migration_names = OpenProject::Plugins::MigrationMapping.migration_files_to_migration_names(MIGRATION_FILES, OLD_PLUGIN_NAME)
Migration::MigrationSquasher.squash(migration_names) do
create_table :announcements do |t|
t.text :text
t.date :show_until
t.boolean :active, default: false
t.timestamps
end
add_index :announcements, [:show_until, :active]
end
end
def down
drop_table :announcements
end
end

@ -475,7 +475,7 @@ If you need to restart the server (for example after a configuration change), do
We heard that `bower install` can fail, if your server is behind a firewall which does not allow `git://` URLs. The error looks like this: We heard that `bower install` can fail, if your server is behind a firewall which does not allow `git://` URLs. The error looks like this:
``` ```
bower openproject-ui_components#with-bower ECMDERR Failed to execute "git ls-remote --tags --heads git://github.com/opf/openproject-ui_components.git", exit code of #128 ECMDERR Failed to execute "git ls-remote --tags --heads git://github.com/finnlabs/angular-modal.git", exit code of #128
Additional error details: Additional error details:
fatal: unable to connect to github.com: fatal: unable to connect to github.com:

@ -35,16 +35,15 @@ angular.module('openproject.uiComponents',
$rootScope.I18n = I18n; $rootScope.I18n = I18n;
}]); }]);
export var configModule = angular.module('openproject.config', []); export var configModule = angular.module('openproject.config', []);
angular.module( export var opServicesModule = angular.module('openproject.services', [
'openproject.services', [ 'openproject.uiComponents',
'openproject.uiComponents', 'openproject.helpers',
'openproject.helpers', 'openproject.workPackages.config',
'openproject.workPackages.config', 'openproject.workPackages.helpers',
'openproject.workPackages.helpers', 'openproject.api',
'openproject.api', 'angular-cache',
'angular-cache', 'openproject.filters'
'openproject.filters' ]);
]);
angular.module('openproject.helpers', ['openproject.services']); angular.module('openproject.helpers', ['openproject.services']);
angular angular
.module('openproject.models', [ .module('openproject.models', [
@ -78,7 +77,7 @@ angular.module('openproject.timelines.directives', [
]); ]);
// work packages // work packages
angular.module('openproject.workPackages', [ export const opWorkPackagesModule = angular.module('openproject.workPackages', [
'openproject.workPackages.activities', 'openproject.workPackages.activities',
'openproject.workPackages.controllers', 'openproject.workPackages.controllers',
'openproject.workPackages.filters', 'openproject.workPackages.filters',
@ -98,24 +97,22 @@ angular.module('openproject.workPackages.filters', [
'openproject.workPackages.helpers' 'openproject.workPackages.helpers'
]); ]);
angular.module('openproject.workPackages.config', []); angular.module('openproject.workPackages.config', []);
angular.module( export const wpControllersModule = angular.module('openproject.workPackages.controllers', [
'openproject.workPackages.controllers', [ 'openproject.models',
'openproject.models', 'openproject.viewModels',
'openproject.viewModels', 'openproject.workPackages.helpers',
'openproject.workPackages.helpers', 'openproject.services',
'openproject.services', 'openproject.workPackages.config',
'openproject.workPackages.config', 'openproject.layout',
'openproject.layout', 'btford.modal'
'btford.modal' ]);
]);
angular.module('openproject.workPackages.models', []); angular.module('openproject.workPackages.models', []);
angular.module( export const wpDirectivesModule = angular.module('openproject.workPackages.directives', [
'openproject.workPackages.directives', [ 'openproject.uiComponents',
'openproject.uiComponents', 'openproject.services',
'openproject.services', 'openproject.workPackages.services',
'openproject.workPackages.services', 'openproject.workPackages.models'
'openproject.workPackages.models' ]);
]);
angular.module('openproject.workPackages.tabs', []); angular.module('openproject.workPackages.tabs', []);
angular.module('openproject.workPackages.activities', []); angular.module('openproject.workPackages.activities', []);

@ -52,6 +52,9 @@ export default class WorkPackageResource extends HalResource {
wp.form = $q.when(resource); wp.form = $q.when(resource);
wp.id = 'new-' + Date.now(); wp.id = 'new-' + Date.now();
// Set update link to form
wp.$links.update = resource.$links.self;
deferred.resolve(wp); deferred.resolve(wp);
}) })
.catch(deferred.reject); .catch(deferred.reject);
@ -125,42 +128,30 @@ export default class WorkPackageResource extends HalResource {
delete plain.updatedAt; delete plain.updatedAt;
var deferred = $q.defer(); var deferred = $q.defer();
this.getForm()
// Always resolve form to the latest form
// This way, we won't have to actively reset it.
this.form = this.$links.update(this.$source);
this.form
.catch(deferred.reject) .catch(deferred.reject)
.then(form => { .then(form => {
var plainPayload = form.payload.$plain();
var schema = form.$embedded.schema;
angular.forEach(plain, (value, key) => { // Override the current schema with
if (typeof(schema[key]) === 'object' && schema[key]['writable'] === true) { // the changes from API
plainPayload[key] = value; this.schema = form.$embedded.schema;
}
});
angular.forEach(plainPayload._links, (_value, key) => { // Merge attributes from form with resource
if (this[key] && typeof(schema[key]) === 'object' && schema[key]['writable'] === true) { var payload = this.mergeWithForm(form);
var value = this[key].href === 'null' ? null : this[key].href;
plainPayload._links[key] = {href: value};
}
});
return this.saveResource(plainPayload) this.saveResource(payload)
.then(workPackage => { .then(workPackage => {
angular.extend(this, workPackage); angular.extend(this, workPackage);
wpCacheService.updateWorkPackageList([this]); wpCacheService.updateWorkPackageList([this]);
deferred.resolve(this); deferred.resolve(this);
}) }).catch((error) => {
.catch((error) => { deferred.reject(error);
deferred.reject(error); });
})
.finally(() => {
// Restore the form for subsequent saves
// e.g., due to changes in lockVersion.
// Not needed for inline create.
if (!this.isNew) {
this.form = null;
}
});
}); });
return deferred.promise; return deferred.promise;
@ -185,6 +176,30 @@ export default class WorkPackageResource extends HalResource {
return this.$links.updateImmediately(payload); return this.$links.updateImmediately(payload);
} }
private mergeWithForm(form) {
var plainPayload = form.payload.$source;
var schema = form.$embedded.schema;
// Merge embedded properties from form payload
// Do not use properties on this, since they may be incomplete
// e.g., when switching to a type that requires a custom field.
angular.forEach(plainPayload, (_value, key) => {
if (typeof(schema[key]) === 'object' && schema[key]['writable'] === true) {
plainPayload[key] = this[key];
}
});
// Merged linked properties from form payload
angular.forEach(plainPayload._links, (_value, key) => {
if (this[key] && typeof(schema[key]) === 'object' && schema[key]['writable'] === true) {
var value = this[key].href === 'null' ? null : this[key].href;
plainPayload._links[key] = {href: value};
}
});
return plainPayload;
}
} }
function wpResource(_$q_:ng.IQService, function wpResource(_$q_:ng.IQService,
@ -201,7 +216,7 @@ function wpResource(_$q_:ng.IQService,
angular angular
.module('openproject.api') .module('openproject.api')
.service('WorkPackageResource', [ .factory('WorkPackageResource', [
'$q', '$q',
'apiWorkPackages', 'apiWorkPackages',
'wpCacheService', 'wpCacheService',

@ -2,7 +2,10 @@
<div class="notification-box--content"> <div class="notification-box--content">
<p> <p>
<span>{{::content.message}}</span> <span>{{::content.message}}</span>
<a ng-click="showFullScreen()" ng-if="displayFullScreenLink">{{::I18n.t('js.work_packages.message_successful_show_in_fullscreen')}}</a> <a ng-click="content.link.target()"
ng-if="!!content.link"
ng-bind="content.link.text">
</a>
</p> </p>
<div data-ng-switch="content.type" data-ng-if="typeable()"> <div data-ng-switch="content.type" data-ng-if="typeable()">
<div data-ng-switch-when="upload"> <div data-ng-switch-when="upload">

@ -1,4 +1,4 @@
//-- copyright // -- copyright
// OpenProject is a project management system. // OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) // Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
// //
@ -24,27 +24,18 @@
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
// //
// See doc/COPYRIGHT.rdoc for more details. // See doc/COPYRIGHT.rdoc for more details.
//++ // ++
module.exports = function(I18n, $timeout,$state,loadingIndicator,ConfigurationService) {
function notificationBox($timeout, I18n, ConfigurationService) {
var notificationBoxController = function(scope, element) { var notificationBoxController = function(scope, element) {
scope.uploadCount = 0; scope.uploadCount = 0;
scope.show = false; scope.show = false;
scope.I18n = I18n; scope.I18n = I18n;
scope.currentState = $state.current.name;
scope.canBeHidden = function() { scope.canBeHidden = function() {
return scope.content.uploads.length > 5; return scope.content.uploads.length > 5;
}; };
scope.displayFullScreenLink = ($state.current.name.indexOf("work-packages.show") == -1 && scope.content.type === "success");
scope.showFullScreen = function(){
scope.remove();
loadingIndicator.mainPage = $state.go.apply($state, ["work-packages.show.activity", $state.params]);
};
scope.removable = function() { scope.removable = function() {
return scope.content.type !== 'upload'; return scope.content.type !== 'upload';
}; };
@ -83,10 +74,14 @@ module.exports = function(I18n, $timeout,$state,loadingIndicator,ConfigurationSe
return { return {
restrict: 'E', restrict: 'E',
replace: true, replace: true,
templateUrl: '/templates/components/notification-box.html', templateUrl: '/components/common/notification-box/notification-box.directive.html',
scope: { scope: {
content: '=' content: '='
}, },
link: notificationBoxController link: notificationBoxController
}; };
}; }
angular
.module('openproject.uiComponents')
.directive('notificationBox', notificationBox);

@ -0,0 +1,132 @@
// -- 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.
// ++
function notifications($rootScope) {
var createNotification = function (message) {
if (typeof message === 'string') {
return {message: message};
}
return message;
},
createSuccessNotification = function (message) {
return _.extend(createNotification(message), {type: 'success'});
},
createWarningNotification = function (message) {
return _.extend(createNotification(message), {type: 'warning'});
},
createErrorNotification = function (message, errors) {
return _.extend(createNotification(message), {
type: 'error',
errors: errors || []
});
},
createNoticeNotification = function (message) {
return _.extend(createNotification(message), {type: ''});
},
createWorkPackageUploadNotification = function (message, uploads) {
if (!uploads) {
throw new Error('Cannot create an upload notification without uploads!');
}
return _.extend(createNotification(message), {
type: 'upload',
uploads: uploads
});
},
broadcast = function (event, data) {
$rootScope.$broadcast(event, data);
},
currentNotifications = [],
notificationAdded = function (newNotification) {
var toRemove = currentNotifications.slice(0);
_.each(toRemove, function (existingNotification) {
if (newNotification.type === 'success' || newNotification.type === 'error') {
remove(existingNotification);
}
});
currentNotifications.push(newNotification);
},
notificationRemoved = function (removedNotification) {
_.remove(currentNotifications, function (element) {
return element === removedNotification;
});
},
clearNotifications = function () {
currentNotifications.forEach(function (notification) {
remove(notification);
});
};
$rootScope.$on('notification.remove', function (_e, notification) {
notificationRemoved(notification);
});
$rootScope.$on('notifications.clearAll', function () {
clearNotifications();
});
// public
var add = function (message) {
var notification = createNotification(message);
broadcast('notification.add', notification);
notificationAdded(notification);
return notification;
},
addError = function (message, errors) {
return add(createErrorNotification(message, errors));
},
addWarning = function (message) {
return add(createWarningNotification(message));
},
addSuccess = function (message) {
return add(createSuccessNotification(message));
},
addNotice = function (message) {
return add(createNoticeNotification(message));
},
addWorkPackageUpload = function (message, uploads) {
return add(createWorkPackageUploadNotification(message, uploads));
},
remove = function (notification) {
broadcast('notification.remove', notification);
};
return {
add: add,
remove: remove,
addError: addError,
addWarning: addWarning,
addSuccess: addSuccess,
addNotice: addNotice,
addWorkPackageUpload: addWorkPackageUpload
};
}
angular
.module('openproject.services')
.factory('NotificationsService', notifications);

@ -1,4 +1,4 @@
//-- copyright // -- copyright
// OpenProject is a project management system. // OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) // Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
// //
@ -24,33 +24,31 @@
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
// //
// See doc/COPYRIGHT.rdoc for more details. // See doc/COPYRIGHT.rdoc for more details.
//++ // ++
describe('NotificationsService', function(){ describe('NotificationsService', function () {
'use strict';
var NotificationsService, var NotificationsService,
$rootScope; $rootScope;
beforeEach(module('openproject.services')); beforeEach(angular.mock.module('openproject.services'));
beforeEach(angular.mock.inject(function (_$rootScope_, _NotificationsService_) {
beforeEach(inject(function(_$rootScope_, _NotificationsService_) {
$rootScope = _$rootScope_; $rootScope = _$rootScope_;
NotificationsService = _NotificationsService_; NotificationsService = _NotificationsService_;
})); }));
it('should be able to create notifications', function() { it('should be able to create notifications', function () {
var notification = NotificationsService.add('message'); var notification = NotificationsService.add('message');
expect(notification).to.eql({ message: 'message' }); expect(notification).to.eql({message: 'message'});
}); });
it('should be able to create warnings', function() { it('should be able to create warnings', function () {
var notification = NotificationsService.addWarning('warning!'); var notification = NotificationsService.addWarning('warning!');
expect(notification).to.eql({ message: 'warning!', type: 'warning' }); expect(notification).to.eql({message: 'warning!', type: 'warning'});
}); });
it('should be able to create error messages with errors', function() { it('should be able to create error messages with errors', function () {
var notification = NotificationsService.addError('a super cereal error', ['fooo', 'baarr']); var notification = NotificationsService.addError('a super cereal error', ['fooo', 'baarr']);
expect(notification).to.eql({ expect(notification).to.eql({
message: 'a super cereal error', message: 'a super cereal error',
@ -59,7 +57,7 @@ describe('NotificationsService', function(){
}); });
}); });
it('should be able to create error messages with only a message', function() { it('should be able to create error messages with only a message', function () {
var notification = NotificationsService.addError('a super cereal error'); var notification = NotificationsService.addError('a super cereal error');
expect(notification).to.eql({ expect(notification).to.eql({
message: 'a super cereal error', message: 'a super cereal error',
@ -68,7 +66,7 @@ describe('NotificationsService', function(){
}); });
}); });
it('should be able to create upload messages with uploads', function() { it('should be able to create upload messages with uploads', function () {
var notification = NotificationsService.addWorkPackageUpload('uploading...', [0, 1, 2]); var notification = NotificationsService.addWorkPackageUpload('uploading...', [0, 1, 2]);
expect(notification).to.eql({ expect(notification).to.eql({
message: 'uploading...', message: 'uploading...',
@ -77,13 +75,13 @@ describe('NotificationsService', function(){
}); });
}); });
it('should throw an Error if trying to create an upload without uploads', function() { it('should throw an Error if trying to create an upload without uploads', function () {
expect(function() { expect(function () {
NotificationsService.addWorkPackageUpload('themUploads'); NotificationsService.addWorkPackageUpload('themUploads');
}).to.throw(Error); }).to.throw(Error);
}); });
it('sends a broadcast on rootScope upon adding', function() { it('sends a broadcast on rootScope upon adding', function () {
sinon.spy($rootScope, '$broadcast'); sinon.spy($rootScope, '$broadcast');
NotificationsService.add('very important'); NotificationsService.add('very important');
@ -91,53 +89,53 @@ describe('NotificationsService', function(){
expect($rootScope.$broadcast).to.have.been.calledWith('notification.add'); expect($rootScope.$broadcast).to.have.been.calledWith('notification.add');
}); });
it('sends a broadcast on rootScope upon removal', function() { it('sends a broadcast on rootScope upon removal', function () {
sinon.spy($rootScope, '$broadcast'); sinon.spy($rootScope, '$broadcast');
NotificationsService.remove({ message: 'blubs', type: 'success' }); NotificationsService.remove({message: 'blubs', type: 'success'});
expect($rootScope.$broadcast).to.have.been.calledWith('notification.remove'); expect($rootScope.$broadcast).to.have.been.calledWith('notification.remove');
}); });
it('sends a broadcast to remove the first notification upon adding a second success notification', it('sends a broadcast to remove the first notification upon adding a second success notification',
function() { function () {
sinon.spy($rootScope, '$broadcast'); sinon.spy($rootScope, '$broadcast');
var firstNotification = NotificationsService.add('blubs'); var firstNotification = NotificationsService.add('blubs');
NotificationsService.addSuccess('blubs2'); NotificationsService.addSuccess('blubs2');
expect($rootScope.$broadcast).to.have.been.calledWith('notification.remove', expect($rootScope.$broadcast).to.have.been.calledWith('notification.remove',
firstNotification); firstNotification);
}); });
it('sends a broadcast to remove the first notification upon adding a second error notification', it('sends a broadcast to remove the first notification upon adding a second error notification',
function() { function () {
sinon.spy($rootScope, '$broadcast'); sinon.spy($rootScope, '$broadcast');
var firstNotification = NotificationsService.add('blubs'); var firstNotification = NotificationsService.add('blubs');
NotificationsService.addError('blubs2'); NotificationsService.addError('blubs2');
expect($rootScope.$broadcast).to.have.been.calledWith('notification.remove', expect($rootScope.$broadcast).to.have.been.calledWith('notification.remove',
firstNotification); firstNotification);
}); });
it('does not send a broadcast upon the second error/success ' + it('does not send a broadcast upon the second error/success ' +
'if the notification has already been removed', 'if the notification has already been removed',
function() { function () {
var firstNotification = NotificationsService.add('blubs'); var firstNotification = NotificationsService.add('blubs');
$rootScope.$broadcast('notification.remove', firstNotification); $rootScope.$broadcast('notification.remove', firstNotification);
sinon.spy($rootScope, '$broadcast'); sinon.spy($rootScope, '$broadcast');
NotificationsService.addError('blubs2'); NotificationsService.addError('blubs2');
expect($rootScope.$broadcast).not.to.have.been.calledWith('notification.remove', expect($rootScope.$broadcast).not.to.have.been.calledWith('notification.remove',
firstNotification); firstNotification);
}); });
}); });

@ -22,7 +22,7 @@
class="{{action.icon}}"> class="{{action.icon}}">
<a role="menuitem" href="" ng-click="triggerContextMenuAction(action.icon, action.link)"> <a role="menuitem" href="" ng-click="triggerContextMenuAction(action.icon, action.link)">
<i ng-class="['icon-action-menu', 'icon-' + action.icon]"></i> <i ng-class="['icon-action-menu', 'icon-' + action.icon]"></i>
<span ng-bind="I18n.t('js.button_' + action.icon)"/> <span ng-bind="action.text"/>
</a> </a>
</li> </li>
</ul> </ul>

@ -83,9 +83,10 @@ describe('workPackageContextMenu', () => {
update: '/work_packages/123/edit', update: '/work_packages/123/edit',
move: '/work_packages/move/new?ids%5B%5D=123', move: '/work_packages/move/new?ids%5B%5D=123',
}; };
var workPackage = Factory.build('PlanningElement', { var workPackage = Factory.build('PlanningElement');
$links: actionLinks workPackage.$source = { _links: actionLinks };
}); workPackage.$links = actionLinks;
var directListElements; var directListElements;
beforeEach(angular.mock.inject((_I18n_) => { beforeEach(angular.mock.inject((_I18n_) => {
@ -125,9 +126,9 @@ describe('workPackageContextMenu', () => {
var actionLinks = { var actionLinks = {
'delete': '/work_packages/bulk', 'delete': '/work_packages/bulk',
}; };
var workPackage = Factory.build('PlanningElement', { var workPackage = Factory.build('PlanningElement');
$links: actionLinks workPackage.$source = { _links: actionLinks };
}); workPackage.$links = actionLinks;
beforeEach(() => { beforeEach(() => {
$rootScope.rows = []; $rootScope.rows = [];

@ -92,17 +92,13 @@ function WorkPackageFieldService($q, $http, $filter, I18n, WorkPackagesHelper,
var attrVisibility = getVisibility(workPackage, field); var attrVisibility = getVisibility(workPackage, field);
var notRequired = !isRequired(workPackage, field); var notRequired = !isRequired(workPackage, field) || hasDefault(workPackage, field);
var empty = isEmpty(workPackage, field); var empty = isEmpty(workPackage, field);
var visible = attrVisibility == 'visible'; // always show var visible = attrVisibility == 'visible'; // always show
var hidden = attrVisibility == 'hidden'; // never show var hidden = attrVisibility == 'hidden'; // never show
// !hidden && !visible => show if not empty // not hidden and not visible => show if not empty (default)
if (workPackage.isNew === true) { return notRequired && !visible && (empty || hidden);
return notRequired && hidden;
} else {
return notRequired && !visible && (empty || hidden);
}
} }
function getVisibility(workPackage, field) { function getVisibility(workPackage, field) {
@ -231,6 +227,14 @@ function WorkPackageFieldService($q, $http, $filter, I18n, WorkPackagesHelper,
return schema.props[field].required; return schema.props[field].required;
} }
function hasDefault(workPackage, field) {
var schema = getSchema(workPackage);
if (_.isUndefined(schema.props[field])) {
return false;
}
return schema.props[field].hasDefault;
}
function isEmbedded(workPackage, field) { function isEmbedded(workPackage, field) {
return !_.isUndefined(workPackage.embedded[field]); return !_.isUndefined(workPackage.embedded[field]);
} }

@ -34,6 +34,13 @@ angular
$urlMatcherFactoryProvider.strictMode(false); $urlMatcherFactoryProvider.strictMode(false);
var panels = { var panels = {
get overview() {
return {
url: '/overview',
template: '<overview-panel work-package="workPackage"></overview-panel>'
};
},
get watchers() { get watchers() {
return { return {
url: '/watchers', url: '/watchers',
@ -180,12 +187,7 @@ angular
} }
} }
}) })
.state('work-packages.list.details.overview', { .state('work-packages.list.details.overview', panels.overview)
url: '/overview',
templateUrl: '/templates/work_packages/tabs/overview.html',
controller: 'DetailsTabOverviewController',
controllerAs: 'vm',
})
.state('work-packages.list.details.activity', panels.activity) .state('work-packages.list.details.activity', panels.activity)
.state('work-packages.list.details.activity.details', panels.activityDetails) .state('work-packages.list.details.activity.details', panels.activityDetails)
.state('work-packages.list.details.relations', panels.relations) .state('work-packages.list.details.relations', panels.relations)

@ -72,7 +72,6 @@
rows="rows" rows="rows"
query="query" query="query"
group-by="query.groupBy" group-by="query.groupBy"
group-by-column="groupByColumn"
display-sums="query.displaySums" display-sums="query.displaySums"
resource="resource" resource="resource"
activation-callback="openWorkPackageInFullView(id, force)"> activation-callback="openWorkPackageInFullView(id, force)">

@ -26,17 +26,28 @@
// See doc/COPYRIGHT.rdoc for more details. // See doc/COPYRIGHT.rdoc for more details.
// ++ // ++
angular function WorkPackageShowController($scope,
.module('openproject.workPackages.controllers') $rootScope,
.controller('WorkPackageShowController', WorkPackageShowController); $state,
$window,
function WorkPackageShowController($scope, $rootScope, $state, workPackage, I18n, $q,
RELATION_TYPES, RELATION_IDENTIFIERS, $q, WorkPackagesHelper, PathHelper, UsersHelper, PERMITTED_MORE_MENU_ACTIONS,
WorkPackageService, CommonRelationsHandler, RELATION_TYPES,
ChildrenRelationsHandler, ParentRelationsHandler, WorkPackagesOverviewService, RELATION_IDENTIFIERS,
WorkPackageFieldService, EditableFieldsState, WorkPackagesDisplayHelper, NotificationsService, workPackage,
WorkPackageAuthorization, PERMITTED_MORE_MENU_ACTIONS, HookService, $window, I18n,
WorkPackageAttachmentsService, AuthorisationService, inplaceEditAll) { WorkPackagesHelper,
PathHelper,
UsersHelper,
WorkPackageService,
CommonRelationsHandler,
ChildrenRelationsHandler,
ParentRelationsHandler,
EditableFieldsState,
WorkPackageAuthorization,
HookService,
AuthorisationService,
inplaceEditAll) {
$scope.editAll = inplaceEditAll; $scope.editAll = inplaceEditAll;
$scope.canEdit = EditableFieldsState.canEdit; $scope.canEdit = EditableFieldsState.canEdit;
@ -116,7 +127,7 @@ function WorkPackageShowController($scope, $rootScope, $state, workPackage, I18n
}; };
var authorization = new WorkPackageAuthorization($scope.workPackage); var authorization = new WorkPackageAuthorization($scope.workPackage);
$scope.permittedActions = angular.extend(getPermittedActions(authorization, PERMITTED_MORE_MENU_ACTIONS), $scope.permittedActions = angular.extend(getPermittedActions(authorization, PERMITTED_MORE_MENU_ACTIONS),
getPermittedPluginActions(authorization)); getPermittedPluginActions(authorization));
$scope.actionsAvailable = Object.keys($scope.permittedActions).length > 0; $scope.actionsAvailable = Object.keys($scope.permittedActions).length > 0;
// END stuff copied from details toolbar directive... // END stuff copied from details toolbar directive...
@ -154,7 +165,7 @@ function WorkPackageShowController($scope, $rootScope, $state, workPackage, I18n
$scope.workPackage = workPackage; $scope.workPackage = workPackage;
$scope.isWatched = workPackage.links.hasOwnProperty('unwatch'); $scope.isWatched = workPackage.links.hasOwnProperty('unwatch');
$scope.displayWatchButton = workPackage.links.hasOwnProperty('unwatch') || $scope.displayWatchButton = workPackage.links.hasOwnProperty('unwatch') ||
workPackage.links.hasOwnProperty('watch'); workPackage.links.hasOwnProperty('watch');
// watchers // watchers
if(workPackage.links.watchers) { if(workPackage.links.watchers) {
@ -191,8 +202,8 @@ function WorkPackageShowController($scope, $rootScope, $state, workPackage, I18n
RELATION_TYPES[key]) RELATION_TYPES[key])
).then(function(relations) { ).then(function(relations) {
var relationsHandler = new CommonRelationsHandler(workPackage, var relationsHandler = new CommonRelationsHandler(workPackage,
relations, relations,
RELATION_IDENTIFIERS[key]); RELATION_IDENTIFIERS[key]);
$scope[key] = relationsHandler; $scope[key] = relationsHandler;
}); });
} }
@ -208,7 +219,7 @@ function WorkPackageShowController($scope, $rootScope, $state, workPackage, I18n
// Toggle early to avoid delay. // Toggle early to avoid delay.
$scope.isWatched = !$scope.isWatched; $scope.isWatched = !$scope.isWatched;
WorkPackageService.toggleWatch($scope.workPackage) WorkPackageService.toggleWatch($scope.workPackage)
.then(function() { refreshWorkPackage() }, outputError); .then(function() { refreshWorkPackage() }, outputError);
}; };
$scope.canViewWorkPackageWatchers = function() { $scope.canViewWorkPackageWatchers = function() {
@ -224,11 +235,11 @@ function WorkPackageShowController($scope, $rootScope, $state, workPackage, I18n
function getFocusAnchorLabel(tab, workPackage) { function getFocusAnchorLabel(tab, workPackage) {
var tabLabel = I18n.t('js.work_packages.tabs.' + tab), var tabLabel = I18n.t('js.work_packages.tabs.' + tab),
params = { params = {
tab: tabLabel, tab: tabLabel,
type: workPackage.props.type, type: workPackage.props.type,
subject: workPackage.props.subject subject: workPackage.props.subject
}; };
return I18n.t('js.label_work_package_details_you_are_here', params); return I18n.t('js.label_work_package_details_you_are_here', params);
} }
@ -237,62 +248,8 @@ function WorkPackageShowController($scope, $rootScope, $state, workPackage, I18n
$state.current.url.replace(/\//, ''), $state.current.url.replace(/\//, ''),
$scope.workPackage $scope.workPackage
); );
// Stuff copied from DetailsTabOverviewController
var vm = this;
vm.groupedFields = [];
vm.hideEmptyFields = true;
vm.workPackage = $scope.workPackage;
vm.shouldHideGroup = function(group) {
return WorkPackagesDisplayHelper.shouldHideGroup(vm.hideEmptyFields,
vm.groupedFields,
group,
vm.workPackage);
};
vm.isFieldHideable = WorkPackagesDisplayHelper.isFieldHideable;
vm.getLabel = WorkPackagesDisplayHelper.getLabel;
vm.isSpecified = WorkPackagesDisplayHelper.isSpecified;
vm.hasNiceStar = WorkPackagesDisplayHelper.hasNiceStar;
vm.showToggleButton = WorkPackagesDisplayHelper.showToggleButton;
vm.filesExist = false;
activate();
WorkPackageAttachmentsService.hasAttachments(vm.workPackage).then(function(bool) {
vm.filesExist = bool;
});
function activate() {
$scope.$watch('workPackage.schema', function(schema) {
if (schema) {
WorkPackagesDisplayHelper.setFocus();
vm.workPackage = $scope.workPackage;
}
});
vm.groupedFields = WorkPackagesOverviewService.getGroupedWorkPackageOverviewAttributes();
$scope.$watchCollection('vm.workPackage.form', function() {
var schema = WorkPackageFieldService.getSchema(vm.workPackage);
var otherGroup = _.find(vm.groupedFields, {groupName: 'other'});
otherGroup.attributes = [];
_.forEach(schema.props, function(prop, propName) {
if (propName.match(/^customField/)) {
otherGroup.attributes.push(propName);
}
});
otherGroup.attributes.sort(function(a, b) {
var getLabel = function(field) {
return vm.getLabel(vm.workPackage, field);
};
var left = getLabel(a).toLowerCase(),
right = getLabel(b).toLowerCase();
return left.localeCompare(right);
});
});
$scope.$on('workPackageUpdatedInEditor', function() {
NotificationsService.addSuccess(I18n.t('js.notice_successful_update'));
});
}
} }
angular
.module('openproject.workPackages.controllers')
.controller('WorkPackageShowController', WorkPackageShowController);

@ -59,61 +59,7 @@
<div class="work-packages--split-view"> <div class="work-packages--split-view">
<div class="work-packages--left-panel"> <div class="work-packages--left-panel">
<div class="work-packages--panel-inner"> <div class="work-packages--panel-inner">
<div class="attributes-group"> <wp-single-view></wp-single-view>
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text">
<!-- {{ I18n.t('js.label_description') }} -->
{{type.props.name}} #{{workPackage.props.id}}
</h3>
</div>
</div>
<div class="single-attribute wiki">
<wp-field field-name="'description'"></wp-field>
</div>
</div>
<div ng-repeat="group in vm.groupedFields" ng-hide="vm.shouldHideGroup(group.groupName)" class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text"
ng-bind="I18n.t('js.work_packages.property_groups.' + group.groupName)"></h3>
</div>
<div class="attributes-group--header-toggle">
<panel-expander tabindex="-1" ng-if="vm.showToggleButton() && $first"
collapsed="vm.hideEmptyFields"
expand-text="{{ I18n.t('js.label_show_attributes') }}"
collapse-text="{{ I18n.t('js.label_hide_attributes') }}">
</panel-expander>
</div>
</div>
<div class="attributes-key-value">
<div
ng-hide="vm.hideEmptyFields && vm.isFieldHideable(vm.workPackage, field)"
ng-if="vm.isSpecified(vm.workPackage, field)"
ng-repeat-start="field in group.attributes"
class="attributes-key-value--key"
id="attributes-key-value--key-{{field}}">
{{vm.getLabel(vm.workPackage, field)}}
<span class="required" ng-if="vm.hasNiceStar(vm.workPackage, field)"> *</span>
</div>
<div
ng-hide="vm.hideEmptyFields && vm.isFieldHideable(vm.workPackage, field)"
ng-if="vm.isSpecified(vm.workPackage, field)"
ng-repeat-end
class="attributes-key-value--value-container">
<wp-field field-name="field"></wp-field>
</div>
</div>
</div>
<work-package-attachments edit data-ng-show="!vm.hideEmptyFields || vm.filesExist" work-package="vm.workPackage"></work-package-attachments>
<edit-actions-bar></edit-actions-bar> <edit-actions-bar></edit-actions-bar>
</div> </div>
</div> </div>

@ -1,4 +1,4 @@
//-- copyright // -- copyright
// OpenProject is a project management system. // OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) // Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
// //
@ -24,13 +24,13 @@
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
// //
// See doc/COPYRIGHT.rdoc for more details. // See doc/COPYRIGHT.rdoc for more details.
//++ // ++
/* jshint expr: true */
/* globals WebKitBlobBuilder */ declare const WebKitBlobBuilder:any;
describe('workPackageAttachmentsService', function() { describe('workPackageAttachmentsService', function() {
'use strict'; var WorkPackageAttachmentsService;
var WorkPackageAttachmentsService, $httpBackend; var $httpBackend;
// mock me a work package // mock me a work package
// TODO: remove that hyperagent.js nonsense asap // TODO: remove that hyperagent.js nonsense asap
@ -61,9 +61,9 @@ describe('workPackageAttachmentsService', function() {
} }
}; };
beforeEach(module('openproject.workPackages')); beforeEach(angular.mock.module('openproject.workPackages'));
beforeEach(inject(function(_WorkPackageAttachmentsService_, _$httpBackend_){ beforeEach(angular.mock.inject(function(_WorkPackageAttachmentsService_, _$httpBackend_){
WorkPackageAttachmentsService = _WorkPackageAttachmentsService_; WorkPackageAttachmentsService = _WorkPackageAttachmentsService_;
$httpBackend = _$httpBackend_; $httpBackend = _$httpBackend_;
})); }));

@ -0,0 +1,109 @@
// -- 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 {opServicesModule} from '../../../angular-modules.ts';
function wpAttachmentsService($q, $timeout, $http, Upload, I18n, NotificationsService) {
var upload = (workPackage, files) => {
var uploadPath = workPackage.$links.addAttachment.$link.href;
var uploads = _.map(files, (file:any) => {
var options = {
url: uploadPath,
fields: {
metadata: {
fileName: file.name,
description: file.description
}
},
file: file
};
return Upload.upload(options);
});
// notify the user
var message = I18n.t('js.label_upload_notification', {
id: workPackage.id,
subject: workPackage.subject
});
var notification = NotificationsService.addWorkPackageUpload(message, uploads);
var allUploadsDone = $q.defer();
$q.all(uploads).then(function () {
$timeout(function () { // let the notification linger for a bit
NotificationsService.remove(notification);
allUploadsDone.resolve();
}, 700);
}, function (err) {
allUploadsDone.reject(err);
});
return allUploadsDone.promise;
},
load = function (workPackage, reload) {
var path = workPackage.$links.attachments.$link.href,
attachments = $q.defer();
$http.get(path, {cache: !reload}).success(function (response) {
attachments.resolve(response._embedded.elements);
}).error(function (err) {
attachments.reject(err);
});
return attachments.promise;
},
remove = function (fileOrAttachment) {
var removal = $q.defer();
if (angular.isObject(fileOrAttachment._links)) {
var path = fileOrAttachment._links.self.href;
$http.delete(path).success(function () {
removal.resolve(fileOrAttachment);
}).error(function (err) {
removal.reject(err);
});
} else {
removal.resolve(fileOrAttachment);
}
return removal.promise;
},
hasAttachments = function (workPackage) {
var existance = $q.defer();
load(workPackage).then(function (attachments:any) {
existance.resolve(attachments.length > 0);
});
return existance.promise;
};
return {
upload: upload,
remove: remove,
load: load,
hasAttachments: hasAttachments
};
}
opServicesModule.factory('WorkPackageAttachmentsService', wpAttachmentsService);

@ -1,19 +1,19 @@
<span class="wp-table--cell-span" <span class="wp-table--cell-span"
ng-switch="vm.displayType" ng-switch="$ctrl.displayType"
wp-field="vm.workPackage" wp-field="$ctrl.workPackage"
field-name="vm.attribute"> field-name="$ctrl.attribute">
<progress-bar ng-switch-when="Percent" <progress-bar ng-switch-when="Percent"
progress="vm.displayText" progress="$ctrl.displayText"
width="80px"> width="80px">
</progress-bar> </progress-bar>
<span ng-switch-when="SelfLink" title="{{ vm.displayText }}"> <span ng-switch-when="SelfLink" title="{{ $ctrl.displayText }}">
<a ng-href="{{ vm.displayLink }}">{{ vm.displayText }}</a> <a ng-href="{{ $ctrl.displayLink }}">{{ $ctrl.displayText }}</a>
</span> </span>
<span ng-switch-default <span ng-switch-default
title="{{ vm.displayText }}" title="{{ $ctrl.displayText }}"
ng-class="{ 'work-package--placeholder' : vm.displayText == '-' }"> ng-class="{ 'work-package--placeholder' : $ctrl.displayText == '-' }">
{{ vm.displayText }} {{ $ctrl.displayText }}
</span> </span>
</span> </span>

@ -45,7 +45,7 @@ describe('wpDisplayAttr directive', () => {
beforeEach(angular.mock.inject(($rootScope, $compile) => { beforeEach(angular.mock.inject(($rootScope, $compile) => {
var html = ` var html = `
<wp-display-attr object="workPackage" schema="schema" attribute="attribute"> <wp-display-attr work-package="workPackage" schema="schema" attribute="attribute">
</wp-display-attr> </wp-display-attr>
`; `;

@ -33,7 +33,7 @@ export default class WorkPackageDisplayAttributeController {
public displayType:string; public displayType:string;
public displayLink:string; public displayLink:string;
public attribute:string; public attribute:string;
public object:any; public workPackage:any;
public schema:HalResource; public schema:HalResource;
constructor(protected $scope:ng.IScope, constructor(protected $scope:ng.IScope,
@ -42,7 +42,7 @@ export default class WorkPackageDisplayAttributeController {
protected WorkPackagesHelper:any) { protected WorkPackagesHelper:any) {
this.displayText = I18n.t('js.work_packages.placeholders.default'); this.displayText = I18n.t('js.work_packages.placeholders.default');
$scope.$watch('vm.object.' + this.attribute, () => { $scope.$watch('$ctrl.workPackage.' + this.attribute, () => {
this.updateAttribute(); this.updateAttribute();
}); });
} }
@ -55,7 +55,7 @@ export default class WorkPackageDisplayAttributeController {
else if (this.attribute === 'id') { else if (this.attribute === 'id') {
// Show a link to the work package for the ID // Show a link to the work package for the ID
this.displayType = 'SelfLink'; this.displayType = 'SelfLink';
this.displayLink = this.PathHelper.workPackagePath(this.object.id); this.displayLink = this.PathHelper.workPackagePath(this.workPackage.id);
} }
else { else {
this.displayType = this.schema[this.attribute].type; this.displayType = this.schema[this.attribute].type;
@ -64,22 +64,22 @@ export default class WorkPackageDisplayAttributeController {
protected updateAttribute() { protected updateAttribute() {
this.schema.$load().then(() => { this.schema.$load().then(() => {
if (this.object.isNew && this.attribute === 'id') { if (this.workPackage.isNew && this.attribute === 'id') {
this.displayText = 'text'; this.displayText = 'text';
this.displayText = ''; this.displayText = '';
return; return;
} }
if (!this.object[this.attribute]) { if (!this.workPackage[this.attribute]) {
this.displayText = this.I18n.t('js.work_packages.placeholders.default'); this.displayText = this.I18n.t('js.work_packages.placeholders.default');
return; return;
} }
this.setDisplayType(); this.setDisplayType();
var text = this.object[this.attribute].value || var text = this.workPackage[this.attribute].value ||
this.object[this.attribute].name || this.workPackage[this.attribute].name ||
this.object[this.attribute]; this.workPackage[this.attribute];
this.displayText = this.WorkPackagesHelper.formatValue(text, this.displayType); this.displayText = this.WorkPackagesHelper.formatValue(text, this.displayType);
}); });
@ -94,13 +94,13 @@ function wpDisplayAttr() {
scope: { scope: {
schema: '=', schema: '=',
object: '=', workPackage: '=',
attribute: '=' attribute: '='
}, },
bindToController: true, bindToController: true,
controller: WorkPackageDisplayAttributeController, controller: WorkPackageDisplayAttributeController,
controllerAs: 'vm' controllerAs: '$ctrl'
}; };
} }

@ -34,6 +34,7 @@ function WorkPackageNewController($scope,
$rootScope, $rootScope,
$state, $state,
$stateParams, $stateParams,
I18n,
PathHelper, PathHelper,
WorkPackagesOverviewService, WorkPackagesOverviewService,
WorkPackageFieldService, WorkPackageFieldService,
@ -75,7 +76,17 @@ function WorkPackageNewController($scope,
vm.showToggleButton = WorkPackagesDisplayHelper.showToggleButton; vm.showToggleButton = WorkPackagesDisplayHelper.showToggleButton;
vm.notifyCreation = function() { vm.notifyCreation = function() {
NotificationsService.addSuccess(I18n.t('js.notice_successful_create')); NotificationsService.addSuccess({
message: I18n.t('js.notice_successful_create'),
link: {
target: function() {
loadingIndicator.mainPage = $state.go.apply($state,
["work-packages.show.activity",
$state.params]);
},
text: I18n.t('js.work_packages.message_successful_show_in_fullscreen')
}
});
}; };
vm.getHeading = function() { vm.getHeading = function() {
if (vm.parentWorkPackage !== undefined) { if (vm.parentWorkPackage !== undefined) {

@ -0,0 +1,299 @@
// -- 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 {opWorkPackagesModule} from "../../../angular-modules";
function wpSingleViewFieldService($filter,
I18n,
WorkPackagesHelper,
inplaceEditErrors) {
function getSchema(workPackage) {
return workPackage.schema;
}
function isEditable(workPackage, field) {
// no form - no editing
if (!workPackage.form) {
return false;
}
var schema = getSchema(workPackage);
// TODO: extract to strategy if new cases arise
if (field === 'date') {
// nope
return schema['startDate'].writable && schema['dueDate'].writable;
//return workPackage.schema.startDate.writable
// && workPackage.schema.dueDate.writable;
}
if(schema[field].type === 'Date') {
return true;
}
var isWritable = schema[field].writable;
// not writable if no embedded allowed values
if (isWritable && schema[field]._links && allowedValuesEmbedded(workPackage, field)) {
isWritable = getEmbeddedAllowedValues(workPackage, field).length > 0;
}
return isWritable;
}
function isSpecified(workPackage, field) {
var schema = getSchema(workPackage);
if (field === 'date') {
// kind of specified
return true;
}
return !_.isUndefined(schema[field]);
}
// under special conditions fields will be shown
// irregardless if they are empty or not
// e.g. when an error should trigger the editing state
// of an empty field after type change
function isHideable(workPackage, field) {
if (inplaceEditErrors.errors && inplaceEditErrors.errors[field]) {
return false;
}
var attrVisibility = getVisibility(workPackage, field);
var notRequired = !isRequired(workPackage, field);
var empty = isEmpty(workPackage, field);
var visible = attrVisibility == 'visible'; // always show
var hidden = attrVisibility == 'hidden'; // never show
// !hidden && !visible => show if not empty
if (workPackage.isNew === true) {
return notRequired && hidden;
} else {
return notRequired && !visible && (empty || hidden);
}
}
function getVisibility(workPackage, field) {
if (field == "date") {
return getDateVisibility(workPackage);
} else {
var schema = workPackage.schema;
var prop = schema && schema[field];
return prop && prop.visibility;
}
}
/**
* There isn't actually a 'date' field for work packages.
* There are two fields: 'start_date' and 'due_date'
* Though they are displayed together in one row, as one 'field'.
* Since the schema doesn't know any field named 'date' we
* derive the visibility for the imaginary 'date' field from
* the actual schema values of 'due_date' and 'start_date'.
*
* 'visible' > 'default' > 'hidden'
* Meaning, for instance, that if at least one field is 'visible'
* both will be shown. Even if the other is 'hidden'.
*
* Note: this is duplicated in app/views/types/_form.html.erb
*/
function getDateVisibility(workPackage) {
var a = getVisibility(workPackage, "startDate");
var b = getVisibility(workPackage, "dueDate");
var values = [a, b];
if (_.contains(values, "visible")) {
return "visible";
} else if (_.contains(values, "default")) {
return "default";
} else if (_.contains(values, "hidden")) {
return "hidden";
} else {
return undefined;
}
}
function isMilestone(workPackage) {
// TODO: this should be written as "only use the form when editing"
// otherwise always use the simple way
// currently we don't know the context in which this method is called
var formAvailable = !_.isUndefined(workPackage.form);
if (formAvailable) {
var allowedValues = workPackage.schema.type.$embedded.allowedValues;
var currentType = workPackage.$links.type.$link.href;
return _.some(allowedValues, function(allowedValue) {
return allowedValue.href === currentType &&
allowedValue.isMilestone;
});
} else {
return workPackage.type.isMilestone;
}
}
function getValue(workPackage, field) {
var payload = workPackage;
if (field === 'date') {
if(isMilestone(workPackage)) {
return payload['dueDate'];
}
return {
startDate: payload['startDate'],
dueDate: payload['dueDate']
};
}
if (!_.isUndefined(payload[field])) {
return payload[field];
}
if (isEmbedded(payload, field)) {
return payload.$embedded[field];
}
if (payload.$links[field] && payload.$links[field].$link.href !== null) {
return payload.$links[field];
}
return null;
}
function allowedValuesEmbedded(workPackage, field) {
var schema = getSchema(workPackage);
return _.isArray(schema[field]._links.allowedValues);
}
function getEmbeddedAllowedValues(workPackage, field) {
var options = [];
var schema = getSchema(workPackage);
return schema[field].$embedded.allowedValues;
}
function isRequired(workPackage, field) {
var schema = getSchema(workPackage);
if (_.isUndefined(schema[field])) {
return false;
}
return schema[field].required;
}
function isEmbedded(workPackage, field) {
return !_.isUndefined(workPackage.$embedded[field]);
}
function getLabel(workPackage, field) {
var schema = getSchema(workPackage);
if (field === 'date') {
// special case
return I18n.t('js.work_packages.properties.date');
}
return schema[field].name;
}
function isEmpty(workPackage, field) {
if (field === 'date') {
return (
getValue(workPackage, 'startDate') === null &&
getValue(workPackage, 'dueDate') === null
);
}
var value = format(workPackage, field);
if (value === null || value === '') {
return true;
}
if (value.html === '') {
return true;
}
if (field === 'spentTime' && workPackage[field] === 'PT0S') {
return true;
}
if (value.$embedded && _.isArray(value.$embedded.elements)) {
return value.$embedded.elements.length === 0;
}
return false;
}
function format(workPackage, field) {
var schema = getSchema(workPackage);
if (field === 'date') {
if(isMilestone(workPackage)) {
return workPackage['dueDate'];
}
return {
startDate: workPackage.startDate,
dueDate: workPackage.dueDate,
noStartDate: I18n.t('js.label_no_start_date'),
noEndDate: I18n.t('js.label_no_due_date')
};
}
var value = workPackage[field];
if (_.isUndefined(value)) {
return getValue(workPackage, field, true);
}
if (value === null) {
return null;
}
var fieldMapping = {
dueDate: 'date',
startDate: 'date',
createdAt: 'datetime',
updatedAt: 'datetime'
}[field] || schema[field] && schema[field].type;
switch(fieldMapping) {
case('Duration'):
var hours = moment.duration(value).asHours();
var formattedHours = $filter('number')(hours, 2);
return I18n.t('js.units.hour', { count: formattedHours });
case('Boolean'):
return value ? I18n.t('js.general_text_yes') : I18n.t('js.general_text_no');
case('Date'):
return value;
case('Float'):
return $filter('number')(value);
default:
return WorkPackagesHelper.formatValue(value, fieldMapping);
}
}
return {
isEditable: isEditable,
isRequired: isRequired,
isSpecified: isSpecified,
isHideable: isHideable,
isMilestone: isMilestone,
isEmbedded: isEmbedded,
getLabel: getLabel,
};
}
opWorkPackagesModule.service('wpSingleViewField', wpSingleViewFieldService);

@ -0,0 +1,63 @@
<div ng-if="$ctrl.workPackage" wp-edit-form="$ctrl.workPackage">
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text">
{{ $ctrl.workPackage.type.name }} #{{ $ctrl.workPackage.id }}
</h3>
</div>
<div class="attributes-group--header-container-right">
<span ng-bind="$ctrl.I18n.t('js.label_last_updated_on')"/>
<op-date date-value="$ctrl.workPackage.updatedAt"></op-date>
</div>
</div>
<div
wp-edit-field="'description'"
class="single-attribute wiki">
<wp-display-attr
schema="$ctrl.workPackage.schema"
work-package="$ctrl.workPackage"
attribute="'description'">
</wp-display-attr>
</div>
</div>
<div ng-repeat="group in $ctrl.groupedFields" ng-hide="$ctrl.shouldHideGroup(group.groupName)" class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text"
ng-bind="$ctrl.I18n.t('js.work_packages.property_groups.' + group.groupName)"></h3>
</div>
<div class="attributes-group--header-toggle">
<panel-expander tabindex="-1" ng-if="$ctrl.wpSingleView.showToggleButton() && $first"
collapsed="$ctrl.hideEmptyFields"
expand-text="{{ $ctrl.I18n.t('js.label_show_attributes') }}"
collapse-text="{{ $ctrl.I18n.t('js.label_hide_attributes') }}">
</panel-expander>
</div>
</div>
<div class="attributes-key-value">
<div
ng-hide="$ctrl.hideEmptyFields && $ctrl.wpSingleView.isFieldHideable($ctrl.workPackage, field)"
ng-if="$ctrl.wpSingleView.isSpecified($ctrl.workPackage, field)"
ng-repeat-start="field in group.attributes" class="attributes-key-value--key">
{{$ctrl.wpSingleView.getLabel($ctrl.workPackage, field)}}
<span class="required" ng-if="$ctrl.wpSingleView.hasNiceStar($ctrl.workPackage, field)"> *</span>
</div>
<div
ng-hide="$ctrl.hideEmptyFields && $ctrl.wpSingleView.isFieldHideable($ctrl.workPackage, field)"
ng-if="$ctrl.wpSingleView.isSpecified($ctrl.workPackage, field)"
ng-repeat-end
wp-edit-field="field"
class="attributes-key-value--value-container">
<wp-display-attr schema="$ctrl.workPackage.schema" work-package="$ctrl.workPackage" attribute="field"></wp-display-attr>
</div>
</div>
</div>
<work-package-attachments edit work-package="$ctrl.workPackage" data-ng-show="!$ctrl.hideEmptyFields || $ctrl.filesExist"></work-package-attachments>
</div>

@ -0,0 +1,125 @@
// -- 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 {opWorkPackagesModule} from "../../../angular-modules";
import {scopedObservable} from "../../../helpers/angular-rx-utils";
export class WorkPackageSingleViewController {
public workPackage;
public groupedFields:any[] = [];
public hideEmptyFields:boolean = true;
public filesExist:boolean = false;
constructor(protected $scope,
protected $state,
protected loadingIndicator,
protected $stateParams,
public wpSingleView,
protected I18n,
protected wpCacheService,
protected NotificationsService,
protected WorkPackagesOverviewService,
protected WorkPackageFieldService,
protected inplaceEditAll,
protected WorkPackageAttachmentsService) {
scopedObservable($scope, wpCacheService.loadWorkPackage($stateParams.workPackageId)).subscribe(wp => {
this.workPackage = wp;
this.workPackage.schema.$load();
this.groupedFields = WorkPackagesOverviewService.getGroupedWorkPackageOverviewAttributes();
WorkPackageAttachmentsService.hasAttachments(this.workPackage).then(bool => {
this.filesExist = bool;
});
$scope.$watch('$ctrl.workPackage.schema', schema => {
if (schema) {
this.wpSingleView.setFocus();
}
});
$scope.$watchCollection('$ctrl.workPackage.form', () => {
var schema = WorkPackageFieldService.getSchema(this.workPackage);
var otherGroup:any = _.find(this.groupedFields, {groupName: 'other'});
otherGroup.attributes = [];
angular.forEach(schema.props, (prop, propName) => {
if (propName.match(/^customField/)) {
otherGroup.attributes.push(propName);
}
});
otherGroup.attributes.sort((a, b) => {
var getLabel = field => this.wpSingleView.getLabel(this.workPackage, field);
var left = getLabel(a).toLowerCase();
var right = getLabel(b).toLowerCase();
return left.localeCompare(right);
});
});
});
$scope.$on('workPackageUpdatedInEditor', () => {
NotificationsService.addSuccess({
message: I18n.t('js.notice_successful_update'),
link: {
target: () => {
loadingIndicator.mainPage = $state.go(
...["work-packages.show.activity", $state.params]);
},
text: I18n.t('js.work_packages.message_successful_show_in_fullscreen')
}
});
});
}
public shouldHideGroup(group) {
return this.wpSingleView.shouldHideGroup(
this.hideEmptyFields, this.groupedFields, group, this.workPackage);
}
}
function wpSingleViewDirective() {
return {
restrict: 'E',
templateUrl: '/components/work-packages/wp-single-view/wp-single-view.directive.html',
scope: {},
bindToController: true,
controller: WorkPackageSingleViewController,
controllerAs: '$ctrl'
};
}
opWorkPackagesModule.directive('wpSingleView', wpSingleViewDirective);

@ -0,0 +1,140 @@
//-- 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 {opWorkPackagesModule} from "../../../angular-modules";
function wpSingleViewService($window, $timeout, wpSingleViewField) {
// specifies unhideable (during creation)
var unhideableFields = [
'subject',
'type',
'status',
'description',
'priority'
];
var firstTimeFocused = false;
var isGroupHideable = function (groupedFields, groupName, workPackage, cb) {
if (!workPackage) {
return true;
}
var group = _.find(groupedFields, {groupName: groupName});
var isHideable = typeof cb === 'undefined' ? isFieldHideable : cb;
return group.attributes.length === 0 || _.every(group.attributes, function(field) {
return isHideable(workPackage, field);
});
},
isGroupEmpty = function (groupedFields, groupName) {
var group = _.find(groupedFields, {groupName: groupName});
return group.attributes.length === 0;
},
shouldHideGroup = function(hideEmptyActive, groupedFields, groupName, workPackage, cb) {
return hideEmptyActive && isGroupHideable(groupedFields, groupName, workPackage, cb) ||
!hideEmptyActive && isGroupEmpty(groupedFields, groupName);
},
isFieldHideable = function (workPackage, field) {
if (!workPackage) {
return true;
}
return wpSingleViewField.isHideable(workPackage, field);
},
isFieldHideableOnCreate = function(workPackage, field) {
if (!workPackage) {
return true;
}
if (!isSpecified(workPackage, field)) {
return true;
}
if (!isEditable(workPackage, field)) {
return true;
}
if (_.contains(unhideableFields, field)) {
return !wpSingleViewField.isEditable(workPackage, field);
}
if (wpSingleViewField.isRequired(workPackage, field)) {
return false;
}
return wpSingleViewField.isHideable(workPackage, field);
},
isSpecified = function (workPackage, field) {
if (!workPackage) {
return false;
}
return wpSingleViewField.isSpecified(workPackage, field);
},
isEditable = function(workPackage, field) {
return wpSingleViewField.isEditable(workPackage, field);
},
hasNiceStar = function (workPackage, field) {
if (!workPackage) {
return false;
}
return wpSingleViewField.isRequired(workPackage, field) &&
wpSingleViewField.isEditable(workPackage, field);
},
getLabel = function (workPackage, field) {
if (!(workPackage && typeof field === 'string')) {
return '';
}
return wpSingleViewField.getLabel(workPackage, field);
},
setFocus = function() {
if (!firstTimeFocused) {
firstTimeFocused = true;
$timeout(function() {
// TODO: figure out a better way to fix the wp table columns bug
// where arrows are misplaced when not resizing the window
angular.element($window).trigger('resize');
angular.element('.work-packages--details--subject .focus-input').focus();
});
}
},
showToggleButton = function () {
return true;
};
return {
isGroupHideable: isGroupHideable,
isGroupEmpty: isGroupEmpty,
shouldHideGroup: shouldHideGroup,
isFieldHideable: isFieldHideable,
isFieldHideableOnCreate: isFieldHideableOnCreate,
isSpecified: isSpecified,
isEditable: isEditable,
hasNiceStar: hasNiceStar,
getLabel: getLabel,
setFocus: setFocus,
showToggleButton: showToggleButton
};
}
opWorkPackagesModule.factory('wpSingleView', wpSingleViewService);

@ -54,6 +54,11 @@ class WorkPackageInlineCreateButtonController extends WorkPackageCreateButtonCon
} }
}); });
// Need to reset the state when the work package is refreshed hard
$rootScope.$on('workPackagesRefreshRequired', _ => {
this.show();
});
this.apiWorkPackages.availableProjects().then(resource => { this.apiWorkPackages.availableProjects().then(resource => {
this.canCreate = (resource && resource.total > 0); this.canCreate = (resource && resource.total > 0);
this.availableProjects = resource.elements; this.availableProjects = resource.elements;
@ -71,8 +76,16 @@ class WorkPackageInlineCreateButtonController extends WorkPackageCreateButtonCon
return !this.canCreate || this.$state.includes('**.new'); return !this.canCreate || this.$state.includes('**.new');
} }
public get projectIdentifierForCreate() {
if (this.inProjectContext) {
return this.projectIdentifier;
} else {
return this.availableProjects[0].identifier;
}
}
public addWorkPackageRow() { public addWorkPackageRow() {
this.WorkPackageResource.fromCreateForm(this.availableProjects[0].identifier).then(wp => { this.WorkPackageResource.fromCreateForm(this.projectIdentifierForCreate).then(wp => {
this._wp = wp; this._wp = wp;
wp.inlineCreated = true; wp.inlineCreated = true;

@ -116,7 +116,7 @@ export class WorkPackageEditFieldController {
public set editable(enabled:boolean) { public set editable(enabled:boolean) {
this._editable = enabled; this._editable = enabled;
this.$element.toggleClass('-editable', enabled); this.$element.toggleClass('-editable', !!enabled);
} }
public shouldFocus() { public shouldFocus() {
@ -190,8 +190,8 @@ function wpEditField() {
scope: { scope: {
fieldName: '=wpEditField', fieldName: '=wpEditField',
fieldIndex: '=fieldIndex', fieldIndex: '=',
columns: '=columns' columns: '='
}, },
require: ['^wpEditForm', 'wpEditField'], require: ['^wpEditForm', 'wpEditField'],

@ -32,10 +32,13 @@ export class WorkPackageEditFormController {
public firstActiveField:string; public firstActiveField:string;
constructor( constructor(
protected I18n,
protected NotificationsService, protected NotificationsService,
protected $q, protected $q,
protected QueryService, protected QueryService,
protected $state,
protected $rootScope, protected $rootScope,
protected loadingIndicator,
protected $timeout) { protected $timeout) {
} }
@ -60,6 +63,7 @@ export class WorkPackageEditFormController {
angular.forEach(this.fields, field => field.setErrorState(false)); angular.forEach(this.fields, field => field.setErrorState(false));
deferred.resolve(); deferred.resolve();
this.showSaveNotification();
this.$rootScope.$emit('workPackageSaved', this.workPackage); this.$rootScope.$emit('workPackageSaved', this.workPackage);
this.$rootScope.$emit('workPackagesRefreshInBackground'); this.$rootScope.$emit('workPackagesRefreshInBackground');
}) })
@ -76,6 +80,20 @@ export class WorkPackageEditFormController {
return deferred.promise; return deferred.promise;
} }
private showSaveNotification() {
var message = 'js.notice_successful_' + (this.workPackage.inlineCreated ? 'create' : 'update');
this.NotificationsService.addSuccess({
message: this.I18n.t(message),
link: {
target: _ => {
this.loadingIndicator.mainPage = this.$state.go.apply(this.$state,
["work-packages.show.activity", { workPackageId: this.workPackage.id }]);
},
text: this.I18n.t('js.work_packages.message_successful_show_in_fullscreen')
}
});
}
private handleSubmissionErrors(error:any, deferred:any) { private handleSubmissionErrors(error:any, deferred:any) {
// Process single API errors // Process single API errors

@ -0,0 +1,11 @@
<wp-single-view></wp-single-view>
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text">{{ vm.I18n.t('js.label_latest_activity') }}</h3>
</div>
</div>
<activity-panel template="overview" work-package="workPackage"></activity-panel>
</div>

@ -26,44 +26,13 @@
// See doc/COPYRIGHT.rdoc for more details. // See doc/COPYRIGHT.rdoc for more details.
// ++ // ++
import {scopedObservable} from "../../../helpers/angular-rx-utils"; import {wpDirectivesModule} from "../../../angular-modules";
import {openprojectModule} from "../../../angular-modules";
import IScope = angular.IScope;
import WorkPackage = op.WorkPackage;
import {WorkPackageCacheService} from "../../work-packages/work-package-cache.service";
import WorkPackageResource from "../../api/api-v3/hal-resources/work-package-resource.service";
export class OverviewPanelController { function overviewPanel(){
public workPackage: WorkPackageResource;
constructor($scope: IScope, $stateParams: any, private wpCacheService: WorkPackageCacheService) {
const wpId = parseInt($stateParams.workPackageId);
scopedObservable($scope, wpCacheService.loadWorkPackage(wpId)).subscribe(wp => {
this.workPackage = wp;
this.workPackage.schema.$load().then((schema) => {
// TODO use schema
});
});
}
}
function wpOverviewPanel() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: '/components/wp-panels/overview-panel/overview-panel.directive.html',
// scope: {
// workPackage: '=wpEditForm'
// },
templateUrl: "/components/wp-panels/overview-panel/wp-overview-panel.directive.html",
controller: OverviewPanelController,
controllerAs: '$ctrl',
bindToController: true
}; };
} }
openprojectModule.directive('wpOverviewPanel', wpOverviewPanel); wpDirectivesModule.directive('overviewPanel', overviewPanel);

@ -1,17 +0,0 @@
<div wp-edit-form="$ctrl.workPackage">
<div ng-repeat="column in columns"
class="{{column.name}}"
lang="{{column.custom_field && column.custom_field.name_locale || locale}}"
wp-edit-field="column.name">
<wp-display-attr attribute="column.name"
schema="$ctrl.workPackage.schema"
object="$ctrl.workPackage">
</wp-display-attr>
</div>
</div>

@ -26,19 +26,26 @@
// See doc/COPYRIGHT.rdoc for more details. // See doc/COPYRIGHT.rdoc for more details.
//++ //++
module.exports = function(PERMITTED_BULK_ACTIONS, WorkPackagesTableService, UrlParamsHelper) { angular
.module('openproject.workPackages.helpers')
.factory('WorkPackageContextMenuHelper', WorkPackageContextMenuHelper);
function WorkPackageContextMenuHelper(PERMITTED_BULK_ACTIONS, WorkPackagesTableService, HookService, UrlParamsHelper) {
function getPermittedActionLinks(workPackage, permittedActionConstants) { function getPermittedActionLinks(workPackage, permittedActionConstants) {
var singularPermittedActions = []; var singularPermittedActions = [];
var allowedActions = getAllowedActions(workPackage.$links, permittedActionConstants); var allowedActions = getAllowedActions(workPackage, permittedActionConstants);
angular.forEach(allowedActions, function(allowedAction) { angular.forEach(allowedActions, function(allowedAction) {
singularPermittedActions.push({ singularPermittedActions.push({
icon: allowedAction.icon, icon: allowedAction.icon,
link: workPackage text: allowedAction.text,
.$links[allowedAction.link] link: workPackage
.href .$source
}); ._links[allowedAction.link]
.href
});
}); });
return singularPermittedActions; return singularPermittedActions;
@ -49,15 +56,16 @@ module.exports = function(PERMITTED_BULK_ACTIONS, WorkPackagesTableService, UrlP
var permittedActions = _.filter(PERMITTED_BULK_ACTIONS, function(action) { var permittedActions = _.filter(PERMITTED_BULK_ACTIONS, function(action) {
return _.every(workPackages, function(workPackage) { return _.every(workPackages, function(workPackage) {
return getAllowedActions(workPackage.$links, [action]).length === 1; return getAllowedActions(workPackage, [action]).length >= 1;
}); });
}); });
angular.forEach(permittedActions, function(permittedAction) { angular.forEach(permittedActions, function(permittedAction) {
bulkPermittedActions.push({ bulkPermittedActions.push({
icon: permittedAction.icon, icon: permittedAction.icon,
link: getBulkActionLink(permittedAction, text: permittedAction.text,
workPackages) link: getBulkActionLink(permittedAction,
}); workPackages)
});
}); });
return bulkPermittedActions; return bulkPermittedActions;
@ -80,20 +88,28 @@ module.exports = function(PERMITTED_BULK_ACTIONS, WorkPackagesTableService, UrlP
return link + '?' + queryParts.join('&'); return link + '?' + queryParts.join('&');
} }
function getAllowedActions(links, actions) { function getAllowedActions(workPackage, actions) {
var allowedActions = []; var allowedActions = [];
angular.forEach(actions, function(action) { angular.forEach(actions, function(action) {
if (links.hasOwnProperty(action.link)) { if (workPackage.$links.hasOwnProperty(action.link)) {
action.text = I18n.t('js.button_' + action.icon);
allowedActions.push(action); allowedActions.push(action);
} }
}); });
angular.forEach(HookService.call('workPackageTableContextMenu'), function(action) {
if (workPackage.$links.hasOwnProperty(action.link)) {
var index = action.indexBy ? action.indexBy(allowedActions) : allowedActions.length;
allowedActions.splice(index, 0, action)
}
});
return allowedActions; return allowedActions;
} }
var WorkPackageContextMenuHelper = { var WorkPackageContextMenuHelper = {
getPermittedActions: function(workPackages, permittedActionConstants) { getPermittedActions: function (workPackages, permittedActionConstants) {
if (workPackages.length === 1) { if (workPackages.length === 1) {
return getPermittedActionLinks(workPackages[0], permittedActionConstants); return getPermittedActionLinks(workPackages[0], permittedActionConstants);
} else if (workPackages.length > 1) { } else if (workPackages.length > 1) {
@ -103,4 +119,4 @@ module.exports = function(PERMITTED_BULK_ACTIONS, WorkPackagesTableService, UrlP
}; };
return WorkPackageContextMenuHelper; return WorkPackageContextMenuHelper;
}; }

@ -33,29 +33,30 @@ angular
function wpGroupHeader() { function wpGroupHeader() {
return { return {
restrict: 'A', restrict: 'A',
link: function(scope) {
compile: function() { scope.currentGroup = scope.row.groupName;
return {
pre: function(scope) {
scope.currentGroup = scope.row.groupName;
scope.currentGroupObject = _.find(scope.resource.groups, function(o) { scope.currentGroupObject = _.find(scope.resource.groups, function(o) {
return o.value === scope.row.groupName; var value = o.value == null ? '' : o.value;
}); return value === scope.row.groupName;
});
pushGroup(scope.currentGroup); if (scope.currentGroupObject && scope.currentGroupObject.value === null) {
scope.currentGroupObject.value = I18n.t('js.placeholders.default');
}
scope.toggleCurrentGroup = function() { pushGroup(scope.currentGroup);
scope.groupExpanded[scope.currentGroup] = !scope.groupExpanded[scope.currentGroup];
};
function pushGroup(group) { scope.toggleCurrentGroup = function() {
if (scope.groupExpanded[group] === undefined) { scope.groupExpanded[scope.currentGroup] = !scope.groupExpanded[scope.currentGroup];
scope.groupExpanded[group] = true;
}
}
}
}; };
function pushGroup(group) {
if (scope.groupExpanded[group] === undefined) {
scope.groupExpanded[group] = true;
}
}
} }
}; };
} }

@ -1,6 +1,6 @@
<div class="generic-table--container work-package-table--container" <div class="generic-table--container work-package-table--container"
ng-class="{ '-with-footer': displaySums }"> ng-class="{ '-with-footer': displaySums }">
<div class="generic-table--results-container" ng-if="rows.length"> <div class="generic-table--results-container">
<table interactive-table class="keyboard-accessible-list generic-table"> <table interactive-table class="keyboard-accessible-list generic-table">
<colgroup> <colgroup>
<col highlight-col /> <col highlight-col />
@ -37,13 +37,24 @@
</thead> </thead>
<tbody> <tbody>
<!-- Empty row notification -->
<tr id="empty-row-notification" ng-if="!rows.length">
<td colspan="{{ numTableColumns }}">
<span>
<i class="icon-info1 icon-context"></i>
<strong ng-bind="text.noResults"></strong>
<span ng-bind="text.noResultsDescription"></span>
</span>
</td>
</tr>
<!-- Group headers --> <!-- Group headers -->
<tr wp-group-header <tr wp-group-header
ng-repeat-start="row in rows track by row.object.id" ng-repeat-start="row in rows track by row.object.id+row.groupName"
ng-if="!!groupByColumn && ng-if="!!groupByColumn &&
($first || row.groupName !== rows[$index-1].groupName)" ($first || row.groupName !== rows[$index-1].groupName)"
ng-show="row.groupName != undefined"
ng-class="{ ng-class="{
group: true, group: true,
open: groupExpanded[currentGroup], open: groupExpanded[currentGroup],
@ -51,7 +62,7 @@
keyboard_hover: true keyboard_hover: true
}" }"
id="group-header-{{ row.groupName }}"> id="group-header-{{ row.groupName }}">
<td colspan="{{ columns.length + 2 - (!!hideWorkPackageDetails * 1) }}"> <td colspan="{{ numTableColumns }}">
<div ng-class="[ <div ng-class="[
'expander', 'expander',
'icon-context', 'icon-context',
@ -138,7 +149,7 @@
ng-class="{ '-short': column.name == 'id' }"> ng-class="{ '-short': column.name == 'id' }">
<wp-display-attr attribute="column.name" <wp-display-attr attribute="column.name"
schema="row.object.schema" schema="row.object.schema"
object="row.object" work-package="row.object"
ng-class="[row.level > 0 && column.name == 'subject' && 'icon-context icon-arrow-right5 icon-small']"> ng-class="[row.level > 0 && column.name == 'subject' && 'icon-context icon-arrow-right5 icon-small']">
</wp-display-attr> </wp-display-attr>
</td> </td>
@ -167,7 +178,7 @@
<td ng-repeat="column in columns"> <td ng-repeat="column in columns">
<wp-display-attr attribute="column.name" <wp-display-attr attribute="column.name"
schema="resource.sumsSchema" schema="resource.sumsSchema"
object="currentGroupObject.sums"> work-package="currentGroupObject.sums">
</wp-display-attr> </wp-display-attr>
</td> </td>
</tr> </tr>
@ -176,7 +187,7 @@
<!-- Inline create button --> <!-- Inline create button -->
<tr class="wp-inline-create-button-row"> <tr class="wp-inline-create-button-row">
<!-- Add 2 to the colspan attr because of the id and the checkbox columns --> <!-- Add 2 to the colspan attr because of the id and the checkbox columns -->
<td colspan="{{ columns.length + 2 }}"> <td colspan="{{ numTableColumns }}">
<wp-inline-create-button <wp-inline-create-button
project-identifier="projectIdentifier" project-identifier="projectIdentifier"
query="query" query="query"
@ -198,7 +209,7 @@
<wp-display-attr class="generic-table--footer-outer" <wp-display-attr class="generic-table--footer-outer"
attribute="column.name" attribute="column.name"
schema="resource.sumsSchema" schema="resource.sumsSchema"
object="resource.totalSums"> work-package="resource.totalSums">
</wp-display-attr> </wp-display-attr>
</td> </td>
</tr> </tr>
@ -207,12 +218,4 @@
<div class="generic-table--header-background"></div> <div class="generic-table--header-background"></div>
<div class="generic-table--footer-background" ng-if="sumsLoaded()"></div> <div class="generic-table--footer-background" ng-if="sumsLoaded()"></div>
</div> </div>
<div class="generic-table--no-results-container" ng-if="!rows.length">
<i class="icon-info1"></i>
<h2 class="generic-table--no-results-title">
{{ text.noResults }}
</h2>
<div class="generic-table--no-results-description"
ng-bind-html="text.noResultsDescription"></div>
</div>
</div> </div>

@ -30,7 +30,14 @@ angular
.module('openproject.workPackages.directives') .module('openproject.workPackages.directives')
.directive('wpTable', wpTable); .directive('wpTable', wpTable);
function wpTable(WorkPackagesTableService, $window, PathHelper, apiWorkPackages, $state){ function wpTable(
WorkPackagesTableService,
WorkPackageService,
$window,
PathHelper,
apiWorkPackages,
$state
){
return { return {
restrict: 'E', restrict: 'E',
replace: true, replace: true,
@ -52,6 +59,9 @@ function wpTable(WorkPackagesTableService, $window, PathHelper, apiWorkPackages,
link: function(scope, element) { link: function(scope, element) {
var activeSelectionBorderIndex; var activeSelectionBorderIndex;
// Total columns = all available columns + id + checkbox
scope.numTableColumns = scope.columns.length + 2;
scope.workPackagesTableData = WorkPackagesTableService.getWorkPackagesTableData(); scope.workPackagesTableData = WorkPackagesTableService.getWorkPackagesTableData();
scope.workPackagePath = PathHelper.workPackagePath; scope.workPackagePath = PathHelper.workPackagePath;
@ -60,9 +70,7 @@ function wpTable(WorkPackagesTableService, $window, PathHelper, apiWorkPackages,
event.pageY -= topMenuHeight; event.pageY -= topMenuHeight;
}; };
// groupings applyGrouping();
scope.grouped = scope.groupByColumn !== undefined;
scope.groupExpanded = {};
scope.$watchCollection('columns', function() { scope.$watchCollection('columns', function() {
// force Browser rerender // force Browser rerender
@ -88,6 +96,8 @@ function wpTable(WorkPackagesTableService, $window, PathHelper, apiWorkPackages,
if (scope.displaySums) { if (scope.displaySums) {
fetchSumsSchema(); fetchSumsSchema();
} }
applyGrouping();
}); });
scope.$watch('displaySums', function(sumsToBeDisplayed) { scope.$watch('displaySums', function(sumsToBeDisplayed) {
@ -97,6 +107,14 @@ function wpTable(WorkPackagesTableService, $window, PathHelper, apiWorkPackages,
} }
}); });
function applyGrouping() {
if (scope.groupByColumn != scope.workPackagesTableData.groupByColumn) {
scope.groupByColumn = scope.workPackagesTableData.groupByColumn;
scope.grouped = scope.groupByColumn !== undefined;
scope.groupExpanded = {};
}
}
function fetchTotalSums() { function fetchTotalSums() {
apiWorkPackages apiWorkPackages
// TODO: use the correct page offset and per page options // TODO: use the correct page offset and per page options
@ -125,17 +143,6 @@ function wpTable(WorkPackagesTableService, $window, PathHelper, apiWorkPackages,
WorkPackagesTableService.setCheckedStateForAllRows(scope.rows, state); WorkPackagesTableService.setCheckedStateForAllRows(scope.rows, state);
}; };
var groupableColumns = WorkPackagesTableService.getGroupableColumns();
scope.$watch('query.groupBy', function(groupBy) {
if (scope.columns) {
var groupByColumnIndex = groupableColumns.map(function(column){
return column.name;
}).indexOf(groupBy);
scope.groupByColumn = groupableColumns[groupByColumnIndex];
}
});
// Thanks to http://stackoverflow.com/a/880518 // Thanks to http://stackoverflow.com/a/880518
function clearSelection() { function clearSelection() {
if(document.selection && document.selection.empty) { if(document.selection && document.selection.empty) {
@ -173,6 +180,11 @@ function wpTable(WorkPackagesTableService, $window, PathHelper, apiWorkPackages,
} }
scope.selectWorkPackage = function(row, $event) { scope.selectWorkPackage = function(row, $event) {
// The current row is the last selected work package
// not matter what other rows are (de-)selected below.
// Thus save that row for the details view button
WorkPackageService.cache().put('preselectedWorkPackageId', row.object.id);
if ($event.target.type != 'checkbox') { if ($event.target.type != 'checkbox') {
var currentRowCheckState = row.checked; var currentRowCheckState = row.checked;
var multipleChecked = mulipleRowsChecked(); var multipleChecked = mulipleRowsChecked();
@ -221,7 +233,7 @@ function WorkPackagesTableController($scope, $rootScope) {
sumFor: I18n.t('js.label_sum_for'), sumFor: I18n.t('js.label_sum_for'),
allWorkPackages: I18n.t('js.label_all_work_packages'), allWorkPackages: I18n.t('js.label_all_work_packages'),
noResults: I18n.t('js.work_packages.no_results.title'), noResults: I18n.t('js.work_packages.no_results.title'),
noResultsDescription: I18n.t('js.work_packages.no_results.description_html') noResultsDescription: I18n.t('js.work_packages.no_results.description')
}; };
$scope.$watch('workPackagesTableData.allRowsChecked', function(checked) { $scope.$watch('workPackagesTableData.allRowsChecked', function(checked) {

@ -116,8 +116,15 @@ function WorkPackagesTableService($filter, QueryService, WorkPackagesTableHelper
return workPackagesTableData.groupBy; return workPackagesTableData.groupBy;
}, },
getGroupByColumn: function() {
return workPackagesTableData.groupByColumn;
},
setGroupBy: function(groupBy) { setGroupBy: function(groupBy) {
var groupableColumns = workPackagesTableData.groupableColumns;
workPackagesTableData.groupBy = groupBy; workPackagesTableData.groupBy = groupBy;
workPackagesTableData.groupByColumn = _.find(groupableColumns, { name: groupBy });
}, },
removeRow: function(row) { removeRow: function(row) {

@ -74,11 +74,6 @@ angular.module('openproject.services')
'fields[]': 'status_id', 'fields[]': 'status_id',
'operators[status_id]': 'o' 'operators[status_id]': 'o'
}) })
.service('NotificationsService', [
'I18n',
'$rootScope',
require('./notifications-service.js')
])
.service('ApiNotificationsService', [ .service('ApiNotificationsService', [
'NotificationsService', 'NotificationsService',
'ApiHelper', 'ApiHelper',

@ -1,130 +0,0 @@
//-- 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.
//++
module.exports = function(I18n, $rootScope) {
'use strict';
// private
var createNotification = function(message) {
if(typeof message === 'string') {
return { message: message };
}
return message;
},
createSuccessNotification = function(message) {
return _.extend(createNotification(message), { type: 'success' });
},
createWarningNotification = function(message) {
return _.extend(createNotification(message), { type: 'warning' });
},
createErrorNotification = function(message, errors) {
return _.extend(createNotification(message), {
type: 'error',
errors: errors || []
});
},
createNoticeNotification = function(message) {
return _.extend(createNotification(message), { type: '' });
},
createWorkPackageUploadNotification = function(message, uploads) {
if(!uploads) {
throw new Error('Cannot create an upload notification without uploads!');
}
return _.extend(createNotification(message), {
type: 'upload',
uploads: uploads
});
},
broadcast = function(event, data) {
$rootScope.$broadcast(event, data);
},
currentNotifications = [],
notificationAdded = function(newNotification) {
var toRemove = currentNotifications.slice(0);
_.each(toRemove, function(existingNotification) {
if (newNotification.type === 'success' || newNotification.type === 'error') {
remove(existingNotification);
}
});
currentNotifications.push(newNotification);
},
notificationRemoved = function(removedNotification) {
_.remove(currentNotifications, function(element) {
return element === removedNotification;
});
},
clearNotifications = function() {
_.remove(currentNotifications);
};
$rootScope.$on('notification.remove', function(_e, notification) {
notificationRemoved(notification);
});
$rootScope.$on('notifications.clearAll', function() {
clearNotifications();
});
// public
var add = function(message) {
var notification = createNotification(message);
broadcast('notification.add', notification);
notificationAdded(notification);
return notification;
},
addError = function(message, errors) {
return add(createErrorNotification(message, errors));
},
addWarning = function(message) {
return add(createWarningNotification(message));
},
addSuccess = function(message) {
return add(createSuccessNotification(message));
},
addNotice = function(message) {
return add(createNoticeNotification(message));
},
addWorkPackageUpload = function(message, uploads) {
return add(createWorkPackageUploadNotification(message, uploads));
},
remove = function(notification) {
broadcast('notification.remove', notification);
};
return {
add: add,
remove: remove,
addError: addError,
addWarning: addWarning,
addSuccess: addSuccess,
addNotice: addNotice,
addWorkPackageUpload: addWorkPackageUpload
};
};

@ -1,73 +0,0 @@
<hr>
Start
<hr>
<wp-overview-panel></wp-overview-panel>
<hr>
Ende
<hr>
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text">
{{ type.props.name }} #{{ workPackage.props.id }}
</h3>
</div>
<div class="attributes-group--header-container-right">
<span ng-bind="I18n.t('js.label_last_updated_on')"/>
<op-date date-value="workPackage.props.updatedAt"></op-date>
</div>
</div>
<div class="single-attribute wiki">
<wp-field field-name="'description'"></wp-field>
</div>
</div>
<div ng-repeat="group in vm.groupedFields" ng-hide="vm.shouldHideGroup(group.groupName)" class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text"
ng-bind="I18n.t('js.work_packages.property_groups.' + group.groupName)"></h3>
</div>
<div class="attributes-group--header-toggle">
<panel-expander tabindex="-1" ng-if="vm.showToggleButton() && $first"
collapsed="vm.hideEmptyFields"
expand-text="{{ I18n.t('js.label_show_attributes') }}"
collapse-text="{{ I18n.t('js.label_hide_attributes') }}">
</panel-expander>
</div>
</div>
<div class="attributes-key-value">
<div
ng-hide="vm.hideEmptyFields && vm.isFieldHideable(vm.workPackage, field)"
ng-if="vm.isSpecified(vm.workPackage, field)"
ng-repeat-start="field in group.attributes" class="attributes-key-value--key">
{{vm.getLabel(vm.workPackage, field)}}
<span class="required" ng-if="vm.hasNiceStar(vm.workPackage, field)"> *</span>
</div>
<div
ng-hide="vm.hideEmptyFields && vm.isFieldHideable(vm.workPackage, field)"
ng-if="vm.isSpecified(vm.workPackage, field)"
ng-repeat-end
class="attributes-key-value--value-container">
<wp-field field-name="field"></wp-field>
</div>
</div>
</div>
<work-package-attachments edit work-package="vm.workPackage" data-ng-show="!vm.hideEmptyFields || vm.filesExist"></work-package-attachments>
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text">{{ I18n.t('js.label_latest_activity') }}</h3>
</div>
</div>
<activity-panel template="overview" work-package="workPackage"></activity-panel>
</div>

@ -96,14 +96,6 @@ angular.module('openproject.uiComponents')
.directive('zoomSlider', ['I18n', require('./zoom-slider-directive')]) .directive('zoomSlider', ['I18n', require('./zoom-slider-directive')])
.directive('clickNotification', ['$timeout','NotificationsService', require('./click-notification-directive')]) .directive('clickNotification', ['$timeout','NotificationsService', require('./click-notification-directive')])
.directive('notifications', [require('./notifications-directive')]) .directive('notifications', [require('./notifications-directive')])
.directive('notificationBox', [
'I18n',
'$timeout',
'$state',
'loadingIndicator',
'ConfigurationService',
require('./notification-box-directive')
])
.directive('uploadProgress', [require('./upload-progress-directive')]) .directive('uploadProgress', [require('./upload-progress-directive')])
.directive('attachmentIcon', [require('./attachment-icon-directive')]) .directive('attachmentIcon', [require('./attachment-icon-directive')])
.filter('ancestorsExpanded', require('./filters/ancestors-expanded-filter')) .filter('ancestorsExpanded', require('./filters/ancestors-expanded-filter'))

@ -1,104 +0,0 @@
//-- 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.
//++
module.exports = function(
$scope,
WorkPackagesOverviewService,
WorkPackageFieldService,
EditableFieldsState,
inplaceEditAll,
WorkPackageDisplayHelper,
NotificationsService,
WorkPackageAttachmentsService
) {
var vm = this;
vm.groupedFields = [];
vm.hideEmptyFields = true;
vm.workPackage = $scope.workPackage;
//Show all attributes in Edit-Mode
$scope.$watch(function(){
return inplaceEditAll.state;
},function(newState, oldState){
if(newState !== oldState){
vm.hideEmptyFields = !newState;
}
});
vm.shouldHideGroup = function(group) {
return WorkPackageDisplayHelper.shouldHideGroup(vm.hideEmptyFields,
vm.groupedFields,
group,
vm.workPackage);
};
vm.isFieldHideable = WorkPackageDisplayHelper.isFieldHideable;
vm.getLabel = WorkPackageDisplayHelper.getLabel;
vm.isSpecified = WorkPackageDisplayHelper.isSpecified;
vm.hasNiceStar = WorkPackageDisplayHelper.hasNiceStar;
vm.showToggleButton = WorkPackageDisplayHelper.showToggleButton;
vm.filesExist = false;
activate();
WorkPackageAttachmentsService.hasAttachments(vm.workPackage).then(function(bool) {
vm.filesExist = bool;
});
function activate() {
$scope.$watch('workPackage.schema', function(schema) {
if (schema) {
WorkPackageDisplayHelper.setFocus();
vm.workPackage = $scope.workPackage;
}
});
vm.groupedFields = WorkPackagesOverviewService.getGroupedWorkPackageOverviewAttributes();
$scope.$watchCollection('vm.workPackage.form', function() {
var schema = WorkPackageFieldService.getSchema(vm.workPackage);
var otherGroup = _.find(vm.groupedFields, {groupName: 'other'});
otherGroup.attributes = [];
_.forEach(schema.props, function(prop, propName) {
if (propName.match(/^customField/)) {
otherGroup.attributes.push(propName);
}
});
otherGroup.attributes.sort(function(a, b) {
var getLabel = function(field) {
return vm.getLabel(vm.workPackage, field);
};
var left = getLabel(a).toLowerCase(),
right = getLabel(b).toLowerCase();
return left.localeCompare(right);
});
});
$scope.$on('workPackageUpdatedInEditor', function() {
NotificationsService.addSuccess(I18n.t('js.notice_successful_update'));
});
}
};

@ -35,17 +35,6 @@ angular.module('openproject.workPackages.controllers')
.constant('USER_TYPE', 'user') .constant('USER_TYPE', 'user')
.constant('TIME_ENTRY_TYPE', 'time_entry') .constant('TIME_ENTRY_TYPE', 'time_entry')
.constant('USER_FIELDS', ['assignee', 'author', 'responsible']) .constant('USER_FIELDS', ['assignee', 'author', 'responsible'])
.controller('DetailsTabOverviewController', [
'$scope',
'WorkPackagesOverviewService',
'WorkPackageFieldService',
'EditableFieldsState',
'inplaceEditAll',
'WorkPackagesDisplayHelper',
'NotificationsService',
'WorkPackageAttachmentsService',
require('./details-tab-overview-controller')
])
.constant('ADD_WATCHER_SELECT_INDEX', -1) .constant('ADD_WATCHER_SELECT_INDEX', -1)
.constant('RELATION_TYPES', { .constant('RELATION_TYPES', {
relatedTo: 'Relation::Relates', relatedTo: 'Relation::Relates',

@ -70,7 +70,7 @@ module.exports = function(
scope.rejectedFiles = []; scope.rejectedFiles = [];
scope.size = ConversionService.fileSize; scope.size = ConversionService.fileSize;
scope.hasRightToUpload = !!(workPackage.links.addAttachment || workPackage.isNew); scope.hasRightToUpload = !!(workPackage.$links.addAttachment || workPackage.isNew);
var currentlyRemoving = []; var currentlyRemoving = [];
scope.remove = function(file) { scope.remove = function(file) {

@ -49,10 +49,6 @@ angular.module('openproject.workPackages.helpers')
link: 'delete' link: 'delete'
} }
]) ])
.service('WorkPackageContextMenuHelper', ['PERMITTED_BULK_ACTIONS',
'WorkPackagesTableService', 'UrlParamsHelper', require(
'./work-package-context-menu-helper')
])
.factory('WorkPackagesHelper', ['TimezoneService', 'currencyFilter', .factory('WorkPackagesHelper', ['TimezoneService', 'currencyFilter',
'CustomFieldHelper', require('./work-packages-helper') 'CustomFieldHelper', require('./work-packages-helper')
]) ])

@ -31,16 +31,18 @@ module.exports = function(WorkPackageFieldService, $window, $timeout) {
// specifies unhideable (during creation) // specifies unhideable (during creation)
var unhideableFields = [ var unhideableFields = [
'subject', 'subject',
'type', 'description'
'status',
'description',
'priority'
]; ];
var firstTimeFocused = false; var firstTimeFocused = false;
var isGroupHideable = function (groupedFields, groupName, workPackage, cb) { var isGroupHideable = function (groupedFields, groupName, workPackage, cb) {
if (!workPackage) { if (!workPackage) {
return true; return true;
} }
if (groupName === 'details') {
return false; // never hide details to keep show all button arround
}
var group = _.find(groupedFields, {groupName: groupName}); var group = _.find(groupedFields, {groupName: groupName});
var isHideable = typeof cb === 'undefined' ? isFieldHideable : cb; var isHideable = typeof cb === 'undefined' ? isFieldHideable : cb;
return group.attributes.length === 0 || _.every(group.attributes, function(field) { return group.attributes.length === 0 || _.every(group.attributes, function(field) {
@ -78,10 +80,6 @@ module.exports = function(WorkPackageFieldService, $window, $timeout) {
return !WorkPackageFieldService.isEditable(workPackage, field); return !WorkPackageFieldService.isEditable(workPackage, field);
} }
if (WorkPackageFieldService.isRequired(workPackage, field)) {
return false;
}
return WorkPackageFieldService.isHideable(workPackage, field); return WorkPackageFieldService.isHideable(workPackage, field);
}, },
isSpecified = function (workPackage, field) { isSpecified = function (workPackage, field) {

@ -52,14 +52,4 @@ angular.module('openproject.workPackages.services')
.service('WorkPackagesOverviewService', [ .service('WorkPackagesOverviewService', [
'WORK_PACKAGE_ATTRIBUTES', 'WORK_PACKAGE_ATTRIBUTES',
require('./work-packages-overview-service') require('./work-packages-overview-service')
])
.service('WorkPackageAttachmentsService', [
'Upload', // 'Upload' is provided by ngFileUpload
'PathHelper',
'I18n',
'NotificationsService',
'$q',
'$timeout',
'$http',
require('./work-package-attachments-service')
]); ]);

@ -1,105 +0,0 @@
//-- 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.
//++
module.exports = function(Upload, PathHelper, I18n, NotificationsService, $q, $timeout, $http) {
'use strict';
var upload = function(workPackage, files) {
var uploadPath = workPackage.links.addAttachment.url();
// for file in files build some promises, create a notification per WP,
// notify the noticiation (wat?) about progress
var uploads = _.map(files, function(file) {
var options = {
url: uploadPath,
fields: {
metadata: {
fileName: file.name,
description: file.description
}
},
file: file
};
return Upload.upload(options);
});
// notify the user
var message = I18n.t('js.label_upload_notification', {
id: workPackage.props.id,
subject: workPackage.props.subject
});
var notification = NotificationsService.addWorkPackageUpload(message, uploads);
var allUploadsDone = $q.defer();
$q.all(uploads).then(function() {
$timeout(function() { // let the notification linger for a bit
NotificationsService.remove(notification);
allUploadsDone.resolve();
}, 700);
}, function(err) {
allUploadsDone.reject(err);
});
return allUploadsDone.promise;
},
load = function(workPackage, reload) {
var path = workPackage.links.attachments.url(),
attachments = $q.defer();
$http.get(path, { cache: !reload }).success(function(response) {
attachments.resolve(response._embedded.elements);
}).error(function(err) {
attachments.reject(err);
});
return attachments.promise;
},
remove = function(fileOrAttachment) {
var removal = $q.defer();
if (angular.isObject(fileOrAttachment._links)) {
var path = fileOrAttachment._links.self.href;
$http.delete(path).success(function() {
removal.resolve(fileOrAttachment);
}).error(function(err) {
removal.reject(err);
});
} else {
removal.resolve(fileOrAttachment);
}
return removal.promise;
},
hasAttachments = function(workPackage) {
var existance = $q.defer();
load(workPackage).then(function(attachments) {
existance.resolve(attachments.length > 0);
});
return existance.promise;
};
return {
upload: upload,
remove: remove,
load: load,
hasAttachments: hasAttachments
};
};

@ -102,9 +102,9 @@ describe('WorkPackageContextMenuHelper', function() {
} }
}; };
var workPackage = Factory.build('PlanningElement', { var workPackage = Factory.build('PlanningElement');
$links: actionLinks workPackage.$source = { _links : actionLinks };
}); workPackage.$links = actionLinks;
describe('when an array with a single work package is passed as an argument', function() { describe('when an array with a single work package is passed as an argument', function() {
var workPackages = new Array(workPackage); var workPackages = new Array(workPackage);
@ -126,13 +126,15 @@ describe('WorkPackageContextMenuHelper', function() {
}); });
describe('when more than one work package is passed as an argument', function() { describe('when more than one work package is passed as an argument', function() {
var anotherWorkPackage = Factory.build('PlanningElement', { var anotherWorkPackage = Factory.build('PlanningElement');
$links: { anotherWorkPackage.$source = {
update: { _links: {
href: '/work_packages/234/edit' update: {
} href: '/work_packages/234/edit'
} }
}); }
};
anotherWorkPackage.$links = { update: '/work_packages/234/edit' };
var workPackages = [anotherWorkPackage, workPackage]; var workPackages = [anotherWorkPackage, workPackage];
beforeEach(inject(function(_WorkPackagesTableService_) { beforeEach(inject(function(_WorkPackagesTableService_) {

@ -128,8 +128,6 @@ module.exports = {
'angular-context-menu': 'angular-context-menu/dist/angular-context-menu.js', 'angular-context-menu': 'angular-context-menu/dist/angular-context-menu.js',
'mousetrap': 'mousetrap/mousetrap.js', 'mousetrap': 'mousetrap/mousetrap.js',
'hyperagent': 'hyperagent/dist/hyperagent', 'hyperagent': 'hyperagent/dist/hyperagent',
'openproject-ui_components':
'openproject-ui_components/app/assets/javascripts/angular/ui-components-app',
'ngFileUpload': 'ng-file-upload/ng-file-upload' 'ngFileUpload': 'ng-file-upload/ng-file-upload'
}, pluginAliases) }, pluginAliases)
}, },

@ -65,7 +65,7 @@ module API
end end
def model_required? def model_required?
true false
end end
private private

@ -41,6 +41,7 @@ module API
value_representer:, value_representer:,
link_factory:, link_factory:,
required: true, required: true,
has_default: false,
writable: true, writable: true,
visibility: nil, visibility: nil,
current_user: nil) current_user: nil)
@ -50,6 +51,7 @@ module API
super(type: type, super(type: type,
name: name, name: name,
required: required, required: required,
has_default: has_default,
writable: writable, writable: writable,
visibility: visibility, visibility: visibility,
current_user: current_user) current_user: current_user)

@ -34,12 +34,13 @@ module API
module Decorators module Decorators
class PropertySchemaRepresenter < ::API::Decorators::Single class PropertySchemaRepresenter < ::API::Decorators::Single
def initialize( def initialize(
type:, name:, required: true, writable: true, type:, name:, required: true, has_default: false, writable: true,
visibility: nil, current_user: nil visibility: nil, current_user: nil
) )
@type = type @type = type
@name = name @name = name
@required = required @required = required
@has_default = has_default
@writable = writable @writable = writable
@visibility = visibility || 'default' @visibility = visibility || 'default'
@ -49,6 +50,7 @@ module API
attr_accessor :type, attr_accessor :type,
:name, :name,
:required, :required,
:has_default,
:writable, :writable,
:visibility, :visibility,
:min_length, :min_length,
@ -58,6 +60,7 @@ module API
property :type, exec_context: :decorator property :type, exec_context: :decorator
property :name, exec_context: :decorator property :name, exec_context: :decorator
property :required, exec_context: :decorator property :required, exec_context: :decorator
property :has_default, exec_context: :decorator
property :writable, exec_context: :decorator property :writable, exec_context: :decorator
property :visibility, exec_context: :decorator property :visibility, exec_context: :decorator
property :min_length, exec_context: :decorator property :min_length, exec_context: :decorator

@ -30,11 +30,32 @@
module API module API
module Decorators module Decorators
class Schema < Single class Schema < Single
module InstanceMethods
module_function
def call_or_use(object)
if object.respond_to? :call
instance_exec(&object)
else
object
end
end
def call_or_translate(object, rep_class = self.class.represented_class)
if object.respond_to? :call
instance_exec(&object)
else
rep_class.human_attribute_name(object)
end
end
end
class << self class << self
def schema(property, def schema(property,
type:, type:,
name_source: property, name_source: property,
required: true, required: true,
has_default: false,
writable: -> { represented.writable?(property) }, writable: -> { represented.writable?(property) },
visibility: nil, visibility: nil,
min_length: nil, min_length: nil,
@ -51,6 +72,7 @@ module API
type: type, type: type,
name: name, name: name,
required: call_or_use(required), required: call_or_use(required),
has_default: call_or_use(has_default),
writable: call_or_use(writable), writable: call_or_use(writable),
visibility: call_or_use(visibility)) visibility: call_or_use(visibility))
schema.min_length = min_length schema.min_length = min_length
@ -61,7 +83,13 @@ module API
}, },
writeable: false, writeable: false,
if: show_if, if: show_if,
required: required required: required,
has_default: has_default,
name_source: lambda {
API::Decorators::Schema::InstanceMethods
.call_or_translate name_source,
self.represented_class
}
end end
def schema_with_allowed_link(property, def schema_with_allowed_link(property,
@ -69,6 +97,7 @@ module API
name_source: property, name_source: property,
href_callback:, href_callback:,
required: true, required: true,
has_default: false,
writable: -> { represented.writable?(property) }, writable: -> { represented.writable?(property) },
visibility: nil, visibility: nil,
show_if: true) show_if: true)
@ -81,6 +110,7 @@ module API
type: type, type: type,
name: call_or_translate(name_source), name: call_or_translate(name_source),
required: call_or_use(required), required: call_or_use(required),
has_default: call_or_use(has_default),
writable: call_or_use(writable), writable: call_or_use(writable),
visibility: call_or_use(visibility)) visibility: call_or_use(visibility))
@ -91,7 +121,13 @@ module API
representer representer
}, },
if: show_if, if: show_if,
required: required required: required,
has_default: has_default,
name_source: lambda {
API::Decorators::Schema::InstanceMethods
.call_or_translate name_source,
self.represented_class
}
end end
def schema_with_allowed_collection(property, def schema_with_allowed_collection(property,
@ -103,6 +139,7 @@ module API
value_representer:, value_representer:,
link_factory:, link_factory:,
required: true, required: true,
has_default: false,
writable: -> { represented.writable?(property) }, writable: -> { represented.writable?(property) },
visibility: nil, visibility: nil,
show_if: true) show_if: true)
@ -118,6 +155,7 @@ module API
value_representer: value_representer, value_representer: value_representer,
link_factory: -> (value) { instance_exec(value, &link_factory) }, link_factory: -> (value) { instance_exec(value, &link_factory) },
required: call_or_use(required), required: call_or_use(required),
has_default: call_or_use(has_default),
writable: call_or_use(writable), writable: call_or_use(writable),
visibility: call_or_use(visibility)) visibility: call_or_use(visibility))
@ -126,7 +164,13 @@ module API
representer representer
}, },
if: show_if, if: show_if,
required: required required: required,
has_default: has_default,
name_source: lambda {
API::Decorators::Schema::InstanceMethods
.call_or_translate name_source,
self.represented_class
}
end end
def represented_class def represented_class
@ -139,7 +183,11 @@ module API
end end
end end
include InstanceMethods
attr_reader :form_embedded attr_reader :form_embedded
def initialize(represented, current_user:, form_embedded: false) def initialize(represented, current_user:, form_embedded: false)
@form_embedded = form_embedded @form_embedded = form_embedded
super(represented, current_user: current_user) super(represented, current_user: current_user)
@ -147,22 +195,6 @@ module API
private private
def call_or_use(object)
if object.respond_to? :call
instance_exec(&object)
else
object
end
end
def call_or_translate(object)
if object.respond_to? :call
instance_exec(&object)
else
self.class.represented_class.human_attribute_name(object)
end
end
def _type def _type
'Schema' 'Schema'
end end

@ -37,6 +37,8 @@ module API
module Projects module Projects
class ProjectCollectionRepresenter < ::API::Decorators::UnpaginatedCollection class ProjectCollectionRepresenter < ::API::Decorators::UnpaginatedCollection
element_decorator ::API::V3::Projects::ProjectRepresenter element_decorator ::API::V3::Projects::ProjectRepresenter
self.to_eager_load = ::API::V3::Projects::ProjectRepresenter.to_eager_load
end end
end end
end end

@ -78,6 +78,8 @@ module API
def _type def _type
'Project' 'Project'
end end
self.to_eager_load = [:enabled_modules, :project_type]
end end
end end
end end

@ -237,6 +237,7 @@ module API
type: TYPE_MAP[custom_field.field_format], type: TYPE_MAP[custom_field.field_format],
name_source: -> (*) { custom_field.name }, name_source: -> (*) { custom_field.name },
required: custom_field.is_required, required: custom_field.is_required,
has_default: (not custom_field.default_value.nil?),
writable: true, writable: true,
min_length: (custom_field.min_length if custom_field.min_length > 0), min_length: (custom_field.min_length if custom_field.min_length > 0),
max_length: (custom_field.max_length if custom_field.max_length > 0), max_length: (custom_field.max_length if custom_field.max_length > 0),

@ -38,7 +38,9 @@ module API
end end
get do get do
available_projects = WorkPackage.allowed_target_projects_on_create(current_user) available_projects = WorkPackage
.allowed_target_projects_on_create(current_user)
.includes(Projects::ProjectCollectionRepresenter.to_eager_load)
self_link = api_v3_paths.available_projects_on_create self_link = api_v3_paths.available_projects_on_create
Projects::ProjectCollectionRepresenter.new(available_projects, Projects::ProjectCollectionRepresenter.new(available_projects,
self_link, self_link,

@ -38,7 +38,9 @@ module API
end end
get do get do
available_projects = WorkPackage.allowed_target_projects_on_move(current_user) available_projects = WorkPackage
.allowed_target_projects_on_move(current_user)
.includes(Projects::ProjectCollectionRepresenter.to_eager_load)
self_link = api_v3_paths.available_projects_on_edit(@work_package.id) self_link = api_v3_paths.available_projects_on_edit(@work_package.id)
Projects::ProjectCollectionRepresenter.new(available_projects, Projects::ProjectCollectionRepresenter.new(available_projects,
self_link, self_link,

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

Loading…
Cancel
Save