Merge pull request #5867 from opf/feature/25999/timeline-labels

[25999] Make timeline labels configurable

[ci skip]
pull/5642/head
Oliver Günther 7 years ago committed by GitHub
commit 695a8cc3fd
  1. 1
      app/assets/stylesheets/content/work_packages/timelines/_timelines.sass
  2. 49
      app/assets/stylesheets/content/work_packages/timelines/elements/_bar.sass
  3. 83
      app/assets/stylesheets/content/work_packages/timelines/elements/_labels.sass
  4. 37
      app/assets/stylesheets/content/work_packages/timelines/elements/_milestone.sass
  5. 1
      app/contracts/queries/base_contract.rb
  6. 10
      app/models/query/timelines.rb
  7. 1
      app/services/update_query_from_params_service.rb
  8. 2
      config/boot.rb
  9. 1
      config/locales/en.yml
  10. 11
      config/locales/js-en.yml
  11. 5
      db/migrate/20170818063404_add_timeline_labels_to_query.rb
  12. 12
      docs/api/apiv3/endpoints/queries.apib
  13. 8
      frontend/app/components/api/api-v3/hal-resources/query-resource.service.ts
  14. 4
      frontend/app/components/api/api-v3/hal-resources/schema-resource.service.ts
  15. 9
      frontend/app/components/context-menus/settings-menu/settings-menu.controller.ts
  16. 9
      frontend/app/components/context-menus/settings-menu/settings-menu.service.html
  17. 75
      frontend/app/components/modals/timelines-modal/timelines-modal.controller.ts
  18. 45
      frontend/app/components/modals/timelines-modal/timelines-modal.service.html
  19. 40
      frontend/app/components/modals/timelines-modal/timelines-modal.service.ts
  20. 19
      frontend/app/components/wp-edit-form/display-field-renderer.ts
  21. 6
      frontend/app/components/wp-fast-table/state/wp-table-columns.service.ts
  22. 42
      frontend/app/components/wp-fast-table/state/wp-table-timeline.service.ts
  23. 32
      frontend/app/components/wp-fast-table/wp-table-timeline.ts
  24. 7
      frontend/app/components/wp-query/url-params-helper.ts
  25. 125
      frontend/app/components/wp-table/timeline/cells/timeline-cell-renderer.ts
  26. 88
      frontend/app/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts
  27. 12
      frontend/app/components/wp-table/timeline/cells/wp-timeline-cell-mouse-handler.ts
  28. 16
      frontend/app/components/wp-table/timeline/cells/wp-timeline-cell.ts
  29. 31
      frontend/app/work_packages/helpers/work-packages-helper.ts
  30. 3
      frontend/tests/unit/tests/work_packages/controllers/menus/options-dropdown-menu-controller-test.js
  31. 2
      lib/api/v3/queries/query_representer.rb
  32. 7
      lib/api/v3/queries/schemas/query_schema_representer.rb
  33. 179
      spec/features/work_packages/timeline/timeline_labels_spec.rb
  34. 19
      spec/lib/api/v3/queries/query_representer_generation_spec.rb
  35. 14
      spec/lib/api/v3/queries/schemas/query_schema_representer_spec.rb
  36. 11
      spec/models/query_spec.rb
  37. 66
      spec/support/components/timelines/configuration_modal.rb
  38. 76
      spec/support/components/timelines/timeline_row.rb
  39. 6
      spec/support/pages/work_packages_timeline.rb

@ -3,6 +3,7 @@
@import 'elements/_hover'
@import 'elements/_milestone'
@import 'elements/_relation'
@import 'elements/_labels'
@import '_timelines_header'
@import '_timelines_static_elements'

@ -30,55 +30,6 @@
max-width: 20%
height: 100%
.labelLeft
pointer-events: none
//display: inline-block
display: none
padding: 2px 5px
top: 0
white-space: nowrap
position: absolute
left: -85px
width: 75px
background-color: white
border: 1px solid #d4d4d4
border-radius: 5px
height: 16px
font-size: 12px
.containerRight
pointer-events: none
display: inline-block
position: absolute
margin: 0 0 0 0
padding: 0 0 0 0
top: -2px
left: 100%
white-space: nowrap
height: 16px
.labelRight.not-empty
pointer-events: none
//display: inline-block
display: none
background-color: white
border: 1px solid #d4d4d4
border-radius: 5px
padding: 2px 5px 2px 5px
margin-left: 10px
font-size: 12px
height: 16px
.labelFarRight
pointer-events: none
height: 16px
display: inline-block
white-space: nowrap
font-style: italic
margin-left: 5px
padding: 5px 5px 2px 5px
font-size: 13px
&.-readonly
cursor: not-allowed !important

@ -0,0 +1,83 @@
.timeline-element
// Label style
.-label-style.not-empty
background-color: white
border: 1px solid #d4d4d4
border-radius: 5px
height: 16px
font-size: 12px
padding: 2px 5px
.labelLeft.not-empty,
.labelHoverLeft.not-empty
pointer-events: none
white-space: nowrap
// Position container left of bar
position: absolute
left: 0px
// Then translate by its own width + some margin
transform: translateX(calc(-100% - 10px))
font-size: 12px
// Add some top padding to the text (NOT the hover labels!)
.labelLeft
top: 3px
.containerRight
pointer-events: none
display: inline-block
position: absolute
margin: 0 0 0 0
padding: 0 0 0 0
top: -2px
left: 100%
white-space: nowrap
line-height: 10px
font-size: 12px
height: 16px
.labelRight.not-empty
display: inline-block
pointer-events: none
margin-left: 20px
font-size: 12px
.labelFarRight
height: 16px
display: inline-block
white-space: nowrap
font-style: italic
margin-left: 15px
padding: 5px 5px 2px 5px
font-size: 13px
// label hover right needs different position
// since its not part of containerRight
.labelHoverRight
pointer-events: none
display: none
top: 0
white-space: nowrap
// Position container right of bar
position: absolute
right: 0px
// Then translate by its own width + some margin
transform: translateX(calc(100% + 10px))
font-size: 12px
&.-editable
cursor: ew-resize
.show-on-hover
display: none
// Hide or show elements on hover
.wp-timeline-cell.row-hovered
.show-on-hover
display: inline-block
.hide-on-hover
display: none

@ -7,42 +7,13 @@
transform: rotate(45deg)
transform-origin: center center
.containerRight
pointer-events: none
display: inline-block
position: absolute
margin: 0 0 0 0
padding: 0 0 0 0
top: -2px
left: 100%
white-space: nowrap
line-height: 10px
height: 16px
.labelRight.not-empty
pointer-events: none
display: none
//display: inline-block
background-color: white
border: 1px solid #d4d4d4
border-radius: 5px
padding: 2px 5px 2px 5px
margin-left: 20px
margin-right: -15px
font-size: 12px
.labelFarRight
height: 16px
display: inline-block
white-space: nowrap
font-style: italic
margin-left: 15px
padding: 5px 5px 2px 5px
font-size: 13px
&.-editable
cursor: ew-resize
// Add more margin to hover right
.labelHoverRight
transform: translateX(calc(100% + 20px))
.active-selection-mode
.timeline-element.milestone

