Merge pull request #4927 from opf/timeline

Timeline
pull/5232/head
Oliver Günther 8 years ago committed by GitHub
commit 205aab63b2
  1. 139
      app/assets/stylesheets/content/_table.sass
  2. 4
      app/assets/stylesheets/content/_work_packages.sass
  3. 45
      app/assets/stylesheets/content/work_packages/timelines/_slider.sass
  4. 55
      app/assets/stylesheets/content/work_packages/timelines/_timelines.sass
  5. 49
      app/assets/stylesheets/content/work_packages/timelines/elements/_bar.sass
  6. 20
      app/assets/stylesheets/content/work_packages/timelines/elements/_milestone.sass
  7. 4
      app/assets/stylesheets/layout/_main_menu.sass
  8. 12
      app/assets/stylesheets/layout/_print.sass
  9. 24
      app/assets/stylesheets/layout/_work_package.sass
  10. 6
      app/models/queries/relations/filters/involved_filter.rb
  11. 2
      app/views/wiki/show.html.erb
  12. 1
      config/locales/js-en.yml
  13. 22
      features/work_packages/moves/work_package_moves_new_copy.feature
  14. 4
      frontend/app/components/api/api-v3/hal-resource-types/hal-resource-types.config.ts
  15. 75
      frontend/app/components/api/api-v3/hal-resources/type-resource.service.ts
  16. 24
      frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts
  17. 8
      frontend/app/components/common/config/configuration.service.ts
  18. 235
      frontend/app/components/common/interactive-table/interactive-table.directive.ts
  19. 78
      frontend/app/components/common/ui/detect-dimension-changes.directive.ts
  20. 86
      frontend/app/components/common/ui/op-drag-scroll.directive.ts
  21. 2
      frontend/app/components/input/transformers/transform-date.directive.ts
  22. 2
      frontend/app/components/open-project.module.test.ts
  23. 2
      frontend/app/components/routing/main/work-packages.html
  24. 5
      frontend/app/components/routing/wp-list/wp-list.controller.ts
  25. 11
      frontend/app/components/routing/wp-list/wp.list.html
  26. 14
      frontend/app/components/states.service.ts
  27. 35
      frontend/app/components/work-packages/work-package-cache.service.ts
  28. 2
      frontend/app/components/wp-buttons/wp-button.template.html
  29. 4
      frontend/app/components/wp-buttons/wp-buttons.module.ts
  30. 12
      frontend/app/components/wp-buttons/wp-timeline-toggle-button/wp-timeline-toggle-button.directive.html
  31. 75
      frontend/app/components/wp-buttons/wp-timeline-toggle-button/wp-timeline-toggle-button.directive.ts
  32. 3
      frontend/app/components/wp-fast-table/builders/cell-builder.ts
  33. 9
      frontend/app/components/wp-fast-table/builders/details-link-builder.ts
  34. 36
      frontend/app/components/wp-fast-table/builders/rows/editing-row-builder.ts
  35. 53
      frontend/app/components/wp-fast-table/builders/rows/row-refresh-builder.ts
  36. 12
      frontend/app/components/wp-fast-table/builders/rows/rows-builder.ts
  37. 31
      frontend/app/components/wp-fast-table/builders/rows/single-row-builder.ts
  38. 58
      frontend/app/components/wp-fast-table/builders/timeline-cell-builder.ts
  39. 14
      frontend/app/components/wp-fast-table/handlers/state/rows-transformer.ts
  40. 33
      frontend/app/components/wp-fast-table/handlers/state/timeline-transformer.ts
  41. 2
      frontend/app/components/wp-fast-table/handlers/table-handler-registry.ts
  42. 15
      frontend/app/components/wp-inline-create/inline-create-row-builder.ts
  43. 306
      frontend/app/components/wp-table/timeline/cell-renderer/timeline-cell-renderer.ts
  44. 172
      frontend/app/components/wp-table/timeline/cell-renderer/timeline-milestone-cell-renderer.ts
  45. 83
      frontend/app/components/wp-table/timeline/controls/wp-timeline.dummy-control.directive.ts
  46. 14
      frontend/app/components/wp-table/timeline/controls/wp-timeline.dummy-controls.directive.html
  47. 226
      frontend/app/components/wp-table/timeline/wp-timeline-cell-mouse-handler.ts
  48. 171
      frontend/app/components/wp-table/timeline/wp-timeline-cell.ts
  49. 215
      frontend/app/components/wp-table/timeline/wp-timeline-container.directive.ts
  50. 214
      frontend/app/components/wp-table/timeline/wp-timeline-global.directive.ts
  51. 446
      frontend/app/components/wp-table/timeline/wp-timeline.header.ts
  52. 42
      frontend/app/components/wp-table/timeline/wp-timeline.today-line.ts
  53. 125
      frontend/app/components/wp-table/timeline/wp-timeline.ts
  54. 82
      frontend/app/components/wp-table/wp-table.directive.html
  55. 12
      frontend/app/components/wp-table/wp-table.directive.ts
  56. 2
      frontend/app/global.js
  57. 11
      frontend/app/helpers/angular-rx-utils.ts
  58. 2
      frontend/app/templates/components/selectable_title.html
  59. 11
      frontend/app/typings.d.ts
  60. 33
      frontend/app/typings/open-project.typings.d.ts
  61. 0
      frontend/bower.json
  62. 1166
      frontend/npm-shrinkwrap.json
  63. 2
      frontend/package.json
  64. 10
      lib/open_project/design.rb

@ -32,12 +32,6 @@
to
opacity: 0
$generic-table--font-size: 0.875rem
$generic-table--header-font-size: 0.875rem
$generic-table--header-height: 40px
$generic-table--footer-height: 34px
$input-elements: input, 'input.form--text-field', select, 'select.form--select', '.form--field-affix', 'a.button'
.generic-table--container
@ -86,7 +80,9 @@ table.generic-table
&.-sticky
position: sticky
top: 0
background: white
// z-index needs be higher than row z-index
z-index: 200
tr
&:hover
background: none
@ -145,11 +141,11 @@ table.generic-table
tbody
tr
border-bottom: 1px solid $table-row-border-color
&:hover
background: $table-row-highlighting-color
td
border-bottom: 1px solid $table-row-border-color
max-width: 300px
min-width: 150px
overflow: hidden
@ -175,6 +171,22 @@ table.generic-table
min-width: 50px
width: 50px
// In the interactive table the behaviour is like this:
// * if there is more space available than is required to render
// all columns, the container width is set to 100%.
// Then, td.-max will take up all space available and it will cause all other
// elements to shrink to their minimum value. td-max will grow even beyond
// what is specified as max-width.
// * if the contents requires more space than the container width permits,
// then the container width is set to the width calculated by summing up
// all the column widths. For td.-max, the max-width will be taken to be
// the column width because of the combination of max-width and width: 100%.
// as a result, td.-max will aways have at least a width of max-width, but it can
// become even wider.
&.-max
width: 100%
max-width: 600px
&.info
a
text-decoration: none
@ -188,68 +200,57 @@ table.generic-table
padding: 0 8px
margin: 0
.generic-table--footer-outer
position: absolute
bottom: 0
padding: 0 6px
line-height: $generic-table--footer-height
z-index: 1
.generic-table--header-outer,
.generic-table--sort-header-outer,
.generic-table--empty-header
width: 100%
background: $body-background
border-bottom: 1px solid $table-header-border-color
padding: 0 6px
line-height: $generic-table--header-height
height: $generic-table--header-height
z-index: 1
&:hover,
&.hover
background: #f8f8f8
.generic-table--column-spacer
white-space: nowrap
padding: 0 6px
visibility: hidden
height: 0px
line-height: 0px
.generic-table--sort-header,
white-space: nowrap
width: 100%
clear: both
.generic-table--footer-outer
position: absolute
bottom: 0
padding: 0 6px
line-height: $generic-table--footer-height
z-index: 1
.generic-table--header-outer,
.generic-table--sort-header-outer,
.generic-table--empty-header
padding: 0 6px
line-height: $generic-table--header-height
z-index: 1
&:hover,
&.hover
background: #f8f8f8
.generic-table--column-spacer
white-space: nowrap
padding: 0 6px
visibility: hidden
height: 0px
line-height: 0px
.generic-table--sort-header
white-space: nowrap
width: 100%
clear: both
display: block
& > a,
& > span
display: block
& > a,
& > span
display: block
font-weight: bold
overflow: hidden
text-overflow: ellipsis
& > a
float: left
width: calc(100% - 18px)
& > .dropdown-indicator
width: 1em
text-align: right
overflow: visible
min-width: 1em
visibility: hidden
&:hover > .dropdown-indicator
visibility: visible
.generic-table--cell-controls
white-space: nowrap
&.-no-border
border: none
box-shadow: none
font-weight: bold
overflow: hidden
text-overflow: ellipsis
& > a
float: left
width: calc(100% - 18px)
& > .dropdown-indicator
width: 1em
text-align: right
overflow: visible
min-width: 1em
visibility: hidden
&:hover > .dropdown-indicator
visibility: visible
.generic-table--footer-background
position: absolute

@ -45,3 +45,7 @@
// Tabs
@import work_packages/tabs/_activities
@import work_packages/tabs/_relations
// Timelines
@import work_packages/timelines/_timelines

@ -0,0 +1,45 @@
.wp-timeline--slider-wrapper
position: absolute
z-index: 101
// Slider
.noUi-horizontal.wp-timeline--slider
padding-right: 32px
.noUi-handle
left: -1px
height: calc(1rem - 2px)
top: 0
box-shadow: none
background: $gray
&:before, &:after
height: calc(1rem - 6px)
top: 1px
background: $light-gray
&:before
left: calc(50% - 1px)
&:after
left: calc(50% + 1px)
.noUi-origin
right: -32px
// Scroll slider
.wp-timeline--slider
position: relative
height: 1rem
border-radius: none
.ui-slider-handle
position: absolute
top: 0
width: 1rem
height: 100%
cursor: default
touch-action: none
border-radius: none
background: $primary-color

