Merge branch 'dev' into feature/split-types-form-into-tabs

pull/5208/head
Wieland Lindenthal 8 years ago
commit ee290a6c62
  1. 2
      .rubocop.yml
  2. 2
      .travis.yml
  3. 5
      app/assets/stylesheets/content/_notifications.sass
  4. 1
      app/assets/stylesheets/content/_work_packages.sass
  5. 40
      app/assets/stylesheets/content/work_packages/_table_hierarchy.sass
  6. 4
      app/assets/stylesheets/openproject/_legacy.sass
  7. 2
      app/controllers/versions_controller.rb
  8. 16
      app/seeders/admin_user_seeder.rb
  9. 11
      app/views/common/_tabs.html.erb
  10. 6
      config/locales/js-en.yml
  11. 1
      docker-compose.pullpreview.yml
  12. 1
      frontend/app/components/api/api-v3/hal-resource-types/hal-resource-types.config.ts
  13. 16
      frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts
  14. 4
      frontend/app/components/common/edit-actions-bar/edit-actions-bar.directive.html
  15. 12
      frontend/app/components/common/edit-actions-bar/edit-actions-bar.directive.ts
  16. 25
      frontend/app/components/context-menus/settings-menu/settings-menu.controller.ts
  17. 25
      frontend/app/components/context-menus/settings-menu/settings-menu.service.html
  18. 4
      frontend/app/components/input/transformers/transform-date.directive.ts
  19. 8
      frontend/app/components/states.service.ts
  20. 5
      frontend/app/components/work-packages/work-package-cache.service.test.ts
  21. 2
      frontend/app/components/wp-activity/user/user-activity-directive.ts
  22. 7
      frontend/app/components/wp-fast-table/builders/rows/grouped-rows-builder.ts
  23. 238
      frontend/app/components/wp-fast-table/builders/rows/hierarchy-rows-builder.ts
  24. 9
      frontend/app/components/wp-fast-table/builders/rows/rows-builder.ts
  25. 5
      frontend/app/components/wp-fast-table/builders/rows/single-row-builder.ts
  26. 2
      frontend/app/components/wp-fast-table/handlers/row/click-handler.ts
  27. 2
      frontend/app/components/wp-fast-table/handlers/row/double-click-handler.ts
  28. 42
      frontend/app/components/wp-fast-table/handlers/row/hierarchy-click-handler.ts
  29. 56
      frontend/app/components/wp-fast-table/handlers/state/hierarchy-transformer.ts
  30. 6
      frontend/app/components/wp-fast-table/handlers/state/rows-transformer.ts
  31. 5
      frontend/app/components/wp-fast-table/handlers/table-handler-registry.ts
  32. 14
      frontend/app/components/wp-fast-table/helpers/wp-table-hierarchy-helpers.ts
  33. 8
      frontend/app/components/wp-fast-table/state/wp-table-columns.service.ts
  34. 89
      frontend/app/components/wp-fast-table/state/wp-table-hierarchy.service.ts
  35. 18
      frontend/app/components/wp-fast-table/wp-fast-table.ts
  36. 12
      frontend/app/components/wp-fast-table/wp-table.interfaces.ts
  37. 4
      frontend/app/components/wp-relations/wp-relations-hierarchy-row/wp-relations-hierarchy-row.directive.ts
  38. 1
      frontend/app/typings/open-project.typings.d.ts
  39. 7
      frontend/tests/unit/tests/components/input/transformers/transform-date-test.js
  40. 15
      spec/features/work_packages/details/activity_comments_spec.rb
  41. 95
      spec/features/work_packages/table/hierarchy_spec.rb
  42. 8
      spec/models/design_color_spec.rb
  43. 2
      spec/requests/api/v3/roles/role_resource_spec.rb
  44. 75
      spec/support/components/work_packages/hierarchies.rb
  45. 2
      spec/views/layouts/base.html.erb_spec.rb

@ -125,7 +125,7 @@ LineEndConcatenation:
Enabled: false
LineLength:
Max: 100
Max: 130
MethodLength:
Enabled: false

@ -110,8 +110,6 @@ notifications:
on_failure: always
on_pull_requests: false
rooms:
# core
- secure: "jTMREkFmtNRxgHYN7PQpCpqbJ4icGPfTKAUuY/UvjAgn7Lv0VRqkhikBPSJs+zzwTBcHIY+xMLLF5fRCU+gcq/1mNbl8jNpXIO9JSxqRkxUlKYGP5q1hYrPKrhRGquzGQSR/q1XiTmIDkZ17yl8PQa+5c9NH9GrMx6xMzD5GWOQ="
# CE
- secure: "mQqyZRjOix72MAAcjIanPOCfzMlQOqMhYOEd+6SNCcK7nb9ku0rPtFYWtifvp3+ajA5YKVJZ/W2qRn+Flw99zg8SfhPBV89SALXapUnjuZW0rcPexP0vrQW/AR6176DG3+WQOM8BFOYmN1yuGsz7YZK3xZo7yPp8XHKwzWoEYS6BOEUyfyE5T8dGJGqIKwpGEnnpBJf+CVsXeX56xg6wL+9CPVIEDb7IcrPoYQ5K6Kh9gq+7Ube7I7lbsSpdm1TAS4si7G9A6IaJ4WD1QvnUDrajGz2IM/bkP0zs50kGSrbagm707QMC8P4aLzJ64aUOfziyeYA1BSiDl5dZCUc3/dJtjMkUoRlmErwe6x8N5mwn6iVQ5LtWQeJIiy+wrBvjnghkl2/B7z3iZvEsDl6Uip6Dtv7ccmSskkh0ulsEykxWdAsRddpSaEYHv5pqex9aVgIMljM8o3DFTQclhVyGM0nrryDiMjhkCR5spighp/uR7nHEULlmJMGilyjqy6iB3/S6O1CXY110jpgYEAqhxkY9VA1hYYVLsoKV90uUGlnJcQRqQPoP3q1OqUGlRT5Y2ydM2FTBvsBGB+bhMQgZhZORqlkfIYzWDoUs4v6vM9v16XsP6TeXzNd0BPCZ+WamtOZBpwvSXZl85lqIEFONZ12uIr1R/sPsv6bQndN6gLk="
# EE

