diff --git a/.rubocop.yml b/.rubocop.yml index f0f4c177b4..a8408a729c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -125,7 +125,7 @@ LineEndConcatenation: Enabled: false LineLength: - Max: 100 + Max: 130 MethodLength: Enabled: false diff --git a/.travis.yml b/.travis.yml index 4e5cb32012..250d5753e4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/app/assets/stylesheets/content/_notifications.sass b/app/assets/stylesheets/content/_notifications.sass index 542d250e90..51599d47c8 100644 --- a/app/assets/stylesheets/content/_notifications.sass +++ b/app/assets/stylesheets/content/_notifications.sass @@ -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 diff --git a/app/assets/stylesheets/content/_work_packages.sass b/app/assets/stylesheets/content/_work_packages.sass index 5bd631fc6b..673f837c6f 100644 --- a/app/assets/stylesheets/content/_work_packages.sass +++ b/app/assets/stylesheets/content/_work_packages.sass @@ -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 diff --git a/app/assets/stylesheets/content/work_packages/_table_hierarchy.sass b/app/assets/stylesheets/content/work_packages/_table_hierarchy.sass new file mode 100644 index 0000000000..8321e8a1be --- /dev/null +++ b/app/assets/stylesheets/content/work_packages/_table_hierarchy.sass @@ -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) \ No newline at end of file diff --git a/app/assets/stylesheets/openproject/_legacy.sass b/app/assets/stylesheets/openproject/_legacy.sass index b4ee30d9e3..135cfb7ba1 100644 --- a/app/assets/stylesheets/openproject/_legacy.sass +++ b/app/assets/stylesheets/openproject/_legacy.sass @@ -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 diff --git a/app/controllers/versions_controller.rb b/app/controllers/versions_controller.rb index efbc63a1b1..5c996ef077 100644 --- a/app/controllers/versions_controller.rb +++ b/app/controllers/versions_controller.rb @@ -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 diff --git a/app/seeders/admin_user_seeder.rb b/app/seeders/admin_user_seeder.rb index a69d070be9..32237f9317 100644 --- a/app/seeders/admin_user_seeder.rb +++ b/app/seeders/admin_user_seeder.rb @@ -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 diff --git a/app/views/common/_tabs.html.erb b/app/views/common/_tabs.html.erb index 324fc9d8d8..16567ff3e1 100644 --- a/app/views/common/_tabs.html.erb +++ b/app/views/common/_tabs.html.erb @@ -50,10 +50,13 @@ See doc/COPYRIGHT.rdoc for more details. - + +<% content_for :header_tags do %> + +<% end %> <% tabs.each do |tab| %> <%= content_tag('div', diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 92dc78ad12..1e3a681485 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -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 ..." diff --git a/docker-compose.pullpreview.yml b/docker-compose.pullpreview.yml index a953554a8a..e8261b9b90 100644 --- a/docker-compose.pullpreview.yml +++ b/docker-compose.pullpreview.yml @@ -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 diff --git a/frontend/app/components/api/api-v3/hal-resource-types/hal-resource-types.config.ts b/frontend/app/components/api/api-v3/hal-resource-types/hal-resource-types.config.ts index f511c3b917..aeb7fa6c1e 100644 --- a/frontend/app/components/api/api-v3/hal-resource-types/hal-resource-types.config.ts +++ b/frontend/app/components/api/api-v3/hal-resource-types/hal-resource-types.config.ts @@ -35,6 +35,7 @@ function halResourceTypesConfig(halResourceTypes:HalResourceTypesService) { className: 'WorkPackageResource', attrTypes: { parent: 'WorkPackage', + ancestors: 'WorkPackage', children: 'WorkPackage', relations: 'Relation', schema: 'Schema' diff --git a/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts b/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts index ddce26f917..3a47aa4bba 100644 --- a/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts +++ b/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts @@ -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; diff --git a/frontend/app/components/common/edit-actions-bar/edit-actions-bar.directive.html b/frontend/app/components/common/edit-actions-bar/edit-actions-bar.directive.html index 4d5a5269a7..c98f2ed672 100644 --- a/frontend/app/components/common/edit-actions-bar/edit-actions-bar.directive.html +++ b/frontend/app/components/common/edit-actions-bar/edit-actions-bar.directive.html @@ -3,7 +3,7 @@ id="work-packages--edit-actions-save" class="button -alt-highlight" accesskey="3" - ng-click="$ctrl.onSave()"> + ng-click="$ctrl.save()"> @@ -11,7 +11,7 @@ id="work-packages--edit-actions-cancel" class="button" accesskey="7" - ng-click="$ctrl.onCancel()"> + ng-click="$ctrl.cancel()"> diff --git a/frontend/app/components/common/edit-actions-bar/edit-actions-bar.directive.ts b/frontend/app/components/common/edit-actions-bar/edit-actions-bar.directive.ts index be40d9a2c9..a80e07e487 100644 --- a/frontend/app/components/common/edit-actions-bar/edit-actions-bar.directive.ts +++ b/frontend/app/components/common/edit-actions-bar/edit-actions-bar.directive.ts @@ -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(); } } diff --git a/frontend/app/components/context-menus/settings-menu/settings-menu.controller.ts b/frontend/app/components/context-menus/settings-menu/settings-menu.controller.ts index cbf4f4d402..4271958637 100644 --- a/frontend/app/components/context-menus/settings-menu/settings-menu.controller.ts +++ b/frontend/app/components/context-menus/settings-menu/settings-menu.controller.ts @@ -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; diff --git a/frontend/app/components/context-menus/settings-menu/settings-menu.service.html b/frontend/app/components/context-menus/settings-menu/settings-menu.service.html index 550dc8ee84..df79fcc48b 100644 --- a/frontend/app/components/context-menus/settings-menu/settings-menu.service.html +++ b/frontend/app/components/context-menus/settings-menu/settings-menu.service.html @@ -6,7 +6,16 @@ {{ I18n.t('js.toolbar.settings.columns') }}
  • {{ I18n.t('js.toolbar.settings.sort_by') }}
  • -
  • {{ I18n.t('js.toolbar.settings.group_by') }}
  • +
  • + + + {{ I18n.t('js.toolbar.settings.group_by') }} + +
  • @@ -15,6 +24,20 @@
  • +
  • + + + + + + + +
  • (), // Current state of collapsed groups (if any) collapsedGroups: new State<{[identifier:string]: boolean}>(), + // Hierarchies of table + hierarchies: new State(), // State to be updated when the table is up to date rendered:new State(), // Subject used to unregister all listeners of states above. diff --git a/frontend/app/components/work-packages/work-package-cache.service.test.ts b/frontend/app/components/work-packages/work-package-cache.service.test.ts index 86c67e06f9..bc54306129 100644 --- a/frontend/app/components/work-packages/work-package-cache.service.test.ts +++ b/frontend/app/components/work-packages/work-package-cache.service.test.ts @@ -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) } diff --git a/frontend/app/components/wp-activity/user/user-activity-directive.ts b/frontend/app/components/wp-activity/user/user-activity-directive.ts index 7a4d0dab80..b5205fa7a7 100644 --- a/frontend/app/components/wp-activity/user/user-activity-directive.ts +++ b/frontend/app/components/wp-activity/user/user-activity-directive.ts @@ -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; }); diff --git a/frontend/app/components/wp-fast-table/builders/rows/grouped-rows-builder.ts b/frontend/app/components/wp-fast-table/builders/rows/grouped-rows-builder.ts index 6659750662..b20e8bb544 100644 --- a/frontend/app/components/wp-fast-table/builders/rows/grouped-rows-builder.ts +++ b/frontend/app/components/wp-fast-table/builders/rows/grouped-rows-builder.ts @@ -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 diff --git a/frontend/app/components/wp-fast-table/builders/rows/hierarchy-rows-builder.ts b/frontend/app/components/wp-fast-table/builders/rows/hierarchy-rows-builder.ts new file mode 100644 index 0000000000..a154c66411 --- /dev/null +++ b/frontend/app/components/wp-fast-table/builders/rows/hierarchy-rows-builder.ts @@ -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 = ` + + ${this.text.leaf(level)} + + `; + } else { + const className = collapsed ? indicatorCollapsedClass : ''; + hierarchyIndicator.innerHTML = ` + + + ${this.text.expanded(level)} + ${this.text.collapsed(level)} + + `; + } + 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']; diff --git a/frontend/app/components/wp-fast-table/builders/rows/rows-builder.ts b/frontend/app/components/wp-fast-table/builders/rows/rows-builder.ts index d8b4487e6b..722f006beb 100644 --- a/frontend/app/components/wp-fast-table/builders/rows/rows-builder.ts +++ b/frontend/app/components/wp-fast-table/builders/rows/rows-builder.ts @@ -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. diff --git a/frontend/app/components/wp-fast-table/builders/rows/single-row-builder.ts b/frontend/app/components/wp-fast-table/builders/rows/single-row-builder.ts index 42c91616aa..c05ae741a7 100644 --- a/frontend/app/components/wp-fast-table/builders/rows/single-row-builder.ts +++ b/frontend/app/components/wp-fast-table/builders/rows/single-row-builder.ts @@ -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; } diff --git a/frontend/app/components/wp-fast-table/handlers/row/click-handler.ts b/frontend/app/components/wp-fast-table/handlers/row/click-handler.ts index c28617b86d..a4b8646939 100644 --- a/frontend/app/components/wp-fast-table/handlers/row/click-handler.ts +++ b/frontend/app/components/wp-fast-table/handlers/row/click-handler.ts @@ -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; } diff --git a/frontend/app/components/wp-fast-table/handlers/row/double-click-handler.ts b/frontend/app/components/wp-fast-table/handlers/row/double-click-handler.ts index a5ff4447fe..489e10d425 100644 --- a/frontend/app/components/wp-fast-table/handlers/row/double-click-handler.ts +++ b/frontend/app/components/wp-fast-table/handlers/row/double-click-handler.ts @@ -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; } diff --git a/frontend/app/components/wp-fast-table/handlers/row/hierarchy-click-handler.ts b/frontend/app/components/wp-fast-table/handlers/row/hierarchy-click-handler.ts new file mode 100644 index 0000000000..0903f7fef3 --- /dev/null +++ b/frontend/app/components/wp-fast-table/handlers/row/hierarchy-click-handler.ts @@ -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']; diff --git a/frontend/app/components/wp-fast-table/handlers/state/hierarchy-transformer.ts b/frontend/app/components/wp-fast-table/handlers/state/hierarchy-transformer.ts new file mode 100644 index 0000000000..185c858962 --- /dev/null +++ b/frontend/app/components/wp-fast-table/handlers/state/hierarchy-transformer.ts @@ -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']; diff --git a/frontend/app/components/wp-fast-table/handlers/state/rows-transformer.ts b/frontend/app/components/wp-fast-table/handlers/state/rows-transformer.ts index 626b9097cd..e92f490a31 100644 --- a/frontend/app/components/wp-fast-table/handlers/state/rows-transformer.ts +++ b/frontend/app/components/wp-fast-table/handlers/state/rows-transformer.ts @@ -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; } diff --git a/frontend/app/components/wp-fast-table/handlers/table-handler-registry.ts b/frontend/app/components/wp-fast-table/handlers/table-handler-registry.ts index fd54665f71..f80182d4ed 100644 --- a/frontend/app/components/wp-fast-table/handlers/table-handler-registry.ts +++ b/frontend/app/components/wp-fast-table/handlers/table-handler-registry.ts @@ -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) { diff --git a/frontend/app/components/wp-fast-table/helpers/wp-table-hierarchy-helpers.ts b/frontend/app/components/wp-fast-table/helpers/wp-table-hierarchy-helpers.ts new file mode 100644 index 0000000000..039a592d51 --- /dev/null +++ b/frontend/app/components/wp-fast-table/helpers/wp-table-hierarchy-helpers.ts @@ -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}`; +} diff --git a/frontend/app/components/wp-fast-table/state/wp-table-columns.service.ts b/frontend/app/components/wp-fast-table/state/wp-table-columns.service.ts index 43eaed5b92..6fe0fed3e0 100644 --- a/frontend/app/components/wp-fast-table/state/wp-table-columns.service.ts +++ b/frontend/app/components/wp-fast-table/state/wp-table-columns.service.ts @@ -171,11 +171,3 @@ export class WorkPackageTableColumnsService { } opServicesModule.service('wpTableColumns', WorkPackageTableColumnsService); - - - - - - - - diff --git a/frontend/app/components/wp-fast-table/state/wp-table-hierarchy.service.ts b/frontend/app/components/wp-fast-table/state/wp-table-hierarchy.service.ts new file mode 100644 index 0000000000..b379ab7671 --- /dev/null +++ b/frontend/app/components/wp-fast-table/state/wp-table-hierarchy.service.ts @@ -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; + + 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); \ No newline at end of file diff --git a/frontend/app/components/wp-fast-table/wp-fast-table.ts b/frontend/app/components/wp-fast-table/wp-fast-table.ts index 18c78f75d7..3e7ce02be8 100644 --- a/frontend/app/components/wp-fast-table/wp-fast-table.ts +++ b/frontend/app/components/wp-fast-table/wp-fast-table.ts @@ -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))!; } /** diff --git a/frontend/app/components/wp-fast-table/wp-table.interfaces.ts b/frontend/app/components/wp-fast-table/wp-table.interfaces.ts index aa86ab32cb..b74ec69ba9 100644 --- a/frontend/app/components/wp-fast-table/wp-table.interfaces.ts +++ b/frontend/app/components/wp-fast-table/wp-table.interfaces.ts @@ -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}; +} diff --git a/frontend/app/components/wp-relations/wp-relations-hierarchy-row/wp-relations-hierarchy-row.directive.ts b/frontend/app/components/wp-relations/wp-relations-hierarchy-row/wp-relations-hierarchy-row.directive.ts index 0458c60fee..77c9b9a8cb 100644 --- a/frontend/app/components/wp-relations/wp-relations-hierarchy-row/wp-relations-hierarchy-row.directive.ts +++ b/frontend/app/components/wp-relations/wp-relations-hierarchy-row/wp-relations-hierarchy-row.directive.ts @@ -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; }; diff --git a/frontend/app/typings/open-project.typings.d.ts b/frontend/app/typings/open-project.typings.d.ts index 0439c1c07c..fb8a538b6e 100644 --- a/frontend/app/typings/open-project.typings.d.ts +++ b/frontend/app/typings/open-project.typings.d.ts @@ -170,6 +170,7 @@ declare namespace op { id?:number; columns?:any; displaySums?:any; + displayHierarchy?:any; projectId?:any; groupBy?:string; filters?:any; diff --git a/frontend/tests/unit/tests/components/input/transformers/transform-date-test.js b/frontend/tests/unit/tests/components/input/transformers/transform-date-test.js index 6161e642e0..64ba9acd44 100644 --- a/frontend/tests/unit/tests/components/input/transformers/transform-date-test.js +++ b/frontend/tests/unit/tests/components/input/transformers/transform-date-test.js @@ -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', ''); }); }); diff --git a/spec/features/work_packages/details/activity_comments_spec.rb b/spec/features/work_packages/details/activity_comments_spec.rb index 91a825eaed..c240f5d839 100644 --- a/spec/features/work_packages/details/activity_comments_spec.rb +++ b/spec/features/work_packages/details/activity_comments_spec.rb @@ -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 diff --git a/spec/features/work_packages/table/hierarchy_spec.rb b/spec/features/work_packages/table/hierarchy_spec.rb new file mode 100644 index 0000000000..6ec6334df2 --- /dev/null +++ b/spec/features/work_packages/table/hierarchy_spec.rb @@ -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 diff --git a/spec/models/design_color_spec.rb b/spec/models/design_color_spec.rb index c1bae6d016..7345eaf115 100644 --- a/spec/models/design_color_spec.rb +++ b/spec/models/design_color_spec.rb @@ -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 diff --git a/spec/requests/api/v3/roles/role_resource_spec.rb b/spec/requests/api/v3/roles/role_resource_spec.rb index 1587f98d0c..6831fe8c54 100644 --- a/spec/requests/api/v3/roles/role_resource_spec.rb +++ b/spec/requests/api/v3/roles/role_resource_spec.rb @@ -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 diff --git a/spec/support/components/work_packages/hierarchies.rb b/spec/support/components/work_packages/hierarchies.rb new file mode 100644 index 0000000000..5d714a2142 --- /dev/null +++ b/spec/support/components/work_packages/hierarchies.rb @@ -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 diff --git a/spec/views/layouts/base.html.erb_spec.rb b/spec/views/layouts/base.html.erb_spec.rb index 97819c9297..53f7311e26 100644 --- a/spec/views/layouts/base.html.erb_spec.rb +++ b/spec/views/layouts/base.html.erb_spec.rb @@ -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