diff --git a/frontend/src/app/components/api/api-v3/query-filter-builder.ts b/frontend/src/app/components/api/api-v3/query-filter-builder.ts deleted file mode 100644 index 05554f9f4b..0000000000 --- a/frontend/src/app/components/api/api-v3/query-filter-builder.ts +++ /dev/null @@ -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 - } - }; - } -} diff --git a/frontend/src/app/components/wp-fast-table/state/wp-table-filters.service.ts b/frontend/src/app/components/wp-fast-table/state/wp-table-filters.service.ts index 44c7bea7b5..953aa6d9bd 100644 --- a/frontend/src/app/components/wp-fast-table/state/wp-table-filters.service.ts +++ b/frontend/src/app/components/wp-fast-table/state/wp-table-filters.service.ts @@ -42,7 +42,7 @@ import {mapTo, take} from "rxjs/operators"; @Injectable() export class WorkPackageTableFiltersService extends WorkPackageQueryStateService { - public hidden:Readonly = [ + public hidden:string[] = [ 'id', 'parent', 'datesInterval', @@ -307,6 +307,16 @@ export class WorkPackageTableFiltersService extends WorkPackageQueryStateService * @param filters */ private loadCurrentFiltersSchemas(filters:QueryFilterInstanceResource[]):Promise { - 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); + } + })); } } diff --git a/frontend/src/app/components/wp-query/url-params-helper.ts b/frontend/src/app/components/wp-query/url-params-helper.ts index 0fc8844b0c..1fff418901 100644 --- a/frontend/src/app/components/wp-query/url-params-helper.ts +++ b/frontend/src/app/components/wp-query/url-params-helper.ts @@ -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; diff --git a/frontend/src/app/modules/boards/board/board-actions/status-action.service.ts b/frontend/src/app/modules/boards/board/board-actions/status-action.service.ts index dbf3b81fde..91f469b29a 100644 --- a/frontend/src/app/modules/boards/board/board-actions/status-action.service.ts +++ b/frontend/src/app/modules/boards/board/board-actions/status-action.service.ts @@ -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]); } diff --git a/frontend/src/app/modules/boards/board/board-filter/board-filter.component.html b/frontend/src/app/modules/boards/board/board-filter/board-filter.component.html new file mode 100644 index 0000000000..d51da825e3 --- /dev/null +++ b/frontend/src/app/modules/boards/board/board-filter/board-filter.component.html @@ -0,0 +1 @@ + diff --git a/frontend/src/app/modules/boards/board/board-filter/board-filter.component.ts b/frontend/src/app/modules/boards/board/board-filter/board-filter.component.ts new file mode 100644 index 0000000000..b358bcf262 --- /dev/null +++ b/frontend/src/app/modules/boards/board/board-filter/board-filter.component.ts @@ -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(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); + }); + } +} diff --git a/frontend/src/app/modules/boards/board/board-list/board-list.component.ts b/frontend/src/app/modules/boards/board/board-list/board-list.component.ts index 96995e94bd..fc7b715afb 100644 --- a/frontend/src/app/modules/boards/board/board-list/board-list.component.ts +++ b/frontend/src/app/modules/boards/board/board-list/board-list.component.ts @@ -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(); /** 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; + } } diff --git a/frontend/src/app/modules/boards/board/board-list/board-lists.service.ts b/frontend/src/app/modules/boards/board/board-list/board-lists.service.ts index cb03f81ef1..26ffa50d27 100644 --- a/frontend/src/app/modules/boards/board/board-list/board-lists.service.ts +++ b/frontend/src/app/modules/boards/board/board-list/board-lists.service.ts @@ -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 { + private create(params:Object, filters:ApiV3Filter[]):Promise { + 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 { + public async addQuery(board:Board, queryParams:Object, filters:ApiV3Filter[]):Promise { 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: []}}; + } +} diff --git a/frontend/src/app/modules/boards/board/board.component.html b/frontend/src/app/modules/boards/board/board.component.html index de15ca5a90..e130faf145 100644 --- a/frontend/src/app/modules/boards/board/board.component.html +++ b/frontend/src/app/modules/boards/board/board.component.html @@ -2,41 +2,56 @@ [ngClass]="{ '-editable': board.editable }" class="board--container"> -
-
-
-
- - - -
+ +
+
+
+
+ + + +
+ + + - - +
    -
      -
    • - -
    • -
    • - -
    • -
    +
  • + + +
  • + +
  • + +
  • +
  • + +
  • +
+
-
+ +
+ +
+ +
+ (onRemove)="removeList(board, query)" + [filters]="filters">
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; + } } diff --git a/frontend/src/app/modules/boards/board/board.ts b/frontend/src/app/modules/boards/board/board.ts index 33198c7aef..a77721c3f2 100644 --- a/frontend/src/app/modules/boards/board/board.ts +++ b/frontend/src/app/modules/boards/board/board.ts @@ -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; + } } diff --git a/frontend/src/app/modules/boards/index-page/boards-index-page.component.html b/frontend/src/app/modules/boards/index-page/boards-index-page.component.html index ce4755ff5e..9c029c9194 100644 --- a/frontend/src/app/modules/boards/index-page/boards-index-page.component.html +++ b/frontend/src/app/modules/boards/index-page/boards-index-page.component.html @@ -19,10 +19,10 @@
-
-
+
@@ -66,7 +66,7 @@ -
+ diff --git a/frontend/src/app/modules/boards/index-page/boards-index-page.component.sass b/frontend/src/app/modules/boards/index-page/boards-index-page.component.sass new file mode 100644 index 0000000000..fff0b3389d --- /dev/null +++ b/frontend/src/app/modules/boards/index-page/boards-index-page.component.sass @@ -0,0 +1,2 @@ +.boards--listing-group + position: relative diff --git a/frontend/src/app/modules/boards/index-page/boards-index-page.component.ts b/frontend/src/app/modules/boards/index-page/boards-index-page.component.ts index ab36d02cdf..5c8d2784ad 100644 --- a/frontend/src/app/modules/boards/index-page/boards-index-page.component.ts +++ b/frontend/src/app/modules/boards/index-page/boards-index-page.component.ts @@ -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() { diff --git a/frontend/src/app/modules/boards/new-board-modal/new-board-modal.component.ts b/frontend/src/app/modules/boards/new-board-modal/new-board-modal.component.ts index 530b69028d..53d3ff2bdb 100644 --- a/frontend/src/app/modules/boards/new-board-modal/new-board-modal.component.ts +++ b/frontend/src/app/modules/boards/new-board-modal/new-board-modal.component.ts @@ -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); }); } } diff --git a/frontend/src/app/modules/boards/new-board-modal/new-board-modal.html b/frontend/src/app/modules/boards/new-board-modal/new-board-modal.html index a40efa3e65..8a85502c4a 100644 --- a/frontend/src/app/modules/boards/new-board-modal/new-board-modal.html +++ b/frontend/src/app/modules/boards/new-board-modal/new-board-modal.html @@ -18,7 +18,9 @@

- @@ -40,7 +42,9 @@ - diff --git a/frontend/src/app/modules/boards/openproject-boards.module.ts b/frontend/src/app/modules/boards/openproject-boards.module.ts index 03ac7315fc..ca56d14f90 100644 --- a/frontend/src/app/modules/boards/openproject-boards.module.ts +++ b/frontend/src/app/modules/boards/openproject-boards.module.ts @@ -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, diff --git a/frontend/src/app/modules/hal/dm-services/query-form-dm.service.ts b/frontend/src/app/modules/hal/dm-services/query-form-dm.service.ts index 7dc3f7c6a2..2167577e3f 100644 --- a/frontend/src/app/modules/hal/dm-services/query-form-dm.service.ts +++ b/frontend/src/app/modules/hal/dm-services/query-form-dm.service.ts @@ -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 { // 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 { // We need a valid payload so that we // can check whether form saving is possible. diff --git a/frontend/src/app/modules/hal/resources/query-filter-instance-resource.ts b/frontend/src/app/modules/hal/resources/query-filter-instance-resource.ts index 378c4301a1..25e998891f 100644 --- a/frontend/src/app/modules/hal/resources/query-filter-instance-resource.ts +++ b/frontend/src/app/modules/hal/resources/query-filter-instance-resource.ts @@ -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. * diff --git a/modules/boards/spec/features/board_management_spec.rb b/modules/boards/spec/features/board_management_spec.rb index 8b7b3e9748..8ec8324afa 100644 --- a/modules/boards/spec/features/board_management_spec.rb +++ b/modules/boards/spec/features/board_management_spec.rb @@ -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 diff --git a/modules/boards/spec/features/status_board_spec.rb b/modules/boards/spec/features/status_board_spec.rb index 4f9cc5cb1b..9c5a5f8bd4 100644 --- a/modules/boards/spec/features/status_board_spec.rb +++ b/modules/boards/spec/features/status_board_spec.rb @@ -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 diff --git a/modules/boards/spec/features/support/board_page.rb b/modules/boards/spec/features/support/board_page.rb index 9bef75e0d4..5bc8bf8f7e 100644 --- a/modules/boards/spec/features/support/board_page.rb +++ b/modules/boards/spec/features/support/board_page.rb @@ -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 diff --git a/spec/support/components/work_packages/filters.rb b/spec/support/components/work_packages/filters.rb index 3d558ec8a8..48d0928a85 100644 --- a/spec/support/components/work_packages/filters.rb +++ b/spec/support/components/work_packages/filters.rb @@ -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"