@ -0,0 +1,55 @@
@import '_slider'
@import 'elements/_bar'
@import 'elements/_milestone'
.wp-timeline--th
min-width: 600px !important
.wp-timeline--dummy-controls
position: absolute
right: 20px
background: white
top: calc(#{$generic-table--header-height} + 10px)
border: 1px solid #D7D7D7
box-shadow: 0 5px 3px -4px #DDDDDD
// Should match the thead z-index
z-index: 200
// Ensure even padding around buttons
padding: 5px
line-height: 1
button
margin: 0
.wp-timeline-header-container.generic-table--sort-header-outer
padding: 0
z-index: 100
margin-left: -5px
height: $generic-table--header-height
&:hover
// Show scroll buttons on hover
.wp-timeline--scroll-btn
display: block
.wp-timeline--scroll-wrapper
border-left: 5px solid $gray
// The scroll wrapper is resized to span the entire table.
// Without this, scroll is caught by the header.
pointer-events: none
position: relative
// Overflow must be hidden since the width of the column is fixed.
// Since height is explicitly set, this is effectively
// overflow-x: hidden / overflow-y: visible
overflow: hidden
.wp-timeline-cell
border-bottom: none !important
position: relative
z-index: 100
&.-collapsed
display: none

@ -0,0 +1,49 @@
.timeline-element.bar
position: relative
height: 1em
border-radius: 2px
float: left
z-index: 50
cursor: ew-resize
.leftHandle
position: absolute
left: 0
top: 0
width: 20px
max-width: 20%
height: 100%
cursor: w-resize
//&:hover
// background-image: linear-gradient(90deg, #6c6c6c 0%, rgba(255,255,255,0) 80%)
.rightHandle
position: absolute
right: 0
top: 0
width: 20px
max-width: 20%
height: 100%
cursor: e-resize
//&:hover
// background-image: linear-gradient(90deg, rgba(255,255,255,0) 0%, #6c6c6c 80%)
.leftDateDisplay
position: absolute
background-color: #f4f4f4
border: 1px solid #d4d4d4
border-radius: 5px
top: -5px
left: -85px
padding: 0 5px 0 5px
font-size: 10px
.rightDateDisplay
position: absolute
background-color: #f4f4f4
border: 1px solid #d4d4d4
border-radius: 5px
top: -5px
right: -85px
padding: 0 5px 0 5px
font-size: 10px

@ -0,0 +1,20 @@
.timeline-element.milestone
position: relative
z-index: 50
cursor: ew-resize
.diamond
position: absolute
transform: rotate(45deg)
transform-origin: center center
.rightDateDisplay
position: absolute
background-color: #f4f4f4
border: 1px solid #d4d4d4
border-radius: 5px
top: -5px
right: -85px
padding: 0 5px 0 5px
font-size: 10px

@ -65,7 +65,7 @@ $toggler-width: 40px
@if $main-menu-enable-toggle-highlighting != true
// simultaneously hover all menu item anchor tags
> a
color: $main-menu-selected-font-color
@include varprop(color, main-menu-selected-font-color)
> a
@include default-transition
@ -79,7 +79,7 @@ $toggler-width: 40px
@include varprop(background, main-menu-bg-hover-background)
&:hover, &.selected, &.selected + a
color: $main-menu-selected-font-color
@include varprop(color, main-menu-selected-font-color)
&:hover
@include varprop(background, main-menu-bg-hover-background)

@ -5,9 +5,10 @@
#main-menu,
#sidebar,
#footer,
#breadcrumb,
.contextual,
.other-formats
display:none
display: none
#main
background: #fff
@ -34,3 +35,12 @@
th, td
border: 1px solid #aaa
// Sizes from user agent stylesheet
h1
font-size: 2em
h2
font-size: 1.5em
h3
font-size: 1.17em

@ -333,3 +333,27 @@
-webkit-column-break-inside: avoid
page-break-inside: avoid
break-inside: avoid
// Print styles for WP table
@media print
.controller-work_packages.action-index,
.controller-work_packages.action-show,
.controller-work_packages.full-create
.wp-timeline--slider-wrapper
display: none !important
#content
margin: 0
width: 100%
height: 100%
padding: 10px
#main
top: 0
padding: 0
border: none
.work-packages--list-table-area
height: 100%
.work-package-table--container,
.generic-table--results-container
overflow: hidden
.wp-timeline--scroll-wrapper
border-left-width: 3px

@ -45,11 +45,13 @@ module Queries
end
def where
integer_values = values.map(&:to_i)
case operator
when "="
["from_id IN (?) OR to_id IN (?)", values.join(", "), values.join(", ")]
["from_id IN (?) OR to_id IN (?)", integer_values, integer_values]
when "!"
["from_id NOT IN (?) AND to_id NOT IN (?)", values.join(", "), values.join(", ")]
["from_id NOT IN (?) AND to_id NOT IN (?)", integer_values, integer_values]
end
end
end

@ -110,7 +110,7 @@ See doc/COPYRIGHT.rdoc for more details.
<%= render(partial: "wiki/content", locals: {content: @content}) %>
<%= link_to_attachments @page %>
<% if @editable && authorize_for('wiki', 'add_attachment') %>
<div id="wiki_add_attachment">
<div id="wiki_add_attachment" class="hide-when-print">
<p>
<%= link_to_function l(:label_attachment_new),
%{

@ -58,6 +58,7 @@ en:
button_move: "Move"
button_open_details: "Open details view"
button_open_fullscreen: "Open fullscreen view"
button_timeline: "Toggle timeline view"
button_quote: "Quote"
button_save: "Save"
button_settings: "Settings"

@ -105,25 +105,3 @@ Feature: Copying a work package
And I select "project_1" from "Project"
When I click "Move and follow"
Then I should see "Failed to save 1 work package(s) on 1 selected:"
@javascript @selenium
Scenario: Going to the Copy Page of 2 Work Packages via bulk edit
When I go to the work package index page of the project called "project_1"
And I open the context menu on the work packages:
| issue1 |
| issue2 |
And I follow "Copy" within "#work-package-context-menu"
Then I should see "Copy" within "#content"
And I should not see "Move" within "#content"
# FIXME: Please check this: is this the same issue as reported in #1868
# Scenario: Move an planning element to project with missing type
# When I go to the move page of the work package "pe3"
# And I select "project_1" from "Project"
#
# When I click "Move and follow"
#
# Then I should see "Successful update."
# And I should see "project_1" within ".breadcrumb"

@ -38,7 +38,8 @@ function halResourceTypesConfig(halResourceTypes:HalResourceTypesService) {
ancestors: 'WorkPackage',
children: 'WorkPackage',
relations: 'Relation',
schema: 'Schema'
schema: 'Schema',
type: 'Type'
}
},
Activity: {
@ -58,6 +59,7 @@ function halResourceTypesConfig(halResourceTypes:HalResourceTypesService) {
}
},
Schema: 'SchemaResource',
Type: 'TypeResource',
Error: 'ErrorResource',
User: 'UserResource',
Collection: 'CollectionResource',

@ -0,0 +1,75 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
import {HalResource} from './hal-resource.service';
import {WorkPackageResource} from './work-package-resource.service';
import {CollectionResource} from './collection-resource.service';
import {HalRequestService} from './../hal-request/hal-request.service';
import {opApiModule} from '../../../../angular-modules';
import {States} from '../../../states.service';
import {State} from './../../../../helpers/reactive-fassade';
var states: States;
var halRequest: HalRequestService;
var v3Path:any;
export class TypeResource extends HalResource {
public color:string;
public static loadAll() {
const types = states.types;
const typeUrl = v3Path.types();
halRequest.get(typeUrl).then((result:CollectionResource) => {
result.elements.forEach((value:TypeResource) => {
types.get(value.href as string).put(value);
});
});
}
public get state() {
return states.types.get(this.href as string);
}
}
function typeResource(...args:any[]) {
[
states,
halRequest,
v3Path
] = args;
return TypeResource;
}
typeResource.$inject = [
'states',
'halRequest',
'v3Path'
];
opApiModule.factory('TypeResource', typeResource);

@ -39,6 +39,7 @@ import ITimeoutService = angular.ITimeoutService;
import {States} from '../../../states.service';
import {State} from './../../../../helpers/reactive-fassade';
import {SchemaResource} from './schema-resource.service';
import {TypeResource} from './type-resource.service';
import {RelationResourceInterface} from './relation-resource.service';
interface WorkPackageResourceEmbedded {
@ -59,9 +60,14 @@ interface WorkPackageResourceEmbedded {
schema: SchemaResource;
status: HalResource|any;
timeEntries: HalResource[]|any[];
type: HalResource|any;
type: TypeResource;
version: HalResource|any;
watchers: CollectionResourceInterface;
// For regular work packages
startDate: string;
dueDate: string;
// Only for milestones
date: string;
relatedBy: RelationResourceInterface|null;
}
@ -142,9 +148,13 @@ export class WorkPackageResource extends HalResource {
return this.$source.id || this.idFromLink;
}
public get idFromLink():string {
public static idFromLink(href: string): string {
return href.split('/').pop()!;
}
public get idFromLink(): string {
if (this.href) {
return this.href.split('/').pop()!;
return WorkPackageResource.idFromLink(this.href);
}
return '';
@ -155,10 +165,7 @@ export class WorkPackageResource extends HalResource {
}
public get isMilestone(): boolean {
/**
* it would be better if this was not deduced but rather taken from the type
*/
return this.hasOwnProperty('date');
return this.schema.hasOwnProperty('date');
}
/**
@ -429,8 +436,9 @@ export class WorkPackageResource extends HalResource {
}
public restoreFromPristine(attribute: string) {
if (this.$pristine[attribute]) {
if (this.$pristine.hasOwnProperty(attribute)) {
this[attribute] = this.$pristine[attribute];
delete this.$pristine[attribute];
}
}

@ -38,11 +38,11 @@ function ConfigurationService(
// TODO: this currently saves the request between page reloads,
// but could easily be stored in localStorage
var cache = false;
var path = PathHelper.apiConfigurationPath();
var path:string = PathHelper.apiConfigurationPath();
var fetchSettings = function () {
let data = $q.defer();
let request = $http.get(path) as any;
request.success(function (settings:any) {
var data = $q.defer();
let resolve = $http.get(path) as any;
resolve.success(function (settings:any) {
data.resolve(settings);
}).error(function (err:any) {
data.reject(err);

@ -1,235 +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 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 {opUiComponentsModule} from "../../../angular-modules";
export class interactiveTableController {
// Width of the table and container last applied by this directive
private lastWidthSet:any;
// Width of the scrollbar to account for
private scrollBarWidth = 16;
constructor(protected $element:ng.IAugmentedJQuery,
protected $timeout:ng.ITimeoutService,
protected $interval:ng.IIntervalService,
protected $scope:ng.IScope,
protected $window:ng.IWindowService) {
'ngInject';
// Initialize the interactive table
$timeout(() => {
this.cloneSpacer();
this.setTableWidths();
});
// Watch for global resize events
// (e.g., table should expand to full width of window)
angular.element($window).on('resize', _.debounce(() => this.setTableWidths(), 50));
// Watch for changes in state
// (e.g., detail view opening)
$scope.$on('$stateChangeSuccess', () => {
$timeout(() => this.setTableWidths(), 200);
});
// Watch for changes in the project navigation menu
$scope.$on('openproject.layout.navigationToggled', () => {
$timeout(() => this.setTableWidths(), 200);
});
// Watch for updates not coming through the above methods
// e.g., attributes in the table inititally rendering such as the progress bar
// will cause the table width to expand
var stopInterval = $interval(() => this.refreshWhenNeeded(), 1000, 0, false);
$scope.$on('$destroy', () => {
$interval.cancel(stopInterval);
});
}
private get table() {
return this.$element;
}
private get visible() {
return this.table.is(':visible');
}
private getInnerContainer() {
return this.table.parent('.generic-table--results-container');
}
private getOuterContainer() {
return this.table.closest('.generic-table--container');
}
private isWorkPackagesTable () {
return this.table.closest('.work-package-table--container').length !== 0;
}
private getBackgrounds() {
return this.getInnerContainer()
.find('.generic-table--header-background,.generic-table--footer-background');
}
private getHeadersFooters() {
return this.table.find(
'.generic-table--sort-header-outer,' +
'.generic-table--header-outer,' +
'.generic-table--footer-outer'
);
}
private cloneSpacer() {
this.getHeadersFooters().each((i, el) => {
var element = angular.element(el);
var html = element.text();
var hiddenForSighted = element.find('.hidden-for-sighted').text();
html = html.replace(hiddenForSighted, '');
var spacerHtml = '<div class="generic-table--column-spacer">' + html + '</div>';
var newElement = angular.element(spacerHtml);
newElement.appendTo(element.parent());
});
};
/**
* Return the actual width of the table element (including scrollbar if necessary)
*/
private currentWidth() {
return this.table.width();
}
/**
* Re-adjust the table's container widths
*/
private setTableContainerWidths() {
var width = this.currentWidth();
// account for a possible scrollbar
if (width > document.documentElement.clientWidth - this.scrollBarWidth) {
width += this.scrollBarWidth;
}
this.lastWidthSet = width;
if (width > this.getOuterContainer().width()) {
// force containers to the width of the table
this.getInnerContainer().width(width);
this.getBackgrounds().width(width);
} else {
// ensure table stretches to container sizes
this.getInnerContainer().css('width', '100%');
this.getBackgrounds().css('width', '100%');
}
}
/**
* Correct header and footer widths after table is updated.
*/
private setHeaderFooterWidths() {
this.getHeadersFooters().each((i, el) => {
var element = angular.element(el);
var spacer = element.parent();
var width = spacer.width();
if (width !== 0) {
element.css('width', width + 'px')
.parent().css('width', width + 'px');
}
});
}
/**
* Reset all table element widths to auto.
*/
private invalidateWidths() {
this.getInnerContainer().css('width', 'auto');
this.getBackgrounds().css('width', 'auto');
this.getHeadersFooters().each((i, el) => {
angular.element(el).css('width', 'auto');
});
}
/**
* Update the table itself and its containers after an external event.
* (Resize, column content changes)
*/
private setTableWidths() {
this.invalidateWidths();
this.setTableContainerWidths();
this.setHeaderFooterWidths();
};
private refreshWhenNeeded() {
if(!this.visible) {
return;
}
// If the inner width of the table changed due to some outer event,
// adjust accordingly.
var actualWidth = this.currentWidth();
if (Math.abs(this.lastWidthSet - actualWidth) >= 10) {
return this.setTableWidths();
}
// If any of the outer header widths changed,
// adjust the fixed headers.
this.getHeadersFooters().each((i, el) => {
var element = angular.element(el);
var width = element.parent().width();
if (width !== 0 && element.outerWidth() !== width) {
return this.setTableWidths();
}
});
}
}
function interactiveTable() {
return {
restrict: 'A',
controller: interactiveTableController,
bindToController: true,
link: function(scope:ng.IScope, element:ng.IAugmentedJQuery) {
if (element.filter('table').length === 0) {
throw 'needs to be defined on a \'table\' tag';
}
}
};
};
opUiComponentsModule.directive('interactiveTable', interactiveTable);

@ -0,0 +1,78 @@
//-- 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 {opUiComponentsModule} from '../../../angular-modules';
import {opApiModule} from './../../../angular-modules';
import {debugLog} from '../../../helpers/debug_output';
function requestInterval(fn:Function, delay:number) {
let start:number = new Date().getTime();
let handle:any = {};
function loop() {
handle.value = window.requestAnimationFrame(loop);
var current = new Date().getTime(),
delta = current - start;
if (delta >= delay) {
fn();
start = new Date().getTime();
}
}
handle.value = window.requestAnimationFrame(loop);
return handle;
};
export const opDimensionEventName = 'op:resize';
function detectDimensionChanges($window:ng.IWindowService) {
return {
restrict: 'A',
link: function(scope:ng.IScope, element:ng.IAugmentedJQuery, attr:ng.IAttributes) {
const el = element[0];
let height = 0, width = 0;
requestInterval(() => {
let newHeight = el.offsetHeight;
let newWidth = el.offsetWidth;
if (newHeight !== height ||
newWidth !== width) {
debugLog('Dimension change detected on ', element);
element.trigger(opDimensionEventName);
height = newHeight;
width = newWidth;
}
}, 1000);
}
};
}
opUiComponentsModule.directive('detectDimensionChanges', detectDimensionChanges);

@ -0,0 +1,86 @@
import {opApiModule} from './../../../angular-modules';
//-- 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 {opUiComponentsModule} from '../../../angular-modules';
interface DragScrollData {
delta: {
x: number,
y: number
};
}
function opDragScroll() {
return {
restrict: 'A',
link: function(scope:ng.IScope, element:ng.IAugmentedJQuery, attr:ng.IAttributes) {
const eventName = 'op:dragscroll';
// Is mouse down?
var mousedown = false;
// Position of last mousedown
var mousedownX:number, mousedownY:number;
// Mousedown: Potential drag start
element.on('mousedown', (evt:JQueryEventObject) => {
setTimeout(() => {
mousedown = true;
mousedownX = evt.clientX;
mousedownY = evt.clientY;
}, 50, false);
});
// Mouseup: Potential drag stop
element.on('mouseup', () => { mousedown = false; });
// Mousemove: Report movement if mousedown
element.on('mousemove', (evt:JQueryEventObject) => {
if (!mousedown) {
return;
}
// Trigger drag scroll event
element.trigger(eventName, {
x: evt.clientX - mousedownX,
y: evt.clientY - mousedownY
});
// Update last mouse position
mousedownX = evt.clientX;
mousedownY = evt.clientY;
});
}
};
}
opUiComponentsModule.directive('opDragScroll', opDragScroll);

@ -50,7 +50,7 @@ function transformDate(TimezoneService:any) {
});
}
};
};
}
// TODO:deprecate and replace by transformDate
angular

@ -43,7 +43,7 @@ describe('OpenProject module', () => {
};
var merged:any = opDirective(directive, config);
expect(merged.scope.someValue).to.eq(merged.scope.someOtherValue);
expect(merged.scope['someValue']).to.eq(merged.scope['someOtherValue']);
});
});
});

@ -1,3 +1,3 @@
<div id="work-packages-index">
<div ui-view class="work-packages--page-container"></div>
<div ui-view wp-timeline-container class="work-packages--page-container"></div>
</div>

@ -249,7 +249,10 @@ function WorkPackagesListController($scope:any,
wpListService.fromQueryInstance($scope.query, $scope.projectIdentifier)
.then(function (json:api.ex.WorkPackagesMeta) {
$scope.$broadcast('openproject.workPackages.updateResults');
$scope.$evalAsync(() => setupWorkPackagesTable(json));
$scope.$evalAsync(() => {
wpCacheService.updateWorkPackageList(json.work_packages);
setupWorkPackagesTable(json);
});
});
});

