[40988] Reload add existing search when removing events

https://community.openproject.org/wp/40988
pull/10229/head
Oliver Günther 3 years ago
parent 0e4ca152f5
commit ad6c57cf1d
  1. 4
      frontend/src/app/features/team-planner/team-planner/add-work-packages/add-existing-pane.component.html
  2. 75
      frontend/src/app/features/team-planner/team-planner/add-work-packages/add-existing-pane.component.ts
  3. 10
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.actions.ts
  4. 5
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts
  5. 30
      frontend/src/app/features/work-packages/components/wp-query/url-params-helper.ts
  6. 19
      frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service.ts
  7. 82
      modules/team_planner/spec/features/team_planner_add_existing_work_packages_spec.rb
  8. 21
      modules/team_planner/spec/features/team_planner_remove_event_spec.rb
  9. 14
      modules/team_planner/spec/support/components/add_existing_pane.rb

@ -1,7 +1,7 @@
<div class="op-add-existing-pane--search">
<input
[ngModel]="searchString.values$() | async"
(ngModelChange)="searchStringChanged$.next($event)"
[ngModel]="searchString$ | async"
(ngModelChange)="searchString$.next($event)"
class="op-add-existing-pane--search-input"
data-qa-selector="op-add-existing-pane--search-input"
[placeholder]="text.placeholder"

