diff --git a/frontend/app/cypress/integration/context-menu.spec.ts b/frontend/app/cypress/integration/context-menu.spec.ts index 20ec71f0..71c0c09d 100644 --- a/frontend/app/cypress/integration/context-menu.spec.ts +++ b/frontend/app/cypress/integration/context-menu.spec.ts @@ -14,6 +14,8 @@ */ describe('context menu', function () { + const contextMenuSelector = 'stapps-context-menu-modal'; + beforeEach(function () { cy.interceptSearch({ extends: {query: 'a'}, @@ -33,21 +35,21 @@ describe('context menu', function () { }); it('should sort', function () { - cy.get('stapps-context').within(() => { + cy.get(contextMenuSelector).within(() => { cy.contains('ion-item', 'Name').click(); cy.wait('@search'); }); }); it('should filter', function () { - cy.get('stapps-context').within(() => { + cy.get(contextMenuSelector).within(() => { cy.contains('ion-item', '(17) Akademische Veranstaltung').click(); cy.wait('@search'); }); }); it('should have a working delete button', function () { - cy.get('stapps-context').within(() => { + cy.get(contextMenuSelector).within(() => { cy.contains('ion-item', '(17) Akademische Veranstaltung').click(); cy.get('.checkbox-checked').should('have.length', 1); @@ -60,7 +62,7 @@ describe('context menu', function () { it('should truncate long category items', function () { cy.contains('ion-list', 'Akademische Veranstaltung / Kategorien').within(() => { cy.contains('ion-item', '(1) Tutorium').should('not.exist'); - cy.get('div > ion-button').click(); + cy.get('ion-button').click(); cy.contains('ion-item', '(1) Tutorium').should('exist'); }); }); diff --git a/frontend/app/src/app/modules/data/list/search-page.component.ts b/frontend/app/src/app/modules/data/list/search-page.component.ts index c9dca4b0..58687d14 100644 --- a/frontend/app/src/app/modules/data/list/search-page.component.ts +++ b/frontend/app/src/app/modules/data/list/search-page.component.ts @@ -15,7 +15,12 @@ import {Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; import {Keyboard} from '@capacitor/keyboard'; -import {AlertController, AnimationBuilder, AnimationController} from '@ionic/angular/standalone'; +import { + AlertController, + AnimationBuilder, + AnimationController, + ModalController, +} from '@ionic/angular/standalone'; import {Capacitor} from '@capacitor/core'; import { SCFacet, @@ -36,6 +41,8 @@ import {PositionService} from '../../map/position.service'; import {ConfigProvider} from '../../config/config.provider'; import {searchPageSwitchAnimation} from './search-page-switch-animation'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {ContextMenuModalComponent} from '../../menu/context/context-menu-modal.component'; +import {enterAnimation, leaveAnimation} from '../../menu/context/context-menu-modal.animations'; /** * SearchPageComponent queries things and shows list of things as search results and filter as context menu @@ -173,7 +180,8 @@ export class SearchPageComponent implements OnInit { private readonly route: ActivatedRoute, protected positionService: PositionService, private readonly configProvider: ConfigProvider, - animationController: AnimationController, + protected animationController: AnimationController, + protected modalController: ModalController, ) { this.routeAnimation = searchPageSwitchAnimation(animationController); } @@ -368,4 +376,20 @@ export class SearchPageComponent implements OnInit { this.searchStringChanged(term); } } + + async openContextMenu(): Promise { + const modal = await this.modalController.create({ + component: ContextMenuModalComponent, + cssClass: 'context-menu-modal', + showBackdrop: true, + backdropDismiss: true, + enterAnimation: (baseElement: HTMLElement) => enterAnimation(baseElement, this.animationController), + leaveAnimation: (baseElement: HTMLElement) => leaveAnimation(baseElement, this.animationController), + componentProps: { + contextMenuService: this.contextMenuService, + }, + }); + + await modal.present(); + } } diff --git a/frontend/app/src/app/modules/data/list/search-page.html b/frontend/app/src/app/modules/data/list/search-page.html index 919c589c..11af269e 100644 --- a/frontend/app/src/app/modules/data/list/search-page.html +++ b/frontend/app/src/app/modules/data/list/search-page.html @@ -12,10 +12,6 @@ ~ You should have received a copy of the GNU General Public License along with ~ this program. If not, see . --> - -@if (showContextMenu) { - -} @if (showDrawer && showTopToolbar) { @@ -41,7 +37,7 @@ > @if (showContextMenu) { - + } diff --git a/frontend/app/src/app/modules/favorites/favorites-page.component.ts b/frontend/app/src/app/modules/favorites/favorites-page.component.ts index 15a8378d..ba219155 100644 --- a/frontend/app/src/app/modules/favorites/favorites-page.component.ts +++ b/frontend/app/src/app/modules/favorites/favorites-page.component.ts @@ -13,7 +13,7 @@ * this program. If not, see . */ import {Component, OnInit} from '@angular/core'; -import {AlertController, AnimationController} from '@ionic/angular/standalone'; +import {AlertController, AnimationController, ModalController} from '@ionic/angular/standalone'; import {ActivatedRoute, Router} from '@angular/router'; import {NGXLogger} from 'ngx-logger'; import {debounceTime, distinctUntilChanged, startWith, take} from 'rxjs/operators'; @@ -55,6 +55,7 @@ export class FavoritesPageComponent extends SearchPageComponent implements OnIni private favoritesService: FavoritesService, configProvider: ConfigProvider, animationController: AnimationController, + modalController: ModalController, ) { super( alertController, @@ -68,6 +69,7 @@ export class FavoritesPageComponent extends SearchPageComponent implements OnIni positionService, configProvider, animationController, + modalController, ); } diff --git a/frontend/app/src/app/modules/menu/context/context-menu-modal.animations.ts b/frontend/app/src/app/modules/menu/context/context-menu-modal.animations.ts new file mode 100644 index 00000000..866b519a --- /dev/null +++ b/frontend/app/src/app/modules/menu/context/context-menu-modal.animations.ts @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2025 StApps + * 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, version 3. + * + * 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, see . + */ +import {Animation, AnimationController} from '@ionic/angular'; + +/** + * Defines the animation for showing a modal as a right-hand sidebar. + * @param baseElement The root element of the modal (including Shadow DOM). + * @param animationCtrl The Ionic AnimationController. + * @returns The configured Ionic animation. + */ +export const enterAnimation = (baseElement: HTMLElement, animationCtrl: AnimationController): Animation => { + const root = baseElement.shadowRoot; + + const backdrop = root?.querySelector('ion-backdrop'); + const wrapper = root?.querySelector('.modal-wrapper'); + + // The wrapper needs to be positioned on the right side + if (wrapper instanceof HTMLElement) { + Object.assign(wrapper.style, { + position: 'absolute', + top: '0', + right: '0', + height: '100%', + width: '304px', + maxWidth: '75%', + opacity: '1', + }); + } + + const backdropAnimation = animationCtrl + .create() + .addElement(backdrop!) + .fromTo('opacity', '0.01', 'var(--backdrop-opacity)'); + + const wrapperAnimation = animationCtrl + .create() + .addElement(wrapper!) + .fromTo('transform', 'translateX(100%)', 'translateX(0)'); + + return animationCtrl + .create() + .addElement(baseElement) + .duration(400) + .easing('ease-out') + .addAnimation([backdropAnimation, wrapperAnimation]); +}; + +/** + * Defines the animation for hiding a modal by sliding it out to the right. + * @param baseElement The root element of the modal. + * @param animationCtrl The Ionic AnimationController. + * @returns The configured Ionic animation (reverse of enterAnimation). + */ +export const leaveAnimation = (baseElement: HTMLElement, animationCtrl: AnimationController): Animation => { + return enterAnimation(baseElement, animationCtrl).direction('reverse'); +}; diff --git a/frontend/app/src/app/modules/menu/context/context-menu-modal.component.spec.ts b/frontend/app/src/app/modules/menu/context/context-menu-modal.component.spec.ts new file mode 100644 index 00000000..c9334206 --- /dev/null +++ b/frontend/app/src/app/modules/menu/context/context-menu-modal.component.spec.ts @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2025 StApps + * 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, version 3. + * + * 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, see . + */ +/* eslint-disable @typescript-eslint/no-non-null-assertion,@typescript-eslint/no-explicit-any */ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {CommonModule} from '@angular/common'; +import {TranslateModule} from '@ngx-translate/core'; +import {IonicModule} from '@ionic/angular'; +import {SCThingType} from '@openstapps/core'; +import {ContextMenuModalComponent} from './context-menu-modal.component'; +import {ContextMenuService} from './context-menu.service'; +import {FilterContext, SortContext} from './context-type'; +import {provideIonicAngular, ModalController} from '@ionic/angular/standalone'; +import {BehaviorSubject, of} from 'rxjs'; +import {addIcons} from 'ionicons'; +import {swapVertical, trash} from 'ionicons/icons'; + +describe('ContextMenuModalComponent', () => { + let fixture: ComponentFixture; + let component: ContextMenuModalComponent; + let modalControllerSpy: jasmine.SpyObj; + let contextMenuServiceMock: Partial; + + // Register used icons (suppress warnings) + addIcons({ + delete: trash, + sort: swapVertical, + }); + + beforeEach(async () => { + modalControllerSpy = jasmine.createSpyObj('ModalController', ['dismiss']); + + contextMenuServiceMock = { + filterOptions: new BehaviorSubject(getFilterContextType()), + sortOptions: new BehaviorSubject(getSortContextType()), + filterContextChanged$: of(getFilterContextType()), + sortContextChanged$: of(getSortContextType()), + contextFilterChanged: jasmine.createSpy(), + contextSortChanged: jasmine.createSpy(), + }; + + await TestBed.configureTestingModule({ + declarations: [ContextMenuModalComponent], + imports: [CommonModule, FormsModule, TranslateModule.forRoot(), IonicModule.forRoot()], + providers: [ + provideIonicAngular(), + { + provide: ModalController, + useValue: modalControllerSpy, + }, + { + provide: ContextMenuService, + useValue: contextMenuServiceMock, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ContextMenuModalComponent); + component = fixture.componentInstance; + + component.contextMenuService = contextMenuServiceMock as ContextMenuService; + component.translator = { + translatedPropertyValue: () => 'translated', + } as any; + + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should load initial sort and filter context', () => { + expect(component.sortOption?.value).toBe('relevance'); + expect(component.filterOption?.options?.length).toBeGreaterThan(0); + }); + + it('should display sort items', () => { + const sortItems = fixture.nativeElement.querySelectorAll('.sort-item'); + expect(sortItems.length).toBe(component.sortOption.values.length); + }); + + it('should update and reverse sort value on click', () => { + const value = component.sortOption.values[1]; // "name", reversible + component.sortChanged(component.sortOption, value); + expect(component.sortOption.value).toBe('name'); + expect(component.sortOption.reversed).toBeFalse(); + + component.sortChanged(component.sortOption, value); + expect(component.sortOption.reversed).toBeTrue(); + }); + + it('should call contextFilterChanged when filter is reset', () => { + component.filterOption.options[0].buckets[0].checked = true; + component.resetFilter(component.filterOption); + const allUnchecked = component.filterOption.options.every(opt => + opt.buckets.every(bucket => !bucket.checked), + ); + expect(allUnchecked).toBeTrue(); + expect(contextMenuServiceMock.contextFilterChanged).toHaveBeenCalled(); + }); + + it('should dismiss the modal', () => { + component.dismiss(); + expect(modalControllerSpy.dismiss).toHaveBeenCalled(); + }); +}); + +/** + * + */ +function getSortContextType(): SortContext { + return { + name: 'sort', + reversed: false, + value: 'relevance', + values: [ + {value: 'relevance', reversible: false}, + {value: 'name', reversible: true}, + {value: 'date', reversible: true}, + {value: 'type', reversible: true}, + ], + }; +} + +/** + * + */ +function getFilterContextType(): FilterContext { + return { + name: 'filter', + compact: false, + options: facetsMock + .filter(facet => facet.buckets.length > 0) + .map((facet, i) => ({ + buckets: facet.buckets.map(bucket => ({ + count: bucket.count, + key: bucket.key, + checked: false, + })), + compact: false, + field: facet.field, + onlyOnType: facet.onlyOnType, + info: { + onlyOnType: facet.onlyOnType, + field: facet.field, + sortOrder: i, + }, + })), + }; +} + +const facetsMock = [ + { + buckets: [ + {count: 10, key: 'lecture'}, + {count: 5, key: 'seminar'}, + ], + field: 'type', + onlyOnType: SCThingType.AcademicEvent, + }, + { + buckets: [{count: 7, key: 'research'}], + field: 'categories', + onlyOnType: SCThingType.AcademicEvent, + }, +]; diff --git a/frontend/app/src/app/modules/menu/context/context-menu.component.ts b/frontend/app/src/app/modules/menu/context/context-menu-modal.component.ts similarity index 56% rename from frontend/app/src/app/modules/menu/context/context-menu.component.ts rename to frontend/app/src/app/modules/menu/context/context-menu-modal.component.ts index 0078a894..5daca1e9 100644 --- a/frontend/app/src/app/modules/menu/context/context-menu.component.ts +++ b/frontend/app/src/app/modules/menu/context/context-menu-modal.component.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 StApps + * Copyright (C) 2025 StApps * 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, version 3. @@ -12,76 +12,40 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {Component, Input} from '@angular/core'; import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; import {SCLanguage, SCThingTranslator, SCThingType, SCTranslations} from '@openstapps/core'; import {ContextMenuService} from './context-menu.service'; import {FilterContext, FilterFacet, SortContext, SortContextOption} from './context-type.js'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {ModalController} from '@ionic/angular/standalone'; +import {Component, Input, OnInit, OnDestroy} from '@angular/core'; +import {Subject, takeUntil} from 'rxjs'; -/** - * The context menu - * - * It can be configured with sorting types and filtering on facets - * - * Example:
- * `` - */ @Component({ - selector: 'stapps-context', - templateUrl: 'context-menu.html', + selector: 'stapps-context-menu-modal', + templateUrl: './context-menu-modal.html', }) -export class ContextMenuComponent { - /** - * Id of the content the menu is used for - */ - @Input() - contentId: string; +export class ContextMenuModalComponent implements OnInit, OnDestroy { + @Input() contextMenuService: ContextMenuService; - /** - * Amount of filter options shown on compact view - */ compactFilterOptionCount = 5; - /** - * Container for the filter context - */ filterOption: FilterContext; - /** - * Picks facets based on the compact filter option and sorts - * them based on - * - * No specific type => Type name alphabetically => Bucket count - */ - get facets(): FilterFacet[] { - return this.filterOption.options.filter(it => it.buckets.length > 0); - } - - /** - * Possible languages to be used for translation - */ - language: keyof SCTranslations; - - /** - * Mapping of SCThingType - */ - scThingType = SCThingType; - - /** - * Container for the sort context - */ sortOption: SortContext; - /** - * Core translator - */ + language: keyof SCTranslations; + translator: SCThingTranslator; + scThingType = SCThingType; + + // Using a subject to manage subscriptions for clean-up + private readonly destroy$ = new Subject(); + constructor( private translateService: TranslateService, - private readonly contextMenuService: ContextMenuService, + private readonly modalController: ModalController, ) { this.language = this.translateService.currentLang as keyof SCTranslations; this.translator = new SCThingTranslator(this.language); @@ -90,43 +54,56 @@ export class ContextMenuComponent { this.language = event.lang as keyof SCTranslations; this.translator = new SCThingTranslator(this.language); }); - this.contextMenuService.filterContextChanged$.pipe(takeUntilDestroyed()).subscribe(filterContext => { - this.filterOption = filterContext; + } + + ngOnInit(): void { + const initialFilter = this.contextMenuService.filterOptions.getValue(); + if (initialFilter) { + this.filterOption = initialFilter; + } + + const initialSort = this.contextMenuService.sortOptions.getValue(); + if (initialSort) { + this.sortOption = initialSort; + } + + // Move the subscription logic here. It's now safe to access this.contextMenuService. + this.contextMenuService.filterContextChanged$.pipe(takeUntil(this.destroy$)).subscribe(fc => { + if (fc) { + this.filterOption = fc; + } }); - this.contextMenuService.sortOptions.pipe(takeUntilDestroyed()).subscribe(sortContext => { - this.sortOption = sortContext; + + this.contextMenuService.sortContextChanged$.pipe(takeUntil(this.destroy$)).subscribe(sc => { + if (sc) { + this.sortOption = sc; + } }); } - /** - * Sets selected filter options and updates listener - */ + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + get facets(): FilterFacet[] { + return this.filterOption?.options?.filter(it => it.buckets.length > 0) || []; + } + + resetFilter(option: FilterContext) { + for (const facet of option.options) { + for (const bucket of facet.buckets) { + bucket.checked = false; + } + } + this.contextMenuService.contextFilterChanged(this.filterOption); + } + filterChanged = () => { this.contextMenuService.contextFilterChanged(this.filterOption); }; - /** - * Returns translated property value - */ - getTranslatedPropertyValue(onlyForType: SCThingType, field: string, key?: string): string | undefined { - return this.translator.translatedPropertyValue(onlyForType, field, key); - } - - /** - * Resets filter options - */ - resetFilter = (option: FilterContext) => { - for (const filterFacet of option.options) - for (const filterBucket of filterFacet.buckets) { - filterBucket.checked = false; - } - this.contextMenuService.contextFilterChanged(this.filterOption); - }; - - /** - * Updates selected sort option and updates listener - */ - sortChanged = (option: SortContext, value: SortContextOption) => { + sortChanged(option: SortContext, value: SortContextOption) { if (option.value === value.value) { if (value.reversible) { option.reversed = !option.reversed; @@ -138,5 +115,13 @@ export class ContextMenuComponent { } } this.contextMenuService.contextSortChanged(option); - }; + } + + getTranslatedPropertyValue(onlyForType: SCThingType, field: string, key?: string): string | undefined { + return this.translator.translatedPropertyValue(onlyForType, field, key); + } + + dismiss() { + this.modalController.dismiss(); + } } diff --git a/frontend/app/src/app/modules/menu/context/context-menu-modal.html b/frontend/app/src/app/modules/menu/context/context-menu-modal.html new file mode 100644 index 00000000..bfcf5097 --- /dev/null +++ b/frontend/app/src/app/modules/menu/context/context-menu-modal.html @@ -0,0 +1,104 @@ + + + + +

{{ 'menu.context.title' | translate | titlecase }}

+
+
+
+ + + + + + + + {{ 'menu.context.sort.title' | translate | titlecase }} + + + + {{ 'menu.context.sort.' + value.value | translate | titlecase }} + + + + + + + + + + +
+ + + {{ 'menu.context.filter.title' | translate | titlecase }} + + + + + + + + + {{ facet.info.onlyOnType | titlecase }} / + + {{ facet.info.field | titlecase }} + + + + + + + ({{ bucket.count }}) + {{ + facet.field === 'type' + ? (getTranslatedPropertyValue($any(bucket.key), 'type') | titlecase) + : (facet.onlyOnType && getTranslatedPropertyValue(facet.onlyOnType, facet.field, bucket.key) + | titlecase) + }} + + + + + + {{ 'menu.context.filter.showAll' | translate }} + + +
+
diff --git a/frontend/app/src/app/modules/menu/context/context-menu.component.spec.ts b/frontend/app/src/app/modules/menu/context/context-menu.component.spec.ts deleted file mode 100644 index 1808a04a..00000000 --- a/frontend/app/src/app/modules/menu/context/context-menu.component.spec.ts +++ /dev/null @@ -1,303 +0,0 @@ -/* - * Copyright (C) 2023 StApps - * 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, version 3. - * - * 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, see . - */ -/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/ban-ts-comment */ -import {APP_BASE_HREF, CommonModule, Location, LocationStrategy, PathLocationStrategy} from '@angular/common'; -import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {FormsModule} from '@angular/forms'; -import {ChildrenOutletContexts, RouterModule, UrlSerializer} from '@angular/router'; -import {TranslateModule} from '@ngx-translate/core'; -import {SCFacet, SCThingType} from '@openstapps/core'; -import {ContextMenuComponent} from './context-menu.component'; -import {SettingsModule} from '../../settings/settings.module'; -import {ContextMenuService} from './context-menu.service'; -import {FilterContext, SortContext} from './context-type'; -import {Component} from '@angular/core'; -import {By} from '@angular/platform-browser'; -import {provideIonicAngular} from '@ionic/angular/standalone'; - -@Component({ - template: ` `, -}) -class ContextMenuContainerComponent {} - -describe('ContextMenuComponent', async () => { - let fixture: ComponentFixture; - let instance: ContextMenuComponent; - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [ContextMenuComponent, ContextMenuContainerComponent], - providers: [ - provideIonicAngular(), - ChildrenOutletContexts, - Location, - UrlSerializer, - ContextMenuService, - {provide: LocationStrategy, useClass: PathLocationStrategy}, - {provide: APP_BASE_HREF, useValue: '/'}, - ], - imports: [ - FormsModule, - TranslateModule.forRoot(), - CommonModule, - SettingsModule, - RouterModule.forRoot([]), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ContextMenuContainerComponent); - instance = fixture.debugElement.query(By.directive(ContextMenuComponent)).componentInstance; - }); - - it('should show items in sort context', () => { - instance.sortOption = getSortContextType(); - fixture.detectChanges(); - const sort: HTMLElement = fixture.debugElement.nativeElement.querySelector('.context-sort'); - expect(sort!.querySelector('ion-radio')?.textContent).toContain('relevance'); - }); - - it('should show items in filter context', () => { - instance.filterOption = getFilterContextType(); - fixture.detectChanges(); - const filter: HTMLElement = fixture.debugElement.nativeElement.querySelector('.context-filter'); - const filterItem = filter.querySelector('.filter-group'); - expect(filterItem!.querySelector('ion-list-header')!.textContent).toContain('Type'); - }); - - it('should set sort context value and reverse on click', () => { - instance.sortOption = getSortContextType(); - fixture.detectChanges(); - const sort: HTMLElement = fixture.debugElement.nativeElement.querySelector('.context-sort'); - // @ts-expect-error not relevant for this case - const sortItem: HTMLElement = sort.querySelectorAll('.sort-item')[1]; - sortItem!.click(); - expect(instance.sortOption.value).toEqual('name'); - expect(instance.sortOption.reversed).toBe(false); - - // click again for reverse - sortItem!.click(); - expect(instance.sortOption.reversed).toBe(true); - }); - - it('should show all filterable facets', () => { - // get set facets with non empty buckets - const facets: SCFacet[] = getFilterContextType().options; - - instance.filterOption = getFilterContextType(); - fixture.detectChanges(); - // get filter context div - const filter: HTMLElement = fixture.debugElement.nativeElement.querySelector('.context-filter'); - // get all filter groups that represent a facet - const filterGroups = filter.querySelectorAll('.filter-group'); - - expect(filterGroups.length).toEqual(facets.length); - - for (const facet of facets) { - let filterGroup; - - // get filter option for facets field - // eslint-disable-next-line unicorn/no-array-for-each - filterGroups.forEach(element => { - if ( - element - .querySelector('ion-list-header')! - .textContent!.toString() - .toLowerCase() - .includes(facet.field) - ) { - filterGroup = element; - return; - } - }); - - expect(filterGroup).toBeDefined(); - - const filterItems = filterGroup!.querySelectorAll('.filter-item-label'); - - if (filterItems.length !== facet.buckets.length) { - console.log(JSON.stringify(facet)); - } - expect(filterItems.length).toEqual(facet.buckets.length); - - // check all buckets are shown - for (const bucket of facet.buckets) { - let filterItem; - - for (let i = 0; i < filterItems.length; i++) { - if ( - filterItems.item(i).textContent!.toString().toLowerCase().indexOf(bucket.key.toLowerCase()) > 0 - ) { - filterItem = filterItems.item(i); - break; - } - } - expect(filterItem).toBeDefined(); - } - } - }); - - it('should reset filter', () => { - instance.filterOption = getFilterContextType(); - instance.filterOption.options = [ - { - field: 'type', - buckets: [{count: 10, key: 'date series', checked: true}], - info: { - onlyOnType: SCThingType.AcademicEvent, - field: 'date series', - sortOrder: 0, - }, - }, - ]; - - fixture.detectChanges(); - - // click reset button - const resetButton: HTMLElement = fixture.debugElement.nativeElement.querySelector('.resetFilterButton'); - resetButton.click(); - - expect(instance.filterOption.options[0].buckets[0].checked).toEqual(false); - }); -}); - -/** - * - */ -function getSortContextType(): SortContext { - return { - name: 'sort', - reversed: false, - value: 'relevance', - values: [ - { - reversible: false, - value: 'relevance', - }, - { - reversible: true, - value: 'name', - }, - { - reversible: true, - value: 'date', - }, - { - reversible: true, - value: 'type', - }, - ], - }; -} - -/** - * - */ -function getFilterContextType(): FilterContext { - return { - name: 'filter', - compact: false, - options: facetsMock - .filter(facet => facet.buckets.length > 0) - .map((facet, i) => { - return { - buckets: facet.buckets.map(bucket => { - return { - count: bucket.count, - key: bucket.key, - checked: false, - }; - }), - compact: false, - field: facet.field, - onlyOnType: facet.onlyOnType, - info: { - onlyOnType: facet.onlyOnType, - field: facet.field, - sortOrder: i, - }, - }; - }), - }; -} - -const facetsMock: SCFacet[] = [ - { - buckets: [ - { - count: 60, - key: 'academic event', - }, - { - count: 160, - key: 'message', - }, - { - count: 151, - key: 'date series', - }, - { - count: 106, - key: 'dish', - }, - { - count: 20, - key: 'building', - }, - ], - field: 'type', - }, - { - buckets: [ - { - count: 12, - key: 'Max Mustermann', - }, - { - count: 2, - key: 'Foo Bar', - }, - ], - field: 'performers', - onlyOnType: SCThingType.AcademicEvent, - }, - { - buckets: [ - { - count: 5, - key: 'colloquium', - }, - { - count: 15, - key: 'course', - }, - ], - field: 'categories', - onlyOnType: SCThingType.AcademicEvent, - }, - { - buckets: [ - { - count: 5, - key: 'employees', - }, - { - count: 15, - key: 'students', - }, - ], - field: 'audiences', - onlyOnType: SCThingType.Message, - }, -]; diff --git a/frontend/app/src/app/modules/menu/context/context-menu.html b/frontend/app/src/app/modules/menu/context/context-menu.html deleted file mode 100644 index 2986d7d6..00000000 --- a/frontend/app/src/app/modules/menu/context/context-menu.html +++ /dev/null @@ -1,114 +0,0 @@ - - - - - -

{{ 'menu.context.title' | translate | titlecase }}

-
-
- - - - @if (sortOption) { - - - - {{ 'menu.context.sort.title' | translate | titlecase }} - - @for (value of sortOption.values; track value; let i = $index) { - - - {{ 'menu.context.sort.' + value.value | translate | titlecase }} - @if (sortOption.value === value.value && value.reversible) { - - @if (sortOption.reversed) { - - } - @if (!sortOption.reversed) { - - } - - } - - - } - - } - - - @if (filterOption) { -
- - - {{ 'menu.context.filter.title' | translate | titlecase }} - - - - - @for (facet of facets; track facet) { - -
- - - @if (facet.info.onlyOnType) { - {{ facet.info.onlyOnType | titlecase }} / - - } - {{ facet.info.field | titlecase }} - - -
- @for ( - bucket of !facet.compact ? facet.buckets.slice(0, compactFilterOptionCount) : facet.buckets; - track bucket - ) { - - - ({{ bucket.count }}) - {{ - facet.field === 'type' - ? (getTranslatedPropertyValue($any(bucket.key), 'type') | titlecase) - : (facet.onlyOnType && - getTranslatedPropertyValue(facet.onlyOnType, facet.field, bucket.key) - | titlecase) - }} - - - } - @if (!facet.compact && facet.buckets.length > compactFilterOptionCount) { - - {{ 'menu.context.filter.showAll' | translate }} - - } -
-
-
- } -
- } -
-
diff --git a/frontend/app/src/app/modules/menu/context/context-menu.service.spec.ts b/frontend/app/src/app/modules/menu/context/context-menu.service.spec.ts index 59dd4114..4f24f6b5 100644 --- a/frontend/app/src/app/modules/menu/context/context-menu.service.spec.ts +++ b/frontend/app/src/app/modules/menu/context/context-menu.service.spec.ts @@ -12,14 +12,13 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ - import {TestBed} from '@angular/core/testing'; - import {ContextMenuService} from './context-menu.service'; import {SCFacet} from '@openstapps/core'; import {FilterContext, SortContext} from './context-type'; import {ThingTranslateModule} from '../../../translation/thing-translate.module'; import {TranslateModule} from '@ngx-translate/core'; +import {firstValueFrom, filter} from 'rxjs'; describe('ContextMenuService', () => { let service: ContextMenuService; @@ -36,39 +35,39 @@ describe('ContextMenuService', () => { expect(service).toBeTruthy(); }); - it('should update filterOptions', done => { - service.filterContextChanged$.subscribe(result => { - expect(result).toBeDefined(); - done(); - }); + it('should update filterOptions', async () => { service.updateContextFilter(facetsMock); + + const result = await firstValueFrom(service.filterContextChanged$.pipe(filter(Boolean))); + + expect(result).toBeDefined(); }); - it('should update filterQuery', done => { - service.filterContextChanged$.subscribe(result => { - expect(result).toBeDefined(); - expect(service.contextFilter.options[0].buckets.length).toEqual( - filterContext.options[0].buckets.length, - ); - done(); - }); + it('should update filterQuery', async () => { service.updateContextFilter(facetsMock); + const result = await firstValueFrom(service.filterContextChanged$.pipe(filter(Boolean))); + + expect(result).toBeDefined(); + + const current = service.contextFilter; + + expect(current.options[0].buckets.length).toEqual(filterContext.options[0].buckets.length); }); - it('should update sortOptions', done => { - service.sortContextChanged$.subscribe(result => { - expect(result).toBeDefined(); - done(); - }); + it('should update sortOptions', async () => { service.setContextSort(sortContext); + + const result = await firstValueFrom(service.sortContextChanged$.pipe(filter(Boolean))); + + expect(result).toBeDefined(); }); - it('should update sortQuery', done => { - service.sortContextChanged$.subscribe(result => { - expect(result).toBeDefined(); - done(); - }); + it('should update sortQuery', async () => { service.setContextSort(sortContext); + + const result = await firstValueFrom(service.sortContextChanged$.pipe(filter(Boolean))); + + expect(result).toBeDefined(); }); }); diff --git a/frontend/app/src/app/modules/menu/context/context-menu.service.ts b/frontend/app/src/app/modules/menu/context/context-menu.service.ts index 804fefce..e124df9c 100644 --- a/frontend/app/src/app/modules/menu/context/context-menu.service.ts +++ b/frontend/app/src/app/modules/menu/context/context-menu.service.ts @@ -21,7 +21,7 @@ import { SCThingType, SCTranslations, } from '@openstapps/core'; -import {Subject} from 'rxjs'; +import {BehaviorSubject} from 'rxjs'; import {FilterBucket, FilterContext, FilterFacet, SortContext, TransformedFacet} from './context-type'; import {TranslateService} from '@ngx-translate/core'; import {ThingTranslateService} from '../../../translation/thing-translate.service'; @@ -40,7 +40,7 @@ export class ContextMenuService { /** * Container for the filter context */ - filterOptions = new Subject(); + filterOptions = new BehaviorSubject(undefined); /** * Observable filterContext streams @@ -50,7 +50,7 @@ export class ContextMenuService { /** * Container for the filter query (SCSearchFilter) */ - filterQuery = new Subject(); + filterQuery = new BehaviorSubject(undefined); /** * Observable filterContext streams @@ -65,7 +65,7 @@ export class ContextMenuService { /** * Container for the sort context */ - sortOptions = new Subject(); + sortOptions = new BehaviorSubject(undefined); /** * Observable SortContext streams @@ -75,7 +75,7 @@ export class ContextMenuService { /** * Container for the sort query */ - sortQuery = new Subject(); + sortQuery = new BehaviorSubject(undefined); /** * Observable SortContext streams diff --git a/frontend/app/src/app/modules/menu/menu.module.ts b/frontend/app/src/app/modules/menu/menu.module.ts index de535593..162d61ef 100644 --- a/frontend/app/src/app/modules/menu/menu.module.ts +++ b/frontend/app/src/app/modules/menu/menu.module.ts @@ -19,12 +19,13 @@ import {RouterModule} from '@angular/router'; import {LayoutModule} from '@angular/cdk/layout'; import {TranslateModule} from '@ngx-translate/core'; import {SettingsModule} from '../settings/settings.module'; -import {ContextMenuComponent} from './context/context-menu.component'; import {ContextMenuService} from './context/context-menu.service'; import { IonButton, + IonButtons, IonCheckbox, IonContent, + IonHeader, IonItem, IonLabel, IonList, @@ -39,13 +40,14 @@ import { IonToolbar, } from '@ionic/angular/standalone'; import {IonIconDirective} from 'src/app/util/ion-icon/ion-icon.directive'; +import {ContextMenuModalComponent} from './context/context-menu-modal.component'; /** * Menu module */ @NgModule({ - declarations: [ContextMenuComponent], - exports: [ContextMenuComponent], + declarations: [ContextMenuModalComponent], + exports: [ContextMenuModalComponent], imports: [ CommonModule, IonIconDirective, @@ -69,6 +71,8 @@ import {IonIconDirective} from 'src/app/util/ion-icon/ion-icon.directive'; IonRadioGroup, IonContent, IonToolbar, + IonButtons, + IonHeader, ], providers: [ContextMenuService], })