@ -7,7 +7,7 @@
transition-method="loadQuery">
</selectable-title>
<ul class="toolbar-items">
<ul class="toolbar-items hide-when-print">
<li class="toolbar-item">
<wp-create-button
allowed="!!resource.$links.createWorkPackage"
@ -40,6 +40,10 @@
</li>
</ul>
</li>
<li class="toolbar-item">
<wp-timeline-toggle-button>
</wp-timeline-toggle-button>
</li>
<li class="toolbar-item hidden-for-mobile">
<button id="work-packages-settings-button"
ng-disabled="wpEditModeState.active"
@ -69,7 +73,8 @@
<span ng-bind="::text.jump_to_pagination"></span>
</accessible-by-keyboard>
<div class="work-packages--list">
<div table-timeline
class="work-packages--list">
<div class="work-packages--list-table-area loading-indicator--location"
data-indicator-name="table">
<wp-table ng-if="columns"
@ -86,7 +91,7 @@
</wp-table>
</div>
<div class="work-packages--list-pagination-area">
<div class="work-packages--list-pagination-area hide-when-print">
<table-pagination>
</table-pagination>
</div>

@ -1,3 +1,4 @@
import {WorkPackageTimelineTableController} from './wp-table/timeline/wp-timeline-container.directive';
import {whenDebugging} from '../helpers/debug_output';
import {WorkPackageTable} from './wp-fast-table/wp-fast-table';
import {
@ -9,15 +10,22 @@ import {MultiState, initStates, State} from "../helpers/reactive-fassade";
import {WorkPackageResource} from "./api/api-v3/hal-resources/work-package-resource.service";
import {opServicesModule} from "../angular-modules";
import {SchemaResource} from './api/api-v3/hal-resources/schema-resource.service';
import {TypeResource} from './api/api-v3/hal-resources/type-resource.service';
import {WorkPackageEditForm} from './wp-edit-form/work-package-edit-form';
import {WorkPackageTableMetadata} from './wp-fast-table/wp-table-metadata';
import {Subject} from 'rxjs';
export class States {
/* /api/v3/work_packages */
workPackages = new MultiState<WorkPackageResource>();
/* /api/v3/schemas */
schemas = new MultiState<SchemaResource>();
/* /api/v3/types */
types = new MultiState<TypeResource>();
// Work package table states
table = {
// Metadata of the current table result
@ -35,10 +43,14 @@ export class States {
hierarchies: new State<WPTableHierarchyState>(),
// State to be updated when the table is up to date
rendered:new State<WorkPackageTable>(),
// State to determine timeline visibility
timelineVisible: new State<boolean>(),
// Subject used to unregister all listeners of states above.
stopAllSubscriptions:new Subject()
};
timeline = new State<WorkPackageTimelineTableController>();
// Query states
query = {
// All available columns for selection
@ -54,7 +66,7 @@ export class States {
constructor() {
initStates(this, function (msg: any) {
whenDebugging(() => {
console.trace(msg);
console.debug(msg);
});
});
}

@ -1,4 +1,3 @@
import {WorkPackagesListService} from './../wp-list/wp-list.service';
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
@ -26,16 +25,18 @@ import {WorkPackagesListService} from './../wp-list/wp-list.service';
//
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {opWorkPackagesModule} from "../../angular-modules";
import {WorkPackageResource} from "../api/api-v3/hal-resources/work-package-resource.service";
import {SchemaResource} from './../api/api-v3/hal-resources/schema-resource.service';
import {
WorkPackageResource,
WorkPackageResourceInterface
} from "../api/api-v3/hal-resources/work-package-resource.service";
import {ApiWorkPackagesService} from "../api/api-work-packages/api-work-packages.service";
import {WorkPackageNotificationService} from "./../wp-edit/wp-notification.service";
import {State} from "../../helpers/reactive-fassade";
import IScope = angular.IScope;
import {States} from "../states.service";
import {Observable, Subject} from "rxjs";
import IScope = angular.IScope;
import IPromise = angular.IPromise;
function getWorkPackageId(id: number|string): string {
@ -48,7 +49,9 @@ export class WorkPackageCacheService {
/*@ngInject*/
constructor(private states: States,
private $rootScope: ng.IRootScopeService,
private $q: ng.IQService,
private wpNotificationsService:WorkPackageNotificationService,
private apiWorkPackages: ApiWorkPackagesService) {
}
@ -81,6 +84,26 @@ export class WorkPackageCacheService {
}
}
saveIfChanged(workPackage: WorkPackageResourceInterface): IPromise<WorkPackageResourceInterface> {
if (!(workPackage.dirty || workPackage.isNew)) {
return this.$q.when(workPackage);
}
const deferred = this.$q.defer<WorkPackageResourceInterface>();
workPackage.save()
.then(() => {
this.wpNotificationsService.showSave(workPackage);
this.$rootScope.$emit('workPackagesRefreshInBackground');
deferred.resolve(workPackage);
})
.catch((error) => {
this.wpNotificationsService.handleErrorResponse(error, workPackage);
deferred.reject(error);
});
return deferred.promise;
}
loadWorkPackage(workPackageId: string, forceUpdate = false): State<WorkPackageResource> {
const state = this.states.workPackages.get(getWorkPackageId(workPackageId));
if (forceUpdate) {

@ -3,7 +3,7 @@
title="{{ vm.label }}"
ng-click="vm.performAction()"
ng-attr-accesskey="{{ ::vm.accessKey }}"
ng-disabled="vm.disabled || vm.isActive()"
ng-disabled="vm.disabled || (!vm.isToggle() && vm.isActive())"
ng-class="{ '-active': vm.isActive() }">
<i class="{{ ::vm.iconClass }} button--icon"></i>
<span class="hidden-for-sighted">{{ vm.label }}</span>

@ -82,6 +82,10 @@ export abstract class WorkPackageButtonController {
return this.activationPrefix || this.deactivationPrefix;
}
public isToggle():boolean {
return false;
}
public abstract isActive():boolean;
public abstract performAction():void;

@ -0,0 +1,12 @@
<button id="{{ ::vm.buttonId }}"
class="button"
title="{{ vm.label }}"
ng-click="vm.performAction()"
ng-attr-accesskey="{{ ::vm.accessKey }}"
ng-disabled="vm.disabled"
ng-class="{ '-active': vm.isActive() }"
aria-label="{{ vm.label }}">
<i class="{{ ::vm.iconClass }} button--icon"></i>
<span class="button--text" ng-bind="::vm.buttonText"></span>
<span class="badge -secondary">{{ vm.filterCount }}</span>
</button>

@ -0,0 +1,75 @@
// -- 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 {wpButtonsModule} from '../../../angular-modules';
import {WorkPackageButtonController, wpButtonDirective} from '../wp-buttons.module';
import {WorkPackageTimelineTableController} from './../../wp-table/timeline/wp-timeline-container.directive';
export class WorkPackageTimelineButtonController extends WorkPackageButtonController {
public wpTimelineContainer:WorkPackageTimelineTableController ;
public buttonId:string = 'work-packages-timeline-toggle-button';
public iconClass:string = 'icon-view-timeline';
constructor(public I18n:op.I18n) {
'ngInject';
super(I18n);
}
public get labelKey():string {
return 'js.button_timeline';
}
public isToggle():boolean {
return true;
}
public isActive():boolean {
return this.wpTimelineContainer && this.wpTimelineContainer.visible;
}
public performAction() {
this.wpTimelineContainer.toggle();
}
}
function wpTimelineToggleButton():ng.IDirective {
return wpButtonDirective({
require: '^wpTimelineContainer',
controller: WorkPackageTimelineButtonController,
link: (scope:any,
attr:ng.IAttributes,
element:ng.IAugmentedJQuery,
wpTimelineContainer:WorkPackageTimelineTableController) => {
scope.vm.wpTimelineContainer = wpTimelineContainer;
}
});
}
wpButtonsModule.directive('wpTimelineToggleButton', wpTimelineToggleButton);

@ -8,6 +8,7 @@ export const requiredClassName = '-required';
export const readOnlyClassName = '-read-only';
export const placeholderClassName = '-placeholder';
export const cellClassName = 'wp-table--cell-span';
export const wpCellTdClassName = 'wp-table--wp-cell-span';
export const cellEmptyPlaceholder = '-';
export class CellBuilder {
@ -23,7 +24,7 @@ export class CellBuilder {
const fieldSchema = workPackage.schema[name];
const td = document.createElement('td');
td.classList.add(tdClassName, name);
td.classList.add(tdClassName, wpCellTdClassName, name);
const span = document.createElement('span');
span.classList.add(cellClassName, 'inplace-edit', 'wp-edit-field', name);
span.dataset['fieldName'] = name;

@ -1,6 +1,8 @@
import {WorkPackageResource} from './../../api/api-v3/hal-resources/work-package-resource.service';
import {injectorBridge} from '../../angular/angular-injector-bridge.functions';
import {UiStateLinkBuilder} from './ui-state-link-builder';
export const detailsLinkTdClass = 'wp-table--details-column';
export const detailsLinkClassName = 'wp-table--details-link';
export class DetailsLinkBuilder {
@ -18,10 +20,10 @@ export class DetailsLinkBuilder {
this.uiStatebuilder = new UiStateLinkBuilder();
}
public build(workPackage:WorkPackageResource, row:HTMLElement) {
public build(workPackage:WorkPackageResource):HTMLElement {
// Append details button
let td = document.createElement('td');
td.classList.add('wp-table--details-column', 'hide-when-print', '-short');
td.classList.add(detailsLinkTdClass, 'hide-when-print', '-short');
let detailsLink = this.uiStatebuilder.linkToDetails(
workPackage.id,
@ -35,7 +37,8 @@ export class DetailsLinkBuilder {
detailsLink.appendChild(icon);
td.appendChild(detailsLink);
row.appendChild(td);
return td;
}
}

@ -1,36 +0,0 @@
import {WorkPackageEditForm} from '../../../wp-edit-form/work-package-edit-form';
import {locateRow} from '../../helpers/wp-table-row-helpers';
import {WorkPackageTable} from '../../wp-fast-table';
import {WorkPackageTableRow} from '../../wp-table.interfaces';
import {SingleRowBuilder} from './single-row-builder';
export class EditingRowBuilder extends SingleRowBuilder {
/**
* Refresh a row that is currently being edited, that is, some edit fields may be open
*/
public refreshEditing(row:WorkPackageTableRow, editForm:WorkPackageEditForm):HTMLElement|null {
// Get the row for the WP if refreshing existing
const rowElement:HTMLElement|null = row.element || locateRow(row.workPackageId);
if (!rowElement) {
return null;
}
// Detach all existing columns
let tds = jQuery(rowElement).find('td').detach();
// Iterate all columns, reattaching or rendering new columns
this.columns.forEach((column:string) => {
let oldTd = tds.filter(`td.${column}`);
// Reattach the column if its currently being edited
if (editForm.activeFields[column] && oldTd.length) {
rowElement.appendChild(oldTd[0]);
} else {
this.buildCell(row.object, column, rowElement);
}
});
return rowElement;
}
}

@ -0,0 +1,53 @@
import {wpCellTdClassName} from '../cell-builder';
import {timelineCellClassName} from '../timeline-cell-builder';
import {WorkPackageEditForm} from '../../../wp-edit-form/work-package-edit-form';
import {locateRow} from '../../helpers/wp-table-row-helpers';
import {WorkPackageTable} from '../../wp-fast-table';
import {WorkPackageTableRow} from '../../wp-table.interfaces';
import {SingleRowBuilder} from './single-row-builder';
import {detailsLinkClassName} from '../details-link-builder';
export class RowRefreshBuilder extends SingleRowBuilder {
/**
* Refresh a row that is currently being edited, that is, some edit fields may be open
*/
public refreshRow(row:WorkPackageTableRow, editForm:WorkPackageEditForm|null):HTMLElement|null {
// Get the row for the WP if refreshing existing
const rowElement = row.element || locateRow(row.workPackageId);
if (!rowElement) {
return null;
}
// Iterate all columns, reattaching or rendering new columns
const jRow = jQuery(rowElement);
// Detach all current edit cells
const cells = jRow.find(`.${wpCellTdClassName}`).detach();
// Remember the order of all new edit cells
const newCells:HTMLElement[] = [];
this.columns.forEach((column:string) => {
const oldTd = jRow.find(`td.${column}`);
// Skip the replacement of the column if this is being edited.
if (this.isColumnBeingEdited(editForm, column)) {
newCells.push(oldTd[0]);
return;
}
// Otherwise, refresh that cell and append it
const cell = this.buildCell(row.object, column);
newCells.push(cell);
});
jRow.prepend(newCells);
return rowElement;
}
private isColumnBeingEdited(editForm:WorkPackageEditForm|null, column:string) {
return editForm && editForm.activeFields[column];
}
}

@ -1,5 +1,5 @@
import {RowRefreshBuilder} from './row-refresh-builder';
import {WorkPackageTableMetadata} from '../../wp-table-metadata';
import {EditingRowBuilder} from './editing-row-builder';
import {States} from '../../../states.service';
import {SingleRowBuilder} from './single-row-builder';
import {WorkPackageTableColumnsService} from '../../state/wp-table-columns.service';
@ -11,11 +11,11 @@ export abstract class RowsBuilder {
public states:States;
protected rowBuilder:SingleRowBuilder;
protected editingRowBuilder:EditingRowBuilder;
protected refreshBuilder:RowRefreshBuilder;
constructor() {
this.rowBuilder = new SingleRowBuilder();
this.editingRowBuilder = new EditingRowBuilder();
this.refreshBuilder = new RowRefreshBuilder();
}
/**
@ -37,11 +37,7 @@ export abstract class RowsBuilder {
*/
public refreshRow(row:WorkPackageTableRow, table:WorkPackageTable):HTMLElement|null {
let editing = this.states.editing.get(row.workPackageId).getCurrentValue();
if (editing) {
return this.editingRowBuilder.refreshEditing(row, editing);
}
return this.buildEmptyRow(row, table);
return this.refreshBuilder.refreshRow(row, editing);
}
/**

@ -1,3 +1,4 @@
import {TimelineCellBuilder} from '../timeline-cell-builder';
import {WorkPackageTable} from '../../wp-fast-table';
import {WorkPackageTableRow} from '../../wp-table.interfaces';
import {States} from '../../../states.service';
@ -12,6 +13,7 @@ import {rowId} from '../../helpers/wp-table-row-helpers';
export const rowClassName = 'wp-table--row';
export const internalColumnDetails = '__internal-detailsLink';
export const internalColumnTimelines = '__internal-timelines';
export class SingleRowBuilder {
// Injections
@ -23,6 +25,8 @@ export class SingleRowBuilder {
protected cellBuilder = new CellBuilder();
// Details Link builder
protected detailsLinkBuilder = new DetailsLinkBuilder();
// Timeline builder
protected timelineCellBuilder = new TimelineCellBuilder();
constructor() {
injectorBridge(this);
@ -33,19 +37,28 @@ export class SingleRowBuilder {
* It is not responsible for subscribing to updates.
*/
public get columns():string[] {
return (this.states.table.columns.getCurrentValue() || []);
}
/**
* Returns the current set of columns, augmented by the internal columns
* we add for buttons and timeline.
*/
public get augmentedColumns():string[] {
const editColums = (this.states.table.columns.getCurrentValue() || []);
return editColums.concat(internalColumnDetails);
// Add details and timelines column as last table column
return editColums.concat(internalColumnDetails, internalColumnTimelines);
}
public buildCell(workPackage:WorkPackageResource, column:string, row:HTMLElement):void {
public buildCell(workPackage:WorkPackageResource, column:string):HTMLElement {
switch (column) {
case internalColumnTimelines:
return this.timelineCellBuilder.build(workPackage);
case internalColumnDetails:
this.detailsLinkBuilder.build(workPackage, row);
break;
return this.detailsLinkBuilder.build(workPackage);
default:
const cell = this.cellBuilder.build(workPackage, column);
row.appendChild(cell);
return this.cellBuilder.build(workPackage, column);
}
}
@ -55,9 +68,11 @@ export class SingleRowBuilder {
*/
public buildEmpty(workPackage:WorkPackageResource):HTMLElement {
let row = this.createEmptyRow(workPackage);
let cell = null;
this.columns.forEach((column:string) => {
this.buildCell(workPackage, column, row);
this.augmentedColumns.forEach((column:string) => {
cell = this.buildCell(workPackage, column);
row.appendChild(cell);
});
// Set the row selection state

@ -0,0 +1,58 @@
import {WorkPackageCacheService} from '../../work-packages/work-package-cache.service';
import {WorkPackageTimelineCell} from '../../wp-table/timeline/wp-timeline-cell';
import {State} from '../../../helpers/reactive-fassade';
import {UiStateLinkBuilder} from './ui-state-link-builder';
import {WorkPackageTimelineTableController} from '../../wp-table/timeline/wp-timeline-container.directive';
import {States} from '../../states.service';
import {WorkPackageResource} from './../../api/api-v3/hal-resources/work-package-resource.service';
import {DisplayField} from './../../wp-display/wp-display-field/wp-display-field.module';
import {injectorBridge} from '../../angular/angular-injector-bridge.functions';
export const timelineCellClassName = 'wp-timeline-cell';
export const timelineCollapsedClassName = '-collapsed';
export class TimelineCellBuilder {
public states:States;
public wpCacheService:WorkPackageCacheService;
constructor() {
injectorBridge(this);
}
public get isVisible():boolean {
return this.states.table.timelineVisible.getCurrentValue() || false;
}
public get timelineInstance():WorkPackageTimelineTableController {
return this.states.timeline.getCurrentValue()!;
}
public build(workPackage:WorkPackageResource):HTMLElement {
const td = document.createElement('td');
td.classList.add(timelineCellClassName, '-max');
if (!this.isVisible) {
td.classList.add(timelineCollapsedClassName);
}
this.buildTimelineCell(td, workPackage);
return td;
}
public buildTimelineCell(cell:HTMLElement, workPackage:WorkPackageResource):void {
// required data for timeline cell
const timelineCell = new WorkPackageTimelineCell(
this.timelineInstance,
this.wpCacheService,
this.states,
workPackage.id,
cell
);
// show timeline cell
timelineCell.activate();
}
}
TimelineCellBuilder.$inject = ['states', 'wpCacheService'];

@ -47,20 +47,6 @@ export class RowsTransformer {
* Will skip rendering when dirty or fresh. Does not check for table changes.
*/
private refreshWorkPackage(table:WorkPackageTable, row:WorkPackageTableRow) {
// If the work package is dirty, we're working on it
if (row.object.dirty) {
debugLog("Skipping row " + row.workPackageId + " since its dirty");
return;
}
// Get the row for the WP if refreshing existing
let oldRow = row.element || locateRow(row.workPackageId) as HTMLElement;
if (oldRow.dataset['lockVersion'] === row.object.lockVersion.toString()) {
debugLog("Skipping row " + row.workPackageId + " since its fresh");
return;
}
table.refreshRow(row);
}
}

@ -0,0 +1,33 @@
import {States} from '../../../states.service';
import {
TimelineCellBuilder,
timelineCellClassName,
timelineCollapsedClassName
} from '../../builders/timeline-cell-builder';
import {WorkPackageTableSelection} from '../../state/wp-table-selection.service';
import {injectorBridge} from '../../../angular/angular-injector-bridge.functions';
import {WPTableRowSelectionState} from '../../wp-table.interfaces';
import {WorkPackageTable} from '../../wp-fast-table';
export class TimelineTransformer {
public states:States;
public timelineCellBuilder = new TimelineCellBuilder();
constructor(table:WorkPackageTable) {
injectorBridge(this);
this.states.table.timelineVisible
.observeUntil(this.states.table.stopAllSubscriptions).subscribe((visible:boolean) => {
this.renderVisibility(visible);
});
}
/**
* Update all currently visible rows to match the selection state.
*/
private renderVisibility(visible:boolean) {
jQuery(`.${timelineCellClassName}`).toggleClass(timelineCollapsedClassName, !visible);
}
}
TimelineTransformer.$inject = ['states'];

@ -1,3 +1,4 @@
import {TimelineTransformer} from './state/timeline-transformer';
import {HierarchyTransformer} from './state/hierarchy-transformer';
import {HierarchyClickHandler} from './row/hierarchy-click-handler';
import {WorkPackageTable} from '../wp-fast-table';
@ -42,6 +43,7 @@ export class TableHandlerRegistry {
SelectionTransformer,
RowsTransformer,
ColumnsTransformer,
TimelineTransformer,
HierarchyTransformer
];

@ -8,7 +8,6 @@ import {rowId} from '../wp-fast-table/helpers/wp-table-row-helpers';
import {States} from '../states.service';
import {WorkPackageTableSelection} from '../wp-fast-table/state/wp-table-selection.service';
import {CellBuilder} from '../wp-fast-table/builders/cell-builder';
import {DetailsLinkBuilder} from '../wp-fast-table/builders/details-link-builder';
import {
internalColumnDetails,
rowClassName,
@ -35,14 +34,12 @@ export class InlineCreateRowBuilder extends SingleRowBuilder {
};
}
public buildCell(workPackage:WorkPackageResource, column:string, row:HTMLElement):void {
public buildCell(workPackage:WorkPackageResource, column:string):HTMLElement {
switch (column) {
case internalColumnDetails:
this.buildCancelButton(row);
break;
return this.buildCancelButton();
default:
const cell = this.cellBuilder.build(workPackage, column);
row.appendChild(cell);
return super.buildCell(workPackage, column);
}
}
@ -72,9 +69,9 @@ export class InlineCreateRowBuilder extends SingleRowBuilder {
return tr;
}
protected buildCancelButton(row:HTMLElement) {
protected buildCancelButton() {
const td = document.createElement('td');
td.classList.add('wp-table--cancel-create-td');
td.classList.add('wp-table--cancel-create-td', '-short');
td.innerHTML = `
<a
@ -84,7 +81,7 @@ export class InlineCreateRowBuilder extends SingleRowBuilder {
</a>
`;
row.appendChild(td);
return td;
}
}

@ -0,0 +1,306 @@
import {WorkPackageResourceInterface} from "../../../api/api-v3/hal-resources/work-package-resource.service";
import {
RenderInfo, calculatePositionValueForDayCount, timelineElementCssClass,
calculatePositionValueForDayCountinPx
} from "../wp-timeline";
import {classNameLeftHandle, classNameRightHandle} from "../wp-timeline-cell-mouse-handler";
import * as moment from 'moment';
import Moment = moment.Moment;
interface CellDateMovement {
// Target values to move work package to
startDate?: moment.Moment;
dueDate?: moment.Moment;
}
export class TimelineCellRenderer {
protected dateDisplaysOnMouseMove: {left?: HTMLElement; right?: HTMLElement} = {};
public get type(): string {
return 'bar';
}
public get fallbackColor(): string {
return '#8CD1E8';
}
public isEmpty(wp: WorkPackageResourceInterface) {
const start = moment(wp.startDate as any);
const due = moment(wp.dueDate as any);
const noStartAndDueValues = _.isNaN(start.valueOf()) && _.isNaN(due.valueOf());
return noStartAndDueValues;
}
public displayPlaceholderUnderCursor(ev: MouseEvent, renderInfo: RenderInfo): HTMLElement {
const days = Math.floor(ev.offsetX / renderInfo.viewParams.pixelPerDay);
const placeholder = document.createElement("div");
placeholder.style.pointerEvents = "none";
placeholder.style.backgroundColor = "#DDDDDD";
placeholder.style.position = "absolute";
placeholder.style.height = "1em";
placeholder.style.width = "30px";
placeholder.style.left = (days * renderInfo.viewParams.pixelPerDay) + "px";
return placeholder;
}
/**
* Assign changed dates to the work package.
* For generic work packages, assigns start and due date.
*
*/
public assignDateValues(wp: WorkPackageResourceInterface, dates: CellDateMovement) {
this.assignDate(wp, 'startDate', dates.startDate!);
this.assignDate(wp, 'dueDate', dates.dueDate!);
this.updateLeftRightMovedLabel(dates.startDate!, dates.dueDate!);
}
/**
* Restore the original date, if any was set.
*/
public onCancel(wp: WorkPackageResourceInterface) {
wp.restoreFromPristine('startDate');
wp.restoreFromPristine('dueDate');
}
/**
* Handle movement by <delta> days of the work package to either (or both) edge(s)
* depending on which initial date was set.
*/
public onDaysMoved(wp: WorkPackageResourceInterface,
dayUnderCursor: Moment,
delta: number,
direction: "left" | "right" | "both" | "create" | "dragright"): CellDateMovement {
const initialStartDate = wp.$pristine['startDate'];
const initialDueDate = wp.$pristine['dueDate'];
let dates: CellDateMovement = {};
if (direction === "left") {
dates.startDate = moment(initialStartDate || initialDueDate).add(delta, "days");
} else if (direction === "right") {
dates.dueDate = moment(initialDueDate || initialStartDate).add(delta, "days");
} else if (direction === "both") {
if (initialStartDate) {
dates.startDate = moment(initialStartDate).add(delta, "days");
}
if (initialDueDate) {
dates.dueDate = moment(initialDueDate).add(delta, "days");
}
} else if (direction === "dragright") {
dates.dueDate = moment(wp.startDate).clone().add(delta, "days");
}
// avoid negative "overdrag" if only start or due are changed
if (direction !== "both") {
if (dates.startDate != undefined && dates.startDate.isAfter(moment(wp.dueDate))) {
dates.startDate = moment(wp.dueDate);
} else if (dates.dueDate != undefined && dates.dueDate.isBefore(moment(wp.startDate))) {
dates.dueDate = moment(wp.startDate);
}
}
return dates;
}
public onMouseDown(ev: MouseEvent,
dateForCreate: string|null,
renderInfo: RenderInfo,
elem: HTMLElement): "left" | "right" | "both" | "dragright" | "create" {
renderInfo.workPackage.storePristine('startDate');
renderInfo.workPackage.storePristine('dueDate');
let direction: "left" | "right" | "both" | "create" | "dragright";
// Update the cursor and maybe set start/due values
if (jQuery(ev.target).hasClass(classNameLeftHandle)) {
// only left
direction = "left";
this.forceCursor('w-resize');
if (renderInfo.workPackage.startDate === null) {
renderInfo.workPackage.startDate = renderInfo.workPackage.dueDate;
}
} else if (jQuery(ev.target).hasClass(classNameRightHandle) || dateForCreate) {
// only right
direction = "right";
this.forceCursor('e-resize');
if (renderInfo.workPackage.dueDate === null) {
renderInfo.workPackage.dueDate = renderInfo.workPackage.startDate;
}
} else {
// both
direction = "both";
this.forceCursor('ew-resize');
}
this.dateDisplaysOnMouseMove = [null, null];
if (dateForCreate) {
renderInfo.workPackage.startDate = dateForCreate;
renderInfo.workPackage.dueDate = dateForCreate;
direction = "dragright";
}
if (!jQuery(ev.target).hasClass(classNameRightHandle) && renderInfo.workPackage.startDate) {
// create left date label
const leftInfo = document.createElement("div");
leftInfo.className = "leftDateDisplay";
this.dateDisplaysOnMouseMove.left = leftInfo;
elem.appendChild(leftInfo);
}
if (!jQuery(ev.target).hasClass(classNameLeftHandle) && renderInfo.workPackage.dueDate) {
// create right date label
const rightInfo = document.createElement("div");
rightInfo.className = "rightDateDisplay";
this.dateDisplaysOnMouseMove.right = rightInfo;
elem.appendChild(rightInfo);
}
this.updateLeftRightMovedLabel(
moment(renderInfo.workPackage.startDate),
moment(renderInfo.workPackage.dueDate));
return direction;
}
public onMouseDownEnd() {
this.dateDisplaysOnMouseMove.left && this.dateDisplaysOnMouseMove.left.remove();
this.dateDisplaysOnMouseMove.right && this.dateDisplaysOnMouseMove.right.remove();
this.dateDisplaysOnMouseMove = {};
}
/**
* @return true, if the element should still be displayed.
* false, if the element must be removed from the timeline.
*/
public update(timelineCell: HTMLElement, bar: HTMLDivElement, renderInfo: RenderInfo): boolean {
const wp = renderInfo.workPackage;
// general settings - bar
bar.style.marginLeft = renderInfo.viewParams.scrollOffsetInPx + "px";
bar.style.backgroundColor = this.typeColor(renderInfo.workPackage);
const viewParams = renderInfo.viewParams;
let start = moment(wp.startDate as any);
let due = moment(wp.dueDate as any);
if (_.isNaN(start.valueOf()) && _.isNaN(due.valueOf())) {
bar.style.visibility = "hidden";
} else {
bar.style.visibility = "visible";
}
// only start date, fade out bar to the right
if (_.isNaN(due.valueOf()) && !_.isNaN(start.valueOf())) {
due = start.clone();
bar.style.backgroundColor = "inherit";
const color = this.typeColor(renderInfo.workPackage);
bar.style.backgroundImage = `linear-gradient(90deg, ${color} 0%, rgba(255,255,255,0) 80%)`;
}
// only due date, fade out bar to the left
if (_.isNaN(start.valueOf()) && !_.isNaN(due.valueOf())) {
start = due.clone();
bar.style.backgroundColor = "inherit";
const color = this.typeColor(renderInfo.workPackage);
bar.style.backgroundImage = `linear-gradient(90deg, rgba(255,255,255,0) 0%, ${color} 100%)`;
}
// offset left
const offsetStart = start.diff(viewParams.dateDisplayStart, "days");
bar.style.left = calculatePositionValueForDayCount(viewParams, offsetStart);
// duration
const duration = due.diff(start, "days") + 1;
bar.style.width = calculatePositionValueForDayCount(viewParams, duration);
// ensure minimum width
if (!_.isNaN(start.valueOf()) || !_.isNaN(due.valueOf())) {
bar.style.minWidth = "30px";
}
return true;
}
getLeftmostPosition(renderInfo: RenderInfo): number {
const wp = renderInfo.workPackage;
let start = moment(wp.startDate as any);
start = _.isNaN(start.valueOf()) ? moment(wp.dueDate).clone() : start;
const offsetStart = start.diff(renderInfo.viewParams.dateDisplayStart, "days");
return calculatePositionValueForDayCountinPx(renderInfo.viewParams, offsetStart);
}
getRightmostPosition(renderInfo: RenderInfo): number {
const wp = renderInfo.workPackage;
let start = moment(wp.startDate as any);
start = _.isNaN(start.valueOf()) ? moment(wp.dueDate).clone() : start;
let due = moment(wp.dueDate as any);
due = _.isNaN(due.valueOf()) ? start.clone() : due;
const offsetStart = start.diff(renderInfo.viewParams.dateDisplayStart, "days");
const duration = due.diff(start, "days") + 1;
return calculatePositionValueForDayCountinPx(renderInfo.viewParams, offsetStart + duration);
}
/**
* Render the generic cell element, a bar spanning from
* start to due date.
*/
public render(renderInfo: RenderInfo): HTMLDivElement {
const bar = document.createElement("div");
const left = document.createElement("div");
const right = document.createElement("div");
bar.className = timelineElementCssClass + " " + this.type;
left.className = classNameLeftHandle;
right.className = classNameRightHandle;
bar.appendChild(left);
bar.appendChild(right);
return bar;
}
protected typeColor(wp: WorkPackageResourceInterface): string {
let type = wp.type && wp.type.state.getCurrentValue();
if (type && type.color) {
return type.color;
}
return this.fallbackColor;
}
protected assignDate(wp: WorkPackageResourceInterface, attributeName: string, value: moment.Moment) {
if (value) {
wp[attributeName] = value.format("YYYY-MM-DD") as any;
}
}
/**
* Force the cursor to the given cursor type.
*/
protected forceCursor(cursor: string) {
jQuery(".hascontextmenu").css("cursor", cursor);
jQuery("." + timelineElementCssClass).css("cursor", cursor);
}
private updateLeftRightMovedLabel(start: Moment, due: Moment) {
if (this.dateDisplaysOnMouseMove.left && start) {
this.dateDisplaysOnMouseMove.left.innerText = start.format("L");
}
if (this.dateDisplaysOnMouseMove.right && due) {
this.dateDisplaysOnMouseMove.right.innerText = due.format("L");
}
}
}

@ -0,0 +1,172 @@
import {WorkPackageResourceInterface} from "../../../api/api-v3/hal-resources/work-package-resource.service";
import {TimelineCellRenderer} from "./timeline-cell-renderer";
import {
RenderInfo,
calculatePositionValueForDayCount,
timelineElementCssClass,
calculatePositionValueForDayCountinPx
} from "../wp-timeline";
import * as moment from "moment";
import Moment = moment.Moment;
interface CellMilestoneMovement {
// Target value to move milestone to
date?: moment.Moment;
}
export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
public get type(): string {
return 'milestone';
}
public get fallbackColor(): string {
return '#C0392B';
}
public isEmpty(wp: WorkPackageResourceInterface) {
const date = moment(wp.date as any);
const noDateValue = _.isNaN(date.valueOf());
return noDateValue;
}
public displayPlaceholderUnderCursor(ev: MouseEvent, renderInfo: RenderInfo): HTMLElement {
const days = Math.floor(ev.offsetX / renderInfo.viewParams.pixelPerDay);
const placeholder = document.createElement("div");
placeholder.className = "timeline-element milestone";
placeholder.style.pointerEvents = "none";
placeholder.style.height = "1em";
placeholder.style.width = "1em";
placeholder.style.left = (days * renderInfo.viewParams.pixelPerDay) + "px";
const diamond = document.createElement("div");
diamond.className = "diamond";
diamond.style.backgroundColor = "#DDDDDD";
diamond.style.left = "0.5em";
diamond.style.height = "1em";
diamond.style.width = "1em";
placeholder.appendChild(diamond);
return placeholder;
}
/**
* Assign changed dates to the work package.
* For generic work packages, assigns start and due date.
*
*/
public assignDateValues(wp: WorkPackageResourceInterface, dates: CellMilestoneMovement) {
this.assignDate(wp, 'date', dates.date!);
this.updateMilestoneMovedLabel(dates.date!);
}
/**
* Restore the original date, if any was set.
*/
public onCancel(wp: WorkPackageResourceInterface) {
wp.restoreFromPristine('date');
}
/**
* Handle movement by <delta> days of milestone.
*/
public onDaysMoved(wp: WorkPackageResourceInterface,
dayUnderCursor: Moment,
delta: number,
direction: "left" | "right" | "both" | "create" | "dragright") {
const initialDate = wp.$pristine['date'];
let dates: CellMilestoneMovement = {};
if (initialDate) {
dates.date = moment(initialDate).add(delta, "days");
}
return dates;
}
public onMouseDown(ev: MouseEvent,
dateForCreate: string|null,
renderInfo: RenderInfo,
elem: HTMLElement): "left" | "right" | "both" | "create" | "dragright" {
let direction: "left" | "right" | "both" | "create" | "dragright" = "both";
renderInfo.workPackage.storePristine('date');
this.forceCursor('ew-resize');
if (dateForCreate) {
renderInfo.workPackage.date = dateForCreate;
direction = "create";
return direction;
}
// create date label
const dateInfo = document.createElement("div");
dateInfo.className = "rightDateDisplay";
this.dateDisplaysOnMouseMove.right = dateInfo;
elem.appendChild(dateInfo);
this.updateMilestoneMovedLabel(moment(renderInfo.workPackage.date));
return direction;
}
public update(timelineCell: HTMLElement, element: HTMLDivElement, renderInfo: RenderInfo): boolean {
const wp = renderInfo.workPackage;
const viewParams = renderInfo.viewParams;
const date = moment(wp.date as any);
// abort if no start or due date
if (!wp.date) {
return false;
}
const diamond = jQuery(".diamond", element)[0];
element.style.marginLeft = viewParams.scrollOffsetInPx + "px";
element.style.width = '1em';
element.style.height = '1em';
diamond.style.width = '1em';
diamond.style.height = '1em';
diamond.style.backgroundColor = this.typeColor(wp);
// offset left
const offsetStart = date.diff(viewParams.dateDisplayStart, "days");
element.style.left = 'calc(0.5em + ' + calculatePositionValueForDayCount(viewParams, offsetStart) + ')';
return true;
}
getLeftmostPosition(renderInfo: RenderInfo): number {
const wp = renderInfo.workPackage;
let start = moment(wp.date as any);
const offsetStart = start.diff(renderInfo.viewParams.dateDisplayStart, "days");
return calculatePositionValueForDayCountinPx(renderInfo.viewParams, offsetStart) + (renderInfo.viewParams.pixelPerDay / 4);
}
getRightmostPosition(renderInfo: RenderInfo): number {
return this.getLeftmostPosition(renderInfo);
}
/**
* Render a milestone element, a single day event with no resize, but
* move functionality.
*/
public render(renderInfo: RenderInfo): HTMLDivElement {
const element = document.createElement("div");
element.className = timelineElementCssClass + " " + this.type;
const diamond = document.createElement("div");
diamond.className = "diamond";
element.appendChild(diamond);
return element;
}
private updateMilestoneMovedLabel(date: Moment) {
this.dateDisplaysOnMouseMove.right!.innerText = date.format("L");
}
}

@ -0,0 +1,83 @@
// -- 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 {openprojectModule} from "../../../../angular-modules";
import {WorkPackageTimelineTableController} from '../wp-timeline-container.directive';
import {ZoomLevel} from '../wp-timeline';
import IDirective = angular.IDirective;
import IScope = angular.IScope;
class WorkPackageTimelineControlController {
private wpTimeline: WorkPackageTimelineTableController;
hscroll: number;
currentZoom: number;
minZoomLevel = ZoomLevel.DAYS;
maxZoomLevel = ZoomLevel.YEARS;
text:any;
static $inject = ['I18n'];
constructor(private I18n:op.I18n) {
this.text = {
zoomIn: I18n.t('js.timelines.zoom.in'),
zoomOut: I18n.t('js.timelines.zoom.out'),
}
}
$onInit() {
this.hscroll = this.wpTimeline.viewParameterSettings.scrollOffsetInDays;
this.currentZoom = ZoomLevel.DAYS;
}
updateScroll() {
this.wpTimeline.viewParameterSettings.scrollOffsetInDays = this.hscroll;
this.wpTimeline.refreshScrollOnly();
}
updateZoom(delta:number) {
this.currentZoom += delta;
this.wpTimeline.viewParameterSettings.zoomLevel = this.currentZoom;
this.wpTimeline.refreshView();
}
}
openprojectModule.component("timelineControl", {
templateUrl: '/components/wp-table/timeline/controls/wp-timeline.dummy-controls.directive.html',
controller: WorkPackageTimelineControlController,
require: {
wpTimeline: '^wpTimelineContainer'
}
});

@ -0,0 +1,14 @@
<div class="wp-timeline--dummy-controls hide-when-print" ng-if="$ctrl.wpTimeline.visible">
<div class="timeline-controls--zoom-buttons">
<button class="button -transparent"
ng-disabled="$ctrl.currentZoom == $ctrl.maxZoomLevel"
ng-click="$ctrl.updateZoom(1)">
<i class="button--icon icon-zoom-out"></i>
</button>
<button class="button -transparent"
ng-disabled="$ctrl.currentZoom == $ctrl.minZoomLevel"
ng-click="$ctrl.updateZoom(-1)">
<i class="button--icon icon-zoom-in"></i>
</button>
</div>
</div

@ -0,0 +1,226 @@
// -- 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 {timelineElementCssClass, RenderInfo} from "./wp-timeline";
import {WorkPackageCacheService} from "../../work-packages/work-package-cache.service";
import {WorkPackageTimelineTableController} from "./wp-timeline-container.directive";
import {TimelineCellRenderer} from "./cell-renderer/timeline-cell-renderer";
import {WorkPackageResourceInterface} from "../../api/api-v3/hal-resources/work-package-resource.service";
import {keyCodes} from "../../common/keyCodes.enum";
import IScope = angular.IScope;
import * as moment from 'moment';
import Moment = moment.Moment;
const classNameBar = "bar";
export const classNameLeftHandle = "leftHandle";
export const classNameRightHandle = "rightHandle";
function getCursorOffsetInDaysFromLeft(renderInfo: RenderInfo, ev: MouseEvent) {
const header = renderInfo.viewParams.timelineHeader;
const headerLeft = header.getAbsoluteLeftCoordinates();
const cursorOffsetLeftInPx = ev.clientX - headerLeft;
const cursorOffsetLeftInDays = Math.floor(cursorOffsetLeftInPx / renderInfo.viewParams.pixelPerDay);
return cursorOffsetLeftInDays;
}
export function registerWorkPackageMouseHandler(this: void,
getRenderInfo: () => RenderInfo,
workPackageTimeline: WorkPackageTimelineTableController,
wpCacheService: WorkPackageCacheService,
cell: HTMLElement,
bar: HTMLDivElement,
renderer: TimelineCellRenderer,
renderInfo: RenderInfo) {
let mouseDownStartDay: number|null = null;// also flag to signal active drag'n'drop
let dateStates:any;
let placeholderForEmptyCell: HTMLElement;
const jBody = jQuery("body");
// handles change to existing work packages
bar.onmousedown = (ev: MouseEvent) => {
workPackageMouseDownFn(ev);
};
// handles initial creation of start/due values
cell.onmousemove = handleMouseMoveOnEmptyCell;
function applyDateValues(dates:{[name:string]: Moment}) {
const wp = renderInfo.workPackage;
// Let the renderer decide which fields we change
renderer.assignDateValues(wp, dates);
// Update the work package to refresh dates columns
wpCacheService.updateWorkPackage(wp);
}
function workPackageMouseDownFn(ev: MouseEvent) {
ev.preventDefault();
workPackageTimeline.disableViewParamsCalculation = true;
mouseDownStartDay = getCursorOffsetInDaysFromLeft(renderInfo, ev);
// Determine what attributes of the work package should be changed
const direction = renderer.onMouseDown(ev, null, renderInfo, bar);
jBody.on("mousemove", createMouseMoveFn(direction));
jBody.on("keyup", keyPressFn);
jBody.on("mouseup", () => deactivate(false));
}
function createMouseMoveFn(direction: "left" | "right" | "both" | "create" | "dragright") {
return (ev: JQueryEventObject) => {
const mev: MouseEvent = ev as any;
const days = getCursorOffsetInDaysFromLeft(renderInfo, mev) - mouseDownStartDay;
const offsetDayCurrent = Math.floor(ev.offsetX / renderInfo.viewParams.pixelPerDay);
const dayUnderCursor = renderInfo.viewParams.dateDisplayStart.clone().add(offsetDayCurrent, "days");
dateStates = renderer.onDaysMoved(renderInfo.workPackage, dayUnderCursor, days, direction);
applyDateValues(dateStates);
}
}
function keyPressFn(ev: JQueryEventObject) {
const kev: KeyboardEvent = ev as any;
if (kev.keyCode === keyCodes.ESCAPE) {
deactivate(true);
}
}
function handleMouseMoveOnEmptyCell(ev: MouseEvent) {
// const renderInfo = getRenderInfo();
const wp = renderInfo.workPackage;
if (!renderer.isEmpty(wp)) {
return;
}
// placeholder logic
placeholderForEmptyCell && placeholderForEmptyCell.remove();
placeholderForEmptyCell = renderer.displayPlaceholderUnderCursor(ev, renderInfo);
cell.appendChild(placeholderForEmptyCell);
// abort if mouse leaves cell
cell.onmouseleave = () => {
placeholderForEmptyCell.remove();
};
// create logic
cell.onmousedown = (ev) => {
placeholderForEmptyCell.remove();
bar.style.pointerEvents = "none";
ev.preventDefault();
const offsetDayStart = Math.floor(ev.offsetX / renderInfo.viewParams.pixelPerDay);
const clickStart = renderInfo.viewParams.dateDisplayStart.clone().add(offsetDayStart, "days");
const dateForCreate = clickStart.format("YYYY-MM-DD");
const mouseDownType = renderer.onMouseDown(ev, dateForCreate, renderInfo, bar);
renderer.update(cell, bar, renderInfo);
if (mouseDownType === "create") {
deactivate(false);
ev.preventDefault();
return;
}
cell.onmousemove = (ev) => {
const offsetDayCurrent = Math.floor(ev.offsetX / renderInfo.viewParams.pixelPerDay);
const dayUnderCursor = renderInfo.viewParams.dateDisplayStart.clone().add(offsetDayCurrent, "days");
const widthInDays = offsetDayCurrent - offsetDayStart;
const moved = renderer.onDaysMoved(wp, dayUnderCursor, widthInDays, mouseDownType);
renderer.assignDateValues(wp, moved);
wpCacheService.updateWorkPackage(wp);
};
cell.onmouseleave = () => {
deactivate(true);
};
cell.onmouseup = () => {
deactivate(false);
};
jBody.on("keyup", keyPressFn);
};
}
function deactivate(cancelled: boolean) {
workPackageTimeline.disableViewParamsCalculation = false;
cell.onmousemove = handleMouseMoveOnEmptyCell;
cell.onmousedown = _.noop;
cell.onmouseleave = _.noop;
cell.onmouseup = _.noop;
bar.style.pointerEvents = "auto";
jBody.off("mouseup");
jBody.off("mousemove");
jBody.off("keyup");
jQuery(".hascontextmenu").css("cursor", "context-menu");
jQuery("." + timelineElementCssClass).css("cursor", '');
jQuery("." + classNameLeftHandle).css("cursor", "w-resize");
jQuery("." + classNameBar).css("cursor", "ew-resize");
jQuery("." + classNameRightHandle).css("cursor", "e-resize");
mouseDownStartDay = null;
dateStates = {};
renderer.onMouseDownEnd();
// const renderInfo = getRenderInfo();
const wp = renderInfo.workPackage;
if (cancelled) {
// cancelled
renderer.onCancel(wp);
wpCacheService.updateWorkPackage(wp);
workPackageTimeline.refreshView();
} else {
// Persist the changes
saveWorkPackage(wp);
}
}
function saveWorkPackage(workPackage: WorkPackageResourceInterface) {
wpCacheService.saveIfChanged(workPackage)
.catch(() => {
if (!workPackage.isNew) {
// Reset the changes on error
renderer.onCancel(workPackage);
}
})
.finally(() => {
workPackageTimeline.refreshView();
});
}
}

@ -0,0 +1,171 @@
// -- 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 {States} from "../../states.service";
import {RenderInfo} from "./wp-timeline";
import {WorkPackageTimelineTableController} from "./wp-timeline-container.directive";
import {WorkPackageCacheService} from "../../work-packages/work-package-cache.service";
import {registerWorkPackageMouseHandler} from "./wp-timeline-cell-mouse-handler";
import {TimelineMilestoneCellRenderer} from "./cell-renderer/timeline-milestone-cell-renderer";
import {TimelineCellRenderer} from "./cell-renderer/timeline-cell-renderer";
import {Subscription} from "rxjs";
import {WorkPackageResourceInterface} from "../../api/api-v3/hal-resources/work-package-resource.service";
import IScope = angular.IScope;
import * as moment from 'moment';
import Moment = moment.Moment;
import {Observable} from 'rxjs';
const renderers = {
milestone: new TimelineMilestoneCellRenderer(),
generic: new TimelineCellRenderer()
};
export class WorkPackageTimelineCell {
private subscription: Subscription;
public latestRenderInfo: RenderInfo;
private wpElement: HTMLDivElement|null = null;
private elementShape: string;
constructor(private workPackageTimeline: WorkPackageTimelineTableController,
private wpCacheService: WorkPackageCacheService,
private states: States,
private workPackageId: string,
public timelineCell: HTMLElement) {
}
activate() {
Observable.combineLatest(
this.workPackageTimeline.addWorkPackage(this.workPackageId),
this.states.table.timelineVisible.observeUntil(this.states.table.stopAllSubscriptions)
).subscribe((state:[any, boolean]) => {
const renderInfo = state[0];
if (state[1]) {
this.updateView(renderInfo);
this.workPackageTimeline.globalService.updateWorkPackageInfo(this);
}
});
}
deactivate() {
this.clear();
this.workPackageTimeline.globalService.removeWorkPackageInfo(this.workPackageId);
this.subscription && this.subscription.unsubscribe();
}
getLeftmostPosition(): number {
const renderer = this.cellRenderer(this.latestRenderInfo.workPackage);
return renderer.getLeftmostPosition(this.latestRenderInfo);
}
getRightmostPosition(): number {
const renderer = this.cellRenderer(this.latestRenderInfo.workPackage);
return renderer.getRightmostPosition(this.latestRenderInfo);
}
private clear() {
this.timelineCell.innerHTML = "";
this.wpElement = null;
}
private lazyInit(renderer: TimelineCellRenderer, renderInfo: RenderInfo) {
const wasRendered = this.wpElement !== null && this.wpElement.parentNode;
// If already rendered with correct shape, ignore
if (wasRendered && (this.elementShape === renderer.type)) {
return;
}
// Remove the element first if we're redrawing
if (wasRendered) {
this.clear();
}
// Render the given element
this.wpElement = renderer.render(renderInfo);
this.elementShape = renderer.type;
// Register the element
this.timelineCell.appendChild(this.wpElement);
registerWorkPackageMouseHandler(
() => this.latestRenderInfo,
this.workPackageTimeline,
this.wpCacheService,
this.timelineCell,
this.wpElement,
renderer,
renderInfo);
//-------------------------------------------------
// TODO Naive horizontal scroll logic, for testing purpose only
jQuery(this.timelineCell).on("wheel", ev => {
const mwe = ev.originalEvent as MouseWheelEvent;
// horizontal scroll
// if (Math.abs(mwe.deltaY) < 20) {
mwe.preventDefault();
const scrollInDays = -Math.round(mwe.deltaX / 15);
this.workPackageTimeline.wpTimelineHeader.addScrollDelta(scrollInDays);
// }
// forward vertical scroll
const s = jQuery(".generic-table--results-container");
window.requestAnimationFrame(() => {
s.scrollTop(s.scrollTop() + mwe.deltaY);
// s.stop().animate({scrollTop: s.scrollTop() + mwe.deltaY}, 200);
});
});
//-------------------------------------------------
}
private cellRenderer(workPackage:WorkPackageResourceInterface): TimelineCellRenderer {
if (workPackage.isMilestone) {
return renderers.milestone;
}
return renderers.generic;
}
private updateView(renderInfo: RenderInfo) {
this.latestRenderInfo = renderInfo;
const renderer = this.cellRenderer(renderInfo.workPackage);
// Render initial element if necessary
this.lazyInit(renderer, renderInfo);
// Render the upgrade from renderInfo
const shouldBeDisplayed = renderer.update(this.timelineCell, this.wpElement as HTMLDivElement, renderInfo);
if (!shouldBeDisplayed) {
this.clear();
}
}
}

@ -0,0 +1,215 @@
// -- 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 {openprojectModule} from "../../../angular-modules";
import {TimelineViewParameters, RenderInfo, timelineElementCssClass} from "./wp-timeline";
import {WorkPackageResourceInterface} from "./../../api/api-v3/hal-resources/work-package-resource.service";
import {HalRequestService} from '../../api/api-v3/hal-request/hal-request.service';
import {WpTimelineHeader} from "./wp-timeline.header";
import {States} from "./../../states.service";
import {BehaviorSubject, Observable} from "rxjs";
import * as moment from 'moment';
import Moment = moment.Moment;
import IDirective = angular.IDirective;
import IScope = angular.IScope;
import { WpTimelineGlobalService } from "./wp-timeline-global.directive";
import { opDimensionEventName } from "../../common/ui/detect-dimension-changes.directive";
export class WorkPackageTimelineTableController {
private _viewParameters: TimelineViewParameters = new TimelineViewParameters();
private workPackagesInView: {[id: string]: WorkPackageResourceInterface} = {};
public wpTimelineHeader: WpTimelineHeader;
public readonly globalService = new WpTimelineGlobalService(this.$scope, this.states, this.halRequest);
private updateAllWorkPackagesSubject = new BehaviorSubject<boolean>(true);
private refreshViewRequested = false;
public visible = false;
public disableViewParamsCalculation = false;
constructor(private $scope: IScope,
private $element: ng.IAugmentedJQuery,
private TypeResource:any,
private states: States,
private halRequest: HalRequestService) {
"ngInject";
this.wpTimelineHeader = new WpTimelineHeader(this);
$element.on(opDimensionEventName, () => {
this.refreshView();
});
// TODO: Load only necessary types from API
TypeResource.loadAll();
}
/**
* Toggle whether this instance is currently showing.
*/
public toggle() {
this.visible = !this.visible;
this.states.table.timelineVisible.put(this.visible);
// If hiding view, resize table afterwards
if (!this.visible) {
jQuery(window).trigger('resize');
}
}
/**
* Returns a defensive copy of the currently used view parameters.
*/
getViewParametersCopy(): TimelineViewParameters {
return _.cloneDeep(this._viewParameters);
}
get viewParameterSettings() {
return this._viewParameters.settings;
}
refreshView() {
if (!this.refreshViewRequested) {
setTimeout(() => {
this.calculateViewParams(this._viewParameters);
this.updateAllWorkPackagesSubject.next(true);
this.wpTimelineHeader.refreshView(this._viewParameters);
this.refreshScrollOnly();
this.refreshViewRequested = false;
}, 30);
}
this.refreshViewRequested = true;
}
refreshScrollOnly() {
jQuery("." + timelineElementCssClass).css("margin-left", this._viewParameters.scrollOffsetInPx + "px");
}
addWorkPackage(wpId: string): Observable<RenderInfo> {
const wpObs = this.states.workPackages.get(wpId).observeOnScope(this.$scope)
.map((wp: any) => {
this.workPackagesInView[wp.id] = wp;
const viewParamsChanged = this.calculateViewParams(this._viewParameters);
if (viewParamsChanged) {
// view params have changed, notify all cells
this.globalService.updateViewParameter(this._viewParameters);
this.refreshView();
}
return {
viewParams: this._viewParameters,
workPackage: wp
};
});
return Observable.combineLatest(
wpObs,
this.updateAllWorkPackagesSubject,
(renderInfo: RenderInfo, forceUpdate: boolean) => {
return renderInfo;
}
);
}
private calculateViewParams(currentParams: TimelineViewParameters): boolean {
if (this.disableViewParamsCalculation) {
return false;
}
const newParams = new TimelineViewParameters();
let changed = false;
// Calculate view parameters
for (const wpId in this.workPackagesInView) {
const workPackage = this.workPackagesInView[wpId];
const startDate = workPackage.startDate ? moment(workPackage.startDate) : currentParams.now;
const dueDate = workPackage.dueDate ? moment(workPackage.dueDate) : currentParams.now;
const date = workPackage.date ? moment(workPackage.date) : currentParams.now;
// start date
newParams.dateDisplayStart = moment.min(
newParams.dateDisplayStart,
currentParams.now,
startDate,
date);
// due date
newParams.dateDisplayEnd = moment.max(
newParams.dateDisplayEnd,
currentParams.now,
dueDate,
date);
}
// left spacing
newParams.dateDisplayStart.subtract(3, "days");
// right spacing
const headerWidth = this.wpTimelineHeader.getHeaderWidth();
const pixelPerDay = currentParams.pixelPerDay;
const visibleDays = Math.ceil((headerWidth / pixelPerDay) * 1.5);
newParams.dateDisplayEnd.add(visibleDays, "days");
// Check if view params changed:
// start date
if (!newParams.dateDisplayStart.isSame(this._viewParameters.dateDisplayStart)) {
changed = true;
this._viewParameters.dateDisplayStart = newParams.dateDisplayStart;
}
// end date
if (!newParams.dateDisplayEnd.isSame(this._viewParameters.dateDisplayEnd)) {
changed = true;
this._viewParameters.dateDisplayEnd = newParams.dateDisplayEnd;
}
this._viewParameters.timelineHeader = this.wpTimelineHeader;
return changed;
}
}
function wpTimelineContainer() {
return {
restrict: 'A',
controller: WorkPackageTimelineTableController,
bindToController: true
};
}
openprojectModule.directive('wpTimelineContainer', wpTimelineContainer);

@ -0,0 +1,214 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2017 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 IDirective = angular.IDirective;
import IComponentOptions = angular.IComponentOptions;
import {timelineElementCssClass, TimelineViewParameters} from './wp-timeline';
import {WorkPackageTimelineCell} from './wp-timeline-cell';
import {States} from '../../states.service';
import {HalRequestService} from '../../api/api-v3/hal-request/hal-request.service';
import {RelationResource} from '../../api/api-v3/hal-resources/relation-resource.service';
import {CollectionResource} from '../../api/api-v3/hal-resources/collection-resource.service';
import {debugLog} from '../../../helpers/debug_output';
import {WorkPackageResource} from '../../api/api-v3/hal-resources/work-package-resource.service';
import IScope = angular.IScope;
export const timelineGlobalElementCssClassname = 'timeline-global-element';
function newSegment(vp: TimelineViewParameters,
classId: string,
color: string,
top: number,
left: number,
width: number,
height: number): HTMLElement {
const segment = document.createElement('div');
segment.classList.add(timelineElementCssClass, timelineGlobalElementCssClassname, classId);
segment.style.position = 'absolute';
segment.style.cssFloat = 'left';
segment.style.backgroundColor = 'blue';
// segment.style.backgroundColor = color;
segment.style.marginLeft = vp.scrollOffsetInPx + 'px';
segment.style.top = top + 'px';
segment.style.left = left + 'px';
segment.style.width = width + 'px';
segment.style.height = height + 'px';
return segment;
}
export class TimelineGlobalElement {
private static nextId = 0;
classId = 'timeline-global-element-id-' + TimelineGlobalElement.nextId++;
from: string;
to: string;
}
export class WpTimelineGlobalService {
private workPackageIdOrder: string[] = [];
private viewParameters: TimelineViewParameters;
private cells: {[id: string]: WorkPackageTimelineCell} = {};
private elements: TimelineGlobalElement[] = [];
constructor(scope: IScope, states: States, halRequest: HalRequestService) {
states.table.rows.observeOnScope(scope)
.subscribe(rows => {
this.workPackageIdOrder = rows.map(wp => wp.id.toString());
halRequest.get(
'/api/v3/relations',
{
filter: [{ involved: {operator: '=', values: this.workPackageIdOrder } }]
}).then((collection: CollectionResource) => {
this.elements = [];
this.removeAllElements();
collection.elements.forEach((relation: RelationResource) => {
const fromId = WorkPackageResource.idFromLink(relation.from.href!);
const toId = WorkPackageResource.idFromLink(relation.to.href!);
this.displayRelation(fromId, toId);
});
this.renderElements();
});
});
}
updateViewParameter(viewParams: TimelineViewParameters) {
this.viewParameters = viewParams;
this.update();
}
updateWorkPackageInfo(cell: WorkPackageTimelineCell) {
this.cells[cell.latestRenderInfo.workPackage.id] = cell;
this.update();
}
removeWorkPackageInfo(id: string) {
delete this.cells[id];
this.update();
}
displayRelation(from: string, to: string) {
const elem = new TimelineGlobalElement();
elem.from = from;
elem.to = to;
this.elements.push(elem);
this.update();
}
private update() {
this.removeAllElements();
this.renderElements();
}
private removeAllElements() {
jQuery('.' + timelineGlobalElementCssClassname).remove();
}
private renderElements() {
if (this.viewParameters === undefined) {
debugLog('renderElements() aborted - no viewParameters');
return;
}
const vp = this.viewParameters;
for (let e of this.elements) {
jQuery('.' + e.classId).remove();
const idxFrom = this.workPackageIdOrder.indexOf(e.from);
const idxTo = this.workPackageIdOrder.indexOf(e.to);
const startCell = this.cells[e.from];
const endCell = this.cells[e.to];
if (idxFrom === -1 || idxTo === -1 || _.isNil(startCell) || _.isNil(endCell)) {
continue;
}
const directionY = idxFrom < idxTo ? 1 : -1;
let lastX = startCell.getRightmostPosition();
let targetX = endCell.getLeftmostPosition();
const directionX = targetX > lastX ? 1 : -1;
// start
if (!startCell) {
continue;
}
startCell.timelineCell.appendChild(newSegment(vp, e.classId, 'green', 19, lastX, 10, 1));
lastX += 10;
if (directionY === 1) {
startCell.timelineCell.appendChild(newSegment(vp, e.classId, 'red', 19, lastX, 1, 22));
} else {
startCell.timelineCell.appendChild(newSegment(vp, e.classId, 'red', -1, lastX, 1, 22));
}
// vert segment
for (let index = idxFrom + directionY; index !== idxTo; index += directionY) {
const id = this.workPackageIdOrder[index];
const cell = this.cells[id];
if (_.isNil(cell)) {
continue;
}
cell.timelineCell.appendChild(newSegment(vp, e.classId, 'blue', 0, lastX, 1, 42));
}
// end
if (directionX === 1) {
if (directionY === 1) {
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'green', 0, lastX, 1, 19));
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'blue', 19, lastX, targetX - lastX, 1));
} else {
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'green', 19, lastX, 1, 22));
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'blue', 19, lastX, targetX - lastX, 1));
}
} else {
if (directionY === 1) {
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'green', 0, lastX, 1, 8));
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'blue', 8, targetX - 10, lastX - targetX + 11, 1));
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'green', 8, targetX - 10, 1, 11));
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'red', 19, targetX - 10, 10, 1));
} else {
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'green', 32, lastX, 1, 8));
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'blue', 32, targetX - 10, lastX - targetX + 11, 1));
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'green', 19, targetX - 10, 1, 13));
endCell.timelineCell.appendChild(newSegment(vp, e.classId, 'red', 19, targetX - 10, 10, 1));
}
}
}
}
}

