Merge pull request #7152 from opf/feature/29519/board-wide-filters

[29519] Implement board-wide filters

[ci skip]
pull/7157/head
Oliver Günther 6 years ago committed by GitHub
commit 821108156b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 61
      frontend/src/app/components/api/api-v3/query-filter-builder.ts
  2. 14
      frontend/src/app/components/wp-fast-table/state/wp-table-filters.service.ts
  3. 28
      frontend/src/app/components/wp-query/url-params-helper.ts
  4. 12
      frontend/src/app/modules/boards/board/board-actions/status-action.service.ts
  5. 1
      frontend/src/app/modules/boards/board/board-filter/board-filter.component.html
  6. 92
      frontend/src/app/modules/boards/board/board-filter/board-filter.component.ts
  7. 48
      frontend/src/app/modules/boards/board/board-list/board-list.component.ts
  8. 29
      frontend/src/app/modules/boards/board/board-list/board-lists.service.ts
  9. 80
      frontend/src/app/modules/boards/board/board.component.html
  10. 3
      frontend/src/app/modules/boards/board/board.component.sass
  11. 23
      frontend/src/app/modules/boards/board/board.component.ts
  12. 9
      frontend/src/app/modules/boards/board/board.ts
  13. 8
      frontend/src/app/modules/boards/index-page/boards-index-page.component.html
  14. 2
      frontend/src/app/modules/boards/index-page/boards-index-page.component.sass
  15. 15
      frontend/src/app/modules/boards/index-page/boards-index-page.component.ts
  16. 15
      frontend/src/app/modules/boards/new-board-modal/new-board-modal.component.ts
  17. 8
      frontend/src/app/modules/boards/new-board-modal/new-board-modal.html
  18. 8
      frontend/src/app/modules/boards/openproject-boards.module.ts
  19. 12
      frontend/src/app/modules/hal/dm-services/query-form-dm.service.ts
  20. 5
      frontend/src/app/modules/hal/resources/query-filter-instance-resource.ts
  21. 17
      modules/boards/spec/features/board_management_spec.rb
  22. 42
      modules/boards/spec/features/status_board_spec.rb
  23. 13
      modules/boards/spec/features/support/board_page.rb
  24. 19
      spec/support/components/work_packages/filters.rb

