feat: add action chips to search results

This commit is contained in:
Wieland Schöbl
2021-06-22 15:09:40 +00:00
parent 0aa26020be
commit 67fb4a43c9
27 changed files with 771 additions and 303 deletions

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) 2021 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 {Component, Input} from '@angular/core';
import {SCDateSeries, SCThings, SCThingType} from '@openstapps/core';
/**
* Shows a horizontal list of action chips
*/
@Component({
selector: 'stapps-action-chip-list',
templateUrl: 'action-chip-list.html',
styleUrls: ['action-chip-list.scss'],
})
export class ActionChipListComponent {
/**
* If chips are applicable
*/
applicable: Record<string, () => boolean> = {
'locate': () => this.item.hasOwnProperty('inPlace'),
'event': () => this.item.type === SCThingType.AcademicEvent ||
(this.item.type === SCThingType.DateSeries && (this.item as SCDateSeries).dates.length !== 0),
};
/**
* The item the action belongs to
*/
@Input() item: SCThings;
}

View File

@@ -0,0 +1,4 @@
<div>
<stapps-locate-action-chip *ngIf='applicable["locate"]()' [item]='item'></stapps-locate-action-chip>
<stapps-add-event-action-chip *ngIf='applicable["event"]()' [item]='item'></stapps-add-event-action-chip>
</div>

View File

@@ -0,0 +1,4 @@
div {
display: flex;
flex-direction: row;
}

View File

@@ -0,0 +1,176 @@
/*
* Copyright (C) 2021 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 {ChangeDetectorRef, Component, Input, OnInit} from '@angular/core';
import {SCDateSeries} from '@openstapps/core';
import {every, groupBy, some, sortBy, values} from 'lodash-es';
import {capitalize, last} from 'lodash-es';
enum Selection {
ON = 2,
PARTIAL = 1,
OFF = 1,
}
/**
* A tree
*
* The generic is to preserve type safety of how deep the tree goes.
*/
// tslint:disable-next-line:no-any
class TreeNode<T extends TreeNode<any> | SelectionValue> {
/**
* Value of this node
*/
checked: boolean;
/**
* If items are partially selected
*/
indeterminate: boolean;
/**
* Parent of this node
*/
parent?: TreeNode<TreeNode<T>>;
constructor(readonly children: T[], readonly ref: ChangeDetectorRef) {
this.updateParents();
this.accumulateApplyValues();
}
/**
* Accumulate values of children to set current value
*/
private accumulateApplyValues() {
const selections: number[] =
this.children.map(it => it instanceof TreeNode ?
(it.checked ? Selection.ON : (it.indeterminate ? Selection.PARTIAL : Selection.OFF)) :
(it as SelectionValue).selected ? Selection.ON : Selection.OFF);
this.checked = every(selections, it => it === Selection.ON);
this.indeterminate = this.checked ? false : some(selections, it => it > Selection.OFF);
}
/**
* Apply the value of this node to all child nodes
*/
private applyValueDownwards() {
for (const child of this.children) {
if (child instanceof TreeNode) {
child.checked = this.checked;
child.indeterminate = false;
// tslint:disable-next-line:no-any
(child as TreeNode<any>).applyValueDownwards();
} else {
(child as SelectionValue).selected = this.checked;
}
}
}
/**
* Set all children's parent to this
*/
private updateParents() {
for (const child of this.children) {
if (child instanceof TreeNode) {
child.parent = this as TreeNode<TreeNode<T>>;
}
}
}
/**
* Update values to all parents upwards
*/
private updateValueUpwards() {
this.parent?.accumulateApplyValues();
this.parent?.updateValueUpwards();
}
/**
* Click on this node
*/
click() {
this.checked = !this.checked;
this.indeterminate = false;
this.applyValueDownwards();
this.updateValueUpwards();
}
/**
* Notify that a child's value has changed
*/
notifyChildChanged() {
this.accumulateApplyValues();
this.updateValueUpwards();
}
}
interface SelectionValue {
/**
* Item that was selected
*/
item: SCDateSeries;
/**
* Selection
*/
selected: boolean;
}
/**
* Shows a horizontal list of action chips
*/
@Component({
selector: 'stapps-add-event-popover-component',
templateUrl: 'add-event-popover.html',
styleUrls: ['add-event-popover.scss'],
})
export class AddEventPopoverComponent implements OnInit {
/**
* Lodash alias
*/
capitalize: (item: string) => string = capitalize;
/**
* The item the action belongs to
*/
@Input() items: SCDateSeries[];
/**
* Lodash alias
*/
last: <T>(item: T[] | null | undefined) => T | undefined = last;
/**
* Selection of the item
*/
selection: TreeNode<TreeNode<SelectionValue>>;
constructor(readonly ref: ChangeDetectorRef) {
}
/**
* Init
*/
ngOnInit() {
this.selection =
new TreeNode(values(groupBy(sortBy(this.items.map(item => ({
selected: false,
item: item,
})), it => it.item.frequency), it => it.item.frequency))
.map(item => new TreeNode(item, this.ref)), this.ref);
}
}

