mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-09 09:52:56 +00:00
feat: add easy way to configure search filtering for nested properties
This commit is contained in:
committed by
Rainer Killinger
parent
e75a46633c
commit
2220ab24b3
@@ -1 +1,2 @@
|
||||
src/app/_helpers/data
|
||||
node_modules
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"compilerOptions": {
|
||||
"sourceMap": false,
|
||||
"types": ["cypress"]
|
||||
}
|
||||
},
|
||||
"exclude": []
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
70
src/app/modules/menu/context/facet-filter.ts
Normal file
70
src/app/modules/menu/context/facet-filter.ts
Normal 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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
4
src/config/README.md
Normal 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.
|
||||
@@ -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;
|
||||
52
src/config/search-filter.ts
Normal file
52
src/config/search-filter.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -13,5 +13,6 @@
|
||||
"module": "es2020",
|
||||
"target": "es2017",
|
||||
"lib": ["es2020", "dom"]
|
||||
}
|
||||
},
|
||||
"exclude": ["**/*.spec.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user