@ -39,6 +39,7 @@ module Queries
attribute :display_sums # => sums
attribute :timeline_visible
attribute :timeline_zoom_level
attribute :timeline_labels
attribute :show_hierarchies
attribute :column_names # => columns

@ -33,5 +33,15 @@ module Query::Timelines
included do
enum timeline_zoom_level: %i(days weeks months quarters years)
validates :timeline_zoom_level, inclusion: { in: timeline_zoom_levels.keys }
serialize :timeline_labels, Hash
validate :valid_timeline_labels
def valid_timeline_labels
return unless timeline_labels.present?
valid_keys = %w(farRight left right) == timeline_labels.keys.map(&:to_s).sort
errors.add :timeline_labels, :invalid unless valid_keys
end
end
end

@ -87,6 +87,7 @@ class UpdateQueryFromParamsService
def apply_timeline(params)
query.timeline_visible = params[:timeline_visible] if params.key?(:timeline_visible)
query.timeline_zoom_level = params[:timeline_zoom_level] if params.key?(:timeline_zoom_level)
query.timeline_labels = params[:timeline_labels] if params.key?(:timeline_labels)
end
def apply_hierarchy(params)

@ -40,7 +40,7 @@ require 'bundler/setup' # Set up gems listed in the Gemfile.
# Rails is not yet loaded here
if ENV['RAILS_ENV'] == 'development'
$stderr.puts "Starting with bootstnap."
$stderr.puts "Starting with bootsnap."
require 'bootsnap'

@ -368,6 +368,7 @@ en:
relations_of_type_column: "%{type} relations"
group_by: "Group results by"
filters: "Filters"
timeline_labels: "Timeline labels"
repository:
url: "URL"
role:

@ -343,6 +343,17 @@ en:
text_query_destroy_confirmation: "Are you sure you want to delete the selected query?"
text_attachment_destroy_confirmation: "Are you sure you want to delete the attachment?"
timelines:
gantt_chart: 'Gantt chart'
labels:
bar: 'Bar labels'
left: 'Left'
right: 'Right'
farRight: 'Far right'
showNone: '-- No label --'
description: >
Select the attributes you want to be shown in the respective positions of the gantt chart at all times.
Note that when hovering an element, its date labels will be shown instead of these attributes.
button_activate: 'Activate timeline mode'
button_deactivate: 'Deactivate timeline mode'
cancel: Cancel

@ -0,0 +1,5 @@
class AddTimelineLabelsToQuery < ActiveRecord::Migration[5.0]
def change
add_column :queries, :timeline_labels, :text
end
end

