mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2025-12-13 09:46:20 +00:00
Compare commits
1 Commits
@openstapp
...
194-add-ch
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ef3527429 |
@@ -107,6 +107,7 @@ import {SemesterListItemComponent} from './types/semester/semester-list-item.com
|
||||
import {VideoDetailContentComponent} from './types/video/video-detail-content.component';
|
||||
import {VideoListItemComponent} from './types/video/video-list-item.component';
|
||||
import {ShareButtonComponent} from './elements/share-button.component';
|
||||
import {DataFilterComponent} from './filter/data-filter.component';
|
||||
|
||||
/**
|
||||
* Module for handling data
|
||||
@@ -184,6 +185,7 @@ import {ShareButtonComponent} from './elements/share-button.component';
|
||||
ShareButtonComponent,
|
||||
],
|
||||
imports: [
|
||||
DataFilterComponent,
|
||||
CommonModule,
|
||||
DataRoutingModule,
|
||||
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
|
||||
* 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 {Keyboard} from '@capacitor/keyboard';
|
||||
import {AlertController, AnimationBuilder, AnimationController} from '@ionic/angular';
|
||||
@@ -36,6 +36,7 @@ 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 {DataFilterProvider} from '../filter/data-filter.provider';
|
||||
|
||||
/**
|
||||
* 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',
|
||||
templateUrl: 'search-page.html',
|
||||
styleUrls: ['search-page.scss'],
|
||||
providers: [ContextMenuService],
|
||||
providers: [ContextMenuService, DataFilterProvider],
|
||||
})
|
||||
export class SearchPageComponent implements OnInit {
|
||||
@Input() title = 'search.title';
|
||||
@@ -144,6 +145,8 @@ export class SearchPageComponent implements OnInit {
|
||||
|
||||
destroy$ = inject(DestroyRef);
|
||||
|
||||
dataFilterProvider = inject(DataFilterProvider);
|
||||
|
||||
routeAnimation: AnimationBuilder;
|
||||
|
||||
/**
|
||||
@@ -216,6 +219,7 @@ export class SearchPageComponent implements OnInit {
|
||||
|
||||
try {
|
||||
const result = await this.dataProvider.search(searchOptions);
|
||||
this.dataFilterProvider.context.next(result);
|
||||
this.singleTypeResponse = result.facets.find(facet => facet.field === 'type')?.buckets.length === 1;
|
||||
if (append) {
|
||||
// append results
|
||||
|
||||
@@ -16,62 +16,41 @@
|
||||
<stapps-context contentId="data-list"></stapps-context>
|
||||
<ion-header>
|
||||
@if (showDrawer && showTopToolbar) {
|
||||
<ion-toolbar color="primary" mode="ios">
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [defaultHref]="backUrl"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ title | translate }}</ion-title>
|
||||
</ion-toolbar>
|
||||
<ion-toolbar color="primary" mode="ios">
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [defaultHref]="backUrl"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ title | translate }}</ion-title>
|
||||
</ion-toolbar>
|
||||
}
|
||||
<ion-toolbar color="primary">
|
||||
<ion-searchbar
|
||||
(ngModelChange)="searchStringChanged($event)"
|
||||
(keyup.enter)="hideKeyboard()"
|
||||
(search)="hideKeyboard()"
|
||||
[(ngModel)]="queryText"
|
||||
showClearButton="always"
|
||||
placeholder="{{ placeholder | translate }}"
|
||||
mode="md"
|
||||
type="search"
|
||||
enterkeyhint="search"
|
||||
class="filterable"
|
||||
[autofocus]="!showDefaultData"
|
||||
>
|
||||
<ion-searchbar (ngModelChange)="searchStringChanged($event)" (keyup.enter)="hideKeyboard()"
|
||||
(search)="hideKeyboard()" [(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-icon name="tune"></ion-icon>
|
||||
</ion-menu-button>
|
||||
</ion-searchbar>
|
||||
</ion-toolbar>
|
||||
@if (showNavigation && isHebisAvailable) {
|
||||
<ion-toolbar color="primary" class="category-tab">
|
||||
<ion-buttons class="ion-justify-content-between">
|
||||
<ion-button class="button-active" size="large">{{ 'search.type' | translate }}</ion-button>
|
||||
<ion-button
|
||||
[routerLink]="['/hebis-search']"
|
||||
queryParamsHandling="merge"
|
||||
[routerAnimation]="routeAnimation"
|
||||
fill="outline"
|
||||
size="large"
|
||||
>{{ 'hebisSearch.type' | translate }}
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
<ion-toolbar color="primary" class="category-tab">
|
||||
<ion-buttons class="ion-justify-content-between">
|
||||
<ion-button class="button-active" size="large">{{ 'search.type' | translate }}</ion-button>
|
||||
<ion-button [routerLink]="['/hebis-search']" queryParamsHandling="merge" [routerAnimation]="routeAnimation"
|
||||
fill="outline" size="large">{{ 'hebisSearch.type' | translate }}
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
}
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="content">
|
||||
<div
|
||||
[class.no-results]="!showDefaultData && !items && !loading"
|
||||
[style.display]="!showDefaultData && !items && !loading ? 'block' : 'none'"
|
||||
>
|
||||
<div [class.no-results]="!showDefaultData && !items && !loading"
|
||||
[style.display]="!showDefaultData && !items && !loading ? 'block' : 'none'">
|
||||
<ion-label class="centered-message-container"> {{ searchInstruction | translate }} </ion-label>
|
||||
</div>
|
||||
<stapps-data-list
|
||||
id="data-list"
|
||||
[items]="items | async"
|
||||
[singleType]="singleTypeResponse"
|
||||
(loadmore)="loadMore()"
|
||||
[resetToTop]="queryChanged.asObservable()"
|
||||
[loading]="loading"
|
||||
></stapps-data-list>
|
||||
<stapps-data-filter></stapps-data-filter>
|
||||
<stapps-data-list id="data-list" [items]="items | async" [singleType]="singleTypeResponse" (loadmore)="loadMore()"
|
||||
[resetToTop]="queryChanged.asObservable()" [loading]="loading"></stapps-data-list>
|
||||
</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);
|
||||
}
|
||||
|
||||
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