View File

@@ -0,0 +1,40 @@
<ion-card-content>
<ion-item-group>
<ion-item-divider (click)='selection.click()'>
<ion-label>{{'data.chips.add_events.popover.ALL' | translate}}</ion-label>
<ion-checkbox slot='start'
[checked]='selection.checked'
[indeterminate]='selection.indeterminate'>
</ion-checkbox>
</ion-item-divider>
<ion-item-group *ngFor='let frequency of selection.children'>
<ion-item-divider (click)='frequency.click()'>
<ion-label>{{('frequency' | thingTranslate: frequency.children[0].item) | titlecase}}</ion-label>
<ion-checkbox slot='start'
[checked]='frequency.checked'
[indeterminate]='frequency.indeterminate'>
</ion-checkbox>
</ion-item-divider>
<ion-item *ngFor='let date of frequency.children'
(click)='date.selected = !date.selected; frequency.notifyChildChanged()'>
<ion-label *ngIf='date.item.dates.length > 1; else single_event'>
{{date.item.duration | amDuration: 'hours'}}
{{'data.chips.add_events.popover.AT' | translate}}
{{date.item.dates[0] | amDateFormat: 'HH:mm ddd'}}
{{'data.chips.add_events.popover.UNTIL' | translate}}
{{last(date.item.dates) | amDateFormat: 'll'}}
</ion-label>
<ng-template #single_event>
<ion-label>
{{date.item.duration | amDuration: 'hours'}}
{{'data.chips.add_events.popover.AT' | translate}}
{{last(date.item.dates) | amDateFormat: 'll, HH:mm'}}
</ion-label>
</ng-template>
<ion-checkbox slot='start'
[checked]='date.selected'>
</ion-checkbox>
</ion-item>
</ion-item-group>
</ion-item-group>
</ion-card-content>

View File

@@ -0,0 +1,7 @@
::ng-deep ion-item-divider {
cursor: pointer;
}
ion-card-content {
width: fit-content;
}

View File

@@ -0,0 +1,165 @@
/* tslint:disable:prefer-function-over-method */
/*
* Copyright (C) 2021 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 {Component, Input, OnInit} from '@angular/core';
import {PopoverController} from '@ionic/angular';
import {SCDateSeries, SCThing, SCThingType} from '@openstapps/core';
import {DataProvider} from '../../data.provider';
import {AddEventPopoverComponent} from '../add-event-popover.component';
enum AddEventStates {
ADDED_ALL,
ADDED_SOME,
REMOVED_ALL,
UNAVAILABLE,
}
/**
* Shows a horizontal list of action chips
*/
@Component({
selector: 'stapps-add-event-action-chip',
templateUrl: 'add-event-action-chip.html',
styleUrls: ['add-event-action-chip.scss'],
})
export class AddEventActionChipComponent implements OnInit {
/**
* Associated date series
*/
associatedDateSeries: Promise<SCDateSeries[]>;
/**
* Disabled
*/
disabled: boolean;
/**
* Icon
*/
icon: string;
/**
* Item
*/
@Input() item: SCThing;
/**
* Label
*/
label: string;
/**
* State
*/
state: AddEventStates;
/**
* States
*/
states = {
[AddEventStates.ADDED_ALL]: {
icon: 'events-all',
label: 'data.chips.add_events.ADDED_ALL',
disabled: false,
},
[AddEventStates.ADDED_SOME]: {
icon: 'events-partial',
label: 'data.chips.add_events.ADDED_SOME',
disabled: false,
},
[AddEventStates.REMOVED_ALL]: {
icon: 'events',
label: 'data.chips.add_events.REMOVED_ALL',
disabled: false,
},
[AddEventStates.UNAVAILABLE]: {
icon: 'close',
label: 'data.chips.add_events.UNAVAILABLE',
disabled: true,
},
};
constructor(readonly popoverController: PopoverController,
readonly dataProvider: DataProvider) {
}
/**
* Apply state
*/
applyState(state: AddEventStates) {
this.state = state;
const {label, icon, disabled} = this.states[state];
this.label = label;
this.icon = icon;
this.disabled = disabled;
}
/**
* Init
*/
ngOnInit() {
this.associatedDateSeries = this.item.type === SCThingType.DateSeries ?
Promise.resolve([this.item as SCDateSeries]) :
this.dataProvider.search({
filter: {
arguments: {
filters: [
{
arguments: {
field: 'type',
value: SCThingType.DateSeries,
},
type: 'value',
},
{
arguments: {
field: 'event.uid',
value: this.item.uid,
},
type: 'value',
},
],
operation: 'and',
},
type: 'boolean',
},
})
.then((it) => it.data as SCDateSeries[]);
this.associatedDateSeries.then((it) => this.applyState(
it.length < 1 ? AddEventStates.UNAVAILABLE : AddEventStates.REMOVED_ALL));
}
/**
* Action
*/
// @Override
async onClick(event: MouseEvent) {
const associatedDateSeries = await this.associatedDateSeries;
const popover = await this.popoverController.create({
component: AddEventPopoverComponent,
translucent: true,
cssClass: 'add-event-popover',
componentProps: {
items: associatedDateSeries,
},
event: event,
});
await popover.present();
// TODO: replace dummy implementation
await popover.onDidDismiss();
this.applyState(this.state === AddEventStates.ADDED_ALL ?
AddEventStates.REMOVED_ALL : AddEventStates.ADDED_ALL);
}
}