@ -0,0 +1,446 @@
// -- 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 {
TimelineViewParameters,
timelineElementCssClass,
ZoomLevel,
calculatePositionValueForDayCount
} from "./wp-timeline";
import {todayLine} from "./wp-timeline.today-line";
import {WorkPackageTimelineTableController} from "./wp-timeline-container.directive";
import * as noUiSlider from "nouislider";
import * as moment from 'moment';
import Moment = moment.Moment;
const cssClassTableBody = ".work-package-table";
const cssClassTableContainer = ".generic-table--results-container";
const cssClassHeader = ".wp-timeline-header";
const cssHeaderContainer = ".wp-timeline-header-container";
const scrollBarHeight = 16;
const colorGrey1 = "#AAAAAA";
const colorGrey2 = "#DDDDDD";
export type GlobalElement = (viewParams: TimelineViewParameters, elem: HTMLElement) => any;
type GlobalElementsRegistry = {[name: string]: GlobalElement};
export class WpTimelineHeader {
private globalElementsRegistry: GlobalElementsRegistry = {};
private globalElements: {[type: string]: HTMLElement} = {};
private headerCell: HTMLElement;
private outerHeader: JQuery;
/** UI Scrollbar */
private scrollBar: HTMLElement;
private scrollWrapper: JQuery;
private scrollBarHandle: JQuery;
private scrollBarOrigin: JQuery;
private marginTop: number;
/** Height of the header elements */
private headerHeight = 45;
/** Height of the table body + table header */
private globalHeight: number;
/** The total outer height available to the wrapping container */
private containerHeight: number;
private activeZoomLevel: ZoomLevel;
constructor(protected wpTimeline: WorkPackageTimelineTableController) {
this.addElement("todayline", todayLine);
}
refreshView(vp: TimelineViewParameters) {
this.lazyInit();
this.renderLabels(vp);
this.renderGlobalElements(vp);
this.updateScrollbar(vp);
}
getHeaderWidth() {
return this.outerHeader ? this.outerHeader.width() : 1;
}
getAbsoluteLeftCoordinates(): number {
return jQuery(this.headerCell).offset().left;
}
addElement(name: string, renderer: GlobalElement) {
this.globalElementsRegistry[name] = renderer;
}
removeElement(name: string) {
this.globalElements[name].remove();
delete this.globalElementsRegistry[name];
}
setupScrollbar() {
this.scrollWrapper = jQuery('.wp-timeline--slider-wrapper');
this.scrollBar = this.scrollWrapper.find('.wp-timeline--slider')[0];
noUiSlider.create(this.scrollBar, {
start: [0],
range: {
min: [0],
max: [100]
},
orientation: 'horizontal',
tooltips: false,
});
this.sliderInstance.on('update', (values: any[]) => {
let value = values[0];
this.wpTimeline.viewParameterSettings.scrollOffsetInDays = -value;
this.wpTimeline.refreshScrollOnly();
});
this.scrollBarHandle = this.scrollWrapper.find('.noUi-handle');
this.scrollBarOrigin = this.scrollWrapper.find('.noUi-origin');
}
public addScrollDelta(delta:number) {
const value = (this.wpTimeline.viewParameterSettings.scrollOffsetInDays += delta);
this.sliderInstance.set(-value);
this.wpTimeline.refreshScrollOnly();
}
// noUiSlider doesn't extend the HTMLElement interface
// and thus requires casting for now.
private get sliderInstance(): noUiSlider.noUiSlider {
return (this.scrollBar as noUiSlider.Instance).noUiSlider;
}
private updateScrollbar(vp: TimelineViewParameters) {
const headerWidth = this.getHeaderWidth();
// Update the scrollbar to match the current width
this.scrollWrapper.css('width', headerWidth + 'px');
// Re-position the scrollbar depending on the global height
// It should not be any larger than the container height
if (this.containerHeight > (this.globalHeight + scrollBarHeight)) {
this.scrollWrapper.css('top', this.globalHeight + 'px');
} else {
this.scrollWrapper.css('top', (this.containerHeight - scrollBarHeight) + 'px');
}
let maxWidth = headerWidth,
daysDisplayed = Math.min(vp.maxSteps, Math.floor(maxWidth / vp.pixelPerDay)),
newMax = Math.max(vp.maxSteps - daysDisplayed, 1),
currentValue = <number> this.sliderInstance.get(),
newValue = Math.min(newMax, currentValue),
desiredWidth, newWidth, cssWidth;
// Compute the actual width of the handle depending on the scrollable content
// The width should be no smaller than 30px
desiredWidth = Math.max(vp.maxSteps / vp.pixelPerDay, (40 - vp.pixelPerDay)) * 2;
// The actual width should be no larger than the actual width of the scrollbar
// If the entirety of the timeline is already visible, hide the scrollbar
if (newMax === 1) {
newWidth = maxWidth;
this.scrollWrapper.hide();
} else {
this.scrollWrapper.show();
newWidth = Math.min(maxWidth, desiredWidth);
}
let newCssWidth = newWidth + 'px';
// With changed widths, we need to correct the
// - right padding of the scrollbar (avoid right boundary traversal)
// - width of the actual handle
// - offset for origin of the slider.
jQuery(this.scrollBar).css('padding-right', (newWidth - 1) + 'px');
this.scrollBarHandle.css('width', newCssWidth);
this.scrollBarOrigin.css('right', '-' + newCssWidth);
(this.sliderInstance as any).updateOptions({
start: newValue,
range: {
min: 0,
max: newMax
}
});
}
private lazyInit() {
if (this.headerCell === undefined) {
this.headerCell = jQuery(cssClassHeader)[0];
this.outerHeader = jQuery(cssHeaderContainer);
this.setupScrollbar();
}
this.containerHeight = jQuery(cssClassTableContainer).outerHeight() + this.headerHeight;
this.globalHeight = jQuery(cssClassTableBody).outerHeight() + this.headerHeight;
this.marginTop = this.headerHeight;
this.headerCell.style.height = this.globalHeight + 'px';
}
private renderLabels(vp: TimelineViewParameters) {
if (this.activeZoomLevel === vp.settings.zoomLevel) {
return;
}
jQuery(this.headerCell).empty();
this.globalElements = {};
this.lazyInit();
this.renderGlobalElements(vp);
switch (vp.settings.zoomLevel) {
case ZoomLevel.DAYS:
return this.renderLabelsDays(vp);
case ZoomLevel.WEEKS:
return this.renderLabelsWeeks(vp);
case ZoomLevel.MONTHS:
return this.renderLabelsMonths(vp);
case ZoomLevel.QUARTERS:
return this.renderLabelsQuarters(vp);
case ZoomLevel.YEARS:
return this.renderLabelsYears(vp);
}
this.activeZoomLevel = vp.settings.zoomLevel;
}
private renderLabelsDays(vp: TimelineViewParameters) {
this.renderTimeSlices(vp, "month", 0, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = start.format("MMM YYYY");
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.style.fontWeight = "bold";
cell.style.fontSize = "10px";
cell.style.height = "13px";
});
this.renderTimeSlices(vp, "week", 13, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = start.format("ww");
cell.style.borderColor = `${colorGrey1}`;
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.style.height = (this.globalHeight - 10) + "px";
cell.style.zIndex = "2";
});
this.renderTimeSlices(vp, "day", 23, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = start.format("D");
cell.style.borderColor = `${colorGrey2}`;
cell.style.zIndex = "1";
cell.style.height = (this.globalHeight - 20) + "px";
cell.style.borderTop = `1px solid ${colorGrey1}`;
});
this.renderTimeSlices(vp, "day", 33, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = start.format("dd");
cell.style.height = "15px";
cell.style.paddingTop = "1px";
});
}
private renderLabelsWeeks(vp: TimelineViewParameters) {
this.renderTimeSlices(vp, "month", 0, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.innerHTML = start.format("MMM YYYY");
cell.style.fontWeight = "bold";
cell.style.fontSize = "12px";
cell.style.height = "15px";
});
this.renderTimeSlices(vp, "week", 15, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = start.format("ww");
cell.style.borderColor = `${colorGrey1}`;
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.style.height = (this.globalHeight - 10) + "px";
cell.style.zIndex = "2";
});
this.renderTimeSlices(vp, "day", 25, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = start.format("D");
cell.style.borderColor = `${colorGrey1}`;
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.style.paddingTop = "5px";
cell.style.height = "20px";
});
}
private renderLabelsMonths(vp: TimelineViewParameters) {
this.renderTimeSlices(vp, "year", 0, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.innerHTML = start.format("YYYY");
cell.style.fontWeight = "bold";
cell.style.fontSize = "12px";
cell.style.height = "15px";
});
this.renderTimeSlices(vp, "month", 15, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = start.format("MMM");
cell.style.borderColor = `${colorGrey2}`;
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.style.height = (this.globalHeight - 10) + "px";
});
this.renderTimeSlices(vp, "week", 25, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = start.format("ww");
cell.style.borderColor = `${colorGrey1}`;
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.style.height = "25px";
cell.style.backgroundColor = "white";
cell.style.paddingTop = "5px";
cell.style.height = "20px";
});
}
private renderLabelsQuarters(vp: TimelineViewParameters) {
this.renderTimeSlices(vp, "year", 0, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.innerHTML = start.format("YYYY");
cell.style.fontWeight = "bold";
cell.style.fontSize = "12px";
cell.style.height = "15px";
});
this.renderTimeSlices(vp, "quarter", 15, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = "Q" + start.format("Q");
cell.style.borderColor = `${colorGrey2}`;
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.style.height = (this.globalHeight - 10) + "px";
});
this.renderTimeSlices(vp, "month", 25, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = start.format("MMM");
cell.style.height = "25px";
cell.style.borderColor = `${colorGrey2}`;
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.style.backgroundColor = "white";
cell.style.paddingTop = "5px";
cell.style.height = "20px";
});
}
private renderLabelsYears(vp: TimelineViewParameters) {
this.renderTimeSlices(vp, "year", 0, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = start.format("YYYY");
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.style.borderColor = `${colorGrey1}`;
cell.style.backgroundColor = "white";
cell.style.fontWeight = "bold";
cell.style.fontSize = "12px";
cell.style.height = "15px";
});
this.renderTimeSlices(vp, "quarter", 15, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = "Q" + start.format("Q");
cell.style.borderColor = `${colorGrey2}`;
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.style.height = (this.globalHeight - 10) + "px";
});
this.renderTimeSlices(vp, "month", 25, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = start.format("M");
cell.style.borderColor = `${colorGrey2}`;
cell.style.borderTop = `1px solid ${colorGrey1}`;
cell.style.height = "25px";
cell.style.backgroundColor = "white";
cell.style.paddingTop = "5px";
cell.style.height = "20px";
});
}
renderTimeSlices(vp: TimelineViewParameters,
unit: moment.unitOfTime.DurationConstructor,
marginTop: number,
startView: Moment,
endView: Moment,
cellCallback: (start: Moment, cell: HTMLElement) => void) {
const slices: [Moment, Moment][] = [];
const time = startView.clone().startOf(unit);
const end = endView.clone().endOf(unit);
while (time.isBefore(end)) {
const sliceStart = moment.max(time, startView).clone();
const sliceEnd = moment.min(time.clone().endOf(unit), endView).clone();
time.add(1, unit);
slices.push([sliceStart, sliceEnd]);
}
for (let [start, end] of slices) {
const cell = this.addLabelCell();
cell.style.borderRight = "1px solid black";
cell.style.top = marginTop + "px";
cell.style.left = calculatePositionValueForDayCount(vp, start.diff(startView, "days"));
cell.style.width = calculatePositionValueForDayCount(vp, end.diff(start, "days") + 1);
cell.style.textAlign = "center";
cell.style.fontSize = "8px";
cellCallback(start, cell);
}
}
private addLabelCell(): HTMLElement {
const label = document.createElement("div");
label.className = timelineElementCssClass;
label.style.position = "absolute";
label.style.height = "10px";
label.style.width = "10px";
label.style.top = "0px";
label.style.left = "0px";
label.style.lineHeight = "normal";
this.headerCell.appendChild(label);
return label;
}
private renderGlobalElements(vp: TimelineViewParameters) {
const enabledGlobalElements = _.keys(this.globalElementsRegistry);
const createdGlobalElements = _.keys(this.globalElements);
const newGlobalElements = _.difference(enabledGlobalElements, createdGlobalElements);
// new elements
for (const newElem of newGlobalElements) {
const elem = document.createElement("div");
elem.className = timelineElementCssClass + " wp-timeline-global-element-" + newElem;
elem.style.position = "absolute";
elem.style.top = this.marginTop + "px";
elem.style.zIndex = "100";
this.headerCell.appendChild(elem);
this.globalElements[newElem] = elem;
}
// update elements
for (const elemType of _.keys(this.globalElements)) {
const elem = this.globalElements[elemType];
elem.style.height = this.globalHeight + "px";
this.globalElementsRegistry[elemType](vp, elem);
}
}
}