@ -353,6 +353,11 @@ $nm-upload-box-padding: rem-calc(15) rem-calc(25)
&::before
color: $nm-color-error-icon !important
a.impaired--empty-link,
a.impaired--empty-link:link,
.impaired--empty-link
@include varprop(color, body-font-color)
a.notification-box--target-link
cursor: pointer
text-decoration: underline

@ -28,6 +28,7 @@
// Table editing styles
@import work_packages/table_content
@import work_packages/table_hierarchy
// Specific field styles
@import work_packages/inplace_editing/edit_fields

@ -0,0 +1,40 @@
// Style the hierarchy group indicator arrow
// default: open arrow down
// collapsed: left arrow
.wp-table--hierarchy-indicator
@include varprop(color, body-font-color)
&:hover
text-decoration: none
// Toggle the indicator accessibility texts
// accordingly
.wp-table--hierarchy-indicator-collapsed
display: none
.-hierarchy-collapsed
.wp-table--hierarchy-indicator-expanded
display: none
.wp-table--hierarchy-indicator-collapsed
display: inline
.wp-table--hierarchy-indicator-icon
@include icon-common
@extend .icon-arrow-down2
font-size: 0.75rem
.-hierarchy-collapsed &
@extend .icon-arrow-right7
.wp-table--row[class*="__collapsed-group-"]
display: none
.wp-table--hierarchy-td
min-width: 0px !important
// Highlight the additional hierarchy
// so it becomes clear they're not part of the sort
.wp-table--hierarchy-aditional-row
@include varprop(background, gray-light)

@ -566,10 +566,6 @@ h4.comment
@include breakpoint(medium down)
font-size: $h2-font-size
a.impaired--empty-link,
.impaired--empty-link
color: inherit
#members_add_form
margin-bottom: 1rem
.-flex

@ -88,7 +88,7 @@ class VersionsController < ApplicationController
flash[:notice] = l(:notice_successful_create)
redirect_to controller: '/projects', action: 'settings', tab: 'versions', id: @project
else
format.html do render action: 'new' end
render action: 'new'
end
end
end

@ -56,7 +56,21 @@ class AdminUserSeeder < Seeder
user.mail_notification = User::USER_MAIL_OPTION_ONLY_MY_EVENTS.first
user.language = I18n.locale.to_s
user.status = User::STATUSES[:active]
user.force_password_change = Rails.env != 'development'
user.force_password_change = force_password_change?
end
end
def force_password_change?
Rails.env != 'development' && !force_password_change_disabled?
end
def force_password_change_disabled?
off_values = ["off", "false", "no", "0"]
off_values.include? ENV[force_password_change_env_switch_name]
end
def force_password_change_env_switch_name
"OP_ADMIN_USER_SEEDER_FORCE_PASSWORD_CHANGE"
end
end