View File

@@ -0,0 +1,11 @@
<div *ngIf='(associatedDateSeries | async) as associatedDateSeries; else loading'>
<ion-chip [disabled]='disabled' (click)='$event.stopPropagation(); onClick($event)'>
<ion-icon [name]='icon'></ion-icon>
<ion-label>{{label | translate}}</ion-label>
</ion-chip>
</div>
<ng-template #loading>
<ion-chip>
<ion-skeleton-text animated='true' ></ion-skeleton-text>
</ion-chip>
</ng-template>

View File

@@ -0,0 +1,3 @@
::ng-deep ion-skeleton-text {
width: 50px;
}

View File

@@ -0,0 +1,39 @@
/* tslint:disable:prefer-function-over-method */
/*
* Copyright (C) 2021 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 {Component, Input} from '@angular/core';
import {SCThing} from '@openstapps/core';
/**
* Shows a horizontal list of action chips
*/
@Component({
selector: 'stapps-locate-action-chip',
templateUrl: 'locate-action-chip.html',
})
export class LocateActionChipComponent {
/**
* Item
*/
@Input() item: SCThing;
/**
* Click
*/
onClick(/*event: MouseEvent*/) {
// TODO
}
}

View File

@@ -0,0 +1,7 @@
<ion-chip class='chip-class' (click)='$event.stopPropagation(); onClick()'>
<ion-icon name='location'></ion-icon>
<ion-label>{{'Locate' | translate}}</ion-label>
<ng-template #loading>
<ion-skeleton-text animated='true'></ion-skeleton-text>
</ng-template>
</ion-chip>

View File