@ -35,6 +35,7 @@ Please note, that all the properties listed above will also be embedded when ind
| filters | A set of QueryFilters which will be applied to the work packages to determine the resulting work packages | []QueryFilterInstance | | READ |
| sums | Should sums (of supported properties) be shown? | Boolean | | READ |
| timelineVisible | Should the timeline mode be shown? | Boolean | | READ |
| timelineLabels | Which labels are shown in the timeline, empty when default | QueryTimelineLabels | | READ |
| timelineZoomLevel| Which zoom level should the timeline be rendered in? | String | days, weeks, months, quarters, years | READ |
| showHierarchies | Should the hierarchy mode be enabled? | Boolean | | READ |
| public | Can users besides the owner see the query? | Boolean | | READ |
@ -102,7 +103,7 @@ If the values are nonprimitive (e.g. User, Project), they will be listed as obje
```
## Query [/api/v3/queries/{id}{?offset,pageSize,filters,sortBy,groupBy,showSums,timelineVisible,timelineZoomLevel,showHierarchies}]
## Query [/api/v3/queries/{id}{?offset,pageSize,filters,sortBy,groupBy,showSums,timelineVisible,timelineLabels,timelineZoomLevel,showHierarchies}]
+ Model
+ Body
@ -261,6 +262,7 @@ Retreive an individual query as identified by the id parameter. Then end point a
+ groupBy (optional, string, `status`) ... The column to group by. The grouping criteria is applied to the to the querie's result collection of work packages overriding the query's persisted group criteria.
+ showSums = `false` (optional, boolean, `true`) ... Indicates whether properties should be summed up if they support it. The showSums parameter is applied to the to the querie's result collection of work packages overriding the query's persisted sums property.
+ timelineVisible = `false` (optional, boolean, `true`) ... Indicates whether the timeline should be shown.
+ timelineLabels = `{}` (optional: object, `{}`) ... Overridden labels in the timeline view
+ showHierarchies = `true` (optional, boolean, `true`) ... Indicates whether the hierarchy mode should be enabled.
+ Response 200 (application/hal+json)
@ -435,6 +437,7 @@ Delete the query identified by the id parameter
"sums": false,
"timelineVisible": false,
"timelineZoomLevel": "days",
"timelineLabels": {},
"showHierarchies": true,
"starred": false,
"_embedded": {
@ -1452,6 +1455,13 @@ For more details and all possible responses see the general specification of [Fo
"hasDefault": true,
"writable": true
},
"timelineLabels": {
"type": "QueryTimelineLabels",
"name": "Timeline labels",
"required": false,
"hasDefault": true,
"writable": true
},
"showHierarchies": {
"type": "Boolean",
"name": "Show hierarchies",

@ -46,6 +46,13 @@ interface QueryResourceEmbedded {
export type TimelineZoomLevel = 'days' | 'weeks' | 'months' | 'quarters' | 'years';
export interface TimelineLabels {
left:string|null;
right:string|null;
farRight:string|null;
}
export class QueryResource extends HalResource {
public $embedded:QueryResourceEmbedded;
public id:number;
@ -58,6 +65,7 @@ export class QueryResource extends HalResource {
public sums:boolean;
public timelineVisible:boolean;
public timelineZoomLevel:TimelineZoomLevel;
public timelineLabels:TimelineLabels;
public showHierarchies:boolean;
public public:boolean;
public project:ProjectResource;

@ -41,6 +41,10 @@ export class SchemaResource extends HalResource {
return states.schemas.get(this.href as string);
}
public get availableAttributes() {
return _.keys(this.$source).filter(name => name.indexOf('_') !== 0);
}
public $initialize(source:any) {
super.$initialize(source);

@ -35,6 +35,7 @@ import {QueryResource} from '../../api/api-v3/hal-resources/query-resource.servi
import {QueryFormResource} from '../../api/api-v3/hal-resources/query-form-resource.service';
import {States} from '../../states.service';
import {WorkPackageTableTimelineService} from '../../wp-fast-table/state/wp-table-timeline.service';
interface IMyScope extends ng.IScope {
displaySumsLabel:string;
@ -71,10 +72,12 @@ function SettingsDropdownMenuController($scope:IMyScope,
shareModal:any,
sortingModal:any,
groupingModal:any,
timelinesModal:any,
contextMenu:ContextMenuService,
wpTableHierarchies:WorkPackageTableHierarchiesService,
wpTableSum:WorkPackageTableSumService,
wpTableGroupBy:WorkPackageTableGroupByService,
wpTableTimeline:WorkPackageTableTimelineService,
wpListService:WorkPackagesListService,
states:States,
AuthorisationService:any,
@ -122,6 +125,7 @@ function SettingsDropdownMenuController($scope:IMyScope,
form = formUpdate;
$scope.timelinesVisible = wpTableTimeline.isVisible;
$scope.displayHierarchies = wpTableHierarchies.isEnabled;
$scope.displaySums = wpTableSum.isEnabled;
$scope.isGrouped = wpTableGroupBy.isEnabled;
@ -247,6 +251,11 @@ function SettingsDropdownMenuController($scope:IMyScope,
return AuthorisationService.cannot('query', 'updateImmediately');
};
$scope.showTimelinesModal = function (event:JQueryEventObject) {
event.stopPropagation();
showModal.call(timelinesModal);
};
function showModal(this:any) {
closeAnyContextMenu();
this.activate();

@ -97,5 +97,14 @@
{{ queryCustomFields.name }}
</a>
</li>
<li>
<a class="menu-item"
href=""
ng-disabled="!timelinesVisible"
ng-click="showTimelinesModal($event)">
<op-icon icon-classes="icon-action-menu icon-view-timeline"></op-icon>
{{ I18n.t('js.timelines.gantt_chart') }} ...</a>
</a>
</li>
</ul>
</div>

@ -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 {wpControllersModule} from '../../../angular-modules';
import {WorkPackageTableTimelineService} from '../../wp-fast-table/state/wp-table-timeline.service';
import {WorkPackageTableColumnsService} from '../../wp-fast-table/state/wp-table-columns.service';
function TimelinesModalController(this:any,
timelinesModal:any,
$scope:any,
wpTableTimeline:WorkPackageTableTimelineService,
wpTableColumns:WorkPackageTableColumnsService,
I18n:op.I18n) {
this.name = 'Timelines';
this.closeMe = timelinesModal.deactivate;
$scope.text = {
apply: I18n.t('js.modals.button_apply'),
cancel: I18n.t('js.modals.button_cancel'),
close: I18n.t('js.close_popup_title'),
title: I18n.t('js.timelines.gantt_chart'),
labels: {
description: I18n.t('js.timelines.labels.description'),
bar: I18n.t('js.timelines.labels.bar'),
none: I18n.t('js.timelines.filter.noneSelection'),
left: I18n.t('js.timelines.labels.left'),
right: I18n.t('js.timelines.labels.right'),
farRight: I18n.t('js.timelines.labels.farRight')
}
};
// Current label models
const labels = wpTableTimeline.labels;
$scope.labels = _.clone(labels);
// Available labels
const availableColumns = wpTableColumns
.allPropertyColumns
.sort((a, b) => a.name.localeCompare(b.name));
$scope.availableAttributes = [{ id: '', name: $scope.text.labels.none }].concat(availableColumns);
// Save
$scope.updateLabels = () => {
wpTableTimeline.updateLabels($scope.labels);
timelinesModal.deactivate();
};
}
wpControllersModule.controller('TimelinesModalController', TimelinesModalController);

@ -0,0 +1,45 @@
<div class="ng-modal-window">
<div class="ng-modal-inner modal-content">
<div class="modal-header">
<a><op-icon icon-classes="icon-close" ng-click="$ctrl.closeMe()" title="{{ ::text.close }}"></op-icon></a></div>
<h3>{{ ::text.title }}</h3>
<form name="modalTimelinesForm">
<div id="modal-timelines"
class="modal-content-container">
<p ng-bind="::text.labels.description"></p>
<section class="form--section">
<div class="form--row" ng-repeat="(key, value) in labels">
<div class="form--field">
<label
for="modal-timelines-label-{{key}}"
class="form--label">
{{ text.labels[key] }}
</label>
<div class="form--field-container">
<div class="form--select-container">
<select
id="modal-timelines-label-{{key}}"
ng-model="labels[key]"
focus="$first"
class="form--select"
ng-options="c.id as c.name for c in availableAttributes">
</select>
</div>
</div>
</div>
</div>
</section>
</div>
<button class="button -highlight"
ng-bind="::text.apply"
ng-click="updateLabels()">
</button>
<button class="button"
ng-bind="::text.cancel"
ng-click="$ctrl.closeMe()">
</button>
</form>
</div>
</div>

@ -0,0 +1,40 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
import {wpControllersModule} from '../../../angular-modules';
function timelinesModalService(btfModal:any) {
return btfModal({
controller: 'TimelinesModalController',
controllerAs: '$ctrl',
afterFocusOn: '#work-packages-settings-button',
templateUrl: '/components/modals/timelines-modal/timelines-modal.service.html'
});
}
wpControllersModule.factory('timelinesModal', timelinesModalService);

@ -24,6 +24,18 @@ export class DisplayFieldRenderer {
}
public render(workPackage:WorkPackageResourceInterface, name:string, placeholder = cellEmptyPlaceholder):HTMLSpanElement {
const [field, span] = this.renderFieldValue(workPackage, name, placeholder);
if (field === null) {
return span;
}
this.setSpanAttributes(span, field, name, workPackage);
return span;
}
public renderFieldValue(workPackage:WorkPackageResourceInterface, name:string, placeholder = cellEmptyPlaceholder):[DisplayField|null, HTMLSpanElement] {
const span = document.createElement('span');
const schemaName = workPackage.getSchemaName(name);
const fieldSchema = workPackage.schema[schemaName];
@ -31,18 +43,15 @@ export class DisplayFieldRenderer {
// If the work package does not have that field, return an empty
// span (e.g., for the table).
if (!fieldSchema) {
return span;
return [null, span];
}
const field = this.getField(workPackage, fieldSchema, schemaName);
this.setSpanAttributes(span, field, name, workPackage);
field.render(span, this.getText(field, placeholder));
span.setAttribute('title', this.getLabel(field, workPackage));
span.setAttribute('aria-label', this.getAriaLabel(field, workPackage));
return span;
return [field, span];
}
public getField(workPackage:WorkPackageResourceInterface, fieldSchema:op.FieldSchema, name:string):DisplayField {

@ -255,6 +255,12 @@ export class WorkPackageTableColumnsService extends WorkPackageTableBaseService
return this.availableState.getValueOr([]);
}
public get allPropertyColumns():QueryColumn[] {
return this
.all
.filter((column:QueryColumn) => column._type === queryColumnTypes.PROPERTY);
}
/**
* Get columns not yet selected
*/

@ -30,11 +30,12 @@ import {
TableStateStates, WorkPackageQueryStateService,
WorkPackageTableBaseService
} from "./wp-table-base.service";
import {QueryResource} from "../../api/api-v3/hal-resources/query-resource.service";
import {QueryResource, TimelineLabels} from "../../api/api-v3/hal-resources/query-resource.service";
import {opServicesModule} from "../../../angular-modules";
import {States} from "../../states.service";
import {WorkPackageTableTimelineState} from "./../wp-table-timeline";
import {zoomLevelOrder} from "../../wp-table/timeline/wp-timeline";
import {WorkPackageResourceInterface} from '../../api/api-v3/hal-resources/work-package-resource.service';
export class WorkPackageTableTimelineService extends WorkPackageTableBaseService implements WorkPackageQueryStateService {
protected stateName = 'timelineVisible' as TableStateStates;
@ -44,7 +45,7 @@ export class WorkPackageTableTimelineService extends WorkPackageTableBaseService
}
public initialize(query:QueryResource) {
let current = new WorkPackageTableTimelineState(query.timelineVisible, query.timelineZoomLevel);
let current = new WorkPackageTableTimelineState(query);
this.state.putValue(current);
}
@ -52,13 +53,15 @@ export class WorkPackageTableTimelineService extends WorkPackageTableBaseService
public hasChanged(query:QueryResource) {
const visibilityChanged = this.isVisible !== query.timelineVisible;
const zoomLevelChanged = this.zoomLevel !== query.timelineZoomLevel;
const labelsChanged = !_.isEqual(this.current.labels, query.timelineLabels);
return visibilityChanged || zoomLevelChanged;
return visibilityChanged || zoomLevelChanged || labelsChanged;
}
public applyToQuery(query:QueryResource) {
query.timelineVisible = this.isVisible;
query.timelineZoomLevel = this.zoomLevel;
query.timelineLabels = this.current.labels;
return false;
}
@ -79,7 +82,36 @@ export class WorkPackageTableTimelineService extends WorkPackageTableBaseService
return this.current.zoomLevel;
}
public updateZoom(delta: number) {
public get labels() {
if (_.isEmpty(this.current.labels)) {
return this.current.defaultLabels;
}
return this.current.labels;
}
public updateLabels(labels:TimelineLabels) {
let currentState = this.current;
currentState.labels = labels;
this.state.putValue(currentState);
}
public getNormalizedLabels(workPackage:WorkPackageResourceInterface) {
let labels:TimelineLabels = _.clone(this.current.defaultLabels);
_.each(this.current.labels, (attribute:string, position:keyof TimelineLabels) => {
// Set to null to explicitly disable
if (attribute === '') {
labels[position] = null;
} else {
labels[position] = attribute;
}
});
return labels;
}
public updateZoom(delta:number) {
let currentState = this.current;
let idx = zoomLevelOrder.indexOf(this.current.zoomLevel);
idx += delta;
@ -90,7 +122,7 @@ export class WorkPackageTableTimelineService extends WorkPackageTableBaseService
}
}
private get current():WorkPackageTableTimelineState {
public get current():WorkPackageTableTimelineState {
return this.state.value as WorkPackageTableTimelineState;
}

@ -26,26 +26,28 @@
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {WorkPackageTableBaseState} from "./wp-table-base";
import {TimelineZoomLevel} from '../api/api-v3/hal-resources/query-resource.service';
import {WorkPackageTableBaseState} from './wp-table-base';
import {
QueryResourceInterface, TimelineLabels,
TimelineZoomLevel
} from '../api/api-v3/hal-resources/query-resource.service';
export class WorkPackageTableTimelineState extends WorkPackageTableBaseState<boolean> {
constructor(public visible:boolean, public zoomLevel:TimelineZoomLevel) {
public visible:boolean;
public zoomLevel:TimelineZoomLevel;
public labels:TimelineLabels;
constructor(query:QueryResourceInterface) {
super();
this.visible = query.timelineVisible;
this.zoomLevel = query.timelineZoomLevel;
this.labels = query.timelineLabels;
}
public get current():boolean {
return this.isVisible;
}
public get extractedCompareValue():any {
return this.isVisible;
}
public get currentQueryValue () {
return this.isVisible;
}
public toggle() {
this.visible = !this.visible;
}
@ -53,4 +55,12 @@ export class WorkPackageTableTimelineState extends WorkPackageTableBaseState<boo
public get isVisible() {
return this.visible;
}
public get defaultLabels():TimelineLabels {
return {
left: '',
right: '',
farRight: 'subject'
};
}
}

@ -70,6 +70,10 @@ export class UrlParamsHelperService {
paramsData.tv = query.timelineVisible;
}
if (!_.isEmpty(query.timelineLabels)) {
paramsData.tll = JSON.stringify(query.timelineLabels);
}
paramsData.tzl = query.timelineZoomLevel;
paramsData.hi = !!query.showHierarchies;
@ -128,6 +132,9 @@ export class UrlParamsHelperService {
if (!!properties.tv) {
queryData.timelineVisible = properties.tv;
}
if (!!properties.tll) {
queryData.timelineLabels = JSON.stringify(properties.tll);
}
if (properties.tzl) {
queryData.timelineZoomLevel = properties.tzl;

@ -1,5 +1,5 @@
import * as moment from 'moment';
import {$injectNow} from '../../../angular/angular-injector-bridge.functions';
import {$injectFields, $injectNow} from '../../../angular/angular-injector-bridge.functions';
import {WorkPackageResourceInterface} from '../../../api/api-v3/hal-resources/work-package-resource.service';
import {
calculatePositionValueForDayCount,
@ -9,9 +9,9 @@ import {
timelineMarkerSelectionStartClass
} from '../wp-timeline';
import {
classNameFarRightLabel,
classNameFarRightLabel, classNameHideOnHover, classNameHoverStyle, classNameLeftHoverLabel,
classNameLeftLabel,
classNameRightContainer,
classNameRightContainer, classNameRightHoverLabel,
classNameRightLabel,
classNameShowOnHover,
WorkPackageCellLabels
@ -21,6 +21,10 @@ import Moment = moment.Moment;
import {WorkPackageTimelineTableController} from '../container/wp-timeline-container.directive';
import {hasChildrenInTable} from '../../../wp-fast-table/helpers/wp-table-hierarchy-helpers';
import {WorkPackageChangeset} from '../../../wp-edit-form/work-package-changeset';
import {WorkPackageTableTimelineService} from '../../../wp-fast-table/state/wp-table-timeline.service';
import WorkPackagesHelper = op.WorkPackagesHelper;
import {DisplayFieldRenderer} from '../../../wp-edit-form/display-field-renderer';
import {TimelineLabels} from '../../../api/api-v3/hal-resources/query-resource.service';
interface CellDateMovement {
// Target values to move work package to
@ -28,17 +32,21 @@ interface CellDateMovement {
dueDate?:moment.Moment;
}
export type LabelPosition = 'left' | 'right' | 'farRight';
function calculateForegroundColor(backgroundColor:string):string {
return 'red';
}
export class TimelineCellRenderer {
protected TimezoneService:any;
public TimezoneService:any;
public wpTableTimeline:WorkPackageTableTimelineService;
public fieldRenderer:DisplayFieldRenderer = new DisplayFieldRenderer('table');
protected dateDisplaysOnMouseMove:{ left?:HTMLElement; right?:HTMLElement } = {};
constructor(public workPackageTimeline:WorkPackageTimelineTableController) {
$injectFields(this, 'TimezoneService', 'wpTableTimeline', 'WorkPackagesHelper');
}
public get type():string {
@ -186,7 +194,7 @@ export class TimelineCellRenderer {
* @return true, if the element should still be displayed.
* false, if the element must be removed from the timeline.
*/
public update(bar:HTMLDivElement, renderInfo:RenderInfo):boolean {
public update(bar:HTMLDivElement, labels:WorkPackageCellLabels|null, renderInfo:RenderInfo):boolean {
const changeset = renderInfo.changeset;
// general settings - bar
@ -232,6 +240,11 @@ export class TimelineCellRenderer {
bar.style.minWidth = minWidth + 'px';
}
// Update labels if any
if (labels) {
this.updateLabels(false, labels, changeset);
}
this.checkForActiveSelectionMode(renderInfo, bar);
this.checkForSpecialDisplaySituations(renderInfo, bar);
@ -312,8 +325,7 @@ export class TimelineCellRenderer {
// create left label
const labelLeft = document.createElement('div');
labelLeft.classList.add(classNameLeftLabel);
labelLeft.classList.add(classNameShowOnHover);
labelLeft.classList.add(classNameLeftLabel, classNameHideOnHover);
element.appendChild(labelLeft);
// create right container
@ -323,17 +335,25 @@ export class TimelineCellRenderer {
// create right label
const labelRight = document.createElement('div');
labelRight.classList.add(classNameRightLabel);
labelRight.classList.add(classNameShowOnHover);
labelRight.classList.add(classNameRightLabel, classNameHideOnHover);
containerRight.appendChild(labelRight);
// create far right label
const labelFarRight = document.createElement('div');
labelFarRight.classList.add(classNameFarRightLabel);
labelFarRight.classList.add(classNameShowOnHover);
labelFarRight.classList.add(classNameFarRightLabel, classNameHideOnHover);
containerRight.appendChild(labelFarRight);
const labels = new WorkPackageCellLabels(labelCenter, labelLeft, labelRight, labelFarRight);
// create left hover label
const labelHoverLeft = document.createElement('div');
labelHoverLeft.classList.add(classNameLeftHoverLabel , classNameShowOnHover, classNameHoverStyle);
element.appendChild(labelHoverLeft);
// create right hover label
const labelHoverRight = document.createElement('div');
labelHoverRight.classList.add(classNameRightHoverLabel, classNameShowOnHover, classNameHoverStyle);
element.appendChild(labelHoverRight);
const labels = new WorkPackageCellLabels(labelCenter, labelLeft, labelHoverLeft, labelRight, labelHoverRight, labelFarRight);
this.updateLabels(false, labels, renderInfo.changeset);
return labels;
@ -384,53 +404,52 @@ export class TimelineCellRenderer {
labels:WorkPackageCellLabels,
changeset:WorkPackageChangeset) {
if (!this.TimezoneService) {
this.TimezoneService = $injectNow('TimezoneService');
}
let startStr = changeset.value('startDate');
let dueStr = changeset.value('dueDate');
const subject:string = changeset.value('subject');
const start:Moment | null = startStr ? moment(startStr) : null;
const due:Moment | null = dueStr ? moment(dueStr) : null;
const labelConfiguration = this.wpTableTimeline.getNormalizedLabels(changeset.workPackage);
if (!activeDragNDrop) {
// normal display
if (labels.labelLeft && start) {
labels.labelLeft.textContent = this.TimezoneService.formattedDate(start);
}
if (labels.labelRight && due) {
labels.labelRight.textContent = this.TimezoneService.formattedDate(due);
}
if (labels.labelFarRight) {
labels.labelFarRight.textContent = subject;
}
} else {
// active drag'n'drop
if (labels.labelLeft && start) {
labels.labelLeft.textContent = this.TimezoneService.formattedDate(start);
}
if (labels.labelRight && due) {
labels.labelRight.textContent = this.TimezoneService.formattedDate(due);
}
this.renderLabel(changeset, labels, 'left', labelConfiguration.left);
this.renderLabel(changeset, labels, 'right', labelConfiguration.right);
this.renderLabel(changeset, labels, 'farRight', labelConfiguration.farRight);
}
if (labels.labelLeft) {
if (_.isEmpty(labels.labelLeft.textContent)) {
labels.labelLeft.classList.remove('not-empty');
} else {
labels.labelLeft.classList.add('not-empty');
}
// Update hover labels
this.renderHoverLabels(labels, changeset);
}
protected renderHoverLabels(labels:WorkPackageCellLabels, changeset:WorkPackageChangeset) {
this.renderLabel(changeset, labels, 'leftHover', 'startDate');
this.renderLabel(changeset, labels, 'rightHover', 'dueDate');
}
protected renderLabel(changeset:WorkPackageChangeset,
labels:WorkPackageCellLabels,
position:LabelPosition|'leftHover'|'rightHover',
attribute:string|null) {
// Get the label position
// Skip label if it does not exist (milestones)
let label = labels[position];
if (!label) {
return;
}
if (labels.labelRight) {
if (_.isEmpty(labels.labelRight.textContent)) {
labels.labelRight.classList.remove('not-empty');
} else {
labels.labelRight.classList.add('not-empty');
}
// Reset label value
label.innerHTML = '';
if (attribute === null) {
label.classList.remove('not-empty');
return;
}
}
// Get the rendered field
let [field, span] = this.fieldRenderer.renderFieldValue(changeset.workPackage, attribute);
if (label && field && span) {
label.appendChild(span);
label.classList.add('not-empty');
} else if (label) {
label.classList.remove('not-empty');
}
}
}

@ -2,14 +2,16 @@ import * as moment from 'moment';
import {$injectNow} from '../../../angular/angular-injector-bridge.functions';
import {WorkPackageResourceInterface} from '../../../api/api-v3/hal-resources/work-package-resource.service';
import {calculatePositionValueForDayCountingPx, RenderInfo, timelineElementCssClass} from '../wp-timeline';
import {TimelineCellRenderer} from './timeline-cell-renderer';
import {LabelPosition, TimelineCellRenderer} from './timeline-cell-renderer';
import {
classNameFarRightLabel,
classNameLeftLabel, classNameRightContainer, classNameRightLabel, classNameShowOnHover,
classNameFarRightLabel, classNameHideOnHover, classNameHoverStyle,
classNameLeftLabel, classNameRightContainer, classNameRightHoverLabel, classNameRightLabel,
classNameShowOnHover,
WorkPackageCellLabels
} from './wp-timeline-cell';
import Moment = moment.Moment;
import {WorkPackageChangeset} from '../../../wp-edit-form/work-package-changeset';
import {TimelineLabels} from '../../../api/api-v3/hal-resources/query-resource.service';
interface CellMilestoneMovement {
// Target value to move milestone to
@ -106,7 +108,7 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
return direction;
}
public update(element:HTMLDivElement, renderInfo:RenderInfo): boolean {
public update(element:HTMLDivElement, labels:WorkPackageCellLabels|null, renderInfo:RenderInfo): boolean {
const viewParams = renderInfo.viewParams;
const date = moment(renderInfo.changeset.value('date'));
@ -128,6 +130,11 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
const offsetStart = date.diff(viewParams.dateDisplayStart, 'days');
element.style.left = calculatePositionValueForDayCountingPx(viewParams, offsetStart) + 'px';
// Update labels if any
if (labels) {
this.updateLabels(false, labels, renderInfo.changeset);
}
this.checkForActiveSelectionMode(renderInfo, diamond);
return true;
@ -170,8 +177,7 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
createAndAddLabels(renderInfo:RenderInfo, element:HTMLElement):WorkPackageCellLabels {
// create left label
const labelLeft = document.createElement('div');
labelLeft.classList.add(classNameLeftLabel);
labelLeft.classList.add(classNameShowOnHover);
labelLeft.classList.add(classNameLeftLabel, classNameHideOnHover);
element.appendChild(labelLeft);
// create right container
@ -181,62 +187,64 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
// create right label
const labelRight = document.createElement('div');
labelRight.classList.add(classNameRightLabel);
labelRight.classList.add(classNameShowOnHover);
labelRight.classList.add(classNameRightLabel, classNameHideOnHover);
containerRight.appendChild(labelRight);
// create far right label
const labelFarRight = document.createElement('div');
labelFarRight.classList.add(classNameFarRightLabel);
labelFarRight.classList.add(classNameShowOnHover);
labelFarRight.classList.add(classNameFarRightLabel, classNameHideOnHover);
containerRight.appendChild(labelFarRight);
const labels = new WorkPackageCellLabels(null, labelLeft, labelRight, labelFarRight);
// Create right hover label
const labelHoverRight = document.createElement('div');
labelHoverRight.classList.add(classNameRightHoverLabel, classNameShowOnHover, classNameHoverStyle);
element.appendChild(labelHoverRight);
const labels = new WorkPackageCellLabels(null, labelLeft, null, labelRight, labelHoverRight, labelFarRight);
this.updateLabels(false, labels, renderInfo.changeset);
return labels;
}
protected renderHoverLabels(labels:WorkPackageCellLabels, changeset:WorkPackageChangeset) {
this.renderLabel(changeset, labels, 'rightHover', 'date');
}
protected updateLabels(activeDragNDrop:boolean,
labels:WorkPackageCellLabels,
changeset:WorkPackageChangeset) {
if (!this.TimezoneService) {
this.TimezoneService = $injectNow('TimezoneService');
}
const subject:string = changeset.value('subject');
const dateStr = changeset.value('date');
const date:Moment | null = dateStr ? moment(dateStr) : null;
const labelConfiguration = this.wpTableTimeline.getNormalizedLabels(changeset.workPackage);
if (!activeDragNDrop) {
// normal display
if (labels.labelRight && date) {
labels.labelRight.textContent = this.TimezoneService.formattedDate(date);
}
if (labels.labelFarRight) {
labels.labelFarRight.textContent = subject;
}
} else {
// active drag'n'drop
if (labels.labelRight && date) {
labels.labelRight.textContent = this.TimezoneService.formattedDate(date);
}
}
if (labels.labelLeft) {
if (_.isEmpty(labels.labelLeft.textContent)) {
labels.labelLeft.classList.remove('not-empty');
// Show only one date field if left=start, right=dueDate
if (labelConfiguration.left === 'startDate' && labelConfiguration.right === 'dueDate') {
this.renderLabel(changeset, labels, 'left', null);
this.renderLabel(changeset, labels, 'right', 'date');
} else {
labels.labelLeft.classList.add('not-empty');
this.renderLabel(changeset, labels, 'left', labelConfiguration.left);
this.renderLabel(changeset, labels, 'right', labelConfiguration.right);
}
this.renderLabel(changeset, labels, 'farRight', labelConfiguration.farRight);
}
if (labels.labelRight) {
if (_.isEmpty(labels.labelRight.textContent)) {
labels.labelRight.classList.remove('not-empty');
} else {
labels.labelRight.classList.add('not-empty');
}
// Update hover labels
this.renderHoverLabels(labels, changeset);
}
protected renderLabel(changeset:WorkPackageChangeset,
labels:WorkPackageCellLabels,
position:LabelPosition|'leftHover'|'rightHover',
attribute:string|null) {
// Normalize attribute
if (attribute === 'startDate' || attribute === 'dueDate') {
attribute = 'date';
}
super.renderLabel(changeset, labels, position, attribute);
}
}

@ -123,7 +123,7 @@ export function registerWorkPackageMouseHandler(this: void,
dateStates = renderer.onDaysMoved(renderInfo.changeset, dayUnderCursor, days, direction);
applyDateValues(renderInfo, dateStates);
renderer.update(bar, renderInfo);
renderer.update(bar, labels, renderInfo);
}
}
@ -161,7 +161,7 @@ export function registerWorkPackageMouseHandler(this: void,
const clickStart = renderInfo.viewParams.dateDisplayStart.clone().add(offsetDayStart, 'days');
const dateForCreate = clickStart.format('YYYY-MM-DD');
const mouseDownType = renderer.onMouseDown(ev, dateForCreate, renderInfo, labels, bar);
renderer.update(bar, renderInfo);
renderer.update(bar, labels, renderInfo);
if (mouseDownType === 'create') {
deactivate(false);
@ -175,7 +175,7 @@ export function registerWorkPackageMouseHandler(this: void,
const widthInDays = offsetDayCurrent - offsetDayStart;
const moved = renderer.onDaysMoved(renderInfo.changeset, dayUnderCursor, widthInDays, mouseDownType);
renderer.assignDateValues(renderInfo.changeset, labels, moved);
renderer.update(bar, renderInfo);
renderer.update(bar, labels, renderInfo);
};
cell.onmouseleave = () => {
@ -206,12 +206,12 @@ export function registerWorkPackageMouseHandler(this: void,
dateStates = {};
// const renderInfo = getRenderInfo();
if (cancelled) {
if (cancelled || renderInfo.changeset.empty) {
renderInfo.changeset.clear();
renderer.update(bar, renderInfo);
renderer.update(bar, labels, renderInfo);
renderer.onMouseDownEnd(labels, renderInfo.changeset);
workPackageTimeline.refreshView();
} else if (!renderInfo.changeset.empty) {
} else {
// Persist the changes
saveWorkPackage(renderInfo.changeset)
.finally(() => {

@ -41,15 +41,21 @@ import {$injectFields} from '../../../angular/angular-injector-bridge.functions'
export const classNameLeftLabel = 'labelLeft';
export const classNameRightContainer = 'containerRight';
export const classNameRightLabel = 'labelRight';
export const classNameLeftHoverLabel = 'labelHoverLeft';
export const classNameRightHoverLabel = 'labelHoverRight';
export const classNameHoverStyle = '-label-style';
export const classNameFarRightLabel = 'labelFarRight';
export const classNameShowOnHover = 'show-on-hover';
export const classNameHideOnHover = 'hide-on-hover';
export class WorkPackageCellLabels {
constructor(public readonly labelCenter:HTMLDivElement | null,
public readonly labelLeft:HTMLDivElement | null,
public readonly labelRight:HTMLDivElement | null,
public readonly labelFarRight:HTMLDivElement | null) {
constructor(public readonly center:HTMLDivElement | null,
public readonly left:HTMLDivElement,
public readonly leftHover:HTMLDivElement | null,
public readonly right:HTMLDivElement,
public readonly rightHover:HTMLDivElement | null,
public readonly farRight:HTMLDivElement) {
}
}
@ -180,7 +186,9 @@ export class WorkPackageTimelineCell {
// Render the upgrade from renderInfo
const shouldBeDisplayed = renderer.update(
this.wpElement as HTMLDivElement,
this.labels,
renderInfo);
if (!shouldBeDisplayed) {
this.clear();
}

@ -26,12 +26,14 @@
// See doc/COPYRIGHT.rdoc for more details.
//++
module.exports = function(TimezoneService, currencyFilter, CustomFieldHelper) {
import {HalResource} from '../../components/api/api-v3/hal-resources/hal-resource.service';
module.exports = function(TimezoneService:any, currencyFilter:any, CustomFieldHelper:any) {
var WorkPackagesHelper = {
getRowObjectContent: function(object, option) {
getRowObjectContent: function(object:any, option:any) {
var content = object[option];
var displayContent = function(content) {
var displayContent = function(content:any) {
return content.name || content.subject || content.title || content.value || '';
};
@ -51,7 +53,7 @@ module.exports = function(TimezoneService, currencyFilter, CustomFieldHelper) {
}
},
augmentWorkPackageWithData: function(workPackage, attributeName, isCustomValue, data) {
augmentWorkPackageWithData: function(workPackage:any, attributeName:any, isCustomValue:any, data:any) {
if (isCustomValue && data) {
if (workPackage.custom_values) {
workPackage.custom_values.push(data);
@ -63,10 +65,10 @@ module.exports = function(TimezoneService, currencyFilter, CustomFieldHelper) {
}
},
getFormattedCustomValue: function(object, customField) {
getFormattedCustomValue: function(object:any, customField:any) {
if (!object.custom_values) { return null; }
var values = object.custom_values.filter(function(customValue){
var values = object.custom_values.filter(function(customValue:any){
return customValue && customValue.custom_field_id === customField.id;
});
@ -75,7 +77,7 @@ module.exports = function(TimezoneService, currencyFilter, CustomFieldHelper) {
}
},
getColumnDataId: function(object, column) {
getColumnDataId: function(object:any, column:any) {
var custom_field_id = column.name.match(/^cf_(\d+)$/);
if (custom_field_id) {
@ -88,9 +90,9 @@ module.exports = function(TimezoneService, currencyFilter, CustomFieldHelper) {
}
},
getCFColumnDataId: function(object, custom_field_id) {
getCFColumnDataId: function(object:any, custom_field_id:any) {
var custom_value = _.find(object.custom_values, function(elem) {
var custom_value = _.find(object.custom_values, function(elem:any) {
return elem && (elem.custom_field_id === custom_field_id);
});
@ -102,7 +104,7 @@ module.exports = function(TimezoneService, currencyFilter, CustomFieldHelper) {
}
},
getStaticColumnDataId: function(object, column) {
getStaticColumnDataId: function(object:any, column:any) {
switch (column.name) {
case 'parent':
return object.parent_id;
@ -116,13 +118,13 @@ module.exports = function(TimezoneService, currencyFilter, CustomFieldHelper) {
}
},
getFormattedColumnData: function(object, column) {
getFormattedColumnData: function(object:any, column:any) {
var value = WorkPackagesHelper.getRowObjectContent(object, column.name);
return WorkPackagesHelper.formatValue(value, column.meta_data.data_type);
},
formatValue: function(value, dataType) {
formatValue: function(value:any, dataType:any) {
switch(dataType) {
case 'datetime':
var dateTime;
@ -143,11 +145,14 @@ module.exports = function(TimezoneService, currencyFilter, CustomFieldHelper) {
case 'Date':
return TimezoneService.formattedDate(value);
default:
if (value instanceof HalResource) {
return value.name;
}
return value;
}
},
parseDateTime: function(value) {
parseDateTime: function(value:any) {
return new Date(Date.parse(value.replace(/(A|P)M$/, '')));
}
};

@ -113,6 +113,9 @@ describe('optionsDropdown Directive', function() {
},
table: {
timelineVisible: {
value: {}
},
stopAllSubscriptions: [false]
}
};

@ -245,6 +245,8 @@ module API
property :timeline_zoom_level
property :timeline_labels
attr_accessor :results,
:params

@ -112,6 +112,13 @@ module API
has_default: true,
visibility: false
schema :timeline_labels,
type: 'QueryTimelineLabels',
required: false,
writable: true,
has_default: true,
visibility: false
schema :show_hierarchies,
type: 'Boolean',
required: false,

@ -0,0 +1,179 @@
#-- 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-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
RSpec.feature 'Work package timeline labels',
with_settings: { date_format: '%Y-%m-%d' },
js: true,
selenium: true do
let(:user) { FactoryGirl.create(:admin) }
let(:type) { FactoryGirl.create(:type_bug) }
let(:milestone_type) { FactoryGirl.create(:type, is_milestone: true) }
let(:project) { FactoryGirl.create(:project, types: [type, milestone_type]) }
let(:query_menu) { Components::WorkPackages::QueryMenu.new }
let(:settings_menu) { Components::WorkPackages::SettingsMenu.new }
let(:config_modal) { Components::Timelines::ConfigurationModal.new }
let(:wp_timeline) { Pages::WorkPackagesTimeline.new(project) }
let(:custom_field) do
FactoryGirl.create(
:list_wp_custom_field,
name: "Ingredients",
multi_value: true,
types: [type],
projects: [project],
possible_values: ["ham", "onions", "pineapple", "mushrooms"]
)
end
def custom_value_for(str)
custom_field.custom_options.find { |co| co.value == str }.try(:id)
end
let(:work_package) do
FactoryGirl.create :work_package,
project: project,
type: type,
assigned_to: user,
start_date: '2017-08-21',
due_date: '2017-08-25',
subject: 'My subject',
custom_field_values: { custom_field.id => custom_value_for('onions') }
end
let(:milestone_work_package) do
FactoryGirl.create :work_package,
project: project,
type: milestone_type,
start_date: '2017-08-30',
due_date: '2017-08-30',
subject: 'My milestone'
end
before do
custom_field
milestone_work_package
work_package
login_as(user)
wp_timeline.visit!
wp_timeline.expect_timeline! open: false
wp_timeline.toggle_timeline
end
it 'shows and allows to configure labels' do
# Check default labels (bar type)
row = wp_timeline.timeline_row work_package.id
row.expect_labels left: nil,
right: nil,
farRight: 'My subject'
row.expect_hovered_labels left: '2017-08-21', right: '2017-08-25'
# Check default labels (milestone)
row = wp_timeline.timeline_row milestone_work_package.id
row.expect_labels left: nil,
right: nil,
farRight: 'My milestone'
row.expect_hovered_labels left: nil, right: '2017-08-30'
# Modify label configuration
config_modal.open!
config_modal.expect_labels! left: '(none)',
right: '(none)',
farRight: 'Subject'
config_modal.update_labels left: 'Assignee',
right: 'Type',
farRight: 'Status'
# Check overriden labels
row = wp_timeline.timeline_row work_package.id
row.expect_labels left: user.name,
right: type.name,
farRight: work_package.status.name
# Check default labels (milestone)
row = wp_timeline.timeline_row milestone_work_package.id
row.expect_labels left: '-',
right: milestone_type.name,
farRight: milestone_work_package.status.name
# Save the query
settings_menu.open_and_save_query 'changed labels'
wp_timeline.expect_title 'changed labels'
# Check the query
query = Query.last
expect(query.timeline_labels).to eq 'left' => 'assignee',
'right' => 'type',
'farRight' => 'status'
# Revisit page
wp_timeline.visit_query query
wp_timeline.expect_work_package_listed(work_package, milestone_work_package)
wp_timeline.expect_timeline!(open: true)
# Check overridden labels
row = wp_timeline.timeline_row work_package.id
row.expect_labels left: user.name,
right: type.name,
farRight: work_package.status.name
# Check overridden labels (milestone)
row = wp_timeline.timeline_row milestone_work_package.id
row.expect_labels left: '-',
right: milestone_type.name,
farRight: milestone_work_package.status.name
# Set labels to start|due|subject
config_modal.open!
config_modal.expect_labels! left: 'Assignee',
right: 'Type',
farRight: 'Status'
config_modal.update_labels left: 'Start date',
right: 'Due date',
farRight: 'Subject'
# Check overriden labels
row = wp_timeline.timeline_row work_package.id
row.expect_labels left: '2017-08-21',
right: '2017-08-25',
farRight: work_package.subject
# Check default labels (milestone)
row = wp_timeline.timeline_row milestone_work_package.id
row.expect_labels left: nil,
right: '2017-08-30',
farRight: milestone_work_package.subject
end
end

@ -472,6 +472,10 @@ describe ::API::V3::Queries::QueryRepresenter do
is_expected.to be_json_eql(query.timeline_zoom_level.to_json).at_path('timelineZoomLevel')
end
it 'should show the default timelineLabels' do
is_expected.to be_json_eql(query.timeline_labels.to_json).at_path('timelineLabels')
end
it 'should indicate whether the query is publicly visible' do
is_expected.to be_json_eql(query.is_public.to_json).at_path('public')
end
@ -643,6 +647,21 @@ describe ::API::V3::Queries::QueryRepresenter do
end
end
describe 'when labels are overridden' do
let(:query) do
FactoryGirl.build_stubbed(:query, project: project).tap do |query|
query.timeline_labels = expected
end
end
let(:expected) do
{ 'left' => 'assignee', 'right' => 'status', 'farRight' => 'type' }
end
it do
is_expected.to be_json_eql(expected.to_json).at_path('timelineLabels')
end
end
describe 'when timeline zoom level is changed' do
let(:query) do
FactoryGirl.build_stubbed(:query, project: project).tap do |query|

@ -220,6 +220,20 @@ describe ::API::V3::Queries::Schemas::QuerySchemaRepresenter do
it_behaves_like 'has no visibility property'
end
describe 'timelineLabels' do
let(:path) { 'timelineLabels' }
it_behaves_like 'has basic schema properties' do
let(:type) { 'QueryTimelineLabels' }
let(:name) { Query.human_attribute_name('timeline_labels') }
let(:required) { false }
let(:writable) { true }
let(:has_default) { true }
end
it_behaves_like 'has no visibility property'
end
describe 'show hierarchies' do
let(:path) { 'showHierarchies' }

@ -62,6 +62,17 @@ describe Query, type: :model do
query.timeline_visible = true
expect(query.timeline_visible).to be_truthy
end
it 'validates the timeline labels hash keys' do
expect(query.timeline_labels).to eq({})
expect(query).to be_valid
query.timeline_labels = { 'left' => 'foobar', 'xyz' => 'bar' }
expect(query).not_to be_valid
query.timeline_labels = { 'left' => 'foobar', 'right' => 'bar', 'farRight' => 'blub' }
expect(query).to be_valid
end
end
describe 'hierarchies' do

@ -0,0 +1,66 @@
#-- 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-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
module Components
module Timelines
class ConfigurationModal
include Capybara::DSL
include RSpec::Matchers
attr_reader :settings_menu
def initialize
@settings_menu = ::Components::WorkPackages::SettingsMenu.new
end
def open!
@settings_menu.open_and_choose 'Gantt chart ...'
end
def get_select(position)
page.find("#modal-timelines-label-#{position}")
end
def expect_labels!(left:, right:, farRight:)
expect(page).to have_select('modal-timelines-label-left', selected: left)
expect(page).to have_select('modal-timelines-label-right', selected: right)
expect(page).to have_select('modal-timelines-label-farRight', selected: farRight)
end
def update_labels(left:, right:, farRight:)
get_select(:left).find('option', text: left).select_option
get_select(:right).find('option', text: right).select_option
get_select(:farRight).find('option', text: farRight).select_option
page.within '.ng-modal-window' do
click_on 'Apply'
end
end
end
end
end

@ -0,0 +1,76 @@
#-- 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-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
module Components
module Timelines
class TimelineRow
include Capybara::DSL
include RSpec::Matchers
attr_reader :container
def initialize(container)
@container = container
end
def hover!
@container.hover
end
def expect_hovered_labels(left:, right:)
hover!
unless left.nil?
expect(container).to have_selector(".labelHoverLeft.not-empty", text: left)
end
unless right.nil?
expect(container).to have_selector(".labelHoverRight.not-empty", text: right)
end
expect(container).to have_selector(".labelLeft", visible: false)
expect(container).to have_selector(".labelRight", visible: false)
expect(container).to have_selector(".labelFarRight", visible: false)
end
def expect_labels(left:, right:, farRight:)
{
labelLeft: left,
labelRight: right,
labelFarRight: farRight
}.each do |className, text|
if text.nil?
expect(container).to have_selector(".#{className}", visible: :all)
expect(container).to have_no_selector(".#{className}.not-empty", wait: 0)
else
expect(container).to have_selector(".#{className}.not-empty", text: text)
end
end
end
end
end
end

@ -36,7 +36,7 @@ module Pages
end
def timeline_row_selector(wp_id)
"#wp-timeline-row-#{wp_id}"
".wp-row-#{wp_id}-timeline"
end
def timeline_container
@ -81,6 +81,10 @@ module Pages
end
end
def timeline_row(wp_id)
::Components::Timelines::TimelineRow.new page.find(timeline_row_selector(wp_id))
end
def zoom_in
page.find('#work-packages-timeline-zoom-in-button').click
end

Loading…
Cancel
Save