@ -0,0 +1,42 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {calculatePositionValueForDayCount, TimelineViewParameters} from "./wp-timeline";
import * as moment from 'moment';
// Today Line
export function todayLine(viewParams: TimelineViewParameters, elem: HTMLElement) {
elem.style.width = "2px";
elem.style.borderLeft = "2px dotted red";
const offsetToday = viewParams.now.diff(viewParams.dateDisplayStart, "days");
const dayProgress = moment().hour() / 24;
elem.style.left = calculatePositionValueForDayCount(viewParams, offsetToday + dayProgress);
elem.style.marginLeft = viewParams.scrollOffsetInPx + "px";
}

@ -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 * as moment from "moment";
import {WorkPackageResourceInterface} from "../../api/api-v3/hal-resources/work-package-resource.service";
import {WpTimelineHeader} from "./wp-timeline.header";
import Moment = moment.Moment;
export const timelineElementCssClass = "timeline-element";
/**
*
*/
export class TimelineViewParametersSettings {
// showDurationInPx = true;
scrollOffsetInDays = 0;
zoomLevel: ZoomLevel = ZoomLevel.DAYS;
}
export enum ZoomLevel {
DAYS, WEEKS, MONTHS, QUARTERS, YEARS
}
/**
*
*/
export class TimelineViewParameters {
readonly now: Moment = moment({hour: 0, minute: 0, seconds: 0});
dateDisplayStart: Moment = moment({hour: 0, minute: 0, seconds: 0});
dateDisplayEnd: Moment = this.dateDisplayStart.clone().add(1, "day");
settings: TimelineViewParametersSettings = new TimelineViewParametersSettings();
timelineHeader: WpTimelineHeader;
get pixelPerDay() {
switch (this.settings.zoomLevel) {
case ZoomLevel.DAYS:
return 30;
case ZoomLevel.WEEKS:
return 15;
case ZoomLevel.MONTHS:
return 6;
case ZoomLevel.QUARTERS:
return 2;
case ZoomLevel.YEARS:
return 0.5;
}
}
get maxWidthInPx() {
return this.maxSteps * this.pixelPerDay;
}
get maxSteps():number {
return this.dateDisplayEnd.diff(this.dateDisplayStart, "days");
}
get scrollOffsetInPx() {
return this.settings.scrollOffsetInDays * this.pixelPerDay;
}
}
/**
*
*/
export interface RenderInfo {
viewParams: TimelineViewParameters;
workPackage: WorkPackageResourceInterface;
}
/**
*
*/
export function calculatePositionValueForDayCountinPx(viewParams: TimelineViewParameters, days: number): number {
const daysInPx = days * viewParams.pixelPerDay;
return daysInPx;
}
/**
*
*/
export function calculatePositionValueForDayCount(viewParams: TimelineViewParameters, days: number): string {
const value = calculatePositionValueForDayCountinPx(viewParams, days);
// if (viewParams.settings.showDurationInPx) {
return value + "px";
// } else {
// return (value / viewParams.maxWidthInPx * 100) + "%";
// }
}