@ -50,10 +50,13 @@ See doc/COPYRIGHT.rdoc for more details.
</div>
</div>
<script>
jQuery(function() { displayTabsButtons(); });
jQuery(window).resize(function() { displayTabsButtons(); });
</script>
<% content_for :header_tags do %>
<script>
jQuery(function() { displayTabsButtons(); });
jQuery(window).resize(function() { displayTabsButtons(); });
</script>
<% end %>
<% tabs.each do |tab| %>
<%= content_tag('div',

@ -433,6 +433,10 @@ en:
header_no_type: 'New work package (Type not yet set)'
header_with_parent: 'New %{type} (Child of %{parent_type} #%{id})'
button: 'Create'
hierarchy:
leaf: 'Work package leaf at level %{level}.'
children_collapsed: 'Hierarchy level %{level}, collapsed. Click to show the filtered children'
children_expanded: 'Hierarchy level %{level}, expanded. Click to collapse the filtered children'
faulty_query:
title: Work packages could not be loaded.
description: Your query is erroneous and could not be processed.
@ -508,6 +512,8 @@ en:
sort_by: "Sort by ..."
group_by: "Group by ..."
display_sums: "Display sums"
display_hierarchy: "Display hierarchy"
hide_hierarchy: "Hide hierarchy"
hide_sums: "Hide sums"
save: "Save"
save_as: "Save as ..."

@ -22,6 +22,7 @@ worker: &ruby
- "SECRET_KEY_BASE=d4e74f017910ac56c6ebad01165b7e1b37f4c9c02e9716836f8670cdc8d65a231e64e4f6416b19c8"
- "RAILS_ENV=production"
- "HEROKU=true"
- "OP_ADMIN_USER_SEEDER_FORCE_PASSWORD_CHANGE=off"
command: "./docker/wait-for-it.sh -t 60 -h db -p 5432 --strict -- bundle exec rake db:migrate db:seed jobs:work"
memory: 384

@ -35,6 +35,7 @@ function halResourceTypesConfig(halResourceTypes:HalResourceTypesService) {
className: 'WorkPackageResource',
attrTypes: {
parent: 'WorkPackage',
ancestors: 'WorkPackage',
children: 'WorkPackage',
relations: 'Relation',
schema: 'Schema'

@ -43,6 +43,7 @@ import {RelationResourceInterface} from './relation-resource.service';
interface WorkPackageResourceEmbedded {
activities: CollectionResourceInterface;
ancestors: WorkPackageResourceInterface[];
assignee: HalResource|any;
attachments: AttachmentCollectionResourceInterface;
author: HalResource|any;
@ -122,7 +123,6 @@ export class WorkPackageResource extends HalResource {
public $embedded: WorkPackageResourceEmbedded;
public $links: WorkPackageResourceLinks;
public id: string;
public schema: SchemaResource;
public $pristine: { [attribute: string]: any } = {};
public parentId: number;
@ -138,6 +138,18 @@ export class WorkPackageResource extends HalResource {
private form:any;
public get id():string {
return this.$source.id || this.idFromLink;
}
public get idFromLink():string {
if (this.href) {
return this.href.split('/').pop()!;
}
return '';
}
public get isNew(): boolean {
return this.id === 'new';
}
@ -529,7 +541,7 @@ export class WorkPackageResource extends HalResource {
public initializeNewResource(form:any) {
this.schema = form.schema;
this.form = $q.when(form);
this.id = 'new';
this.$source.id = 'new';
// Set update link to form
this['update'] = this.$links.update = form.$links.self;

@ -3,7 +3,7 @@
id="work-packages--edit-actions-save"
class="button -alt-highlight"
accesskey="3"
ng-click="$ctrl.onSave()">
ng-click="$ctrl.save()">
<i class="button--icon icon-checkmark"></i>
<span class="button--text" ng-bind="::$ctrl.text.save"></span>
</button>
@ -11,7 +11,7 @@
id="work-packages--edit-actions-cancel"
class="button"
accesskey="7"
ng-click="$ctrl.onCancel()">
ng-click="$ctrl.cancel()">
<i class="button--icon icon-close"></i>
<span class="button--text" ng-bind="::$ctrl.text.cancel"></span>
</button>

@ -31,13 +31,23 @@ import {wpDirectivesModule} from "../../../angular-modules";
export class EditActionsBarController {
public text:any;
public onSave:Function;
public throttledSave:Function;
public onCancel:Function;
constructor(I18n:op.I18n) {
constructor($timeout:ng.ITimeoutService, I18n:op.I18n) {
this.text = {
save: I18n.t('js.button_save'),
cancel: I18n.t('js.button_cancel')
}
this.throttledSave = _.throttle(this.onSave, 500);
}
public save():void {
this.throttledSave();
}
public cancel():void {
this.onCancel();
}
}

@ -27,9 +27,11 @@
import {opWorkPackagesModule} from '../../../angular-modules';
import {ContextMenuService} from '../context-menu.service';
import {WorkPackageTableHierarchyService} from '../../wp-fast-table/state/wp-table-hierarchy.service';
interface IMyScope extends ng.IScope {
displaySumsLabel:string;
displayHierarchies:boolean;
saveQuery:Function;
deleteQuery:Function;
query:op.Query;
@ -42,6 +44,7 @@ interface IMyScope extends ng.IScope {
showGroupingModal:Function;
showSortingModal:Function;
toggleDisplaySums:Function;
toggleHierarchies:Function;
showSettingsModalInvalid:Function;
showShareModalInvalid:Function;
showExportModalInvalid:Function;
@ -64,14 +67,15 @@ function SettingsDropdownMenuController($scope:IMyScope,
sortingModal:any,
groupingModal:any,
contextMenu:ContextMenuService,
wpTableHierarchy:WorkPackageTableHierarchyService,
QueryService:any,
AuthorisationService:any,
NotificationsService:any) {
$scope.displayHierarchies = wpTableHierarchy.isEnabled;
$scope.$watch('query.displaySums', function (newValue) {
$timeout(function () {
$scope.displaySumsLabel = (newValue) ? I18n.t('js.toolbar.settings.hide_sums')
: I18n.t('js.toolbar.settings.display_sums');
});
$scope.displaySumsLabel = (newValue) ? I18n.t('js.toolbar.settings.hide_sums')
: I18n.t('js.toolbar.settings.display_sums');
});
$scope.saveQuery = function (event:JQueryEventObject) {
@ -164,6 +168,10 @@ function SettingsDropdownMenuController($scope:IMyScope,
};
$scope.showGroupingModal = function (event:JQueryEventObject) {
if ($scope.displayHierarchies) {
return;
}
event.stopPropagation();
showModal.call(groupingModal);
updateFocusInModal('selected_columns_new');
@ -175,6 +183,15 @@ function SettingsDropdownMenuController($scope:IMyScope,
updateFocusInModal('modal-sorting-attribute-0');
};
$scope.toggleHierarchies = function () {
if (!!$scope.query.groupBy) {
return;
}
const isEnabled = wpTableHierarchy.isEnabled;
wpTableHierarchy.setEnabled(!isEnabled);
};
$scope.toggleDisplaySums = function () {
closeAnyContextMenu();
$scope.query.displaySums = !$scope.query.displaySums;

@ -6,7 +6,16 @@
<a class="menu-item" href="" ng-click="showColumnsModal($event)"><i class="icon-action-menu icon-columns"></i>{{ I18n.t('js.toolbar.settings.columns') }}</a>
</li>
<li><a class="menu-item" href="" ng-click="showSortingModal($event)"><i class="icon-action-menu icon-sort-by"></i>{{ I18n.t('js.toolbar.settings.sort_by') }}</a></li>
<li><a class="menu-item" href="" ng-click="showGroupingModal($event)"><i class="icon-action-menu icon-group-by"></i>{{ I18n.t('js.toolbar.settings.group_by') }}</a></li>
<li>
<a class="menu-item"
href
ng-disabled="displayHierarchies"
ng-class="{'inactive': displayHierarchies}"
ng-click="showGroupingModal($event)">
<i class="icon-action-menu icon-group-by"></i>
{{ I18n.t('js.toolbar.settings.group_by') }}
</a>
</li>
<li>
<a class="menu-item" href="" ng-click="toggleDisplaySums($event)">
<i ng-if="query.displaySums" class="icon-action-menu icon-checkmark"></i><i ng-if="!query.displaySums" class="icon-action-menu no-icon"></i>
@ -15,6 +24,20 @@
</accessible-element>
</a>
</li>
<li>
<a ng-if="displayHierarchies" class="menu-item" href ng-click="toggleHierarchies($event)">
<i ng-if="displayHierarchies" class="icon-action-menu icon-checkmark"></i>
<span ng-bind="::I18n.t('js.toolbar.settings.hide_hierarchy')"></span>
</a>
<a ng-disabled="query.groupBy"
ng-class="{'inactive': !!query.groupBy}"
ng-if="!displayHierarchies"
class="menu-item"
href
ng-click="toggleHierarchies($event)">
<i ng-if="!displayHierarchies" class="icon-action-menu no-icon"></i>
<span ng-bind="::I18n.t('js.toolbar.settings.display_hierarchy')"></span>
</li>
<li class="dropdown-divider"></li>
<li><a class="menu-item" href="" ng-click="saveQuery($event)"
inaccessible-by-tab="saveQueryInvalid()"

@ -37,13 +37,13 @@ function transformDate(TimezoneService:any) {
ngModelController:any) {
ngModelController.$parsers.push(function(data:any) {
if (!moment(data, 'YYYY-MM-DD', true).isValid()) {
return undefined;
return null;
}
return data;
});
ngModelController.$formatters.push(function(data:any) {
if (!moment(data, 'YYYY-MM-DD', true).isValid()) {
return undefined;
return null;
}
var d = TimezoneService.parseDate(data);
return TimezoneService.formattedISODate(d);

@ -1,6 +1,10 @@
import {whenDebugging} from '../helpers/debug_output';
import {WorkPackageTable} from './wp-fast-table/wp-fast-table';
import {WPTableRowSelectionState, WorkPackageTableRow} from './wp-fast-table/wp-table.interfaces';
import {
WorkPackageTableRow,
WPTableHierarchyState,
WPTableRowSelectionState
} from './wp-fast-table/wp-table.interfaces';
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";
@ -27,6 +31,8 @@ export class States {
selection: new State<WPTableRowSelectionState>(),
// Current state of collapsed groups (if any)
collapsedGroups: new State<{[identifier:string]: boolean}>(),
// Hierarchies of table
hierarchies: new State<WPTableHierarchyState>(),
// State to be updated when the table is up to date
rendered:new State<WorkPackageTable>(),
// Subject used to unregister all listeners of states above.

@ -47,11 +47,11 @@ describe('WorkPackageCacheService', () => {
// dummy 1
const workPackage1 = new _WorkPackageResource_({
id: '1',
_links: {
self: ""
}
});
workPackage1.id = '1';
workPackage1.schema = {
'$load': () => { return $q.when(true) }
};
@ -90,8 +90,7 @@ describe('WorkPackageCacheService', () => {
it('should return/stream a work package every time it gets updated', (done:any) => {
let expected = 0;
let workPackage: any = new WorkPackageResource({_links: {self: ""}});
workPackage.id = '1';
let workPackage: any = new WorkPackageResource({id: '1', _links: {self: ""}});
workPackage.dummy = 0;
workPackage.schema = {
'$load': () => { return $q.when(true) }

@ -125,7 +125,7 @@ function userActivity($uiViewScroll:any,
};
scope.updateComment = function () {
ActivityService.updateComment(scope.activity, scope.activity.editedComment).then(function () {
ActivityService.updateComment(scope.activity, scope.activity.editedComment || '').then(function () {
scope.workPackage.updateActivities();
scope.inEdit = false;
});

@ -30,6 +30,13 @@ export class GroupedRowsBuilder extends RowsBuilder {
};
}
/**
* The hierarchy builder is only applicable if the hierachy mode is active
*/
public isApplicable(table:WorkPackageTable, metaData:WorkPackageTableMetadata) {
return !!metaData.groupBy;
}
/**
* Rebuild the entire grouped tbody from the given table
* @param table

@ -0,0 +1,238 @@
import {collapsedGroupClass, hierarchyGroupClass, hierarchyRootClass} from '../../helpers/wp-table-hierarchy-helpers';
import {WorkPackageTableHierarchyService} from '../../state/wp-table-hierarchy.service';
import {WorkPackageTableMetadata} from '../../wp-table-metadata';
import {UiStateLinkBuilder} from '../ui-state-link-builder';
import {WorkPackageResourceInterface} from '../../../api/api-v3/hal-resources/work-package-resource.service';
import {HalResource} from '../../../api/api-v3/hal-resources/hal-resource.service';
import {WorkPackageTableRow} from '../../wp-table.interfaces';
import {PlainRowsBuilder} from './plain-rows-builder';
import {RowsBuilder} from './rows-builder';
import {States} from '../../../states.service';
import {injectorBridge} from '../../../angular/angular-injector-bridge.functions';
import {WorkPackageTableColumnsService} from '../../state/wp-table-columns.service';
import {WorkPackageTable} from '../../wp-fast-table';
import {SingleRowBuilder} from './single-row-builder';
export const indicatorCollapsedClass = '-hierarchy-collapsed';
export const hierarchyCellClassName = 'wp-table--hierarchy-span';
export class HierarchyRowsBuilder extends PlainRowsBuilder {
// Injections
public states:States;
public wpTableColumns:WorkPackageTableColumnsService;
public wpTableHierarchy:WorkPackageTableHierarchyService;
public I18n:op.I18n;
public uiStateBuilder = new UiStateLinkBuilder();
public text:{
leaf:(level:number) => string;
expanded:(level:number) => string;
collapsed:(level:number) => string;
};
// The group expansion state
constructor() {
super();
injectorBridge(this);
this.text = {
leaf: (level:number) => I18n.t('js.work_packages.hierarchy.leaf', { level: level }),
expanded: (level:number) => I18n.t('js.work_packages.hierarchy.children_expanded', { level: level }),
collapsed: (level:number) => I18n.t('js.work_packages.hierarchy.children_collapsed', { level: level }),
};
}
/**
* The hierarchy builder is only applicable if the hierachy mode is active
*/
public isApplicable(table:WorkPackageTable, metaData:WorkPackageTableMetadata) {
return this.wpTableHierarchy.isEnabled;
}
/**
* Rebuild the entire grouped tbody from the given table
* @param table
*/
public buildRows(table:WorkPackageTable):DocumentFragment {
// Remember all additional rows drawn for hierarchy
const additional:{[workPackageId:string]: WorkPackageResourceInterface} = {};
const tbodyContent = document.createDocumentFragment();
table.rows.forEach((wpId:string) => {
let row:WorkPackageTableRow = table.rowIndex[wpId];
// If this row was already rendered in a hierarchy, ignore it here
if (additional[row.workPackageId]) {
return;
}
// If we have ancestors
if (row.object.ancestors.length) {
this.buildWithHierarchy(table, tbodyContent, row, additional);
} else {
let tr = this.buildEmptyRow(row);
row.element = tr;
tbodyContent.appendChild(tr);
}
additional[row.object.id] = row.object;
});
return tbodyContent;
}
public get colspan():number {
return this.wpTableColumns.columnCount + 1;
}
public buildEmptyRow(row:WorkPackageTableRow, table?:WorkPackageTable, level?:number) {
level = level || row.object.ancestors.length;
const element = this.rowBuilder.buildEmpty(row.object);
const hierarchyIndicator = this.buildHierarchyIndicator(row.object, level);
const state = this.wpTableHierarchy.currentState;
row.object.ancestors.forEach((ancestor:WorkPackageResourceInterface) => {
element.classList.add(`__hierarchy-group-${ancestor.id}`);
if (state.collapsed[ancestor.id]) {
element.classList.add(collapsedGroupClass(ancestor.id));
}
});
element.classList.add(`__hierarchy-root-${row.object.id}`);
jQuery(element).find('td.subject').prepend(hierarchyIndicator);
return element;
}
/**
* Build the hierarchy indicator at the given indentation level.
*/
private buildHierarchyIndicator(workPackage:WorkPackageResourceInterface, level:number):HTMLElement {
const hierarchyIndicator = document.createElement('span');
const collapsed = this.wpTableHierarchy.collapsed(workPackage.id);
hierarchyIndicator.classList.add(hierarchyCellClassName);
hierarchyIndicator.style.width = 10 + (10 * level) + 'px';
hierarchyIndicator.style.paddingLeft = (20 * level) + 'px';
if (workPackage.$loaded && workPackage.isLeaf) {
hierarchyIndicator.innerHTML = `
<span tabindex="0" class="wp-table--leaf-indicator">
<span class="hidden-for-sighted">${this.text.leaf(level)}</span>
</span>
`;
} else {
const className = collapsed ? indicatorCollapsedClass : '';
hierarchyIndicator.innerHTML = `
<a href tabindex="0" role="button" class="wp-table--hierarchy-indicator ${className}">
<span class="wp-table--hierarchy-indicator-icon"></span>
<span class="wp-table--hierarchy-indicator-expanded hidden-for-sighted">${this.text.expanded(level)}</span>
<span class="wp-table--hierarchy-indicator-collapsed hidden-for-sighted">${this.text.collapsed(level)}</span>
</a>
`;
}
return hierarchyIndicator;
}
private buildWithHierarchy(
table:WorkPackageTable,
tbody:DocumentFragment,
row:WorkPackageTableRow,
additional:{[workPackageId:string]: WorkPackageResourceInterface}) {
// Ancestor data [root, med, thisrow]
const ancestors = row.object.ancestors;
const ancestorGroups:string[] = [];
ancestors.forEach((ancestor:WorkPackageResourceInterface, index:number) => {
if (!additional[ancestor.id]) {
let ancestorRow = this.buildAncestorRow(table, ancestor, ancestorGroups, index);
// special case, root without parent
if (index === 0) {
// Simply append the root here
tbody.appendChild(ancestorRow);
} else {
// This ancestor must be inserted in the last position of its root
const parent = ancestors[index-1];
this.insertIntoHierarchy(tbody, ancestorRow, parent.id);
}
additional[ancestor.id] = ancestor;
ancestorGroups.push(hierarchyGroupClass(ancestor.id));
}
});
// Insert this row to parent
const parent = _.last(ancestors);
const tr = this.buildEmptyRow(row);
row.element = tr;
this.insertIntoHierarchy(tbody, tr, parent.id);
}
/**
* Append a row to the given parent hierarchy group.
*/
private insertIntoHierarchy(tbody:DocumentFragment, tr:HTMLElement, parentId:string) {
// Either append to the hierarchy group root (= the parentID row itself)
const hierarchyRoot = `.__hierarchy-root-${parentId}`;
// Or, if it has descendants, append to the LATEST of that set
const hierarchyGroup = `.__hierarchy-group-${parentId}`;
jQuery(tbody).find(`${hierarchyRoot},${hierarchyGroup}`).last().after(tr);
}
/**
* Append an additional ancestor row that is not yet loaded
*/
private buildAncestorRow(
table:WorkPackageTable,
ancestor:WorkPackageResourceInterface,
ancestorGroups:string[],
index:number):HTMLElement {
const loadedRow = table.rowIndex[ancestor.id];
if (loadedRow) {
const tr = this.buildEmptyRow(loadedRow, table, index);
tr.classList.add('wp-table--hierarchy-aditional-row');
return tr;
}
const tr = this.rowBuilder.createEmptyRow(ancestor);
const columns = this.wpTableColumns.currentState;
tr.classList.add(`wp-table--hierarchy-aditional-row`, hierarchyRootClass(ancestor.id), ...ancestorGroups);
// Set available information for ID and subject column
// and print hierarchy indicator at subject field.
columns.forEach((column:string, i:number) => {
const td = document.createElement('td');
if (column === 'subject') {
const textNode = document.createTextNode(ancestor.name);
td.appendChild(this.buildHierarchyIndicator(ancestor, index));
td.appendChild(textNode);
}
if (column === 'id') {
const link = this.uiStateBuilder.linkToShow(
ancestor.id,
ancestor.subject,
ancestor.id
);
td.appendChild(link);
}
tr.appendChild(td);
});
// Append details icon
const td = document.createElement('td');
tr.appendChild(td);
return tr;
}
}
HierarchyRowsBuilder.$inject = ['wpTableColumns', 'wpTableHierarchy', 'states', 'I18n'];

@ -1,3 +1,4 @@
import {WorkPackageTableMetadata} from '../../wp-table-metadata';
import {EditingRowBuilder} from './editing-row-builder';
import {States} from '../../../states.service';
import {SingleRowBuilder} from './single-row-builder';
@ -22,6 +23,14 @@ export abstract class RowsBuilder {
*/
public abstract buildRows(table:WorkPackageTable):DocumentFragment;
/**
* Determine if this builder applies to the current view mode.
*/
public isApplicable(table:WorkPackageTable, metaData:WorkPackageTableMetadata) {
return true;
}
/**
* Refresh a single row after structural changes.
* Will perform dirty checking for when a work package is currently being edited.

@ -38,8 +38,6 @@ export class SingleRowBuilder {
*/
public buildEmpty(workPackage:WorkPackageResource):HTMLElement {
let row = this.createEmptyRow(workPackage);
row.id = `wp-row-${workPackage.id}`;
row.classList.add(rowClassName, 'wp--row', 'issue');
this.columns.forEach((column:string) => {
let cell = this.cellBuilder.build(workPackage, column);
@ -62,10 +60,11 @@ export class SingleRowBuilder {
* @param workPackage
* @returns {any}
*/
private createEmptyRow(workPackage:WorkPackageResource) {
public createEmptyRow(workPackage:WorkPackageResource) {
let tr = document.createElement('tr');
tr.id = rowId(workPackage.id);
tr.dataset['workPackageId'] = workPackage.id;
tr.classList.add(rowClassName, 'wp--row', 'issue');
return tr;
}

@ -39,7 +39,7 @@ export class RowClickHandler implements TableEventHandler {
let row = table.rowObject(element.data('workPackageId'));
// Ignore links
if (target.is('a')) {
if (target.is('a') || target.parent().is('a')) {
return;
}

@ -40,7 +40,7 @@ export class RowDoubleClickHandler implements TableEventHandler {
let row = table.rowObject(element.data('workPackageId'));
// Ignore links
if (target.is('a')) {
if (target.is('a') || target.parent().is('a')) {
return;
}

@ -0,0 +1,42 @@
import {ClickOrEnterHandler} from '../click-or-enter-handler';
import {WorkPackageTableHierarchyService} from '../../state/wp-table-hierarchy.service';
import {injectorBridge} from '../../../angular/angular-injector-bridge.functions';
import {WorkPackageTable} from '../../wp-fast-table';
import {States} from '../../../states.service';
import {TableEventHandler} from '../table-handler-registry';
import {rowClassName} from '../../builders/rows/single-row-builder';
export class HierarchyClickHandler extends ClickOrEnterHandler {
// Injections
public states:States;
public wpTableHierarchy:WorkPackageTableHierarchyService;
constructor() {
super();
injectorBridge(this);
}
public get EVENT() {
return 'click.table.hierarchy';
}
public get SELECTOR() {
return `.${rowClassName} .wp-table--hierarchy-indicator `;
}
public processEvent(table: WorkPackageTable, evt:JQueryEventObject):boolean {
let target = jQuery(evt.target);
// Locate the row from event
let element = target.closest(`.${rowClassName}`);
let wpId = element.data('workPackageId');
this.wpTableHierarchy.toggle(wpId);
evt.stopImmediatePropagation();
evt.preventDefault();
return false;
}
}
HierarchyClickHandler.$inject = ['states', 'wpTableHierarchy'];

@ -0,0 +1,56 @@
import {
collapsedGroupClass,
hierarchyGroupClass,
hierarchyRootClass
} from '../../helpers/wp-table-hierarchy-helpers';
import {indicatorCollapsedClass} from '../../builders/rows/hierarchy-rows-builder';
import {WorkPackageTableHierarchyService} from '../../state/wp-table-hierarchy.service';
import {injectorBridge} from '../../../angular/angular-injector-bridge.functions';
import {WorkPackageTable} from '../../wp-fast-table';
import {WPTableHierarchyState} from '../../wp-table.interfaces';
import {States} from '../../../states.service';
export class HierarchyTransformer {
public wpTableHierarchy:WorkPackageTableHierarchyService;
public states:States;
constructor(table:WorkPackageTable) {
injectorBridge(this);
let enabled = false;
this.wpTableHierarchy.hierarchyState
.observeUntil(this.states.table.stopAllSubscriptions).subscribe((state:WPTableHierarchyState) => {
if (enabled !== state.enabled) {
table.refreshBody();
table.postRender();
} else if (enabled) {
// No change in hierarchy mode
// Refresh groups
this.renderHierarchyState(state);
}
enabled = state.enabled;
});
}
/**
* Update all currently visible rows to match the selection state.
*/
private renderHierarchyState(state:WPTableHierarchyState) {
// Show all hierarchies
jQuery('[class^="__hierarchy-group-"]').removeClass((i:number, classNames:string):string => {
return (classNames.match(/__collapsed-group-\d+/g) || []).join(' ');
});
// Hide all collapsed hierarchies
_.each(state.collapsed, (isCollapsed:boolean, wpId:string) => {
// Hide/Show the descendants.
jQuery(`.${hierarchyGroupClass(wpId)}`).toggleClass(collapsedGroupClass(wpId), isCollapsed);
// Toggle the root style
jQuery(`.${hierarchyRootClass(wpId)} .wp-table--hierarchy-indicator`).toggleClass(indicatorCollapsedClass, isCollapsed);
});
}
}
HierarchyTransformer.$inject = ['wpTableHierarchy', 'states'];

@ -4,7 +4,7 @@ import {locateRow} from '../../helpers/wp-table-row-helpers';
import {States} from '../../../states.service';
import {injectorBridge} from '../../../angular/angular-injector-bridge.functions';
import {WorkPackageTable} from '../../wp-fast-table';
import {WorkPackageResource} from '../../../api/api-v3/hal-resources/work-package-resource.service';
import {WorkPackageResourceInterface} from '../../../api/api-v3/hal-resources/work-package-resource.service';
export class RowsTransformer {
public states:States;
@ -14,7 +14,7 @@ export class RowsTransformer {
// Redraw table if the current row state changed
this.states.table.rows.observeUntil(this.states.table.stopAllSubscriptions)
.subscribe((rows:WorkPackageResource[]) => {
.subscribe((rows:WorkPackageResourceInterface[]) => {
var t0 = performance.now();
table.initialSetup(rows);
@ -27,7 +27,7 @@ export class RowsTransformer {
// Refresh a single row if it exists
this.states.workPackages.observeUntil(this.states.table.stopAllSubscriptions)
.subscribe((nextVal:[string, WorkPackageResource]) => {
.subscribe((nextVal:[string, WorkPackageResourceInterface]) => {
if (!nextVal) {
return;
}

@ -1,3 +1,5 @@
import {HierarchyTransformer} from './state/hierarchy-transformer';
import {HierarchyClickHandler} from './row/hierarchy-click-handler';
import {WorkPackageTable} from '../wp-fast-table';
import {RowClickHandler} from './row/click-handler';
import {RowDoubleClickHandler} from './row/double-click-handler';
@ -18,6 +20,8 @@ export interface TableEventHandler {
export class TableHandlerRegistry {
static eventHandlers = [
// Hierarchy expansion/collapsing
HierarchyClickHandler,
// Clicking or pressing Enter on a single cell, editable or not
EditCellHandler,
// Clicking on the details view
@ -37,6 +41,7 @@ export class TableHandlerRegistry {
SelectionTransformer,
RowsTransformer,
ColumnsTransformer,
HierarchyTransformer
];
static attachTo(table: WorkPackageTable) {

@ -0,0 +1,14 @@
/**
* Returns the collapsed group class for the given ancestor id
*/
export function collapsedGroupClass(ancestorId:string):string {
return `__collapsed-group-${ancestorId}`;
}
export function hierarchyGroupClass(ancestorId:string):string {
return `__hierarchy-group-${ancestorId}`;
}
export function hierarchyRootClass(ancestorId:string):string {
return `__hierarchy-root-${ancestorId}`;
}

@ -171,11 +171,3 @@ export class WorkPackageTableColumnsService {
}
opServicesModule.service('wpTableColumns', WorkPackageTableColumnsService);

@ -0,0 +1,89 @@
import {WorkPackageTableMetadata} from '../wp-table-metadata';
import {States} from '../../states.service';
import {opServicesModule} from '../../../angular-modules';
import {State} from '../../../helpers/reactive-fassade';
import {WPTableHierarchyState} from '../wp-table.interfaces';
export class WorkPackageTableHierarchyService {
// The selected columns state of the current table instance
public hierarchyState:State<WPTableHierarchyState>;
constructor(public states: States, public QueryService:any) {
this.hierarchyState = states.table.hierarchies;
}
/**
* Return whether the current hierarchy mode is active
*/
public get isEnabled():boolean {
return this.currentState.enabled;
}
public setEnabled(active:boolean = true) {
const state = this.currentState;
state.enabled = active;
this.hierarchyState.put(state);
}
/**
* Return whether the given wp ID is collapsed.
*/
public collapsed(wpId:string):boolean {
return this.currentState.collapsed[wpId] === true;
}
/**
* Collapse the hierarchy for this work package
*/
public collapse(wpId:string):void {
this.setState(wpId, true);
}
/**
* Expand the hierarchy for this work package
*/
public expand(wpId:string):void {
this.setState(wpId, false);
}
/**
* Toggle the hierarchy state
*/
public toggle(wpId:string):void {
this.setState(wpId, !this.collapsed(wpId));
}
/**
* Set the collapse/expand state of the given work package id.
*/
private setState(wpId:string, isCollapsed:boolean):void {
const state = this.currentState;
state.collapsed[wpId] = isCollapsed;
this.hierarchyState.put(state);
}
/**
* Get current selection state.
*/
public get currentState():WPTableHierarchyState {
const state = this.hierarchyState.getCurrentValue();
if (state == null) {
return this.initialState;
}
return state;
}
private get initialState():WPTableHierarchyState {
return {
enabled: false,
collapsed: {}
} as WPTableHierarchyState;
}
}
opServicesModule.service('wpTableHierarchy', WorkPackageTableHierarchyService);

@ -1,3 +1,5 @@
import {WorkPackageTableMetadata} from './wp-table-metadata';
import {HierarchyRowsBuilder} from './builders/rows/hierarchy-rows-builder';
import {RowsBuilder} from './builders/rows/rows-builder';
import {WorkPackageCacheService} from '../work-packages/work-package-cache.service';
import {WorkPackageResource} from '../api/api-v3/hal-resources/work-package-resource.service';
@ -20,8 +22,12 @@ export class WorkPackageTable {
public rowIndex:{[id: string]: WorkPackageTableRow} = {};
// WP rows builder
private groupedRowsBuilder = new GroupedRowsBuilder();
private plainRowsBuilder = new PlainRowsBuilder();
// Ordered by priority
private builders = [
new HierarchyRowsBuilder(),
new GroupedRowsBuilder(),
new PlainRowsBuilder()
];
constructor(public tbody:HTMLElement) {
injectorBridge(this);
@ -36,16 +42,12 @@ export class WorkPackageTable {
* Returns the reference to the last table.metadata state value
*/
public get metaData() {
return this.states.table.metadata.getCurrentValue();
return this.states.table.metadata.getCurrentValue() as WorkPackageTableMetadata;
}
public get rowBuilder():RowsBuilder {
const metaData = this.metaData;
if (metaData && metaData.groupBy) {
return this.groupedRowsBuilder;
} else {
return this.plainRowsBuilder;
}
return _.find(this.builders, (builder:RowsBuilder) => builder.isApplicable(this, metaData))!;
}
/**

@ -1,4 +1,7 @@
import {WorkPackageResource} from '../api/api-v3/hal-resources/work-package-resource.service';
import {
WorkPackageResource,
WorkPackageResourceInterface
} from '../api/api-v3/hal-resources/work-package-resource.service';
import {WorkPackageTable} from './wp-fast-table';
/**
@ -7,7 +10,7 @@ import {WorkPackageTable} from './wp-fast-table';
* or the group it belonged to when initially rendered.
*/
export interface WorkPackageTableRow {
object:WorkPackageResource;
object:WorkPackageResourceInterface;
workPackageId:string;
position:number;
element?:HTMLElement;
@ -45,3 +48,8 @@ export interface WPTableRowSelectionState {
// required for shift-offsets
activeRowIndex: number | null;
}
export interface WPTableHierarchyState {
enabled:boolean;
collapsed:{[workPackageId: string]: boolean};
}

@ -20,10 +20,6 @@ class WpRelationsHierarchyRowDirectiveController {
protected PathHelper: op.PathHelper,
protected I18n: op.I18n) {
if (!this.relatedWorkPackage && this.relationType !== 'parent') {
this.relatedWorkPackage = angular.copy(this.workPackage);
}
this.canModifyHierarchy = !!this.workPackage.changeParent;
};

@ -170,6 +170,7 @@ declare namespace op {
id?:number;
columns?:any;
displaySums?:any;
displayHierarchy?:any;
projectId?:any;
groupBy?:string;
filters?:any;

@ -70,10 +70,11 @@ describe('transformDateValue Directive', function() {
});
describe('when given invalid date values', function () {
shouldBehaveLikeAParser('', undefined);
shouldBehaveLikeAParser('invalid', undefined);
shouldBehaveLikeAParser('2016-12', undefined);
shouldBehaveLikeAParser('', null);
shouldBehaveLikeAParser('invalid', null);
shouldBehaveLikeAParser('2016-12', null);
shouldBehaveLikeAFormatter(undefined, '');
shouldBehaveLikeAFormatter(null, '');
shouldBehaveLikeAFormatter('2016-12', '');
});
});

@ -174,6 +174,21 @@ describe 'activity comments', js: true, selenium: true do
edit.submit_by_click
expect(page).to have_selector('.user-comment .message em', text: 'italic text')
expect(page).to have_selector('.user-comment .message', text: 'Comment with italic text')
# Clear the comment
activity = page.find('#activity-2')
page.driver.browser.action.move_to(activity.native).perform
# Check the edit textarea
activity.find('.icon-edit').click
edit = WorkPackageTextAreaField.new wp_page,
'comment',
selector: '.user-comment--form'
edit.set_value ''
edit.submit_by_click
expect(page).to have_no_selector('#activity-2 .user-comment .message em', text: 'italic text')
end
end
end

@ -0,0 +1,95 @@
require 'spec_helper'
describe 'Work Package table hierarchy', js: true do
let(:user) { FactoryGirl.create :admin }
let(:project) { FactoryGirl.create(:project) }
let(:category) { FactoryGirl.create :category, project: project, name: 'Foo' }
let!(:wp_root) { FactoryGirl.create(:work_package, project: project) }
let!(:wp_inter) { FactoryGirl.create(:work_package, project: project, parent: wp_root) }
let!(:wp_leaf) { FactoryGirl.create(:work_package, project: project, category: category, parent: wp_inter) }
let!(:wp_other) { FactoryGirl.create(:work_package, project: project) }
let(:wp_table) { Pages::WorkPackagesTable.new(project) }
let(:hierarchy) { ::Components::WorkPackages::Hierarchies.new }
let!(:query) do
query = FactoryGirl.build(:query, user: user, project: project)
query.column_names = ['subject', 'category']
query.filters.clear
query.add_filter('category_id', '=', [category.id])
query.save!
query
end
def expect_listed(*wps)
wps.each do |wp|
wp_table.expect_work_package_listed(wp)
end
end
def expect_hidden(*wps)
wps.each do |wp|
hierarchy.expect_hidden(wp)
end
end
before do
login_as(user)
end
it 'shows hierarchy correctly' do
wp_table.visit!
expect_listed(wp_root, wp_inter, wp_leaf, wp_other)
hierarchy.expect_no_hierarchies
# Hierarchy mode is disabled by default
hierarchy.enable_hierarchy
hierarchy.expect_hierarchy_at(wp_root)
hierarchy.expect_hierarchy_at(wp_inter)
hierarchy.expect_leaf_at(wp_leaf)
hierarchy.expect_leaf_at(wp_other)
# Toggling hierarchies hides the inner children
hierarchy.toggle_row(wp_root)
# Root, other showing
expect_listed(wp_root, wp_other)
# Inter, Leaf hidden
expect_hidden(wp_inter, wp_leaf)
# Show all again
hierarchy.toggle_row(wp_root)
expect_listed(wp_root, wp_other, wp_inter, wp_leaf)
# Disable hierarchies
hierarchy.disable_hierarchy
hierarchy.expect_no_hierarchies
# Now visiting the query for category
wp_table.visit_query(query)
# Should only list the matching leaf
wp_table.expect_work_package_listed(wp_leaf)
# When toggling hierarchies, shows root and intermediate node
# Hierarchy mode is disabled by default
hierarchy.enable_hierarchy
hierarchy.expect_hierarchy_at(wp_root)
hierarchy.expect_hierarchy_at(wp_inter)
hierarchy.toggle_row(wp_root)
expect_listed(wp_root)
expect_listed(wp_inter, wp_leaf)
# Disabling hierarchy hides them again
hierarchy.disable_hierarchy
expect(page).to have_no_selector("#wp-row-#{wp_root.id}")
expect(page).to have_no_selector("#wp-row-#{wp_inter.id}")
end
end

@ -1,14 +1,13 @@
require 'spec_helper'
RSpec.describe DesignColor, type: :model do
let(:default_primary) { ::OpenProject::Design.variables['primary-color'] }
let(:primary_color) { FactoryGirl.create :"design_color_primary-color" }
describe "#defaults" do
it "returns a hash of default color variables with hex color codes" do
expect(described_class.defaults).to be_a(Hash)
expect(described_class.defaults["primary-color"]).to(
equal(described_class.defaults["primary-color"])
)
expect(described_class.defaults["primary-color"]).to eq(default_primary)
end
end
@ -36,7 +35,8 @@ RSpec.describe DesignColor, type: :model do
end
it "returns default hexcode if hexcode not present" do
expect(described_class.new(variable: "primary-color").get_hexcode).to eq("#3493B3")
expect(described_class.new(variable: "primary-color").get_hexcode)
.to eq(default_primary)
end
end

@ -38,7 +38,7 @@ describe 'API v3 Role resource' do
before do
# Avoid having a builtin role left over from another spec
Role.destroy_all
Role.delete_all
allow(User).to receive(:current).and_return current_user
end

@ -0,0 +1,75 @@
#-- 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 WorkPackages
class Hierarchies
include Capybara::DSL
include RSpec::Matchers
def enable_hierarchy
find('#work-packages-settings-button').click
page.find('#settingsDropdown a.menu-item', text: 'Display hierarchy').click
end
def disable_hierarchy
find('#work-packages-settings-button').click
expect(page).to have_selector('#settingsDropdown .menu-item')
page.find('#settingsDropdown a.menu-item', text: 'Hide hierarchy').click
end
def expect_no_hierarchies
expect(page).to have_no_selector('.wp-table--hierarchy-span')
end
def expect_leaf_at(work_package)
expect(page).to have_selector("#wp-row-#{work_package.id} .wp-table--leaf-indicator")
end
def expect_hierarchy_at(work_package, collapsed = false)
selector = "#wp-row-#{work_package.id} .wp-table--hierarchy-indicator"
collapsed_sel = ".-hierarchy-collapsed"
if collapsed
expect(page).to have_selector("#{selector}#{collapsed_sel}")
else
expect(page).to have_selector(selector)
expect(page).to have_no_selector("#{selector}#{collapsed_sel}")
end
end
def expect_hidden(work_package)
expect(page).to have_selector("#wp-row-#{work_package.id}", visible: :hidden)
end
def toggle_row(work_package)
find("#wp-row-#{work_package.id} .wp-table--hierarchy-indicator").click
end
end
end
end

@ -142,7 +142,7 @@ describe 'layouts/base', type: :view do
it 'renders main favicon' do
expect(rendered).to have_selector(
"link[type='image/x-icon'][href='/assets/favicon.ico']",
"link[type='image/x-icon'][href*='#{OpenProject::Design.favicon_asset_path}']",
visible: false
)
end

Loading…
Cancel
Save