mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-22 09:32:41 +00:00
feat: data filter chips
This commit is contained in:
@@ -107,6 +107,7 @@ import {SemesterListItemComponent} from './types/semester/semester-list-item.com
|
|||||||
import {VideoDetailContentComponent} from './types/video/video-detail-content.component';
|
import {VideoDetailContentComponent} from './types/video/video-detail-content.component';
|
||||||
import {VideoListItemComponent} from './types/video/video-list-item.component';
|
import {VideoListItemComponent} from './types/video/video-list-item.component';
|
||||||
import {ShareButtonComponent} from './elements/share-button.component';
|
import {ShareButtonComponent} from './elements/share-button.component';
|
||||||
|
import {DataFilterComponent} from './filter/data-filter.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module for handling data
|
* Module for handling data
|
||||||
@@ -184,6 +185,7 @@ import {ShareButtonComponent} from './elements/share-button.component';
|
|||||||
ShareButtonComponent,
|
ShareButtonComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
|
DataFilterComponent,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
DataRoutingModule,
|
DataRoutingModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {AsyncPipe, CommonModule} from '@angular/common';
|
||||||
|
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
|
||||||
|
import {IonicModule} from '@ionic/angular';
|
||||||
|
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
|
||||||
|
import {DataFilterProvider} from './data-filter.provider';
|
||||||
|
import {ThingTranslateModule} from 'src/app/translation/thing-translate.module';
|
||||||
|
import {PropertyValueTranslatePipe} from 'src/app/translation/property-value-translate.pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'stapps-data-filter',
|
||||||
|
templateUrl: 'data-filter.html',
|
||||||
|
styleUrls: ['data-filter.scss'],
|
||||||
|
standalone: true,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
IonicModule,
|
||||||
|
IonIconModule,
|
||||||
|
AsyncPipe,
|
||||||
|
ThingTranslateModule,
|
||||||
|
PropertyValueTranslatePipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DataFilterComponent {
|
||||||
|
@Input() expandBucketCount = 3;
|
||||||
|
|
||||||
|
constructor(readonly filterProvider: DataFilterProvider) {}
|
||||||
|
}
|
||||||
75
frontend/app/src/app/modules/data/filter/data-filter.html
Normal file
75
frontend/app/src/app/modules/data/filter/data-filter.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<ion-chip (click)="sortMenu.present($event)">
|
||||||
|
<ion-label>Relevance</ion-label>
|
||||||
|
<ion-icon name="expand_more"></ion-icon>
|
||||||
|
</ion-chip>
|
||||||
|
<ion-popover #sortMenu side="bottom" alignment="start">
|
||||||
|
<ng-template>
|
||||||
|
<ion-content class="ion-padding">
|
||||||
|
<ion-radio-group value="relevance">
|
||||||
|
<ion-radio value="relevance">Relevance</ion-radio>
|
||||||
|
<ion-radio value="name">Name</ion-radio>
|
||||||
|
<ion-radio value="type">Type</ion-radio>
|
||||||
|
</ion-radio-group>
|
||||||
|
</ion-content>
|
||||||
|
</ng-template>
|
||||||
|
</ion-popover>
|
||||||
|
<div class="separator"></div>
|
||||||
|
<ng-container *ngIf="filterProvider.context | async as context">
|
||||||
|
<ng-container *ngFor="let facet of context.facets">
|
||||||
|
<ng-container *ngIf="facet.buckets.length > 1">
|
||||||
|
<ng-template #facetLabel>
|
||||||
|
<ng-container *ngIf="facet.onlyOnType; else notOnlyOnType">
|
||||||
|
<strong>{{facet.onlyOnType | propertyValueTranslate: 'type': facet.onlyOnType | titlecase}}</strong>
|
||||||
|
/ {{facet.field | propertyNameTranslate: facet.onlyOnType | titlecase}}
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #notOnlyOnType>
|
||||||
|
{{facet.field | propertyNameTranslate: $any('building') | titlecase}}
|
||||||
|
</ng-template>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #bucketLabel let-bucket>
|
||||||
|
{{bucket.key | propertyValueTranslate: facet.field: (facet.onlyOnType ?? $any(bucket.key)) |
|
||||||
|
titlecase}}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-container *ngIf="facet.buckets.length <= expandBucketCount; else expandableFacet">
|
||||||
|
<div class="separator"></div>
|
||||||
|
<div class="expanded-facet">
|
||||||
|
<div class="buckets">
|
||||||
|
<ion-chip *ngFor="let bucket of facet.buckets" [outline]="true">
|
||||||
|
<ion-label>
|
||||||
|
<ng-container *ngTemplateOutlet="bucketLabel; context: {$implicit: bucket}"></ng-container>
|
||||||
|
</ion-label>
|
||||||
|
</ion-chip>
|
||||||
|
</div>
|
||||||
|
<div class="facet-label">
|
||||||
|
<ng-container *ngTemplateOutlet="facetLabel"></ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="separator"></div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #expandableFacet>
|
||||||
|
<ion-chip (click)="filterMenu.present($event)" [outline]="true">
|
||||||
|
<ion-label>
|
||||||
|
<ng-container *ngTemplateOutlet="facetLabel"></ng-container>
|
||||||
|
</ion-label>
|
||||||
|
<ion-icon name="expand_more"></ion-icon>
|
||||||
|
</ion-chip>
|
||||||
|
|
||||||
|
<ion-popover #filterMenu side="bottom" alignment="start">
|
||||||
|
<ng-template>
|
||||||
|
<ion-content>
|
||||||
|
<ion-list>
|
||||||
|
<ion-item *ngFor="let bucket of facet.buckets">
|
||||||
|
<ion-checkbox slot="start"></ion-checkbox>
|
||||||
|
<ion-label>{{bucket.key}}</ion-label>
|
||||||
|
<ion-note slot="end">{{bucket.count}}</ion-note>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
</ion-content>
|
||||||
|
</ng-template>
|
||||||
|
</ion-popover>
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {SCSearchResult} from '@openstapps/core';
|
||||||
|
import {BehaviorSubject} from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DataFilterProvider {
|
||||||
|
readonly context = new BehaviorSubject<SCSearchResult | undefined>(undefined);
|
||||||
|
|
||||||
|
readonly userSortOption = new BehaviorSubject<string | undefined>(undefined);
|
||||||
|
|
||||||
|
readonly userFilterOption = new BehaviorSubject(new Map<string, string | number>());
|
||||||
|
}
|
||||||
36
frontend/app/src/app/modules/data/filter/data-filter.scss
Normal file
36
frontend/app/src/app/modules/data/filter/data-filter.scss
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
:host {
|
||||||
|
overflow-x: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-block: var(--spacing-xs);
|
||||||
|
|
||||||
|
> * {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-facet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
> .facet-label {
|
||||||
|
margin-inline: var(--spacing-md);
|
||||||
|
font-size: 0.7em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
align-self: center;
|
||||||
|
|
||||||
|
width: 1px;
|
||||||
|
height: 1.25em;
|
||||||
|
margin-inline: var(--spacing-xs);
|
||||||
|
|
||||||
|
opacity: 0.2;
|
||||||
|
background: currentcolor;
|
||||||
|
|
||||||
|
&:last-child,
|
||||||
|
+ .separator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
* You should have received a copy of the GNU General Public License along with
|
* You should have received a copy of the GNU General Public License along with
|
||||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import {Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
import {Component, DestroyRef, Inject, inject, Input, OnInit} from '@angular/core';
|
||||||
import {ActivatedRoute, Router} from '@angular/router';
|
import {ActivatedRoute, Router} from '@angular/router';
|
||||||
import {Keyboard} from '@capacitor/keyboard';
|
import {Keyboard} from '@capacitor/keyboard';
|
||||||
import {AlertController, AnimationBuilder, AnimationController} from '@ionic/angular';
|
import {AlertController, AnimationBuilder, AnimationController} from '@ionic/angular';
|
||||||
@@ -36,6 +36,7 @@ import {PositionService} from '../../map/position.service';
|
|||||||
import {ConfigProvider} from '../../config/config.provider';
|
import {ConfigProvider} from '../../config/config.provider';
|
||||||
import {searchPageSwitchAnimation} from './search-page-switch-animation';
|
import {searchPageSwitchAnimation} from './search-page-switch-animation';
|
||||||
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||||
|
import {DataFilterProvider} from '../filter/data-filter.provider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SearchPageComponent queries things and shows list of things as search results and filter as context menu
|
* SearchPageComponent queries things and shows list of things as search results and filter as context menu
|
||||||
@@ -44,7 +45,7 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
|||||||
selector: 'stapps-search-page',
|
selector: 'stapps-search-page',
|
||||||
templateUrl: 'search-page.html',
|
templateUrl: 'search-page.html',
|
||||||
styleUrls: ['search-page.scss'],
|
styleUrls: ['search-page.scss'],
|
||||||
providers: [ContextMenuService],
|
providers: [ContextMenuService, DataFilterProvider],
|
||||||
})
|
})
|
||||||
export class SearchPageComponent implements OnInit {
|
export class SearchPageComponent implements OnInit {
|
||||||
@Input() title = 'search.title';
|
@Input() title = 'search.title';
|
||||||
@@ -144,6 +145,8 @@ export class SearchPageComponent implements OnInit {
|
|||||||
|
|
||||||
destroy$ = inject(DestroyRef);
|
destroy$ = inject(DestroyRef);
|
||||||
|
|
||||||
|
dataFilterProvider = inject(DataFilterProvider);
|
||||||
|
|
||||||
routeAnimation: AnimationBuilder;
|
routeAnimation: AnimationBuilder;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,6 +219,7 @@ export class SearchPageComponent implements OnInit {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.dataProvider.search(searchOptions);
|
const result = await this.dataProvider.search(searchOptions);
|
||||||
|
this.dataFilterProvider.context.next(result);
|
||||||
this.singleTypeResponse = result.facets.find(facet => facet.field === 'type')?.buckets.length === 1;
|
this.singleTypeResponse = result.facets.find(facet => facet.field === 'type')?.buckets.length === 1;
|
||||||
if (append) {
|
if (append) {
|
||||||
// append results
|
// append results
|
||||||
|
|||||||
@@ -16,62 +16,41 @@
|
|||||||
<stapps-context contentId="data-list"></stapps-context>
|
<stapps-context contentId="data-list"></stapps-context>
|
||||||
<ion-header>
|
<ion-header>
|
||||||
@if (showDrawer && showTopToolbar) {
|
@if (showDrawer && showTopToolbar) {
|
||||||
<ion-toolbar color="primary" mode="ios">
|
<ion-toolbar color="primary" mode="ios">
|
||||||
<ion-buttons slot="start">
|
<ion-buttons slot="start">
|
||||||
<ion-back-button [defaultHref]="backUrl"></ion-back-button>
|
<ion-back-button [defaultHref]="backUrl"></ion-back-button>
|
||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
<ion-title>{{ title | translate }}</ion-title>
|
<ion-title>{{ title | translate }}</ion-title>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
}
|
}
|
||||||
<ion-toolbar color="primary">
|
<ion-toolbar color="primary">
|
||||||
<ion-searchbar
|
<ion-searchbar (ngModelChange)="searchStringChanged($event)" (keyup.enter)="hideKeyboard()"
|
||||||
(ngModelChange)="searchStringChanged($event)"
|
(search)="hideKeyboard()" [(ngModel)]="queryText" showClearButton="always"
|
||||||
(keyup.enter)="hideKeyboard()"
|
placeholder="{{ placeholder | translate }}" mode="md" type="search" enterkeyhint="search" class="filterable"
|
||||||
(search)="hideKeyboard()"
|
[autofocus]="!showDefaultData">
|
||||||
[(ngModel)]="queryText"
|
|
||||||
showClearButton="always"
|
|
||||||
placeholder="{{ placeholder | translate }}"
|
|
||||||
mode="md"
|
|
||||||
type="search"
|
|
||||||
enterkeyhint="search"
|
|
||||||
class="filterable"
|
|
||||||
[autofocus]="!showDefaultData"
|
|
||||||
>
|
|
||||||
<ion-menu-button menu="context" auto-hide="false">
|
<ion-menu-button menu="context" auto-hide="false">
|
||||||
<ion-icon name="tune"></ion-icon>
|
<ion-icon name="tune"></ion-icon>
|
||||||
</ion-menu-button>
|
</ion-menu-button>
|
||||||
</ion-searchbar>
|
</ion-searchbar>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
@if (showNavigation && isHebisAvailable) {
|
@if (showNavigation && isHebisAvailable) {
|
||||||
<ion-toolbar color="primary" class="category-tab">
|
<ion-toolbar color="primary" class="category-tab">
|
||||||
<ion-buttons class="ion-justify-content-between">
|
<ion-buttons class="ion-justify-content-between">
|
||||||
<ion-button class="button-active" size="large">{{ 'search.type' | translate }}</ion-button>
|
<ion-button class="button-active" size="large">{{ 'search.type' | translate }}</ion-button>
|
||||||
<ion-button
|
<ion-button [routerLink]="['/hebis-search']" queryParamsHandling="merge" [routerAnimation]="routeAnimation"
|
||||||
[routerLink]="['/hebis-search']"
|
fill="outline" size="large">{{ 'hebisSearch.type' | translate }}
|
||||||
queryParamsHandling="merge"
|
</ion-button>
|
||||||
[routerAnimation]="routeAnimation"
|
</ion-buttons>
|
||||||
fill="outline"
|
</ion-toolbar>
|
||||||
size="large"
|
|
||||||
>{{ 'hebisSearch.type' | translate }}
|
|
||||||
</ion-button>
|
|
||||||
</ion-buttons>
|
|
||||||
</ion-toolbar>
|
|
||||||
}
|
}
|
||||||
</ion-header>
|
</ion-header>
|
||||||
|
|
||||||
<ion-content class="content">
|
<ion-content class="content">
|
||||||
<div
|
<div [class.no-results]="!showDefaultData && !items && !loading"
|
||||||
[class.no-results]="!showDefaultData && !items && !loading"
|
[style.display]="!showDefaultData && !items && !loading ? 'block' : 'none'">
|
||||||
[style.display]="!showDefaultData && !items && !loading ? 'block' : 'none'"
|
|
||||||
>
|
|
||||||
<ion-label class="centered-message-container"> {{ searchInstruction | translate }} </ion-label>
|
<ion-label class="centered-message-container"> {{ searchInstruction | translate }} </ion-label>
|
||||||
</div>
|
</div>
|
||||||
<stapps-data-list
|
<stapps-data-filter></stapps-data-filter>
|
||||||
id="data-list"
|
<stapps-data-list id="data-list" [items]="items | async" [singleType]="singleTypeResponse" (loadmore)="loadMore()"
|
||||||
[items]="items | async"
|
[resetToTop]="queryChanged.asObservable()" [loading]="loading"></stapps-data-list>
|
||||||
[singleType]="singleTypeResponse"
|
|
||||||
(loadmore)="loadMore()"
|
|
||||||
[resetToTop]="queryChanged.asObservable()"
|
|
||||||
[loading]="loading"
|
|
||||||
></stapps-data-list>
|
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {Pipe, PipeTransform} from '@angular/core';
|
||||||
|
import {ThingTranslateService} from './thing-translate.service';
|
||||||
|
import {SCThingType, SCThings} from '@openstapps/core';
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'propertyValueTranslate',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class PropertyValueTranslatePipe implements PipeTransform {
|
||||||
|
constructor(private readonly thingTranslate: ThingTranslateService) {}
|
||||||
|
|
||||||
|
transform<
|
||||||
|
K extends string | undefined,
|
||||||
|
T extends SCThingType,
|
||||||
|
U extends keyof Extract<SCThings, {type: T}>,
|
||||||
|
>(value: K, field: (U & string) | string, type: T): K {
|
||||||
|
return this.thingTranslate.getPropertyValue(type, field, value) as K;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,4 +113,8 @@ export class ThingTranslateService {
|
|||||||
|
|
||||||
return this.getParsedResult(translatedPropertyNames, keyPath);
|
return this.getParsedResult(translatedPropertyNames, keyPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPropertyValue(type: SCThingType, field: string, key: string | undefined): string | undefined {
|
||||||
|
return this.translator.translatedPropertyValue(type, field, key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user