@@ -25,6 +25,10 @@ import {MomentModule} from 'ngx-moment';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {MenuModule} from '../menu/menu.module';
import {StorageModule} from '../storage/storage.module';
import {ActionChipListComponent} from './chips/action-chip-list.component';
import {AddEventPopoverComponent} from './chips/add-event-popover.component';
import {AddEventActionChipComponent} from './chips/data/add-event-action-chip.component';
import {LocateActionChipComponent} from './chips/data/locate-action-chip.component';
import {DataFacetsProvider} from './data-facets.provider';
import {DataIconPipe} from './data-icon.pipe';
import {DataRoutingModule} from './data-routing.module';
@@ -77,6 +81,7 @@ import {VideoListItem} from './types/video/video-list-item.component';
*/
@NgModule({
declarations: [
AddEventPopoverComponent,
OffersDetailComponent,
OffersInListComponent,
AddressDetailComponent,
@@ -119,6 +124,9 @@ import {VideoListItem} from './types/video/video-list-item.component';
VideoDetailContentComponent,
VideoListItem,
DataIconPipe,
ActionChipListComponent,
AddEventActionChipComponent,
LocateActionChipComponent,
],
entryComponents: [
DataListComponent,

View File

@@ -7,7 +7,8 @@
::ng-deep {
ion-slides.work-locations {
ion-slide {
display: block; text-align: left;
display: block;
text-align: left;
}
}
}

View File

@@ -1,13 +1,19 @@
<ion-item>
<ion-thumbnail slot="start" class="ion-margin-end">
<ion-thumbnail slot='start' class='ion-margin-end'>
<ion-skeleton-text animated></ion-skeleton-text>
</ion-thumbnail>
<ion-grid>
<ion-row>
<ion-col>
<h2 class="name"><ion-skeleton-text animated style="width: 80%"></ion-skeleton-text></h2>
<p><ion-skeleton-text animated style="width: 80%;"></ion-skeleton-text></p>
<ion-note><ion-skeleton-text animated style="width: 20%"></ion-skeleton-text></ion-note>
<h2 class='name'>
<ion-skeleton-text animated style='width: 80%'></ion-skeleton-text>
</h2>
<p>
<ion-skeleton-text animated style='width: 80%;'></ion-skeleton-text>
</p>
<ion-note>
<ion-skeleton-text animated style='width: 20%'></ion-skeleton-text>
</ion-note>
</ion-col>
</ion-row>
</ion-grid>

View File

@@ -1,30 +1,36 @@
<ion-item button="true" lines="inset" (click)="notifySelect()">
<ion-thumbnail slot="start" *ngIf="!hideThumbnail" class="ion-margin-end">
<ion-icon color="medium" [attr.name]="item.type | dataIcon"></ion-icon>
<ion-item class='ion-text-wrap' button='true' lines='inset' (click)='notifySelect()'>
<div class='item-height-placeholder'></div>
<ion-thumbnail slot='start' *ngIf='!hideThumbnail' class='ion-margin-end'>
<ion-icon color='medium' [attr.name]='item.type | dataIcon'></ion-icon>
</ion-thumbnail>
<ion-label class="ion-text-wrap" [ngSwitch]="true">
<stapps-catalog-list-item [item]="item" *ngSwitchCase="item.type === 'catalog'"></stapps-catalog-list-item>
<stapps-date-series-list-item [item]="item" *ngSwitchCase="item.type === 'date series'"></stapps-date-series-list-item>
<stapps-dish-list-item [item]="item" *ngSwitchCase="item.type === 'dish'"></stapps-dish-list-item>
<stapps-event-list-item [item]="item" *ngSwitchCase="item.type === 'academic event'"></stapps-event-list-item>
<stapps-event-list-item [item]="item" *ngSwitchCase="item.type === 'sport course'"></stapps-event-list-item>
<stapps-favorite-list-item [item]="item" *ngSwitchCase="item.type === 'favorite'"></stapps-favorite-list-item>
<stapps-message-list-item [item]="item" *ngSwitchCase="item.type === 'message'"></stapps-message-list-item>
<stapps-organization-list-item [item]="item" *ngSwitchCase="item.type === 'organization'"></stapps-organization-list-item>
<stapps-person-list-item [item]="item" *ngSwitchCase="item.type === 'person'"></stapps-person-list-item>
<stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'building'"></stapps-place-list-item>
<stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'floor'"></stapps-place-list-item>
<stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'point of interest'"></stapps-place-list-item>
<stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'room'"></stapps-place-list-item>
<stapps-semester-list-item [item]="item" *ngSwitchCase="item.type === 'semester'"></stapps-semester-list-item>
<stapps-video-list-item [item]="item" *ngSwitchCase="item.type === 'video'"></stapps-video-list-item>
<div *ngSwitchDefault>
<h2>
{{'name' | thingTranslate: item}}
</h2>
<p *ngIf="item.description">
<stapps-long-inline-text [text]="'description' | thingTranslate: item" [size]="80"></stapps-long-inline-text>
</p>
<ion-label class='ion-text-wrap' [ngSwitch]='true'>
<div>
<stapps-catalog-list-item [item]='item' *ngSwitchCase="item.type === 'catalog'"></stapps-catalog-list-item>
<stapps-date-series-list-item [item]='item'
*ngSwitchCase="item.type === 'date series'"></stapps-date-series-list-item>
<stapps-dish-list-item [item]='item' *ngSwitchCase="item.type === 'dish'"></stapps-dish-list-item>
<stapps-event-list-item [item]='item' *ngSwitchCase="item.type === 'academic event'"></stapps-event-list-item>
<stapps-event-list-item [item]='item' *ngSwitchCase="item.type === 'sport course'"></stapps-event-list-item>
<stapps-favorite-list-item [item]='item' *ngSwitchCase="item.type === 'favorite'"></stapps-favorite-list-item>
<stapps-message-list-item [item]='item' *ngSwitchCase="item.type === 'message'"></stapps-message-list-item>
<stapps-organization-list-item [item]='item'
*ngSwitchCase="item.type === 'organization'"></stapps-organization-list-item>
<stapps-person-list-item [item]='item' *ngSwitchCase="item.type === 'person'"></stapps-person-list-item>
<stapps-place-list-item [item]='item' *ngSwitchCase="item.type === 'building'"></stapps-place-list-item>
<stapps-place-list-item [item]='item' *ngSwitchCase="item.type === 'floor'"></stapps-place-list-item>
<stapps-place-list-item [item]='item' *ngSwitchCase="item.type === 'point of interest'"></stapps-place-list-item>
<stapps-place-list-item [item]='item' *ngSwitchCase="item.type === 'room'"></stapps-place-list-item>
<stapps-semester-list-item [item]='item' *ngSwitchCase="item.type === 'semester'"></stapps-semester-list-item>
<stapps-video-list-item [item]='item' *ngSwitchCase="item.type === 'video'"></stapps-video-list-item>
<div *ngSwitchDefault>
<h2>
{{'name' | thingTranslate: item}}
</h2>
<p *ngIf='item.description'>
<stapps-long-inline-text [text]="'description' | thingTranslate: item" [size]='80'></stapps-long-inline-text>
</p>
</div>
<stapps-action-chip-list slot='end' [item]='item'></stapps-action-chip-list>
</div>
</ion-label>
</ion-item>

View File

@@ -1,3 +1,13 @@
.item {
ion-label {
width: 100%;
div {
display: flex;
flex-direction: column;
}
}
}
::ng-deep {
ion-grid, ion-col {

View File

@@ -13,8 +13,19 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {CdkVirtualScrollViewport} from '@angular/cdk/scrolling';
import {Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild} from '@angular/core';
import {
Component,
EventEmitter,
HostListener,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
import {SCThings} from '@openstapps/core';
import {ceil} from 'lodash-es';
import {BehaviorSubject, Observable, Subscription} from 'rxjs';
/**
@@ -51,6 +62,10 @@ export class DataListComponent implements OnChanges, OnInit {
* Indicates whether or not the list is to display SCThings of a single type
*/
@Input() singleType = false;
/**
* Items that display the skeleton list
*/
skeletonItems: number[];
/**
* Array of all subscriptions to Observables
*/
@@ -58,9 +73,19 @@ export class DataListComponent implements OnChanges, OnInit {
// tslint:disable-next-line: completed-docs
@ViewChild(CdkVirtualScrollViewport) viewPort: CdkVirtualScrollViewport;
/**
* Calculate how many items would fill the screen
*/
@HostListener('window.resize', ['$event'])
calcSkeletonItems() {
const itemHeight = 122;
this.skeletonItems = new Array(ceil(window.innerHeight / itemHeight));
}
/**
* Uniquely identifies item at a certain list index
*/
// tslint:disable-next-line:prefer-function-over-method
identifyItem(_index: number, item: SCThings) {
return item.uid;
}
@@ -73,18 +98,19 @@ export class DataListComponent implements OnChanges, OnInit {
}
// tslint:disable-next-line: completed-docs
ngOnInit(): void {
if (typeof this.resetToTop !== 'undefined') {
this.subscriptions.push(this.resetToTop.subscribe(() => {
this.viewPort.scrollToIndex(0);
}));
ngOnDestroy(): void {
for (const subscription of this.subscriptions) {
subscription.unsubscribe();
}
}
// tslint:disable-next-line: completed-docs
ngOnDestroy(): void {
for (const subscription of this.subscriptions) {
subscription.unsubscribe();
ngOnInit(): void {
this.calcSkeletonItems();
if (typeof this.resetToTop !== 'undefined') {
this.subscriptions.push(this.resetToTop.subscribe(() => {
this.viewPort.scrollToIndex(0);
}));
}
}

View File

@@ -13,5 +13,5 @@
</ion-label>
</div>
<ion-list [style.display]="items ? 'none': 'block'">
<stapps-skeleton-list-item *ngFor="let skeleton of [1, 2, 3, 4, 5]"></stapps-skeleton-list-item>
<stapps-skeleton-list-item *ngFor="let skeleton of skeletonItems"></stapps-skeleton-list-item>
</ion-list>