Merge pull request #7339 from opf/feature/version-boards

[30250] Version board editing

[ci skip]
pull/7343/head
Oliver Günther 6 years ago committed by GitHub
commit e4b3185d6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      app/assets/stylesheets/content/_editable_toolbar.sass
  2. 10
      config/locales/js-en.yml
  3. 4
      frontend/src/app/angular4-modules.ts
  4. 2
      frontend/src/app/components/op-context-menu/op-context-menu.types.ts
  5. 4
      frontend/src/app/components/states.service.ts
  6. 4
      frontend/src/app/components/states/state-cache.service.ts
  7. 56
      frontend/src/app/components/statuses/status-cache.service.ts
  8. 58
      frontend/src/app/components/versions/version-cache.service.ts
  9. 6
      frontend/src/app/components/wp-card-view/wp-card-view.component.html
  10. 12
      frontend/src/app/components/wp-card-view/wp-card-view.component.ts
  11. 29
      frontend/src/app/modules/boards/board/board-actions/board-action.service.ts
  12. 32
      frontend/src/app/modules/boards/board/board-actions/status/status-action.service.ts
  13. 133
      frontend/src/app/modules/boards/board/board-actions/version-action.service.ts
  14. 223
      frontend/src/app/modules/boards/board/board-actions/version/version-action.service.ts
  15. 48
      frontend/src/app/modules/boards/board/board-actions/version/version-board-header.component.ts
  16. 17
      frontend/src/app/modules/boards/board/board-actions/version/version-board-header.html
  17. 3
      frontend/src/app/modules/boards/board/board-actions/version/version-board-header.sass
  18. 39
      frontend/src/app/modules/boards/board/board-list/board-list-dropdown.directive.ts
  19. 14
      frontend/src/app/modules/boards/board/board-list/board-list.component.html
  20. 58
      frontend/src/app/modules/boards/board/board-list/board-list.component.ts
  21. 19
      frontend/src/app/modules/boards/board/board-list/board-list.service.ts
  22. 29
      frontend/src/app/modules/boards/drag-and-drop/drag-and-drop.service.ts
  23. 13
      frontend/src/app/modules/boards/openproject-boards.module.ts
  24. 4
      frontend/src/app/modules/common/editable-toolbar-title/editable-toolbar-title.sass
  25. 2
      frontend/src/app/modules/common/link-handling/link-handling.ts
  26. 2
      frontend/src/app/modules/hal/dm-services/status-dm.service.ts
  27. 13
      frontend/src/app/modules/hal/dm-services/version-dm.service.ts
  28. 12
      frontend/src/app/modules/hal/resources/version-resource.ts
  29. 4
      frontend/src/app/modules/hal/services/hal-resource.config.ts
  30. 8
      lib/api/v3/versions/version_representer.rb
  31. 17
      modules/boards/config/locales/js-en.yml
  32. 54
      modules/boards/spec/features/action_boards/version_board_spec.rb
  33. 20
      modules/boards/spec/features/support/board_page.rb

@ -13,6 +13,11 @@
.button
margin: 0
.title-container.-small
.editable-toolbar-title--fixed
font-size: 1.2rem
line-height: 32px
input[type="text"].toolbar--editable-toolbar
color: $toolbar-title-color
font-size: 20px

@ -47,16 +47,6 @@ en:
close_filter_title: "Close filter"
close_form_title: "Close form"
boards:
upsale:
teaser_text: 'Improve your agile project management with this flexible Boards view. Create as many boards as you like for anything you would like to keep track of.'
upgrade_to_ee_text: 'Boards is an Enterprise feature. Please upgrade to a paid plan.'
upgrade: 'Upgrade now'
personal_demo: 'Get a personal demo'
lists:
delete: 'Delete list'
edit_version: 'Edit version'
card:
add_new: 'Add new card'
highlighting:

@ -83,6 +83,8 @@ import {DeviceService} from "core-app/modules/common/browser/device.service";
import {MainMenuToggleService} from "core-components/main-menu/main-menu-toggle.service";
import {MainMenuToggleComponent} from "core-components/main-menu/main-menu-toggle.component";
import {MainMenuNavigationService} from "core-components/main-menu/main-menu-navigation.service";
import {StatusCacheService} from "core-components/statuses/status-cache.service";
import {VersionCacheService} from "core-components/versions/version-cache.service";
@NgModule({
imports: [
@ -134,6 +136,8 @@ import {MainMenuNavigationService} from "core-components/main-menu/main-menu-nav
UrlParamsHelperService,
ProjectCacheService,
UserCacheService,
StatusCacheService,
VersionCacheService,
CurrentUserService,
{provide: States, useValue: new States()},
PaginationService,

@ -17,5 +17,5 @@ export interface OpContextMenuItem {
ariaLabel?:string;
linkText?:string;
divider?:boolean;
onClick?:($event:JQueryEventObject) => boolean;
onClick?:($event:JQuery.Event|JQueryEventObject) => boolean;
}

@ -15,6 +15,7 @@ import {QuerySortByResource} from "core-app/modules/hal/resources/query-sort-by-
import {QueryGroupByResource} from "core-app/modules/hal/resources/query-group-by-resource";
import {Input} from "@angular/core";
import {QueryFilterResource} from "core-app/modules/hal/resources/query-filter-resource";
import {VersionResource} from "core-app/modules/hal/resources/version-resource";
export class States extends StatesGroup {
name = 'MainStore';
@ -37,6 +38,9 @@ export class States extends StatesGroup {
/* /api/v3/statuses */
statuses = multiInput<StatusResource>();
/* /api/v3/versions */
versions = multiInput<VersionResource>();
/* /api/v3/users */
users = multiInput<UserResource>();

@ -143,7 +143,7 @@ export abstract class StateCacheService<T> {
* @param force Load the values anyway
* @return {Promise<undefined>} An empty promise to mark when the set of states is filled.
*/
public requireAll(ids:string[], force:boolean = false):Promise<undefined> {
public requireAll(ids:string[], force:boolean = false):Promise<unknown> {
let idsToRequest:string[];
if (force) {
@ -184,5 +184,5 @@ export abstract class StateCacheService<T> {
* Load a set of required values, fill the results into the appropriate states
* and return a promise when all values are inserted.
*/
protected abstract loadAll(ids:string[]):Promise<undefined>;
protected abstract loadAll(ids:string[]):Promise<undefined|unknown>;
}

@ -0,0 +1,56 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {MultiInputState} from "reactivestates";
import {Injectable} from '@angular/core';
import {UserResource} from 'core-app/modules/hal/resources/user-resource';
import {StateCacheService} from 'core-components/states/state-cache.service';
import {UserDmService} from 'core-app/modules/hal/dm-services/user-dm.service';
import {States} from 'core-components/states.service';
import {StatusDmService} from "core-app/modules/hal/dm-services/status-dm.service";
import {StatusResource} from "core-app/modules/hal/resources/status-resource";
@Injectable()
export class StatusCacheService extends StateCacheService<StatusResource> {
constructor(readonly states:States,
readonly statusDm:StatusDmService) {
super();
}
protected load(id:number|string):Promise<StatusResource> {
return this.statusDm.one(id);
}
protected loadAll(ids:string[]):Promise<unknown> {
return this.statusDm.list();
}
protected get multiState():MultiInputState<StatusResource> {
return this.states.statuses;
}
}

@ -0,0 +1,58 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {MultiInputState} from "reactivestates";
import {Injectable} from '@angular/core';
import {UserResource} from 'core-app/modules/hal/resources/user-resource';
import {StateCacheService} from 'core-components/states/state-cache.service';
import {UserDmService} from 'core-app/modules/hal/dm-services/user-dm.service';
import {States} from 'core-components/states.service';
import {StatusDmService} from "core-app/modules/hal/dm-services/status-dm.service";
import {StatusResource} from "core-app/modules/hal/resources/status-resource";
import {VersionResource} from "core-app/modules/hal/resources/version-resource";
import {VersionDmService} from "core-app/modules/hal/dm-services/version-dm.service";
@Injectable()
export class VersionCacheService extends StateCacheService<VersionResource> {
constructor(readonly states:States,
readonly versionDm:VersionDmService) {
super();
}
protected load(id:number|string):Promise<VersionResource> {
return this.versionDm.one(id);
}
protected loadAll(ids:string[]):Promise<unknown> {
return this.versionDm.list();
}
protected get multiState():MultiInputState<VersionResource> {
return this.states.versions;
}
}

@ -13,7 +13,7 @@
[attr.data-is-new]="wp.isNew || undefined"
[attr.data-work-package-id]="wp.id"
(doubleClickOrTap)="handleDblClick(wp)"
[ngClass]="{'-draggable': dragAndDropEnabled, '-new' : wp.isNew }">
[ngClass]="{'-draggable': dragOutOf, '-new' : wp.isNew }">
<div class="wp-card--highlighting"
[ngClass]="cardHighlightingClass(wp)">
@ -60,8 +60,8 @@
[workPackage]="wp"
class="wp-card--status">
</wp-status-button>
<user-avatar *ngIf="hasAssignee(wp)"
[attr.data-user]="wp.assignee"
<user-avatar *ngIf="wp.assignee"
[user]="wp.assignee"
data-class-list="avatar-mini"
class="wp-card--assignee">
</user-avatar>

@ -30,8 +30,6 @@ import {CardHighlightingMode} from "core-components/wp-fast-table/builders/highl
import {AuthorisationService} from "core-app/modules/common/model-auth/model-auth.service";
import {StateService} from "@uirouter/core";
import {States} from "core-components/states.service";
import {input} from "reactivestates";
import {switchMap, tap} from "rxjs/operators";
import {RequestSwitchmap} from "core-app/helpers/rxjs/request-switchmap";
@ -42,7 +40,8 @@ import {RequestSwitchmap} from "core-app/helpers/rxjs/request-switchmap";
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WorkPackageCardViewComponent implements OnInit {
@Input() public dragAndDropEnabled:boolean;
@Input() public dragInto:boolean;
@Input() public dragOutOf:boolean;
@Input() public highlightingMode:CardHighlightingMode;
@Input() public workPackageAddedHandler:(wp:WorkPackageResource) => Promise<unknown>;
@Input() public showStatusButton:boolean = true;
@ -133,10 +132,6 @@ export class WorkPackageCardViewComponent implements OnInit {
this.dragService.remove(this.container.nativeElement);
}
public hasAssignee(wp:WorkPackageResource) {
return !!wp.assignee;
}
public handleDblClick(wp:WorkPackageResource) {
this.goToWpFullView(wp.id!);
}
@ -179,7 +174,8 @@ export class WorkPackageCardViewComponent implements OnInit {
this.dragService.register({
dragContainer: this.container.nativeElement,
scrollContainers: [this.container.nativeElement],
moves: (card:HTMLElement) => this.dragAndDropEnabled && !card.dataset.isNew,
moves: (card:HTMLElement) => this.dragOutOf && !card.dataset.isNew,
accepts: () => this.dragInto,
onMoved: (card:HTMLElement) => {
const wpId:string = card.dataset.workPackageId!;
const toIndex = DragAndDropHelpers.findIndex(card);

@ -3,6 +3,7 @@ import {QueryResource} from "core-app/modules/hal/resources/query-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {Component} from "@angular/compiler/src/core";
import {ComponentType} from "@angular/cdk/portal";
import {OpContextMenuItem} from "core-components/op-context-menu/op-context-menu.types";
export interface BoardActionService {
@ -11,12 +12,19 @@ export interface BoardActionService {
*/
localizedName:string;
/**
* Returns the current filter value ID if any
* @param query
* @returns /api/v3/status/:id if a status filter exists
*/
getFilterHref(query:QueryResource):string|undefined;
/**
* Returns the current filter value if any
* @param query
* @returns /api/v3/status/:id if a status filter exists
*/
getFilterValue(query:QueryResource):string|undefined;
getLoadedFilterValue(query:QueryResource):Promise<HalResource|undefined>;
/**
* Add initial queries to a new board
@ -39,11 +47,26 @@ export interface BoardActionService {
* Get action specific items that shall be shown in the list menu
* @returns {any[]}
*/
getAdditionalListMenuItems(actionAttributeValue:HalResource):Promise<any>;
getAdditionalListMenuItems(query:QueryResource):Promise<OpContextMenuItem[]>;
/**
* Determine whether we can drag items into a given query for the
* selected action value
*
* @param query
* @param value
*/
dragIntoAllowed(query:QueryResource, value:HalResource|undefined):boolean;
/**
* Get the specific component for the autocompleter (e.g versionAutocompleter)
* @returns {Component}
*/
autocompleterComponent():ComponentType<any>;
autocompleterComponent():ComponentType<unknown>;
/**
* Get the specific header component for the board list, or undefined if none
* @returns {Component}
*/
headerComponent():ComponentType<unknown>|undefined;
}

@ -9,12 +9,15 @@ import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
import {CreateAutocompleterComponent} from "core-app/modules/common/autocomplete/create-autocompleter.component";
import {OpContextMenuItem} from "core-components/op-context-menu/op-context-menu.types";
import {StatusCacheService} from "core-components/statuses/status-cache.service";
@Injectable()
export class BoardStatusActionService implements BoardActionService {
constructor(protected boardListsService:BoardListsService,
protected I18n:I18nService,
protected statusCache:StatusCacheService,
protected statusDm:StatusDmService) {
}
@ -27,7 +30,7 @@ export class BoardStatusActionService implements BoardActionService {
* @param query
* @returns /api/v3/status/:id if a status filter exists
*/
public getFilterValue(query:QueryResource):string|undefined {
public getFilterHref(query:QueryResource):string|undefined {
const filter = _.find(query.filters, filter => filter.id === 'status');
if (filter) {
@ -38,6 +41,21 @@ export class BoardStatusActionService implements BoardActionService {
return;
}
/**
* Returns the loaded status
* @param query
*/
public getLoadedFilterValue(query:QueryResource):Promise<undefined|StatusResource> {
const href = this.getFilterHref(query);
if (href) {
const id = HalResource.idFromLink(href);
return this.statusCache.require(id);
} else {
return Promise.resolve(undefined);
}
}
public addActionQueries(board:Board):Promise<Board> {
return this.getStatuses()
.then((results) =>
@ -77,7 +95,7 @@ export class BoardStatusActionService implements BoardActionService {
*/
public getAvailableValues(board:Board, queries:QueryResource[]):Promise<HalResource[]> {
const active = new Set(
queries.map(query => this.getFilterValue(query))
queries.map(query => this.getFilterHref(query))
);
return this.getStatuses()
@ -86,14 +104,22 @@ export class BoardStatusActionService implements BoardActionService {
);
}
public getAdditionalListMenuItems(actionAttributeValue:HalResource):Promise<any> {
public getAdditionalListMenuItems(query:QueryResource):Promise<OpContextMenuItem[]> {
return Promise.resolve([]);
}
dragIntoAllowed(query:QueryResource, value:HalResource|undefined) {
return true;
}
public autocompleterComponent() {
return CreateAutocompleterComponent;
}
public headerComponent() {
return undefined;
}
private getStatuses():Promise<StatusResource[]> {
return this.statusDm
.list()

@ -1,133 +0,0 @@
import {Injectable} from "@angular/core";
import {BoardListsService} from "core-app/modules/boards/board/board-list/board-lists.service";
import {Board} from "core-app/modules/boards/board/board";
import {QueryResource} from "core-app/modules/hal/resources/query-resource";
import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
import {VersionResource} from "core-app/modules/hal/resources/version-resource";
import {VersionDmService} from "core-app/modules/hal/dm-services/version-dm.service";
import {CurrentProjectService} from "core-components/projects/current-project.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {VersionAutocompleterComponent} from "core-app/modules/common/autocomplete/version-autocompleter.component";
@Injectable()
export class BoardVersionActionService implements BoardActionService {
constructor(protected boardListsService:BoardListsService,
protected I18n:I18nService,
protected versionDm:VersionDmService,
protected currentProject:CurrentProjectService,
protected pathHelper:PathHelperService) {
}
public get localizedName() {
return this.I18n.t('js.work_packages.properties.version');
}
/**
* Returns the current filter value if any
* @param query
* @returns /api/v3/versions/:id if a version filter exists
*/
public getFilterValue(query:QueryResource):string|undefined {
const filter = _.find(query.filters, filter => filter.id === 'version');
if (filter) {
const value = filter.values[0] as string|HalResource;
return (value instanceof HalResource) ? value.href! : value;
}
return;
}
public addActionQueries(board:Board):Promise<Board> {
return this.getVersions()
.then((results) => {
return Promise.all<unknown>(
results.map((version:VersionResource) => {
if (version.definingProject.name === this.currentProject.name) {
return this.addActionQuery(board, version);
}
return Promise.resolve(board);
})
)
.then(() => board);
});
}
public addActionQuery(board:Board, value:HalResource):Promise<Board> {
let params:any = {
name: value.name,
};
let filter = { version: {
operator: '=' as FilterOperator,
values: [value.id]
}};
return this.boardListsService.addQuery(board, params, [filter]);
}
/**
* Return available versions for new lists, given the list of active
* queries in the board.
*
* @param board The board we're looking at
* @param queries The active set of queries
*/
public getAvailableValues(board:Board, queries:QueryResource[]):Promise<HalResource[]> {
const active = new Set(
queries.map(query => this.getFilterValue(query))
);
return this.getVersions()
.then(results =>
results.filter(version => !active.has(version.href!))
);
}
/**
* Adds an entry to the list menu to edit the version if allowed
* @param {HalResource} actionAttributeValue
* @returns {Promise<any>}
*/
public getAdditionalListMenuItems(actionAttributeValue:HalResource):Promise<any> {
let items:any = [];
const actionID = actionAttributeValue.id;
if (actionID) {
return this.versionDm.one(parseInt(actionID)).then((version) => {
// Show entry only with correct permissions
if (version.$links.update) {
items.push(
{
linkText: this.I18n.t('js.boards.lists.edit_version'),
externalAction: () => window.open(this.pathHelper.versionEditPath(actionID), '_blank')
}
);
}
return items;
});
} else {
return Promise.resolve(items);
}
}
public autocompleterComponent() {
return VersionAutocompleterComponent;
}
private getVersions():Promise<VersionResource[]> {
if (this.currentProject.id === null) {
return Promise.resolve([]);
}
return this.versionDm
.listForProject(this.currentProject.id)
.then(collection => collection.elements.filter(version => version.status === 'open'));
}
}

@ -0,0 +1,223 @@
import {Injectable} from "@angular/core";
import {BoardListsService} from "core-app/modules/boards/board/board-list/board-lists.service";
import {Board} from "core-app/modules/boards/board/board";
import {QueryResource} from "core-app/modules/hal/resources/query-resource";
import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
import {VersionResource} from "core-app/modules/hal/resources/version-resource";
import {VersionDmService} from "core-app/modules/hal/dm-services/version-dm.service";
import {CurrentProjectService} from "core-components/projects/current-project.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {VersionAutocompleterComponent} from "core-app/modules/common/autocomplete/version-autocompleter.component";
import {OpContextMenuItem} from "core-components/op-context-menu/op-context-menu.types";
import {LinkHandling} from "core-app/modules/common/link-handling/link-handling";
import {StateService} from "@uirouter/core";
import {WorkPackageNotificationService} from "core-components/wp-edit/wp-notification.service";
import {StatusResource} from "core-app/modules/hal/resources/status-resource";
import {VersionCacheService} from "core-components/versions/version-cache.service";
import {VersionBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/version/version-board-header.component";
@Injectable()
export class BoardVersionActionService implements BoardActionService {
constructor(protected boardListsService:BoardListsService,
protected I18n:I18nService,
protected versionDm:VersionDmService,
protected versionCache:VersionCacheService,
protected currentProject:CurrentProjectService,
protected wpNotifications:WorkPackageNotificationService,
protected state:StateService,
protected pathHelper:PathHelperService) {
}
public get localizedName() {
return this.I18n.t('js.work_packages.properties.version');
}
/**
* Returns the current filter value if any
* @param query
* @returns /api/v3/versions/:id if a version filter exists
*/
public getFilterHref(query:QueryResource):string|undefined {
const filter = _.find(query.filters, filter => filter.id === 'version');
if (filter) {
const value = filter.values[0] as string|HalResource;
return (value instanceof HalResource) ? value.href! : value;
}
return;
}
/**
* Returns the loaded status
* @param query
*/
public getLoadedFilterValue(query:QueryResource):Promise<undefined|VersionResource> {
const href = this.getFilterHref(query);
if (href) {
const id = HalResource.idFromLink(href);
return this.versionCache.require(id);
} else {
return Promise.resolve(undefined);
}
}
public addActionQueries(board:Board):Promise<Board> {
return this.getVersions()
.then((results) => {
return Promise.all<unknown>(
results.map((version:VersionResource) => {
if (version.isOpen() && version.definingProject.name === this.currentProject.name) {
return this.addActionQuery(board, version);
}
return Promise.resolve(board);
})
)
.then(() => board);
});
}
public addActionQuery(board:Board, value:HalResource):Promise<Board> {
let params:any = {
name: value.name,
};
let filter = {
version: {
operator: '=' as FilterOperator,
values: [value.id]
}
};
return this.boardListsService.addQuery(board, params, [filter]);
}
/**
* Return available versions for new lists, given the list of active
* queries in the board.
*
* @param board The board we're looking at
* @param queries The active set of queries
*/
public getAvailableValues(board:Board, queries:QueryResource[]):Promise<HalResource[]> {
const active = new Set(
queries.map(query => this.getFilterHref(query))
);
return this.getVersions()
.then(results =>
results.filter(version => !active.has(version.href!))
);
}
/**
* Adds an entry to the list menu to edit the version if allowed
* @param {QueryResource} active query
* @returns {Promise<any>}
*/
public getAdditionalListMenuItems(query:QueryResource):Promise<OpContextMenuItem[]> {
return this
.getLoadedFilterValue(query)
.then(version => {
if (version) {
return this.buildItemsForVersion(version);
} else {
return [];
}
});
}
public autocompleterComponent() {
return VersionAutocompleterComponent;
}
public headerComponent() {
return VersionBoardHeaderComponent;
}
public dragIntoAllowed(query:QueryResource, value:HalResource|undefined) {
return value instanceof VersionResource && value.isOpen();
}
private getVersions():Promise<VersionResource[]> {
if (this.currentProject.id === null) {
return Promise.resolve([]);
}
return this.versionDm
.listForProject(this.currentProject.id)
.then(collection => collection.elements);
}
private patchVersionStatus(version:VersionResource, newStatus:'open'|'closed'|'locked') {
this.versionDm
.patch(version, {status: newStatus })
.then((version) => {
this.versionCache.updateValue(version.id!, version);
this.state.go('.', {}, { reload: true });
})
.catch(error => this.wpNotifications.handleRawError(error));
}
private buildItemsForVersion(version:VersionResource):OpContextMenuItem[] {
const id = version.id!;
return [
{
// Lock version
hidden: !version.isOpen(),
linkText: this.I18n.t('js.boards.version.lock_version'),
onClick: () => {
this.patchVersionStatus(version, 'locked');
return true;
}
},
{
// Unlock version
hidden: !version.isLocked(),
linkText: this.I18n.t('js.boards.version.unlock_version'),
onClick: () => {
this.patchVersionStatus(version, 'open');
return true;
}
},
{
// Close version
hidden: version.isClosed(),
linkText: this.I18n.t('js.boards.version.close_version'),
onClick: () => {
this.patchVersionStatus(version, 'closed');
return true;
}
},
{
// Open version
hidden: !version.isClosed(),
linkText: this.I18n.t('js.boards.version.open_version'),
onClick: () => {
this.patchVersionStatus(version, 'open');
return true;
}
},
{
// Edit link
hidden: !version.$links.update,
linkText: this.I18n.t('js.boards.version.edit_version'),
href: this.pathHelper.versionEditPath(id),
onClick: (evt:JQuery.Event) => {
if (!LinkHandling.isClickedWithModifier(evt)) {
window.open(this.pathHelper.versionEditPath(id), '_blank');
return true;
}
return false;
}
}
];
}
}

@ -0,0 +1,48 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
import {ChangeDetectionStrategy, Component, Input} from "@angular/core";
import {VersionResource} from "core-app/modules/hal/resources/version-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
templateUrl: './version-board-header.html',
styleUrls: ['./version-board-header.sass'],
host: { 'class': 'title-container -small' }
})
export class VersionBoardHeaderComponent {
@Input('resource') public version:VersionResource;
constructor(private I18n:I18nService) {
}
public text = {
isLocked: this.I18n.t('js.boards.version.is_locked'),
isClosed: this.I18n.t('js.boards.version.is_closed')
};
}

@ -0,0 +1,17 @@
<div class="version-board-header"
[ngClass]="{ '-closed': version.isClosed(), '-locked': version.isLocked() }"
*ngIf="version">
<span *ngIf="version.isLocked()"
[attr.title]="text.isLocked"
class="icon-locked icon-context">
</span>
<span *ngIf="version.isClosed()"
[attr.title]="text.isClosed"
class="icon-remove icon-context">
</span>
<h2 [textContent]="version.name"
class="editable-toolbar-title--fixed">
</h2>
</div>

@ -0,0 +1,3 @@
.version-board-header
display: flex
align-items: center

@ -34,19 +34,14 @@ import {OPContextMenuService} from 'core-components/op-context-menu/op-context-m
import {OpModalService} from "core-components/op-modals/op-modal.service";
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
import {BoardListComponent} from "core-app/modules/boards/board/board-list/board-list.component";
import {BoardListService} from "core-app/modules/boards/board/board-list/board-list.service";
import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service";
import {Board} from "core-app/modules/boards/board/board";
import {BoardActionsRegistryService} from "core-app/modules/boards/board/board-actions/board-actions-registry.service";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {OpContextMenuItem} from "core-components/op-context-menu/op-context-menu.types";
@Directive({
selector: '[boardListDropdown]'
})
export class BoardListDropdownMenuDirective extends OpContextMenuTrigger {
/** Action service used by the board */
public actionService:BoardActionService;
private board:Board;
constructor(readonly elementRef:ElementRef,
@ -58,15 +53,14 @@ export class BoardListDropdownMenuDirective extends OpContextMenuTrigger {
readonly querySpace:IsolatedQuerySpace,
readonly cdRef:ChangeDetectorRef,
readonly I18n:I18nService,
readonly BoardListService:BoardListService,
readonly boardActions:BoardActionsRegistryService) {
super(elementRef, opContextMenu);
this.board = this.boardList.board;
}
protected open(evt:JQueryEventObject) {
this.items = this.buildItems();
protected async open(evt:JQueryEventObject) {
this.items = await this.buildItems();
this.opContextMenu.show(this, evt);
}
@ -87,8 +81,8 @@ export class BoardListDropdownMenuDirective extends OpContextMenuTrigger {
return position;
}
private buildItems() {
this.items = [
private async buildItems() {
let items:OpContextMenuItem[] = [
{
disabled: !this.boardList.canDelete,
linkText: this.I18n.t('js.boards.lists.delete'),
@ -101,26 +95,13 @@ export class BoardListDropdownMenuDirective extends OpContextMenuTrigger {
// Add action specific menu entries
if (this.board.isAction) {
this.actionService = this.boardActions.get(this.board.actionAttribute!);
this.querySpace.query.values$().subscribe((query) => {
const actionAttributeValue = this.BoardListService.getActionAttributeValue(this.board, query);
const actionService = this.boardActions.get(this.board.actionAttribute!);
const query = this.querySpace.query.value!;
if (actionAttributeValue !== '') {
this.actionService.getAdditionalListMenuItems(actionAttributeValue).then((items) => {
items.forEach((item:any) => {
this.items.push({
linkText: item.linkText,
onClick: () => {
item.externalAction();
return true;
}
});
});
});
}
});
const additional = await actionService.getAdditionalListMenuItems(query);
return items.concat(additional);
}
return this.items;
return items;
}
}

@ -6,11 +6,16 @@
<div *ngIf="board.isAction"
class="board-list--action-bar"
[ngClass]="boardListActionColorClass(query)">
[ngClass]="actionResourceClass">
</div>
<div class="board-list--header">
<editable-toolbar-title [title]="query.name"
<ndc-dynamic *ngIf="headerComponent"
[ndcDynamicComponent]="headerComponent"
[ndcDynamicInputs]="{ resource: actionResource }">
</ndc-dynamic>
<editable-toolbar-title *ngIf="!headerComponent"
[title]="query.name"
[smallHeader]="true"
[inFlight]="inFlight"
(onSave)="renameQuery(query, $event)"
@ -41,9 +46,10 @@
<op-icon icon-classes="icon-small icon-add"></op-icon>
</button>
<wp-card-view [dragAndDropEnabled]="canEdit"
<wp-card-view [dragOutOf]="canDragOutOf"
[dragInto]="canDragInto"
[workPackageAddedHandler]="workPackageAddedHandler"
[cardsRemovable]="board.isFree && canEdit"
[cardsRemovable]="board.isFree && canDragOutOf"
[highlightingMode]="board.highlightingMode"
[showStatusButton]="showCardStatusButton()">
</wp-card-view>

@ -41,7 +41,9 @@ import {IWorkPackageEditingServiceToken} from "core-components/wp-edit-form/work
import {WorkPackageEditingService} from "core-components/wp-edit-form/work-package-editing-service";
import {WorkPackageCacheService} from "core-components/work-packages/work-package-cache.service";
import {WorkPackageNotificationService} from "core-components/wp-edit/wp-notification.service";
import {BoardListService} from "core-app/modules/boards/board/board-list/board-list.service";
import {BoardActionsRegistryService} from "core-app/modules/boards/board/board-actions/board-actions-registry.service";
import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service";
import {ComponentType} from "@angular/cdk/portal";
@Component({
selector: 'board-list',
@ -73,6 +75,11 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
/** Query loading error, if present */
public loadingError:string|undefined;
/** The action attribute resource if any */
public actionResource:HalResource|undefined;
public actionResourceClass:string = '';
public headerComponent:ComponentType<unknown>|undefined;
/** Rename inFlight */
public inFlight:boolean;
@ -90,7 +97,8 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
};
/** Are we allowed to remove and drag & drop elements ? */
public canEdit:boolean = false;
public canDragInto:boolean = false;
public canDragOutOf:boolean = false;
/** Initially focus the list */
public initiallyFocused:boolean = false;
@ -113,7 +121,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
private readonly loadingIndicator:LoadingIndicatorService,
private readonly wpCacheService:WorkPackageCacheService,
private readonly boardService:BoardService,
private readonly boardListService:BoardListService) {
private readonly boardActionRegistry:BoardActionsRegistryService) {
super(I18n);
}
@ -128,7 +136,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
this.authorisationService
.observeUntil(componentDestroyed(this))
.subscribe(() => {
this.showAddButton = this.canEdit && (this.wpInlineCreate.canAdd || this.canReference);
this.showAddButton = this.canDragInto && (this.wpInlineCreate.canAdd || this.canReference);
});
this.querySpace.query
@ -138,7 +146,8 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
)
.subscribe((query) => {
this.query = query;
this.canEdit = !!this.query.updateOrderedWorkPackages;
this.canDragOutOf = !!this.query.updateOrderedWorkPackages;
this.loadActionAttribute(query);
});
this.updateQuery();
@ -211,10 +220,13 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
.catch(() => this.inFlight = false);
}
public boardListActionColorClass(query:QueryResource):string {
private boardListActionColorClass(value?:HalResource):string {
const attribute = this.board.actionAttribute!;
const value = this.boardListService.getActionAttributeValue(this.board, query) as HalResource;
if (value && value.id) {
return Highlighting.backgroundClass(attribute, value.id!);
} else {
return '';
}
}
public get listName() {
@ -230,6 +242,38 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
this.loadQuery();
}
private loadActionAttribute(query:QueryResource) {
if (!this.board.isAction) {
this.actionResource = undefined;
this.headerComponent = undefined;
this.canDragInto = !!query.updateOrderedWorkPackages;
return;
}
const id = this.actionService.getFilterHref(query);
// Test if we loaded the resource already
if (this.actionResource && id === this.actionResource.href) {
return;
}
// Load the resource
this.actionService.getLoadedFilterValue(query).then(resource => {
this.actionResource = resource;
this.headerComponent = this.actionService.headerComponent();
this.actionResourceClass = this.boardListActionColorClass(resource);
this.canDragInto = this.actionService.dragIntoAllowed(query, resource);
this.showAddButton = this.canDragInto && (this.wpInlineCreate.canAdd || this.canReference);
});
}
/**
* Return the linked action service
*/
private get actionService():BoardActionService {
return this.boardActionRegistry.get(this.board.actionAttribute!);
}
/**
* Handler to properly update the work package, when
* adding to this query requires saving a changeset.

@ -1,19 +0,0 @@
import {Injectable} from "@angular/core";
import {Board} from "core-app/modules/boards/board/board";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {QueryResource} from "core-app/modules/hal/resources/query-resource";
@Injectable()
export class BoardListService {
public getActionAttributeValue(board:Board, query:QueryResource) {
const attribute = board.actionAttribute!;
const filter = _.find(query.filters, f => f.id === attribute);
if (!(filter && filter.values[0] instanceof HalResource)) {
return '';
}
const value = filter.values[0] as HalResource;
return value;
}
}

@ -3,7 +3,6 @@ import {DOCUMENT} from "@angular/common";
import {DragAndDropHelpers} from "core-app/modules/boards/drag-and-drop/drag-and-drop.helpers";
import {DomAutoscrollService} from "core-app/modules/common/drag-and-drop/dom-autoscroll.service";
export interface DragMember {
dragContainer:HTMLElement;
scrollContainers:HTMLElement[];
@ -15,6 +14,9 @@ export interface DragMember {
onAdded:(row:HTMLElement, target:any, source:HTMLElement, sibling:HTMLElement|null) => Promise<boolean>;
/** Remove element from this container */
onRemoved:(row:HTMLElement, target:any, source:HTMLElement, sibling:HTMLElement|null) => void;
/** Move this container accepts elements */
accepts?:(row:HTMLElement, container:any) => boolean;
}
@Injectable()
@ -96,21 +98,24 @@ export class DragAndDropService implements OnDestroy {
});
}
/**
* Retrieve a member from the container, if one exists.
* @param container
*/
protected getMember(container:Element):DragMember|undefined {
return this.members.find(member => member.dragContainer === container);
}
protected initializeDrake(containers:Element[]) {
this.drake = dragula(containers, {
moves: (el:any, container:any, handle:any, sibling:any) => {
let result = false;
this.members.forEach(member => {
if (member.dragContainer === container) {
result = member.moves(el, container, handle, sibling);
return;
}
});
return result;
const member = this.getMember(container);
return member ? member.moves(el, container, handle, sibling) : false;
},
accepts:(el:any, container:any) => {
const member = this.getMember(container);
return (member && member.accepts) ? member.accepts(el, container) : true;
},
accepts: () => true,
invalid: () => false,
direction: 'vertical', // Y axis is considered when determining where an element would be dropped
copy: false, // elements are moved by default, not copied

@ -44,8 +44,6 @@ import {BoardsIndexPageComponent} from "core-app/modules/boards/index-page/board
import {BoardsMenuComponent} from "core-app/modules/boards/boards-sidebar/boards-menu.component";
import {BoardDmService} from "core-app/modules/boards/board/board-dm.service";
import {NewBoardModalComponent} from "core-app/modules/boards/new-board-modal/new-board-modal.component";
import {BoardStatusActionService} from "core-app/modules/boards/board/board-actions/status-action.service";
import {BoardVersionActionService} from "core-app/modules/boards/board/board-actions/version-action.service";
import {BoardActionsRegistryService} from "core-app/modules/boards/board/board-actions/board-actions-registry.service";
import {AddListModalComponent} from "core-app/modules/boards/board/add-list-modal/add-list-modal.component";
import {BoardHighlightingTabComponent} from "core-app/modules/boards/board/configuration-modal/tabs/highlighting-tab.component";
@ -53,7 +51,10 @@ import {AddCardDropdownMenuDirective} from "core-app/modules/boards/board/add-ca
import {BoardFilterComponent} from "core-app/modules/boards/board/board-filter/board-filter.component";
import {DragScrollModule} from "cdk-drag-scroll";
import {BoardListDropdownMenuDirective} from "core-app/modules/boards/board/board-list/board-list-dropdown.directive";
import {BoardListService} from "core-app/modules/boards/board/board-list/board-list.service";
import {VersionBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/version/version-board-header.component";
import {DynamicModule} from "ng-dynamic-component";
import {BoardStatusActionService} from "core-app/modules/boards/board/board-actions/status/status-action.service";
import {BoardVersionActionService} from "core-app/modules/boards/board/board-actions/version/version-action.service";
const menuItemClass = 'board-view-menu-item';
@ -129,6 +130,9 @@ export function registerBoardsModule(injector:Injector) {
OpenprojectWorkPackagesModule,
DragScrollModule,
// Dynamic Module for actions
DynamicModule.withComponents([VersionBoardHeaderComponent]),
// Routes for /boards
UIRouterModule.forChild({
states: BOARDS_ROUTES,
@ -138,7 +142,6 @@ export function registerBoardsModule(injector:Injector) {
providers: [
BoardService,
BoardDmService,
BoardListService,
BoardListsService,
BoardCacheService,
BoardConfigurationService,
@ -167,6 +170,7 @@ export function registerBoardsModule(injector:Injector) {
AddCardDropdownMenuDirective,
BoardListDropdownMenuDirective,
BoardFilterComponent,
VersionBoardHeaderComponent,
],
entryComponents: [
BoardInlineAddAutocompleterComponent,
@ -175,6 +179,7 @@ export function registerBoardsModule(injector:Injector) {
BoardHighlightingTabComponent,
NewBoardModalComponent,
AddListModalComponent,
VersionBoardHeaderComponent,
]
})
export class OpenprojectBoardsModule {

@ -6,10 +6,6 @@
padding: 0
cursor: default
&.-small
font-size: 1.2rem
line-height: 32px
.editable-toolbar-title--save
span:before
color: #5F5F5F

@ -28,7 +28,7 @@
export namespace LinkHandling {
export function isClickedWithModifier(event:JQueryEventObject) {
export function isClickedWithModifier(event:JQueryEventObject|JQuery.Event) {
const modifier = event.ctrlKey || event.shiftKey || event.metaKey;
const middleButton = event.button === 1;

@ -38,7 +38,7 @@ export class StatusDmService {
protected pathHelper:PathHelperService) {
}
public one(id:number):Promise<StatusResource> {
public one(id:string|number):Promise<StatusResource> {
return this.halResourceService
.get<StatusResource>(this.pathHelper.api.v3.statuses.id(id).toString())
.toPromise();

@ -33,6 +33,9 @@ import {VersionResource} from "core-app/modules/hal/resources/version-resource";
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
import {GridResource} from "core-app/modules/hal/resources/grid-resource";
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
import {Observable} from "rxjs";
@Injectable()
export class VersionDmService {
@ -53,7 +56,7 @@ export class VersionDmService {
}
public one(id:number):Promise<VersionResource> {
public one(id:string|number):Promise<VersionResource> {
return this.halResourceService
.get<VersionResource>(this.pathHelper.api.v3.versions.id(id).toString())
.toPromise();
@ -76,4 +79,12 @@ export class VersionDmService {
.get<CollectionResource<ProjectResource>>(this.pathHelper.api.v3.versions.availableProjects.toString())
.toPromise();
}
public patch(resource:VersionResource, payload:Object):Promise<VersionResource> {
return this.halResourceService
.patch<VersionResource>(
this.pathHelper.api.v3.versions.id(resource.id!).toString(),
payload)
.toPromise();
}
}

@ -32,5 +32,17 @@ export class VersionResource extends HalResource {
status:string;
public definingProject:HalResource;
public isLocked() {
return this.status === 'locked';
}
public isOpen() {
return this.status === 'open';
}
public isClosed() {
return this.status === 'closed';
}
}

@ -57,6 +57,7 @@ import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-res
import {GridResource} from "core-app/modules/hal/resources/grid-resource";
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource";
import {NewsResource} from "core-app/modules/hal/resources/news-resource";
import {VersionResource} from "core-app/modules/hal/resources/version-resource";
const halResourceDefaultConfig:{ [typeName:string]:HalResourceFactoryConfigInterface } = {
WorkPackage: {
@ -172,6 +173,9 @@ const halResourceDefaultConfig:{ [typeName:string]:HalResourceFactoryConfigInter
},
News: {
cls: NewsResource
},
Version: {
cls: VersionResource
}
};

@ -67,6 +67,14 @@ module API
}
end
link :delete,
cache_if: -> { current_user_allowed_to(:manage_versions, context: represented.project) } do
{
href: api_v3_paths.version(represented.id),
method: :delete
}
end
associated_resource :project,
as: :definingProject,
skip_render: ->(*) { !represented.project || !represented.project.visible?(current_user) }

@ -6,6 +6,23 @@ en:
label_unnamed_list: 'Unnamed list'
label_board_type: 'Board type'
upsale:
teaser_text: 'Improve your agile project management with this flexible Boards view. Create as many boards as you like for anything you would like to keep track of.'
upgrade_to_ee_text: 'Boards is an Enterprise feature. Please upgrade to a paid plan.'
upgrade: 'Upgrade now'
personal_demo: 'Get a personal demo'
lists:
delete: 'Delete list'
version:
is_locked: 'Version is locked. No items can be added to this version.'
is_closed: 'Version is closed. No items can be added to this version.'
close_version: 'Close version'
open_version: 'Open version'
lock_version: 'Lock version'
unlock_version: 'Unlock version'
edit_version: 'Edit version'
new_board: 'New board'
add_list: 'Add list'
add_card: 'Add card'

@ -67,6 +67,7 @@ describe 'Version action board', type: :feature, js: true do
let!(:closed_version) { FactoryBot.create :version, project: project, status: 'closed', name: 'Closed version' }
let!(:work_package) { FactoryBot.create :work_package, project: project, subject: 'Foo', fixed_version: open_version }
let!(:closed_version_wp) { FactoryBot.create :work_package, project: project, subject: 'Closed', fixed_version: closed_version }
let(:filters) { ::Components::WorkPackages::Filters.new }
def create_new_version_board
@ -101,7 +102,7 @@ describe 'Version action board', type: :feature, js: true do
board_page.expect_card 'Open version', work_package.subject, present: true
board_page.expect_list_option 'Shared version'
board_page.expect_list_option 'Closed version', present: false
board_page.expect_list_option 'Closed version'
board_page.board(reload: true) do |board|
expect(board.name).to eq 'Action board (version)'
@ -204,7 +205,7 @@ describe 'Version action board', type: :feature, js: true do
expect(subjects).to match_array [['Task 1', other_version.id]]
end
it 'allows adding new versions from within the board' do
it 'allows adding new and closed versions from within the board' do
board_page = create_new_version_board
# Add new version (and list)
@ -213,6 +214,55 @@ describe 'Version action board', type: :feature, js: true do
visit settings_project_path(project, tab: 'versions')
expect(page).to have_content 'Completely new version'
board_page.visit!
board_page.add_list nil, value: closed_version.name
board_page.expect_list 'Closed version'
expect(page).to have_selector('.version-board-header.-closed')
# Can open that version
board_page.click_list_dropdown 'Closed version', 'Open version'
expect(page).to have_no_selector('.version-board-header.-closed')
closed_version.reload
expect(closed_version.status).to eq 'open'
# Can lock that version
board_page.click_list_dropdown 'Closed version', 'Lock version'
expect(page).to have_selector('.version-board-header.-locked')
closed_version.reload
expect(closed_version.status).to eq 'locked'
# We can move out of the locked version
board_page.move_card(0, from: 'Closed version', to: 'Open version')
board_page.expect_card('Open version', 'Closed', present: true)
board_page.expect_card('Closed version', 'Closed', present: false)
# Expect work package to be saved in query second
sleep 2
queries = board_page.board(reload: true).contained_queries
open = queries.find_by(name: 'Open version')
closed = queries.find_by(name: 'Closed version')
retry_block do
expect(open.reload.ordered_work_packages.count).to eq(2)
expect(closed.reload.ordered_work_packages.count).to eq(0)
end
subjects = WorkPackage.where(id: open.ordered_work_packages).pluck(:id)
expect(subjects).to match_array [work_package.id, closed_version_wp.id]
closed_version_wp.reload
expect(closed_version_wp.fixed_version_id).to eq(open_version.id)
# But we can not move back to closed
board_page.move_card(0, from: 'Open version', to: 'Closed version')
board_page.expect_card('Open version', 'Closed', present: true)
board_page.expect_card('Closed version', 'Closed', present: false)
board_page.expect_card('Closed version', 'Foo', present: false)
end
end

@ -181,11 +181,11 @@ module Pages
end
def expect_list(name)
expect(page).to have_selector('editable-toolbar-title', text: name)
expect(page).to have_selector('.board-list--header', text: name)
end
def expect_no_list(name)
expect(page).not_to have_selector('editable-toolbar-title', text: name)
expect(page).not_to have_selector('.board-list--header', text: name)
end
def expect_empty
@ -193,12 +193,7 @@ module Pages
end
def remove_list(name)
within_list(name) do
page.find('.board-list--header').hover
page.find('.board-list--menu a').click
end
page.find('.dropdown-menu a', text: 'Delete list').click
click_list_dropdown name, 'Delete list'
accept_alert_dialog!
expect_and_dismiss_notification message: I18n.t('js.notice_successful_update')
@ -206,6 +201,15 @@ module Pages
expect(page).to have_no_selector list_selector(name)
end
def click_list_dropdown(list_name, action)
within_list(list_name) do
page.find('.board-list--header').hover
page.find('.board-list--menu a').click
end
page.find('.dropdown-menu a', text: action).click
end
def expect_list_option(name, present: true)
open_and_fill_add_list_modal name

Loading…
Cancel
Save