@ -1,10 +1,10 @@
<div class="generic-table--container work-package-table--container"
ng-class="{ '-with-footer': displaySums }">
<div class="generic-table--results-container">
ng-class="{ '-with-footer': displaySums }">
<div class="generic-table--results-container" detect-dimension-changes>
<table class="keyboard-accessible-list generic-table work-package-table">
<colgroup>
<col highlight-col />
<col highlight-col ng-repeat="column in columns" />
<col highlight-col/>
<col highlight-col ng-repeat="column in columns"/>
</colgroup>
<caption class="hidden-for-sighted">
<span ng-bind="::text.tableSummary"></span>
@ -26,7 +26,7 @@
query="query"
ng-class="column.name == 'id' && '-short' ">
</th>
<th class="wp-table--details-column -short">
<th class="wp-table--details-column -short hide-when-print">
<div class="generic-table--sort-header-outer">
<accessible-by-keyboard
execute="openColumnsModal()"
@ -35,23 +35,41 @@
</accessible-by-keyboard>
</div>
</th>
<th class="wp-timeline--th" ng-show="wpTimelineContainer.visible">
<div class="wp-timeline--slider-wrapper">
<div class="wp-timeline--slider"></div>
</div>
<div class="wp-timeline-header-controls generic-table--sort-header-outer">
<timeline-control></timeline-control>
</div>
<div class="wp-timeline-header-container generic-table--sort-header-outer">
<div class="wp-timeline--scroll-wrapper">
<span class="generic-table--sort-header wp-timeline-header">
</span>
</div>
</div>
</th>
</tr>
</thead>
<tbody class="work-package--empty-tbody" ng-if="query.hasError || rowcount === 0">
<tr id="empty-row-notification">
<td colspan="{{ columns.length + 1 }}">
<span ng-if="!query.hasError">
<i class="icon-info1 icon-context"></i>
<strong ng-bind="text.noResults.title"></strong>
<span ng-bind="text.noResults.description"></span>
</span>
<span ng-if="query.hasError">
<i class="wp-table--faulty-query-icon icon-warning icon-context"></i>
<strong ng-bind="text.faultyQuery.title"></strong>
<span ng-bind="text.faultyQuery.description"></span>
</span>
</td>
</tr>
<tr id="empty-row-notification">
<td colspan="{{ columns.length + 1 }}">
<span ng-if="!query.hasError">
<i class="icon-info1 icon-context"></i>
<strong ng-bind="text.noResults.title"></strong>
<span ng-bind="text.noResults.description"></span>
</span>
<span ng-if="query.hasError">
<i class="wp-table--faulty-query-icon icon-warning icon-context"></i>
<strong ng-bind="text.faultyQuery.title"></strong>
<span ng-bind="text.faultyQuery.description"></span>
</span>
</td>
</tr>
</tbody>
<tbody class="results-tbody work-package--results-tbody">
</tbody>
@ -61,19 +79,19 @@
query="query">
<tbody>
<tfoot>
<tr ng-if="sumsLoaded()"
class="sum group all issue work_package">
<td ng-repeat="column in columns">
<div class="generic-table--footer-outer">
<span ng-if="$first">{{ text.sumFor }} {{ text.allWorkPackages }}</span>
<wp-display-attr
attribute="column.name"
custom-schema="resource.sumsSchema"
work-package="resource.totalSums">
</wp-display-attr>
</div>
</td>
</tr>
<tr ng-if="sumsLoaded()"
class="sum group all issue work_package">
<td ng-repeat="column in columns">
<div class="generic-table--footer-outer">
<span ng-if="$first">{{ text.sumFor }} {{ text.allWorkPackages }}</span>
<wp-display-attr
attribute="column.name"
custom-schema="resource.sumsSchema"
work-package="resource.totalSums">
</wp-display-attr>
</div>
</td>
</tr>
</tfoot>
</table>
<div class="generic-table--footer-background" ng-if="sumsLoaded()"></div>

