feat: add easy way to configure search filtering for nested properties

This commit is contained in:
Thea Schöbl
2023-03-22 19:40:49 +00:00
committed by Rainer Killinger
parent e75a46633c
commit 2220ab24b3
18 changed files with 237 additions and 68 deletions

View File

@@ -1 +1,2 @@
src/app/_helpers/data
node_modules

View File

@@ -8,7 +8,7 @@
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"project": ["tsconfig.json", "e2e/tsconfig.e2e.json"],
"project": ["tsconfig.json", "tsconfig.spec.json", "e2e/tsconfig.e2e.json"],
"createDefaultProgram": true
},
"extends": [
@@ -44,7 +44,6 @@
],
"unicorn/no-nested-ternary": "off",
"unicorn/better-regex": "off",
"jsdoc/no-types": "error",
"jsdoc/require-param": "off",
"jsdoc/require-param-description": "error",
@@ -52,7 +51,6 @@
"jsdoc/require-returns": "off",
"jsdoc/require-param-type": "off",
"jsdoc/require-returns-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unused-vars": [
"error",

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* 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.
@@ -54,14 +54,14 @@ describe('context menu', function () {
it('should truncate categories', function () {
cy.get('stapps-context').within(() => {
cy.contains('ion-item', '(4) Universitätsveranstaltung').should('not.exist');
cy.contains('ion-item', '(1) Universitätsveranstaltung').should('not.exist');
cy.get('.context-filter > ion-button').click();
cy.contains('ion-item', '(4) Universitätsveranstaltung').should('exist');
});
});
it('should truncate long category items', function () {
cy.contains('ion-list', 'Kategorien | Akademische Veranstaltung').within(() => {
cy.contains('ion-list', 'Akademische Veranstaltung / Kategorien').within(() => {
cy.contains('ion-item', '(1) Tutorium').should('not.exist');
cy.get('div > ion-button').click();
cy.contains('ion-item', '(1) Tutorium').should('exist');

View File

@@ -4,5 +4,6 @@
"compilerOptions": {
"sourceMap": false,
"types": ["cypress"]
}
},
"exclude": []
}

View File

@@ -13,6 +13,7 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {CdkVirtualForOf, CdkVirtualScrollViewport, VirtualScrollStrategy} from '@angular/cdk/scrolling';
import {BehaviorSubject, Subject, Subscription, takeUntil, timer} from 'rxjs';
import {debounceTime, distinctUntilChanged, tap} from 'rxjs/operators';

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* 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.
@@ -159,6 +159,11 @@ describe('ContextMenuComponent', async () => {
{
field: 'type',
buckets: [{count: 10, key: 'date series', checked: true}],
info: {
onlyOnType: SCThingType.AcademicEvent,
field: 'date series',
sortOrder: 0,
},
},
];
@@ -210,7 +215,7 @@ function getFilterContextType(): FilterContext {
compact: false,
options: facetsMock
.filter(facet => facet.buckets.length > 0)
.map(facet => {
.map((facet, i) => {
return {
buckets: facet.buckets.map(bucket => {
return {
@@ -222,6 +227,11 @@ function getFilterContextType(): FilterContext {
compact: false,
field: facet.field,
onlyOnType: facet.onlyOnType,
info: {
onlyOnType: facet.onlyOnType,
field: facet.field,
sortOrder: i,
},
};
}),
};

View File

@@ -17,7 +17,7 @@ import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {SCLanguage, SCThingTranslator, SCThingType, SCTranslations} from '@openstapps/core';
import {Subscription} from 'rxjs';
import {ContextMenuService} from './context-menu.service';
import {FilterContext, SortContext, SortContextOption} from './context-type';
import {FilterContext, FilterFacet, SortContext, SortContextOption} from './context-type';
/**
* The context menu
@@ -49,6 +49,19 @@ export class ContextMenuComponent implements OnDestroy {
*/
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[] {
const options = this.filterOption.compact
? this.filterOption.options.slice(0, this.compactFilterOptionCount)
: this.filterOption.options;
return options.filter(it => it.buckets.length > 0);
}
/**
* Possible languages to be used for translation
*/
@@ -102,18 +115,6 @@ export class ContextMenuComponent implements OnDestroy {
this.contextMenuService.contextFilterChanged(this.filterOption);
};
/**
* Returns translated property name
*/
getTranslatedPropertyName(property: string, onlyForType?: SCThingType): string {
return (
this.translator.translatedPropertyNames(
onlyForType ?? SCThingType.AcademicEvent,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any
)[property];
}
/**
* Returns translated property value
*/

View File

@@ -1,5 +1,5 @@
<!--
~ Copyright (C) 2022 StApps
~ 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.
@@ -41,7 +41,7 @@
<ion-icon *ngIf="!sortOption.reversed" name="arrow_upward"></ion-icon>
</span>
</ion-label>
<ion-radio slot="end" [value]="i"> </ion-radio>
<ion-radio slot="end" [value]="i"></ion-radio>
</ion-item>
</ion-radio-group>
</ion-list>
@@ -55,31 +55,17 @@
</ion-button>
</ion-list-header>
<ion-list
class="filter-group"
*ngFor="
let facet of !filterOption.compact
? filterOption.options.slice(0, compactFilterOptionCount)
: filterOption.options
"
>
<div *ngIf="!facet.field.includes('.')">
<ion-list class="filter-group" *ngFor="let facet of facets">
<div>
<ion-list-header class="h3">
<ion-label>
{{
(facet.onlyOnType
? getTranslatedPropertyName(facet.field, facet.onlyOnType)
: getTranslatedPropertyName(facet.field)
) | titlecase
}}
{{
facet.onlyOnType
? ' | ' + (getTranslatedPropertyValue(facet.onlyOnType, 'type') | titlecase)
: ''
}}
<span *ngIf="facet.info.onlyOnType"
><b>{{ facet.info.onlyOnType | titlecase }}</b> /
</span>
{{ facet.info.field | titlecase }}
</ion-label>
</ion-list-header>
<div *ngIf="facet.buckets.length > 0">
<div>
<ion-item
*ngFor="
let bucket of !facet.compact

View File

@@ -1,14 +1,32 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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';
describe('ContextMenuService', () => {
let service: ContextMenuService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ThingTranslateModule.forRoot(), TranslateModule.forRoot()],
providers: [ContextMenuService],
});
service = TestBed.inject(ContextMenuService);
@@ -123,6 +141,10 @@ const filterContext: FilterContext = {
},
],
field: 'type',
info: {
field: 'type',
sortOrder: 0,
},
},
],
};

View File

@@ -13,9 +13,19 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
import {SCFacet, SCSearchFilter, SCSearchSort, SCThingType} from '@openstapps/core';
import {
SCFacet,
SCSearchFilter,
SCSearchSort,
SCThingTranslator,
SCThingType,
SCTranslations,
} from '@openstapps/core';
import {Subject} from 'rxjs';
import {FilterBucket, FilterContext, FilterFacet, SortContext} from './context-type';
import {FilterBucket, FilterContext, FilterFacet, SortContext, TransformedFacet} from './context-type';
import {TranslateService} from '@ngx-translate/core';
import {ThingTranslateService} from '../../../translation/thing-translate.service';
import {transformFacets} from './facet-filter';
/**
* ContextMenuService provides bidirectional communication of context menu options and search queries
@@ -72,6 +82,11 @@ export class ContextMenuService {
*/
sortQueryChanged$ = this.sortQuery.asObservable();
constructor(
private readonly translate: TranslateService,
private readonly thingTranslate: ThingTranslateService,
) {}
/**
* Returns SCSearchFilter if filterContext value is set, undefined otherwise
*
@@ -178,18 +193,9 @@ export class ContextMenuService {
* Updates the filter context options from given facets
*/
updateContextFilter(facets: SCFacet[]) {
// arrange facet field "type" to first position
facets.sort((a: SCFacet, b: SCFacet) => {
if (a.field === 'type') {
return -1;
}
if (b.field === 'type') {
return 1;
}
return 0;
});
const language = this.translate.currentLang as keyof SCTranslations<unknown>;
const translator = new SCThingTranslator(language);
const transformedFacets = transformFacets(facets, language, this.thingTranslate, translator);
if (!this.contextFilter) {
this.contextFilter = {
@@ -198,23 +204,24 @@ export class ContextMenuService {
};
}
this.updateContextFilterOptions(this.contextFilter, facets);
this.updateContextFilterOptions(this.contextFilter, transformedFacets);
}
/**
* Updates context filter with new facets.
* It preserves the checked status of existing filter options
*/
updateContextFilterOptions = (contextFilter: FilterContext, facets: SCFacet[]) => {
updateContextFilterOptions = (contextFilter: FilterContext, facets: TransformedFacet[]) => {
const newFilterOptions: FilterFacet[] = [];
// iterate new facets
for (const facet of facets) {
for (const {facet, info} of facets) {
if (facet.buckets.length > 0) {
const newFilterFacet: FilterFacet = {
buckets: [],
field: facet.field,
onlyOnType: facet.onlyOnType,
info,
};
newFilterOptions.push(newFilterFacet);

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2020 StApps
* 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.
@@ -12,7 +12,7 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {SCFacet, SCFacetBucket} from '@openstapps/core';
import {SCFacet, SCFacetBucket, SCThingType} from '@openstapps/core';
export type ContextType = FilterContext | SortContext;
@@ -84,6 +84,21 @@ export interface FilterFacet extends SCFacet {
* Compact view of the option buckets
*/
compact?: boolean;
/**
* Translated information about the facet
*/
info: FacetInfo;
}
export interface FacetInfo {
onlyOnType?: SCThingType;
field: string;
sortOrder: number;
}
export interface TransformedFacet {
facet: SCFacet;
info: FacetInfo;
}
export interface FilterBucket extends SCFacetBucket {

View File

@@ -0,0 +1,70 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {TransformedFacet} from './context-type';
import {SCFacet, SCThingTranslator, SCThingType, SCTranslations} from '@openstapps/core';
import {searchFilters} from '../../../../config/search-filter';
import {ThingTranslateService} from '../../../translation/thing-translate.service';
const filterConfig = Object.entries(searchFilters).map(([pattern, entries]) => {
return {
typePattern: new RegExp(`^${pattern}$`),
facetFilter: Object.entries(entries).map(([pattern, facet]) => ({
pattern: new RegExp(`^${pattern}$`),
...facet,
})),
};
});
/**
* Transforms facets to
*
* 1. only include facets that are allowed in the options
* 2. translates all fields
* 3. sorts the facets according to the config
*/
export function transformFacets(
facets: SCFacet[],
language: keyof SCTranslations<unknown>,
thingTranslate: ThingTranslateService,
translator: SCThingTranslator,
): TransformedFacet[] {
return facets
.map(facet => ({
facet,
info: filterConfig
.filter(({typePattern}) => typePattern.test((facet.onlyOnType as string) || ''))
.flatMap(({facetFilter}) =>
facetFilter
.filter(({pattern}) => pattern.test(facet.field))
.map(it => ({
onlyOnType: facet.onlyOnType
? (translator.translatedPropertyValue(facet.onlyOnType, 'type') as SCThingType)
: undefined,
field:
it.translations && it.name
? it.translations[language]?.name || it.name
: thingTranslate.getPropertyName(
facet.onlyOnType || SCThingType.AcademicEvent,
facet.field,
),
sortOrder: it.sortOrder,
})),
)
.sort(({sortOrder: a}, {sortOrder: b}) => a - b)[0],
}))
.filter(({info}) => !!info)
.sort(({info: {sortOrder: a}}, {info: {sortOrder: b}}) => a - b);
}

View File

@@ -14,7 +14,7 @@
*/
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {SCSection} from './sections';
import {SCSection} from '../../../../config/profile-page-sections';
import {AuthHelperService} from '../../auth/auth-helper.service';
import {Observable, Subscription} from 'rxjs';
import {SCAuthorizationProviderType} from '@openstapps/core';

View File

@@ -21,7 +21,7 @@ import {ActivatedRoute} from '@angular/router';
import {ScheduleProvider} from '../../calendar/schedule.provider';
import moment from 'moment';
import {SCIcon} from '../../../util/ion-icon/icon';
import {profilePageSections} from './sections';
import {profilePageSections} from '../../../../config/profile-page-sections';
import {filter, map} from 'rxjs/operators';
const CourseCard = {

4
src/config/README.md Normal file
View File

@@ -0,0 +1,4 @@
# Config files
This is a collection of config files which have been temporarily placed in the app,
but are designed or meant to be supplied by the backend as part of the config.

View File

@@ -22,7 +22,7 @@ import {
SCThingRemoteOrigin,
SCThingType,
} from '@openstapps/core';
import {SCIcon} from '../../../util/ion-icon/icon';
import {SCIcon} from '../app/util/ion-icon/icon';
export const SCSectionThingType = 'section' as SCThingType;
export const SCSectionLinkThingType = 'section link' as SCThingType;

View File

@@ -0,0 +1,52 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {SCTranslations} from '@openstapps/core';
export type RegExpString = string;
export type SCSearchFilterConfig = Record<
RegExpString,
Record<
RegExpString,
{
name?: string;
sortOrder: number;
translations?: SCTranslations<{name: string}>;
}
>
>;
export const searchFilters: SCSearchFilterConfig = {
'.*': {
'type': {
sortOrder: 0,
},
'[^.]*': {
sortOrder: 10,
},
'academicTerms?\\.acronym': {
name: 'semester',
sortOrder: 2,
translations: {
de: {
name: 'Semester',
},
en: {
name: 'Semester',
},
},
},
},
};

View File

@@ -13,5 +13,6 @@
"module": "es2020",
"target": "es2017",
"lib": ["es2020", "dom"]
}
},
"exclude": ["**/*.spec.ts"]
}