@ -1,61 +0,0 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General 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 {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
import {ApiV3Paths} from "core-app/modules/common/path-helper/apiv3/apiv3-paths";
export class QueryFilterBuilder {
constructor(readonly v3:ApiV3Paths) {
}
/**
* Build a query filter object by hand.
*
* @param id
* @param operator
* @param values
*/
public build(id:string, operator:FilterOperator, values:any[]):Object {
return {
"_type": "QueryFilter",
"_links": {
"filter": {
"href": this.v3.resource("/queries/filters/" + id)
},
"schema": {
"href": this.v3.resource("/queries/filter_instance_schemas/" + id)
},
"operator": {
"href": this.v3.resource("/queries/operators/" + operator)
},
"values": values
}
};
}
}

@ -42,7 +42,7 @@ import {mapTo, take} from "rxjs/operators";
@Injectable()
export class WorkPackageTableFiltersService extends WorkPackageQueryStateService<QueryFilterInstanceResource[]> {
public hidden:Readonly<string[]> = [
public hidden:string[] = [
'id',
'parent',
'datesInterval',
@ -307,6 +307,16 @@ export class WorkPackageTableFiltersService extends WorkPackageQueryStateService
* @param filters
*/
private loadCurrentFiltersSchemas(filters:QueryFilterInstanceResource[]):Promise<unknown> {
return Promise.all(filters.map((filter:QueryFilterInstanceResource) => filter.schema.$load()));
return Promise.all(filters.map((filter:QueryFilterInstanceResource) => {
const href = `/api/v3/queries/filter_instance_schemas/${filter.id}`;
if (filter.schema) {
return filter.schema.$load();
} else {
return this.states.schemas
.get(href)
.valuesPromise()
.then(schema => filter.schema = schema as QueryFilterInstanceSchemaResource);
}
}));
}
}

@ -32,6 +32,7 @@ import {HalLink} from 'core-app/modules/hal/hal-link/hal-link';
import {Injectable} from '@angular/core';
import {PaginationService} from 'core-components/table-pagination/pagination-service';
import {QueryFilterInstanceResource} from 'core-app/modules/hal/resources/query-filter-instance-resource';
import {ApiV3Filter, FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
@Injectable()
export class UrlParamsHelperService {
@ -73,7 +74,7 @@ export class UrlParamsHelperService {
paramsData.hi = !!query.showHierarchies;
paramsData.g = _.get(query.groupBy, 'id', '');
paramsData = this.encodeSortBy(paramsData, query);
paramsData = this.encodeFilters(paramsData, query);
paramsData = this.encodeFilters(paramsData, query.filters);
paramsData.pa = additional.page;
paramsData.pp = additional.perPage;
@ -122,10 +123,9 @@ export class UrlParamsHelperService {
return paramsData;
}
private encodeFilters(paramsData:any, query:QueryResource) {
if (query.filters && query.filters.length) {
paramsData.f = query
.filters
public encodeFilters(paramsData:any, filters:QueryFilterInstanceResource[]) {
if (filters && filters.length > 0) {
paramsData.f = filters
.map((filter:any) => {
var id = filter.id;
@ -266,7 +266,7 @@ export class UrlParamsHelperService {
queryData.groupBy = _.get(query.groupBy, 'id', '');
// Filters
queryData.filters = this.buildV3GetFiltersFromQueryResoure(query);
queryData.filters = this.buildV3GetFiltersAsJson(query.filters);
// Sortation
queryData.sortBy = this.buildV3GetSortByFromQuery(query);
@ -304,19 +304,23 @@ export class UrlParamsHelperService {
}
}
private buildV3GetFiltersFromQueryResoure(query:QueryResource) {
let filters = query.filters.map((filter:QueryFilterInstanceResource) => {
public buildV3GetFilters(filters:QueryFilterInstanceResource[]):ApiV3Filter[] {
let newFilters = filters.map((filter:QueryFilterInstanceResource) => {
let id = this.buildV3GetFilterIdFromFilter(filter);
let operator = this.buildV3GetOperatorIdFromFilter(filter);
let values = this.buildV3GetValuesFromFilter(filter);
const filterHash:any = {};
filterHash[id] = { operator: operator, values: values };
const filterHash:ApiV3Filter = {};
filterHash[id] = { operator: operator as FilterOperator, values: values };
return filterHash;
});
return JSON.stringify(filters);
return newFilters;
}
public buildV3GetFiltersAsJson(filter:QueryFilterInstanceResource[]) {
return JSON.stringify(this.buildV3GetFilters(filter));
}
private buildV3GetFilterIdFromFilter(filter:QueryFilterInstanceResource) {
@ -327,7 +331,7 @@ export class UrlParamsHelperService {
private buildV3GetOperatorIdFromFilter(filter:QueryFilterInstanceResource) {
if (filter.operator) {
return filter.operator.id;
return filter.operator.id || filter.operator.idFromLink;
} else {
let href = filter._links.operator.href;

@ -4,17 +4,16 @@ import {PathHelperService} from "core-app/modules/common/path-helper/path-helper
import {Board} from "core-app/modules/boards/board/board";
import {StatusDmService} from "core-app/modules/hal/dm-services/status-dm.service";
import {StatusResource} from "core-app/modules/hal/resources/status-resource";
import {QueryFilterBuilder} from "core-components/api/api-v3/query-filter-builder";
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";
@Injectable()
export class BoardStatusActionService implements BoardActionService {
private readonly v3 = this.pathHelper.api.v3;
private queryFilterBuilder = new QueryFilterBuilder(this.v3);
constructor(protected pathHelper:PathHelperService,
protected boardListService:BoardListsService,
@ -67,11 +66,10 @@ export class BoardStatusActionService implements BoardActionService {
name: value.name,
};
let filter = this.queryFilterBuilder.build(
'status',
'=',
[{ href: this.v3.statuses.id(value.id!).toString() }]
);
let filter = { status: {
operator: '=' as FilterOperator,
values: [value.id]
}};
return this.boardListService.addQuery(board, params, [filter]);
}

@ -0,0 +1,92 @@
import {Component, Input, OnDestroy, OnInit, Output} from "@angular/core";
import {Board} from "core-app/modules/boards/board/board";
import {CurrentProjectService} from "core-components/projects/current-project.service";
import {QueryFormDmService} from "core-app/modules/hal/dm-services/query-form-dm.service";
import {WorkPackageStatesInitializationService} from "core-components/wp-list/wp-states-initialization.service";
import {QueryFormResource} from "core-app/modules/hal/resources/query-form-resource";
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
import {QueryResource} from "core-app/modules/hal/resources/query-resource";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {WorkPackageTableFiltersService} from "core-components/wp-fast-table/state/wp-table-filters.service";
import {componentDestroyed} from "ng2-rx-componentdestroyed";
import {QueryFilterInstanceResource} from "core-app/modules/hal/resources/query-filter-instance-resource";
import {UrlParamsHelperService} from "core-components/wp-query/url-params-helper";
import {StateService} from "@uirouter/core";
import {DebouncedEventEmitter} from "core-components/angular/debounced-event-emitter";
import {skip} from "rxjs/internal/operators";
import {ApiV3Filter} from "core-components/api/api-v3/api-v3-filter-builder";
@Component({
selector: 'board-filter',
templateUrl: './board-filter.component.html'
})
export class BoardFilterComponent implements OnInit, OnDestroy {
@Input() public board:Board;
@Output() public filters = new DebouncedEventEmitter<ApiV3Filter[]>(componentDestroyed(this));
constructor(private readonly currentProjectService:CurrentProjectService,
private readonly querySpace:IsolatedQuerySpace,
private readonly halResourceService:HalResourceService,
private readonly wpStatesInitialization:WorkPackageStatesInitializationService,
private readonly wpTableFilters:WorkPackageTableFiltersService,
private readonly urlParamsHelper:UrlParamsHelperService,
private readonly $state:StateService,
private readonly queryFormDm:QueryFormDmService) {
}
ngOnInit():void {
// Initially load the form once to be able to render filters
this.loadQueryForm();
// Update checksum service whenever filters change
this.updateChecksumOnFilterChanges();
// Remove action attribute from filter service
if (this.board.isAction) {
this.wpTableFilters.hidden.push(this.board.actionAttribute!);
}
}
ngOnDestroy():void {
// Compliance
}
private updateChecksumOnFilterChanges() {
this.wpTableFilters
.observeUntil(componentDestroyed(this))
.pipe(skip(1))
.subscribe(() => {
let query_props:string|null = null;
const filters:QueryFilterInstanceResource[] = this.wpTableFilters.current;
let filterHash = this.urlParamsHelper.buildV3GetFilters(filters);
if (filters.length > 0) {
query_props = JSON.stringify(filterHash);
}
this.filters.emit(filterHash);
this.$state.go('.', { query_props: query_props }, {custom: {notify: false}});
});
}
private loadQueryForm() {
this.queryFormDm
.loadWithParams(
{ filters: JSON.stringify(this.board.filters) },
undefined,
this.currentProjectService.id
)
.then((form:QueryFormResource) => {
const query:QueryResource = this.halResourceService.createHalResourceOfClass(
QueryResource,
form.payload.$source
);
this.querySpace.query.putValue(query);
this.wpStatesInitialization.updateStatesFromForm(query, form);
});
}
}

@ -2,10 +2,10 @@ import {
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter, Input,
EventEmitter, Input, OnChanges,
OnDestroy,
OnInit,
Output,
Output, SimpleChanges,
ViewChild
} from "@angular/core";
import {QueryDmService} from "core-app/modules/hal/dm-services/query-dm.service";
@ -30,6 +30,12 @@ import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/
import {WorkPackageCardViewComponent} from "core-components/wp-card-view/wp-card-view.component";
import {GonService} from "core-app/modules/common/gon/gon.service";
import {WorkPackageStatesInitializationService} from "core-components/wp-list/wp-states-initialization.service";
import {
QueryFilterInstanceResource
} from "core-app/modules/hal/resources/query-filter-instance-resource";
import {UrlParamsHelperService} from "core-components/wp-query/url-params-helper";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {ApiV3Filter} from "core-components/api/api-v3/api-v3-filter-builder";
@Component({
selector: 'board-list',
@ -39,13 +45,16 @@ import {WorkPackageStatesInitializationService} from "core-components/wp-list/wp
{provide: WorkPackageInlineCreateService, useClass: BoardInlineCreateService}
]
})
export class BoardListComponent extends AbstractWidgetComponent implements OnInit, OnDestroy {
export class BoardListComponent extends AbstractWidgetComponent implements OnInit, OnDestroy, OnChanges {
/** Output fired upon query removal */
@Output() onRemove = new EventEmitter<void>();
/** Access to the board resource */
@Input() public board:Board;
/** Access the filters of the board */
@Input() public filters:ApiV3Filter[];
/** Access to the loading indicator element */
@ViewChild('loadingIndicator') indicator:ElementRef;
@ -61,11 +70,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
/** Whether the add button should be shown */
public showAddButton = false;
public readonly columnsQueryProps = {
'columns[]': ['id', 'subject'],
'showHierarchies': false,
'pageSize': 500,
};
public columnsQueryProps:any;
public text = {
addCard: this.I18n.t('js.boards.add_card'),
@ -87,15 +92,15 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
private readonly wpStatesInitialization:WorkPackageStatesInitializationService,
private readonly authorisationService:AuthorisationService,
private readonly wpInlineCreate:WorkPackageInlineCreateService,
private readonly loadingIndicator:LoadingIndicatorService) {
private readonly loadingIndicator:LoadingIndicatorService,
private readonly urlParamsHelperService:UrlParamsHelperService,
private readonly halResourceService:HalResourceService) {
super(I18n);
}
ngOnInit():void {
const boardId:string = this.state.params.board_id;
this.loadQuery();
// Update permission on model updates
this.authorisationService
.observeUntil(componentDestroyed(this))
@ -125,6 +130,13 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
// Interface compatibility
}
ngOnChanges(changes:SimpleChanges) {
if (changes.filters) {
this.setQueryProps(this.filters);
this.loadQuery();
}
}
public get canReference() {
return this.wpInlineCreate.canReference && !!this.Gon.get('permission_flags', 'edit_work_packages');
}
@ -191,4 +203,18 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
private get indicatorInstance() {
return this.loadingIndicator.indicator(jQuery(this.indicator.nativeElement));
}
private setQueryProps(filters:ApiV3Filter[]) {
const existingFilters = (this.resource.options.filters || []) as ApiV3Filter[];
const newFilters = existingFilters.concat(filters);
const newColumnsQueryProps:any = {
'columns[]': ['id', 'subject'],
'showHierarchies': false,
'pageSize': 500,
'filters': JSON.stringify(newFilters),
};
this.columnsQueryProps = newColumnsQueryProps;
}
}

@ -7,13 +7,12 @@ import {QueryResource} from "core-app/modules/hal/resources/query-resource";
import {Board} from "core-app/modules/boards/board/board";
import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {QueryFilterBuilder} from "core-components/api/api-v3/query-filter-builder";
import {ApiV3Filter} from "core-components/api/api-v3/api-v3-filter-builder";
@Injectable()
export class BoardListsService {
private readonly v3 = this.pathHelper.api.v3;
private queryFilterBuilder = new QueryFilterBuilder(this.v3);
constructor(private readonly CurrentProject:CurrentProjectService,
private readonly pathHelper:PathHelperService,
@ -23,13 +22,16 @@ export class BoardListsService {
}
private create(params:Object, filters:unknown[]):Promise<QueryResource> {
private create(params:Object, filters:ApiV3Filter[]):Promise<QueryResource> {
let filterJson = JSON.stringify(filters);
return this.QueryFormDm
.loadWithParams(
{pageSize: 0},
{pageSize: 0,
filters: filterJson},
undefined,
this.CurrentProject.identifier,
this.buildQueryRequest(params, filters)
this.buildQueryRequest(params)
)
.then(form => {
const query = this.QueryFormDm.buildQueryResource(form);
@ -41,7 +43,7 @@ export class BoardListsService {
* Add a free query to the board
*/
public addFreeQuery(board:Board, queryParams:Object) {
const filter = this.queryFilterBuilder.build('manualSort', 'ow', []);
const filter = this.freeBoardQueryFilter();
return this.addQuery(board, queryParams, [filter]);
}
@ -50,7 +52,7 @@ export class BoardListsService {
* @param board
* @param query
*/
public async addQuery(board:Board, queryParams:Object, filters:unknown[]):Promise<Board> {
public async addQuery(board:Board, queryParams:Object, filters:ApiV3Filter[]):Promise<Board> {
const count = board.queries.length;
const query = await this.create(queryParams, filters);
@ -62,7 +64,8 @@ export class BoardListsService {
startColumn: count + 1,
endColumn: count + 2,
options: {
query_id: query.id
query_id: query.id,
filters: filters,
}
};
@ -72,16 +75,18 @@ export class BoardListsService {
return board;
}
private buildQueryRequest(params:Object, filters:unknown[]) {
private buildQueryRequest(params:Object) {
return {
hidden: true,
public: true,
"_links": {
"sortBy": [{"href": this.v3.resource("/queries/sort_bys/manualSorting-asc")}]
},
...params,
filters: filters
...params
};
}
}
private freeBoardQueryFilter():ApiV3Filter {
return {manualSort: {operator: 'ow', values: []}};
}
}

@ -2,41 +2,56 @@
[ngClass]="{ '-editable': board.editable }"
class="board--container">
<div class="toolbar-container -editable">
<div id="toolbar">
<div class="title-container board--header-container">
<div class="wp-show--back-button hide-when-print">
<accessible-by-keyboard (execute)="goBack()"
linkClass="board--back-button button">
<op-icon icon-classes="button--icon icon-back-up"></op-icon>
</accessible-by-keyboard>
</div>
<ng-container wp-isolated-query-space>
<div class="toolbar-container -editable">
<div id="toolbar">
<div class="title-container board--header-container">
<div class="wp-show--back-button hide-when-print">
<accessible-by-keyboard (execute)="goBack()"
linkClass="board--back-button button">
<op-icon icon-classes="button--icon icon-back-up"></op-icon>
</accessible-by-keyboard>
</div>
<editable-toolbar-title [title]="board.name"
[inFlight]="inFlight"
[initialFocus]="isNew"
(onSave)="saveWithNameAndFilters(board, $event)"
[editable]="board.editable"
[showSaveCondition]="!!state.params.query_props">
</editable-toolbar-title>
<editable-toolbar-title [title]="board.name"
[inFlight]="inFlight"
[initialFocus]="isNew"
(onSave)="renameBoard(board, $event)"
[editable]="board.editable">
</editable-toolbar-title>
<ul class="toolbar-items"
*ngIf="showBoardListView()">
<ul class="toolbar-items"
*ngIf="showBoardListView()">
<li class="toolbar-item">
<zen-mode-toggle-button></zen-mode-toggle-button>
</li>
<li *ngIf="board.editable"
class="toolbar-item hidden-for-mobile">
<button title="{{ text.button_more }}"
class="button last board--settings-dropdown toolbar-icon"
boardsToolbarMenu
[boardsToolbarMenu-resource]="board">
<op-icon icon-classes="button--icon icon-show-more"></op-icon>
</button>
</li>
</ul>
<li class="toolbar-item hidden-for-mobile">
<wp-filter-button>
</wp-filter-button>
</li>
<li class="toolbar-item">
<zen-mode-toggle-button></zen-mode-toggle-button>
</li>
<li *ngIf="board.editable"
class="toolbar-item hidden-for-mobile">
<button title="{{ text.button_more }}"
class="button last board--settings-dropdown toolbar-icon"
boardsToolbarMenu
[boardsToolbarMenu-resource]="board">
<op-icon icon-classes="button--icon icon-show-more"></op-icon>
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="boards-filters-container">
<board-filter [board]="board"
(filters)="updateFilters($event)"></board-filter>
</div>
</ng-container>
<div class="boards-list--container"
*ngIf="showBoardListView()"
@ -54,7 +69,8 @@
cdkDragHandle></span>
<board-list [resource]="query"
[board]="board"
(onRemove)="removeList(board, query)"></board-list>
(onRemove)="removeList(board, query)"
[filters]="filters"></board-list>
</div>
<div class="boards-list--add-item -no-text-select"

@ -61,3 +61,6 @@
&.-editable
.boards-list--add-item
cursor: pointer
.boards-filters-container
margin-bottom: 1rem

@ -17,6 +17,10 @@ import {OpModalService} from "core-components/op-modals/op-modal.service";
import {AddListModalComponent} from "core-app/modules/boards/board/add-list-modal/add-list-modal.component";
import {DynamicCssService} from "core-app/modules/common/dynamic-css/dynamic-css.service";
import {BannersService} from "core-app/modules/common/enterprise/banners.service";
import {QueryFilterInstanceResource} from "core-app/modules/hal/resources/query-filter-instance-resource";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {skip} from "rxjs/operators";
import {ApiV3Filter} from "core-components/api/api-v3/api-v3-filter-builder";
@Component({
@ -41,6 +45,9 @@ export class BoardComponent implements OnInit, OnDestroy {
/** Whether we're in flight of updating the board */
public inFlight = false;
/** Board filter */
public filters:ApiV3Filter[];
public text = {
button_more: this.I18n.t('js.button_more'),
delete: this.I18n.t('js.button_delete'),
@ -56,14 +63,12 @@ export class BoardComponent implements OnInit, OnDestroy {
trackByQueryId = (index:number, widget:GridWidgetResource) => widget.options.query_id;
constructor(private readonly state:StateService,
constructor(public readonly state:StateService,
private readonly I18n:I18nService,
private readonly notifications:NotificationsService,
private readonly BoardList:BoardListsService,
private readonly QueryDm:QueryDmService,
private readonly opModalService:OpModalService,
private readonly injector:Injector,
private readonly boardActions:BoardActionsRegistryService,
private readonly BoardCache:BoardCacheService,
private readonly dynamicCss:DynamicCssService,
private readonly Boards:BoardService,
@ -79,12 +84,14 @@ export class BoardComponent implements OnInit, OnDestroy {
let initialized = false;
this.BoardCache
.requireAndStream(id)
.observe(id)
.pipe(
untilComponentDestroyed(this)
)
.subscribe(board => {
this.board = board;
let queryProps = this.state.params.query_props;
this.filters = this.board.filters = queryProps ? JSON.parse(queryProps) : this.board.filters;
if (board.isAction && !initialized) {
this.dynamicCss.requireHighlighting();
@ -97,8 +104,9 @@ export class BoardComponent implements OnInit, OnDestroy {
// Nothing to do.
}
renameBoard(board:Board, newName:string) {
saveWithNameAndFilters(board:Board, newName:string) {
board.name = newName;
board.filters = this.filters;
return this.saveBoard(board);
}
@ -114,6 +122,7 @@ export class BoardComponent implements OnInit, OnDestroy {
this.BoardCache.update(board);
this.notifications.addSuccess(this.text.updateSuccessful);
this.inFlight = false;
this.state.go('.', { query_props: null }, {custom: {notify: false}});
});
}
@ -153,4 +162,8 @@ export class BoardComponent implements OnInit, OnDestroy {
public opReferrer(board:Board) {
return board.isFree ? 'boards#free' : 'boards#status';
}
public updateFilters(filters:QueryFilterInstanceResource[]) {
this.filters = filters;
}
}

@ -1,6 +1,7 @@
import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource";
import {GridResource} from "core-app/modules/hal/resources/grid-resource";
import {CardHighlightingMode} from "core-components/wp-fast-table/builders/highlighting/highlighting-mode.const";
import {ApiV3Filter} from "core-components/api/api-v3/api-v3-filter-builder";
export type BoardDisplayMode = 'table'|'cards';
export type BoardType = 'free'|'action';
@ -68,4 +69,12 @@ export class Board {
public get createdAt() {
return this.grid.createdAt;
}
public get filters():ApiV3Filter[] {
return (this.grid.options.filters || []) as ApiV3Filter[];
}
public set filters(filters:ApiV3Filter[]) {
this.grid.options.filters = filters;
}
}

@ -19,10 +19,10 @@
</ul>
</div>
<div *ngIf="showBoardIndexView() && (boards$ | async) as boards"
class="boards--listing-group loading-indicator--location"
<div class="boards--listing-group loading-indicator--location"
data-indicator-name="boards-module">
<div class="generic-table--container">
<div *ngIf="showBoardIndexView() && (boards$ | async) as boards"
class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table">
<colgroup>
@ -66,7 +66,7 @@
</thead>
<tbody>
<tr *ngIf="boards.length === 0" id="empty-row-notification">
<td colspan="100%">
<td colspan="4">
<span>
<op-icon icon-classes="icon-info1 icon-context"></op-icon>
<span>

@ -1,4 +1,4 @@
import {Component, Injector} from "@angular/core";
import {AfterContentInit, AfterViewInit, Component, Injector, OnInit} from "@angular/core";
import {Observable} from "rxjs";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {BoardService} from "core-app/modules/boards/board/board.service";
@ -8,11 +8,13 @@ import {NotificationsService} from "core-app/modules/common/notifications/notifi
import {OpModalService} from "core-components/op-modals/op-modal.service";
import {NewBoardModalComponent} from "core-app/modules/boards/new-board-modal/new-board-modal.component";
import {BannersService} from "core-app/modules/common/enterprise/banners.service";
import {LoadingIndicatorService} from "core-app/modules/common/loading-indicator/loading-indicator.service";
@Component({
templateUrl: './boards-index-page.component.html'
templateUrl: './boards-index-page.component.html',
styleUrls: ['./boards-index-page.component.sass']
})
export class BoardsIndexPageComponent {
export class BoardsIndexPageComponent implements AfterViewInit {
public text = {
name: this.I18n.t('js.modals.label_name'),
@ -38,9 +40,14 @@ export class BoardsIndexPageComponent {
private readonly I18n:I18nService,
private readonly notifications:NotificationsService,
private readonly opModalService:OpModalService,
private readonly loadingIndicatorService:LoadingIndicatorService,
private readonly injector:Injector,
private readonly bannerService:BannersService) {
this.boardService.loadAllBoards();
}
ngAfterViewInit():void {
const loadingIndicator = this.loadingIndicatorService.indicator('boards-module');
loadingIndicator.promise = this.boardService.loadAllBoards();
}
get canManage() {

@ -36,6 +36,8 @@ import {StateService} from "@uirouter/core";
import {BoardService} from "core-app/modules/boards/board/board.service";
import {BoardCacheService} from "core-app/modules/boards/board/board-cache.service";
import {BoardActionsRegistryService} from "core-app/modules/boards/board/board-actions/board-actions-registry.service";
import {LoadingIndicatorService} from "core-app/modules/common/loading-indicator/loading-indicator.service";
import {WorkPackageNotificationService} from "core-components/wp-edit/wp-notification.service";
@Component({
templateUrl: './new-board-modal.html'
@ -49,6 +51,8 @@ export class NewBoardModalComponent extends OpModalComponent {
public available = this.boardActions.available();
public inFlight = false;
public text:any = {
title: this.I18n.t('js.boards.new_board'),
button_continue: this.I18n.t('js.button_continue'),
@ -72,6 +76,8 @@ export class NewBoardModalComponent extends OpModalComponent {
readonly boardService:BoardService,
readonly boardActions:BoardActionsRegistryService,
readonly boardCache:BoardCacheService,
readonly wpNotifications:WorkPackageNotificationService,
readonly loadingIndicatorService:LoadingIndicatorService,
readonly I18n:I18nService) {
super(locals, cdRef, elementRef);
@ -86,12 +92,19 @@ export class NewBoardModalComponent extends OpModalComponent {
}
private create(params:{ type:BoardType, attribute?:string }) {
this.boardService
this.inFlight = true;
this.loadingIndicatorService.modal.promise = this.boardService
.create(params)
.then((board) => {
this.inFlight = false;
this.closeMe();
this.boardCache.update(board);
this.state.go('boards.show', { board_id: board.id, isNew: true });
})
.catch((error:unknown) => {
this.inFlight = false;
this.wpNotifications.handleRawError(error);
});
}
}

@ -18,7 +18,9 @@
<h3 [textContent]="text.free_board"></h3>
<p [textContent]="text.free_board_text"></p>
<button class="button" (accessibleClick)="createFree()">
<button class="button"
[disabled]="inFlight"
(accessibleClick)="createFree()">
<op-icon icon-classes="icon4 icon-add"></op-icon>
<span [textContent]="text.free_board"></span>
</button>
@ -40,7 +42,9 @@
</div>
</div>
<button class="button" (accessibleClick)="createAction()">
<button class="button"
[disabled]="inFlight"
(accessibleClick)="createAction()">
<op-icon icon-classes="icon4 icon-add"></op-icon>
<span [textContent]="text.action_board"></span>
</button>

@ -49,12 +49,17 @@ import {BoardActionsRegistryService} from "core-app/modules/boards/board/board-a
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";
import {AddCardDropdownMenuDirective} from "core-app/modules/boards/board/add-card-dropdown/add-card-dropdown-menu.directive";
import {BoardFilterComponent} from "core-app/modules/boards/board/board-filter/board-filter.component";
export const BOARDS_ROUTES:Ng2StateDeclaration[] = [
{
name: 'boards',
parent: 'root',
url: '/boards',
url: '/boards?query_props',
params: {
// Use custom encoder/decoder that ensures validity of URL string
query_props: {type: 'opQueryString'}
},
redirectTo: 'boards.list',
component: BoardsRootComponent
},
@ -124,6 +129,7 @@ export function registerActionServices(injector:Injector) {
NewBoardModalComponent,
AddListModalComponent,
AddCardDropdownMenuDirective,
BoardFilterComponent,
],
entryComponents: [
BoardInlineAddAutocompleterComponent,

@ -38,6 +38,10 @@ export class QueryFormDmService {
protected pathHelper:PathHelperService) {
}
/**
* Load the query form for the given existing (or new) query resource
* @param query
*/
public load(query:QueryResource):Promise<QueryFormResource> {
// We need a valid payload so that we
// can check whether form saving is possible.
@ -57,6 +61,14 @@ export class QueryFormDmService {
return query.$links.update(payload);
}
/**
* Load the query form only with the given query props.
*
* @param params
* @param queryId
* @param projectIdentifier
* @param payload
*/
public loadWithParams(params:{}, queryId:string|undefined, projectIdentifier:string|undefined|null, payload:any = {}):Promise<QueryFormResource> {
// We need a valid payload so that we
// can check whether form saving is possible.

@ -48,11 +48,14 @@ export class QueryFilterInstanceResource extends HalResource {
return clone;
}
public get id():string {
return this.filter.id;
}
public get name():string {
return this.filter.name;
}
/**
* Get the complete current schema.
*

@ -38,9 +38,10 @@ describe 'Board management spec', type: :feature, js: true do
end
let(:project) { FactoryBot.create(:project, enabled_module_names: %i[work_package_tracking board_view]) }
let(:role) { FactoryBot.create(:role, permissions: permissions) }
let!(:work_package) { FactoryBot.create :work_package, project: project }
let!(:work_package) { FactoryBot.create :work_package, subject: 'Foo', project: project }
let(:board_index) { Pages::BoardIndex.new(project) }
let(:filters) { ::Components::WorkPackages::Filters.new }
before do
with_enterprise_token :board_view
@ -130,6 +131,20 @@ describe 'Board management spec', type: :feature, js: true do
subjects = WorkPackage.where(id: second.ordered_work_packages).pluck(:subject)
expect(subjects).to match_array [work_package.subject, 'Task 1']
# Filter for Task
filters.expect_filter_count 0
filters.open
filters.quick_filter 'Task'
sleep 2
# Expect task to match, work_package invisible
board_page.expect_card('First', 'Task 1', present: false)
board_page.expect_card('Second', 'Task 1', present: true)
board_page.expect_card('Second', work_package.subject, present: false)
filters.quick_filter ''
sleep 2
# Remove card again
board_page.remove_card 'Second', work_package.subject, 0

@ -51,7 +51,9 @@ describe 'Status action board', type: :feature, js: true do
let!(:open_status) { FactoryBot.create :default_status, name: 'Open' }
let!(:other_status) { FactoryBot.create :status, name: 'Whatever' }
let!(:closed_status) { FactoryBot.create :status, is_closed: true, name: 'Closed' }
let!(:work_package) { FactoryBot.create :work_package, project: project, status: other_status }
let!(:work_package) { FactoryBot.create :work_package, project: project, subject: 'Foo', status: other_status }
let(:filters) { ::Components::WorkPackages::Filters.new }
let!(:workflow_type) {
FactoryBot.create(:workflow,
@ -148,6 +150,44 @@ describe 'Status action board', type: :feature, js: true do
board_page.expect_card('Whatever', 'Task 1', present: false)
board_page.expect_card('Closed', 'Task 1', present: true)
# Add filter
# Filter for Task
filters.expect_filter_count 0
filters.open
# Expect that status is not available for global filter selection
filters.expect_available_filter 'Status', present: false
filters.quick_filter 'Task'
board_page.expect_changed
sleep 2
board_page.expect_card('Closed', 'Task 1', present: true)
board_page.expect_card('Whatever', work_package.subject, present: false)
# Expect query props to be present
url = URI.parse(page.current_url).query
expect(url).to include("query_props=")
# Save that filter
board_page.save
# Expect filter to be saved in board
board_page.board(reload: true) do |board|
expect(board.options['filters']).to eq [{ 'search' => { 'operator' => '**', 'values' => ['Task'] } }]
end
# Revisit board
board_page.visit!
# Expect filter to be present
filters.expect_filter_count 1
filters.open
filters.expect_quick_filter 'Task'
# No query props visible
board_page.expect_not_changed
# Remove query
board_page.remove_list 'Whatever'
queries = board_page.board(reload: true).contained_queries

@ -164,6 +164,19 @@ module Pages
end
end
def save
page.find('.editable-toolbar-title--save').click
expect_and_dismiss_notification message: 'Successful update.'
end
def expect_changed
expect(page).to have_selector('.editable-toolbar-title--save')
end
def expect_not_changed
expect(page).to have_no_selector('.editable-toolbar-title--save')
end
def expect_list(name)
expect(page).to have_field('editable-toolbar-title', with: name)
end

@ -44,7 +44,7 @@ module Components
end
def expect_filter_count(num)
expect(filter_button).to have_selector('.badge', text: num)
expect(filter_button).to have_selector('.badge', text: num, wait: 10)
end
def expect_open
@ -55,6 +55,23 @@ module Components
expect(page).to have_selector(filters_selector, visible: :hidden)
end
def expect_quick_filter(text)
expect(page).to have_field('filter-by-text-input', with: text)
end
def quick_filter(text)
input = page.find('#filter-by-text-input')
input.hover
input.click
input.set text
sleep 1
end
def expect_available_filter(name, present: true)
expect(page).to have_conditional_selector(present, '.advanced-filters--add-filter-value option', text: name)
end
def add_filter_by(name, operator, value, selector = nil)
select name, from: "add_filter_select"

Loading…
Cancel
Save