@ -28,6 +28,7 @@
import {scopedObservable} from "../../helpers/angular-rx-utils";
import {KeepTabService} from "../wp-panels/keep-tab/keep-tab.service";
import {WorkPackageTimelineTableController} from './timeline/wp-timeline-container.directive';
import * as MouseTrap from "mousetrap";
import {States} from './../states.service';
import {WorkPackageCacheService} from '../work-packages/work-package-cache.service';
@ -64,6 +65,7 @@ function wpTable(
return {
restrict: 'E',
replace: true,
require: '^wpTimelineContainer',
templateUrl: '/components/wp-table/wp-table.directive.html',
scope: {
projectIdentifier: '=',
@ -80,9 +82,17 @@ function wpTable(
controller: WorkPackagesTableController,
link: function(scope:any, element:ng.IAugmentedJQuery) {
link: function(scope:any,
element:ng.IAugmentedJQuery,
attributes:ng.IAttributes,
wpTimelineContainer:WorkPackageTimelineTableController) {
var activeSelectionBorderIndex;
scope.wpTimelineContainer = wpTimelineContainer;
states.timeline.put(wpTimelineContainer);
states.table.timelineVisible.put(wpTimelineContainer.visible);
// Total columns = all available columns + id + action link
// Clear any old table subscribers
states.table.stopAllSubscriptions.next();

@ -83,4 +83,6 @@ require('ui-select/dist/select.min.css');
require('ng-dialog/js/ngDialog.min.js');
require('ng-dialog/css/ngDialog.min.css');
require('nouislider/distribute/nouislider.min.css');
require('URIjs/src/URITemplate');

@ -29,3 +29,14 @@ export function scopedObservable<T>(scope: IScope, observable: Observable<T>): O
});
}
export function asyncTest<T>(done: (error?: any) => void, fn: (value: T) => any): (T:any) => any {
return (value: T) => {
try {
fn(value);
done();
} catch (err) {
done(err);
}
}
}

@ -6,7 +6,7 @@
collision-container="#content"
locals="selectedTitle,groups,transitionMethod">
<accessible-by-keyboard link-title="{{ I18n.t('js.toolbar.search_query_title') }}" >
<i class="icon-pulldown icon-button icon-small">
<i class="icon-pulldown icon-button icon-small hide-when-print">
</i>
{{ selectedTitle | characters:50 }}
</accessible-by-keyboard>

@ -1 +1,12 @@
/// <reference path="../tests/typings/tests.d.ts" />
/*
monkey patch noUiSlider
*/
declare namespace noUiSlider {
//noinspection JSUnusedGlobalSymbols
interface Options {
tooltips: boolean;
}
}

@ -61,29 +61,6 @@ declare namespace api {
raw:string;
html:string;
}
interface WorkPackage {
id:number|string;
lockVersion:number;
subject:string;
description:Formattable;
parentId:number;
startDate:Date;
dueDate:Date;
estimatedTime:Duration;
spentTime:Duration;
percentageDone:number;
createdAt:Date;
updatedAt:Date;
}
interface Project {
}
interface Query {
}
}
/**
@ -211,16 +188,6 @@ declare namespace op {
name?:string;
}
interface WorkPackageLinks {
schema:FieldSchema;
}
interface WorkPackage extends api.v3.WorkPackage, WorkPackageLinks {
[attribute:string]:any;
getForm():any;
save():any;
}
interface QueryParams {
offset?:number;
pageSize?:number;

File diff suppressed because it is too large Load Diff

@ -39,6 +39,7 @@
"@types/mocha": "^2.2.39",
"@types/mousetrap": "^1.5.33",
"@types/ng-dialog": "^0.6.0",
"@types/nouislider": "^9.0.0",
"@types/promises-a-plus": "0.0.27",
"@types/rosie": "0.0.30",
"@types/sinon": "^1.16.35",
@ -91,6 +92,7 @@
"ng-dialog": "^0.6.4",
"ng-file-upload": "~5.0.9",
"ngtemplate-loader": "^0.1.2",
"nouislider": "^9.0.0",
"observable-array": "0.0.4",
"phantomjs-polyfill": "0.0.2",
"postcss-loader": "^1.2.2",

@ -90,8 +90,8 @@ module OpenProject
'main-menu-item-border-width' => "1px",
'main-menu-enable-toggle-highlighting' => "false",
'main-menu-bg-color' => "#F8F8F8",
'main-menu-bg-selected-background' => "url() no-repeat $primary-color",
'main-menu-bg-hover-background' => "url() no-repeat $primary-color-dark",
'main-menu-bg-selected-background' => "$primary-color",
'main-menu-bg-hover-background' => "$primary-color-dark",
'main-menu-font-color' => "#333333",
'main-menu-selected-font-color' => "$font-color-on-primary",
'main-menu-font-size' => "15px",
@ -202,7 +202,11 @@ module OpenProject
'button--highlight-background-color' => "$primary-color",
'button--highlight-background-hover-color' => "$primary-color-dark",
'button--alt-highlight-font-color' => "$font-color-on-alternative",
'button--text-icon-spacing' => "0.65em"
'button--text-icon-spacing' => "0.65em",
'generic-table--font-size' => '0.875rem',
'generic-table--header-font-size' => '0.875rem',
'generic-table--header-height' => '45px',
'generic-table--footer-height' => '34px'
}.freeze
##

Loading…
Cancel
Save