@ -3,6 +3,7 @@ import {
Component,
ElementRef,
HostBinding,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
@ -10,9 +11,9 @@ import { I18nService } from 'core-app/core/i18n/i18n.service';
import { imagePath } from 'core-app/shared/helpers/images/path-helper';
import {
BehaviorSubject,
combineLatest,
Observable,
of,
Subject,
} from 'rxjs';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
@ -21,8 +22,8 @@ import {
debounceTime,
distinctUntilChanged,
map,
startWith,
switchMap,
tap,
} from 'rxjs/operators';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { WorkPackageNotificationService } from 'core-app/features/work-packages/services/notifications/work-package-notification.service';
@ -31,9 +32,11 @@ import { UrlParamsHelperService } from 'core-app/features/work-packages/componen
import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { CalendarDragDropService } from 'core-app/features/team-planner/team-planner/calendar-drag-drop.service';
import { input } from 'reactivestates';
import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper';
import { StateService } from '@uirouter/core';
import { ActionsService } from 'core-app/core/state/actions/actions.service';
import { teamPlannerEventRemoved } from 'core-app/features/team-planner/team-planner/planner/team-planner.actions';
import { WorkPackageViewFiltersService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service';
@Component({
selector: 'op-add-existing-pane',
@ -41,7 +44,7 @@ import { StateService } from '@uirouter/core';
styleUrls: ['./add-existing-pane.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddExistingPaneComponent extends UntilDestroyedMixin implements OnInit {
export class AddExistingPaneComponent extends UntilDestroyedMixin implements OnInit, OnDestroy {
@HostBinding('class.op-add-existing-pane') className = true;
@ViewChild('container') container:ElementRef;
@ -56,17 +59,30 @@ export class AddExistingPaneComponent extends UntilDestroyedMixin implements OnI
}
}
/** Observable to the current search filter term */
public searchString = input<string>('');
/** Input for search requests */
public searchStringChanged$:Subject<string> = new Subject<string>();
public searchString$ = new BehaviorSubject<string>('');
isEmpty$ = new BehaviorSubject<boolean>(true);
isLoading$ = new BehaviorSubject<boolean>(false);
currentWorkPackages$ = this.calendarDrag.draggableWorkPackages$;
currentWorkPackages$ = combineLatest([
this.calendarDrag.draggableWorkPackages$,
this.querySpace.results.values$(),
])
.pipe(
map(([draggable, rendered]) => {
const renderedIds = rendered.elements.map((el) => el.id as string);
return draggable.filter((wp) => !renderedIds.includes(wp.id as string));
}),
);
workPackageRemoved$:Observable<unknown> = this
.actions$
.ofType(teamPlannerEventRemoved)
.pipe(
startWith(null),
);
text = {
empty_state: this.I18n.t('js.team_planner.quick_add.empty_state'),
@ -86,19 +102,34 @@ export class AddExistingPaneComponent extends UntilDestroyedMixin implements OnI
private readonly urlParamsHelper:UrlParamsHelperService,
private readonly calendarDrag:CalendarDragDropService,
private readonly $state:StateService,
private readonly actions$:ActionsService,
private readonly wpFilters:WorkPackageViewFiltersService,
) {
super();
}
ngOnInit():void {
this.searchStringChanged$
combineLatest([
this
.searchString$
.pipe(
distinctUntilChanged(),
debounceTime(500),
),
this
.wpFilters
.updates$()
.pipe(
startWith(null),
),
this.workPackageRemoved$,
])
.pipe(
this.untilDestroyed(),
distinctUntilChanged(),
debounceTime(500),
tap((val) => this.searchString.putValue(val)),
map(([searchString]) => searchString),
switchMap((searchString:string) => this.searchWorkPackages(searchString)),
).subscribe((results) => {
)
.subscribe((results) => {
this.calendarDrag.draggableWorkPackages$.next(results);
this.isEmpty$.next(results.length === 0);
@ -106,7 +137,6 @@ export class AddExistingPaneComponent extends UntilDestroyedMixin implements OnI
});
}
// eslint-disable-next-line @angular-eslint/use-lifecycle-interface
ngOnDestroy():void {
super.ngOnDestroy();
this.calendarDrag.destroyDrake();
@ -123,15 +153,12 @@ export class AddExistingPaneComponent extends UntilDestroyedMixin implements OnI
return of([]);
}
const filters:ApiV3FilterBuilder = new ApiV3FilterBuilder();
const queryResults = this.querySpace.results.value;
// Add any visible global filters
const activeFilters = this.wpFilters.currentlyVisibleFilters;
const filters:ApiV3FilterBuilder = this.urlParamsHelper.filterBuilderFrom(activeFilters);
filters.add('subjectOrId', '**', [searchString]);
if (queryResults && queryResults.elements.length > 0) {
filters.add('id', '!', queryResults.elements.map((wp:WorkPackageResource) => wp.id || ''));
}
// Add the existing filter, if any
this.addExistingFilters(filters);
@ -139,7 +166,7 @@ export class AddExistingPaneComponent extends UntilDestroyedMixin implements OnI
.apiV3Service
.withOptionalProject(this.currentProject.id)
.work_packages
.filtered(filters)
.filtered(filters, { pageSize: '-1' })
.get()
.pipe(
map((collection) => collection.elements),
@ -152,11 +179,11 @@ export class AddExistingPaneComponent extends UntilDestroyedMixin implements OnI
}
clearInput():void {
this.searchStringChanged$.next('');
this.searchString$.next('');
}
get isSearching():boolean {
return this.searchString.value !== '';
return this.searchString$.value !== '';
}
showDisabledText(wp:WorkPackageResource):string {

@ -0,0 +1,10 @@
import { ID } from '@datorama/akita';
import {
action,
props,
} from 'ts-action';
export const teamPlannerEventRemoved = action(
'[Team planner] Event removed from team planner',
props<{ workPackage:ID }>(),
);

@ -62,6 +62,8 @@ import { StatusResource } from 'core-app/features/hal/resources/status-resource'
import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset';
import { KeepTabService } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service';
import { HalError } from 'core-app/features/hal/services/hal-error';
import { ActionsService } from 'core-app/core/state/actions/actions.service';
import { teamPlannerEventRemoved } from 'core-app/features/team-planner/team-planner/planner/team-planner.actions';
@Component({
selector: 'op-team-planner',
@ -187,6 +189,7 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
readonly apiV3Service:ApiV3Service,
readonly calendarDrag:CalendarDragDropService,
readonly keepTab:KeepTabService,
readonly actions$:ActionsService,
) {
super();
}
@ -491,6 +494,8 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
changeset.setValue('dueDate', null);
await this.saveChangeset(changeset);
this.actions$.dispatch(teamPlannerEventRemoved({ workPackage: workPackage.id as string }));
}
private mapToCalendarEvents(workPackages:WorkPackageResource[]):EventInput[] {

@ -35,10 +35,12 @@ import { Injectable } from '@angular/core';
import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource';
import {
ApiV3Filter,
ApiV3FilterBuilder,
FilterOperator,
} from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import { PaginationService } from 'core-app/shared/components/table-pagination/pagination-service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { QueryFilterResource } from 'core-app/features/hal/resources/query-filter-resource';
export interface QueryPropsFilter {
n:string;
@ -381,6 +383,20 @@ export class UrlParamsHelperService {
return newFilters;
}
public filterBuilderFrom(filters:QueryFilterInstanceResource[]) {
const builder:ApiV3FilterBuilder = new ApiV3FilterBuilder();
filters.forEach((filter:QueryFilterInstanceResource) => {
const id = this.buildV3GetFilterIdFromFilter(filter);
const operator = this.buildV3GetOperatorIdFromFilter(filter) as FilterOperator;
const values = this.buildV3GetValuesFromFilter(filter)
builder.add(id, operator, values);
});
return builder;
}
public buildV3GetFiltersAsJson(filter:QueryFilterInstanceResource[], contextual = {}) {
return JSON.stringify(this.buildV3GetFilters(filter, contextual));
}
@ -391,6 +407,13 @@ export class UrlParamsHelperService {
return this.idFromHref(href);
}
public buildV3GetValuesFromFilter(filter:QueryFilterInstanceResource|QueryFilterResource) {
if (filter.values) {
return _.map(filter.values, (v:any) => this.queryFilterValueToParam(v));
}
return _.map(filter._links.values, (v:any) => this.idFromHref(v.href));
}
private buildV3GetOperatorIdFromFilter(filter:QueryFilterInstanceResource) {
if (filter.operator) {
return filter.operator.id || idFromLink(filter.operator.href);
@ -400,13 +423,6 @@ export class UrlParamsHelperService {
return this.idFromHref(href);
}
private buildV3GetValuesFromFilter(filter:QueryFilterInstanceResource) {
if (filter.values) {
return _.map(filter.values, (v:any) => this.queryFilterValueToParam(v));
}
return _.map(filter._links.values, (v:any) => this.idFromHref(v.href));
}
private buildV3GetSortByFromQuery(query:QueryResource) {
const sortBys = query.sortBy ? query.sortBy : query._links.sortBy;
const sortByIds = sortBys.map((sort:QuerySortByResource) => {

@ -255,7 +255,7 @@ export class WorkPackageViewFiltersService extends WorkPackageQueryStateService<
return _.findIndex(this.current, (f) => f.id === id);
}
public applyToQuery(query:QueryResource) {
public applyToQuery(query:QueryResource):boolean {
query.filters = this.cloneFilters();
return true;
}
@ -272,7 +272,7 @@ export class WorkPackageViewFiltersService extends WorkPackageQueryStateService<
* Returns a deep clone of the current filters set, may be used
* to modify the filters without altering this state.
*/
public cloneFilters() {
public cloneFilters():QueryFilterInstanceResource[] {
return cloneHalResourceCollection<QueryFilterInstanceResource>(this.rawFilters);
}
@ -284,18 +284,18 @@ export class WorkPackageViewFiltersService extends WorkPackageQueryStateService<
return this.lastUpdatedState.value || [];
}
public get currentlyVisibleFilters() {
public get currentlyVisibleFilters():QueryFilterInstanceResource[] {
const invisibleFilters = new Set(this.hidden);
invisibleFilters.delete('search');
return _.reject(this.currentFilterResources, (filter) => invisibleFilters.has(filter.id));
return _.reject(this.current, (filter) => invisibleFilters.has(filter.id));
}
/**
* Replace this filter state, but only if the given filters are complete
* @param newState
*/
public replaceIfComplete(newState:QueryFilterInstanceResource[]) {
public replaceIfComplete(newState:QueryFilterInstanceResource[]):void {
if (this.isComplete(newState)) {
this.update(newState);
} else {
@ -306,7 +306,7 @@ export class WorkPackageViewFiltersService extends WorkPackageQueryStateService<
/**
* Filters service depends on two states
*/
public onReady() {
public onReady():Promise<null> {
return combine(this.pristineState, this.availableState)
.values$()
.pipe(
@ -323,13 +323,6 @@ export class WorkPackageViewFiltersService extends WorkPackageQueryStateService<
return _.differenceBy(this.availableFilters, filters, (filter) => filter.id);
}
/**
* Map current filter instances to their FilterResource
*/
private get currentFilterResources():QueryFilterResource[] {
return this.rawFilters.map((filter:QueryFilterInstanceResource) => filter.filter);
}
isAvailable(el:QueryFilterInstanceResource):boolean {
return !!this.availableFilters.find((available) => available.id === el.id);
}

@ -88,12 +88,37 @@ describe 'Team planner add existing work packages', type: :feature, js: true do
end
# Open the left hand pane
page.find('[data-qa-selector="op-team-planner--add-existing-toggle"]').click
add_existing_pane.expect_open
add_existing_pane.open
add_existing_pane.expect_empty
end
context 'with a removable item' do
let!(:second_wp) do
create :work_package,
project: project,
subject: 'Task 2',
assigned_to: other_user,
start_date: 10.days.from_now,
due_date: 12.days.from_now
end
it 'shows work packages removed from the team planner' do
team_planner.within_lane(user) do
team_planner.expect_event first_wp
end
add_existing_pane.search first_wp.subject
add_existing_pane.expect_empty
# Remove task 1 from the team planner
team_planner.drag_to_remove_dropzone first_wp, expect_removable: true
sleep 2
add_existing_pane.expect_result first_wp
end
end
it 'allows to add work packages via drag&drop from the left hand shortlist' do
# Search for a work package
add_existing_pane.search 'Task'
@ -150,11 +175,58 @@ describe 'Team planner add existing work packages', type: :feature, js: true do
# Change the filter for the whole page
filters.set_filter 'Status', 'open', nil
# Search again, and the filter criteria are applied
add_existing_pane.search 'Ta'
# Expect the filter to auto update
add_existing_pane.expect_result second_wp
add_existing_pane.expect_result third_wp, visible: false
end
context 'with a subproject' do
let!(:sub_project) do
create(:project, name: 'Child', parent: project, enabled_module_names: %w[work_package_tracking])
end
let!(:sub_work_package) do
create(:work_package, subject: 'Subtask', project: sub_project)
end
let!(:user) do
create :user,
member_in_projects: [project, sub_project],
member_with_permissions: %w[
view_work_packages edit_work_packages add_work_packages
view_team_planner manage_team_planner
save_queries manage_public_queries
]
end
let(:dropdown) { ::Components::ProjectIncludeComponent.new }
it 'applies the project include filter' do
# Search for the work package in the child project
add_existing_pane.search 'Subtask'
add_existing_pane.expect_empty
add_existing_pane.expect_result sub_work_package, visible: false
dropdown.expect_count 1
dropdown.toggle!
dropdown.expect_open
dropdown.expect_checkbox(project.id, true)
dropdown.expect_checkbox(sub_project.id, false)
dropdown.toggle_checkbox(sub_project.id)
dropdown.expect_checkbox(project.id, true)
dropdown.expect_checkbox(sub_project.id, true)
dropdown.click_button 'Apply'
dropdown.expect_closed
dropdown.expect_count 2
# Expect the filter to auto update
add_existing_pane.expect_result sub_work_package
end
end
end
context 'without permission to edit' do

@ -30,6 +30,7 @@
require 'spec_helper'
require_relative './shared_context'
require_relative '../support/components/add_existing_pane'
describe 'Team planner remove event', type: :feature, js: true do
include_context 'with team planner full access'
@ -91,4 +92,24 @@ describe 'Team planner remove event', type: :feature, js: true do
team_planner.drag_to_remove_dropzone non_removable_wp, expect_removable: false
team_planner.drag_to_remove_dropzone removable_wp, expect_removable: true
end
context 'with the add existing open searching for the task' do
let(:add_existing_pane) { ::Components::AddExistingPane.new }
it 'the removed task shows up again' do
# Open the left hand pane
add_existing_pane.open
add_existing_pane.expect_empty
# Search for the task, expect empty
add_existing_pane.search 'task'
add_existing_pane.expect_empty
# Remove the task
team_planner.drag_to_remove_dropzone removable_wp, expect_removable: true
# Should show up in add existing
add_existing_pane.expect_result removable_wp
end
end
end

@ -37,6 +37,11 @@ module Components
"[data-qa-selector='add-existing-pane']"
end
def open
page.find('[data-qa-selector="op-team-planner--add-existing-toggle"]').click
expect_open
end
def expect_open
expect(page).to have_selector(selector)
end
@ -54,8 +59,13 @@ module Components
end
def expect_result(work_package, visible: true)
expect(page)
.to have_conditional_selector(visible, "[data-qa-selector='op-add-existing-pane--wp-#{work_package.id}']")
if visible
expect(page)
.to have_selector("[data-qa-selector='op-add-existing-pane--wp-#{work_package.id}']", wait: 10)
else
expect(page)
.to have_no_selector("[data-qa-selector='op-add-existing-pane--wp-#{work_package.id}']")
end
end
def drag_wp_by_pixel(work_package, by_x, by_y)

Loading…
Cancel
Save