Merge pull request #4890 from GabrielInTheWorld/freshSearch

Implements the 'global search' and moves the 'global spinner' to 'site.component'
This commit is contained in:
Sean 2019-08-26 16:53:38 +02:00 committed by GitHub
commit e3a7cbf935
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1474 additions and 595 deletions

View File

@ -1,4 +1,3 @@
<div class="content">
<router-outlet></router-outlet>
<os-global-spinner></os-global-spinner>
</div>

View File

@ -11,22 +11,30 @@ import { DataStoreUpgradeService } from './core/core-services/data-store-upgrade
import { LoadFontService } from './core/ui-services/load-font.service';
import { LoginDataService } from './core/ui-services/login-data.service';
import { OperatorService } from './core/core-services/operator.service';
import { OverlayService } from './core/ui-services/overlay.service';
import { PingService } from './core/core-services/ping.service';
import { PrioritizeService } from './core/core-services/prioritize.service';
import { RoutingStateService } from './core/ui-services/routing-state.service';
import { ServertimeService } from './core/core-services/servertime.service';
import { SpinnerService } from './core/ui-services/spinner.service';
import { ThemeService } from './core/ui-services/theme.service';
import { ViewUser } from './site/users/models/view-user';
/**
* Enhance array with own functions
* TODO: Remove once flatMap made its way into official JS/TS (ES 2019?)
*/
declare global {
/**
* Enhance array with own functions
* TODO: Remove once flatMap made its way into official JS/TS (ES 2019?)
*/
interface Array<T> {
flatMap(o: any): any[];
}
/**
* Enhances the number object to calculate real modulo operations.
* (not remainder)
*/
interface Number {
modulo(n: number): number;
}
}
/**
@ -75,7 +83,7 @@ export class AppComponent {
loginDataService: LoginDataService,
constantsService: ConstantsService, // Needs to be started, so it can register itself to the WebsocketService
themeService: ThemeService,
private spinnerService: SpinnerService,
private overlayService: OverlayService,
countUsersService: CountUsersService, // Needed to register itself.
configService: ConfigService,
loadFontService: LoadFontService,
@ -95,8 +103,9 @@ export class AppComponent {
// change default JS functions
this.overloadArrayToString();
this.overloadFlatMap();
this.overloadModulo();
// Show the spinner initial
spinnerService.setVisibility(true, translate.instant('Loading data. Please wait ...'));
appRef.isStable
.pipe(
@ -173,13 +182,23 @@ export class AppComponent {
};
}
/**
* Enhances the number object with a real modulo operation (not remainder).
* TODO: Remove this, if the remainder operation is changed to modulo.
*/
private overloadModulo(): void {
Number.prototype.modulo = function(n: number): number {
return ((this % n) + n) % n;
};
}
/**
* Function to check if the user is existing and the app is already stable.
* If both conditions true, hide the spinner.
*/
private checkConnectionProgress(): void {
if ((this.user || this.operator.isAnonymous) && this.isStable) {
this.spinnerService.setVisibility(false);
this.overlayService.setSpinner(false, null, true);
}
}
}

View File

@ -11,7 +11,6 @@ import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { CoreModule } from './core/core.module';
import { environment } from '../environments/environment';
import { GlobalSpinnerComponent } from './site/common/components/global-spinner/global-spinner.component';
import { LoginModule } from './site/login/login.module';
import { OpenSlidesTranslateModule } from './core/translate/openslides-translate-module';
import { SlidesModule } from './slides/slides.module';
@ -28,7 +27,7 @@ export function AppLoaderFactory(appLoadService: AppLoadService): () => Promise<
* Global App Module. Keep it as clean as possible.
*/
@NgModule({
declarations: [AppComponent, GlobalSpinnerComponent],
declarations: [AppComponent],
imports: [
BrowserModule,
HttpClientModule,

View File

@ -0,0 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { OverlayService } from './overlay.service';
describe('OverlayService', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('should be created', () => {
const service: OverlayService = TestBed.get(OverlayService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,121 @@
import { Injectable } from '@angular/core';
import { MatDialog, MatDialogRef, ProgressSpinnerMode } from '@angular/material';
import { Observable, Subject } from 'rxjs';
import { largeDialogSettings } from 'app/shared/utils/dialog-settings';
import { SuperSearchComponent } from 'app/site/common/components/super-search/super-search.component';
/**
* Optional configuration for the spinner.
*/
export interface SpinnerConfig {
/**
* The mode of the spinner. Defaults to `'indeterminate'`
*/
mode?: ProgressSpinnerMode;
/**
* The diameter of the svg.
*/
diameter?: number;
/**
* The width of the stroke of the spinner.
*/
stroke?: number;
/**
* An optional value, if the spinner is in `'determinate'-mode`.
*/
value?: number;
}
/**
* Component to control the visibility of components, that overlay the whole window.
* Like `global-spinner.component` and `super-search.component`.
*/
@Injectable({
providedIn: 'root'
})
export class OverlayService {
/**
* Holds the reference to the search-dialog.
* Necessary to prevent opening multiple dialogs at once.
*/
private searchReference: MatDialogRef<SuperSearchComponent> = null;
/**
* Subject, that holds the visibility and message. The component can observe this.
*/
private spinner: Subject<{ isVisible: boolean; text?: string; config?: SpinnerConfig }> = new Subject();
/**
* Boolean, whether appearing of the spinner should be prevented next time.
*/
private preventAppearingSpinner: boolean;
/**
* Boolean to indicate, if the spinner has already appeared.
*/
private spinnerHasAppeared = false;
/**
*
* @param dialogService Injects the `MatDialog` to show the `super-search.component`
*/
public constructor(private dialogService: MatDialog) {}
/**
* Function to change the visibility of the `global-spinner.component`.
*
* @param isVisible flag, if the spinner should be shown.
* @param text optional. If the spinner should show a message.
* @param preventAppearing optional. Wether to prevent showing the spinner the next time.
*/
public setSpinner(isVisible: boolean, text?: string, preventAppearing?: boolean, config?: SpinnerConfig): void {
if (!(this.preventAppearingSpinner && !this.spinnerHasAppeared && isVisible)) {
setTimeout(() => this.spinner.next({ isVisible, text, config }));
if (isVisible) {
this.spinnerHasAppeared = true;
}
}
this.preventAppearingSpinner = preventAppearing;
}
/**
* Function to get the visibility as observable.
*
* @returns class member `visibility`.
*/
public getSpinner(): Observable<{ isVisible: boolean; text?: string; config?: SpinnerConfig }> {
return this.spinner;
}
/**
* Sets the state of the `SuperSearchComponent`.
*
* @param isVisible If the component should be shown or not.
*/
public showSearch(data?: any): void {
if (!this.searchReference) {
this.searchReference = this.dialogService.open(SuperSearchComponent, {
...largeDialogSettings,
data: data ? data : null,
disableClose: false,
panelClass: 'super-search-container'
});
this.searchReference.afterClosed().subscribe(() => {
this.searchReference = null;
});
}
}
/**
* Function to reset the properties for the spinner.
*
* Necessary to get the initial state, if the user logs out
* and still stays at the website.
*/
public logout(): void {
this.spinnerHasAppeared = false;
this.preventAppearingSpinner = false;
}
}

View File

@ -8,9 +8,50 @@ import { Searchable } from '../../site/base/searchable';
import { ViewModelStoreService } from '../core-services/view-model-store.service';
/**
* The representation every searchable model should use to represent their data.
* Defines, how the properties look like
*/
export type SearchRepresentation = string[];
export interface SearchProperty {
/**
* A string, that contains the specific value.
*/
key: string | null;
/**
* The value of the property as string.
*/
value: string | null;
/**
* If some properties should be grouped into one card (for the preview),
* they can be unified to `blockProperties`.
*/
blockProperties?: SearchProperty[];
/**
* A flag to specify, if a value could be rendered `innerHTML`.
*/
trusted?: boolean;
}
/**
* SearchRepresentation the system looks by.
*/
export interface SearchRepresentation {
/**
* The representation every searchable model should use to represent their data.
*/
searchValue: string[];
/**
* The properties the representation contains.
*/
properties: SearchProperty[];
/**
* An optional type. This is useful for mediafiles to decide which type they have.
*/
type?: string;
}
/**
* Our representation of a searchable model for external use.
@ -47,7 +88,7 @@ export interface SearchResult {
collectionString: string;
/**
* This verbodeName must have the right cardianlity. If there is exactly one model in `models`,
* This verboseName must have the right cardianlity. If there is exactly one model in `models`,
* it should have a singular value, else a plural name.
*/
verboseName: string;
@ -63,6 +104,21 @@ export interface SearchResult {
models: (BaseViewModel & Searchable)[];
}
/**
* Interface, that describes a pair of a (translated) value and a relating collection.
*/
export interface TranslatedCollection {
/**
* The value
*/
value: string;
/**
* The collectionString, the value relates to.
*/
collection: string;
}
/**
* This service cares about searching the DataStore and managing models, that are searchable.
*/
@ -137,18 +193,28 @@ export class SearchService {
* @param query The search query
* @param inCollectionStrings All connection strings which should be used for searching.
* @param sortingProperty Sorting by `id` or `title`.
* @param dedicatedId Optional parameter. Useful to look for a specific id in the given collectionStrings.
*
* @returns All search results sorted by the model's title (via `getTitle()`).
*/
public search(query: string, inCollectionStrings: string[], sortingProperty: 'id' | 'title'): SearchResult[] {
public search(
query: string,
inCollectionStrings: string[],
sortingProperty: 'title' | 'id' = 'title',
dedicatedId?: number
): SearchResult[] {
query = query.toLowerCase();
return this.searchModels
.filter(s => inCollectionStrings.includes(s.collectionString))
.filter(s => inCollectionStrings.indexOf(s.collectionString) !== -1)
.map(searchModel => {
const results = this.viewModelStore
.getAll(searchModel.collectionString)
.map(x => x as (BaseViewModel & Searchable))
.filter(model => model.formatForSearch().some(text => text.toLowerCase().includes(query)))
.filter(model =>
dedicatedId
? model.id === dedicatedId
: model.formatForSearch().searchValue.some(text => text.toLowerCase().indexOf(query) !== -1)
)
.sort((a, b) => {
switch (sortingProperty) {
case 'id':
@ -165,4 +231,32 @@ export class SearchService {
};
});
}
/**
* Splits the given collections and translates the single values.
*
* @param collections All the collections, that should be translated.
*
* @returns {Array} An array containing the single values of the collections and the translated ones.
* These values point to the `collectionString` the user can search for.
*/
public getTranslatedCollectionStrings(): TranslatedCollection[] {
const nextCollections: TranslatedCollection[] = this.searchModels.flatMap((model: SearchModel) => [
{ value: model.verboseNamePlural, collection: model.collectionString },
{ value: model.verboseNameSingular, collection: model.collectionString }
]);
const tmpCollections = [...nextCollections];
for (const entry of tmpCollections) {
const translatedValue = this.translate.instant(entry.value);
if (!nextCollections.find(item => item.value === translatedValue)) {
nextCollections.push({ value: translatedValue, collection: entry.collection });
}
}
const sequentialNumber = 'Sequential number';
nextCollections.push(
{ value: sequentialNumber, collection: 'motions/motion' },
{ value: this.translate.instant(sequentialNumber), collection: 'motions/motion' }
);
return nextCollections;
}
}

View File

@ -1,18 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { SpinnerService } from './spinner.service';
describe('SpinnerService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [SpinnerService]
});
});
it('should be created', inject([SpinnerService], (service: SpinnerService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -1,41 +0,0 @@
// External imports
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
/**
* Service for the `global-spinner.component`
*
* Handles the visibility of the global-spinner.
*/
@Injectable({
providedIn: 'root'
})
export class SpinnerService {
/**
* Subject, that holds the visibility and message. The component can observe this.
*/
private visibility: Subject<{ isVisible: boolean; text?: string }> = new Subject<{
isVisible: boolean;
text?: string;
}>();
/**
* Function to change the visibility of the `global-spinner.component`.
*
* @param isVisible flag, if the spinner should be shown.
* @param text optional. If the spinner should show a message.
*/
public setVisibility(isVisible: boolean, text?: string): void {
setTimeout(() => this.visibility.next({ isVisible, text }));
}
/**
* Function to get the visibility as observable.
*
* @returns class member `visibility`.
*/
public getVisibility(): Observable<{ isVisible: boolean; text?: string }> {
return this.visibility;
}
}

View File

@ -28,6 +28,11 @@
</div>
<div class="spacer"></div>
<!-- Button to open the global search -->
<button *ngIf="!vp.isMobile" mat-icon-button (click)="openSearch()">
<mat-icon>search</mat-icon>
</button>
<div class="toolbar-right">
<!-- Extra controls slot -->
<ng-content select=".extra-controls-slot"></ng-content>

View File

@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { MainMenuService } from 'app/core/core-services/main-menu.service';
import { OverlayService } from 'app/core/ui-services/overlay.service';
import { RoutingStateService } from 'app/core/ui-services/routing-state.service';
import { ViewportService } from 'app/core/ui-services/viewport.service';
@ -155,7 +156,8 @@ export class HeadBarComponent implements OnInit {
private menu: MainMenuService,
private router: Router,
private route: ActivatedRoute,
private routingState: RoutingStateService
private routingState: RoutingStateService,
private overlayService: OverlayService
) {}
/**
@ -193,6 +195,13 @@ export class HeadBarComponent implements OnInit {
this.saveEvent.next(true);
}
/**
* Opens the `super-search.component`.
*/
public openSearch(): void {
this.overlayService.showSearch();
}
/**
* Exits the view to return to the previous page or
* visit the parent view again.

View File

@ -0,0 +1,41 @@
<ng-content></ng-content>
<div *ngIf="model" [ngSwitch]="model.collectionString" class="preview-container">
<div *ngSwitchCase="'mediafiles/mediafile'">
<div *ngIf="modelType.includes('image')">
<img [src]="model.getDetailStateURL()" />
</div>
<div *ngIf="modelType.includes('pdf')">
<pdf-viewer
[original-size]="false"
[fit-to-page]="true"
[autoresize]="true"
[src]="model.getDetailStateURL()"
[style.display]="'block'"
></pdf-viewer>
</div>
<div *ngIf="modelType.includes('directory')">
<div *ngFor="let entry of formattedSearchValue">
<mat-card class="os-card" *ngIf="entry.value !== ''">
<div class="key-part">{{ entry.key | translate }}</div>
<div>{{ entry.value }}</div>
</mat-card>
</div>
</div>
</div>
<div *ngSwitchDefault>
<div *ngFor="let entry of formattedSearchValue">
<mat-card class="os-card" *ngIf="entry.value !== '' && !entry.blockProperties">
<div class="key-part">{{ entry.key | translate }}</div>
<div *ngIf="!entry.trusted">{{ entry.value }}</div>
<div *ngIf="entry.trusted" [innerHTML]="sanitize(entry.value)"></div>
</mat-card>
<mat-card class="os-card" *ngIf="entry.blockProperties">
<div *ngFor="let property of entry.blockProperties">
<div class="key-part">{{ property.key | translate }}</div>
<div *ngIf="!property.trusted">{{ property.value }}</div>
<div *ngIf="property.trusted" [innerHTML]="sanitize(property.value)"></div>
</div>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,18 @@
.preview-container {
padding: 0 8px;
.key-part {
font-size: 11px;
color: #666666;
& + div {
margin-bottom: 5px;
}
}
img {
padding: 8px 0;
max-width: 100%;
height: auto;
}
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { PreviewComponent } from './preview.component';
fdescribe('PreviewComponent', () => {
let component: PreviewComponent;
let fixture: ComponentFixture<PreviewComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PreviewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,61 @@
import { Component, Input } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { SearchProperty } from 'app/core/ui-services/search.service';
import { BaseViewModel } from 'app/site/base/base-view-model';
import { Searchable } from 'app/site/base/searchable';
@Component({
selector: 'os-preview',
templateUrl: './preview.component.html',
styleUrls: ['./preview.component.scss']
})
export class PreviewComponent {
/**
* Sets the view-model, whose properties are displayed.
*
* @param model The view-model. Typeof `BaseViewModel & Searchable`.
*/
@Input()
public set viewModel(model: BaseViewModel & Searchable) {
if (model) {
this.model = model;
const representation = model.formatForSearch();
this.formattedSearchValue = representation.properties;
this.modelType = representation.type;
}
}
/**
* The view-model.
*/
public model: BaseViewModel & Searchable;
/**
* An array of `SearchProperty`. This contains all key-value-pair attributes of the model.
*/
public formattedSearchValue: SearchProperty[];
/**
* The type of the model. This is only set, if the model is from type 'mediafile'.
*/
public modelType: string;
/**
* Default constructor
*
* @param sanitizer DomSanitizer
*/
public constructor(private sanitizer: DomSanitizer) {}
/**
* Function to sanitize any text to show html.
*
* @param text The text to sanitize.
*
* @returns {SafeHtml} The sanitized text as `HTML`.
*/
public sanitize(text: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(text);
}
}

View File

@ -7,11 +7,12 @@
[autofocus]="autofocus"
[placeholder]="placeholder"
class="rounded-input"
[ngClass]="[size]"
[ngClass]="[size, borderRadius, hasChildren ? 'children-bottom' : '']"
[formControl]="modelForm"
(keyup)="keyPressed($event)"
(blur)="blur()"
/>
<div *ngIf="modelForm.value !== ''" class="input-suffix">
<mat-icon (mouseup)="clear()">close</mat-icon>
<mat-icon (click)="clear()">close</mat-icon>
</div>
</div>

View File

@ -1,47 +1,74 @@
.input-container {
position: relative;
height: 100%;
&,
div {
display: flex;
align-items: center;
z-index: 1;
}
div {
position: absolute;
&.input-prefix {
left: 8px;
@import '~@angular/material/theming';
:host.full-width {
width: 100%;
}
@mixin os-rounded-input-style($theme) {
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
$foreground-color: mat-color($foreground, icon);
.input-container {
position: relative;
height: 100%;
&,
div {
display: flex;
align-items: center;
z-index: 1;
}
&.input-suffix {
right: 8px;
color: #666;
}
}
.rounded-input {
outline: 0;
z-index: 0;
height: 24px;
width: 100%;
padding: 8px 39px;
border-radius: 32px;
font-size: 16px;
border: 1px solid #ccc;
color: #666;
transition: all 0.25s ease;
&.small {
height: 14px;
font-size: 14px;
width: 100px;
&:focus {
width: 200px;
div {
position: absolute;
&.input-prefix {
left: 8px;
}
&.input-suffix {
right: 8px;
color: $foreground-color;
}
}
}
mat-icon {
cursor: pointer;
.rounded-input {
outline: 0;
z-index: 0;
height: 24px;
width: 100%;
padding: 8px 39px;
border-radius: 32px;
font-size: 16px;
border: 1px solid #ccc;
background: mat-color($background, background);
color: $foreground-color;
transition: all 0.25s ease;
&.small {
height: 14px;
font-size: 14px;
width: 100px;
&:focus {
width: 200px;
}
}
&.medium-border-radius {
border-radius: 16px;
}
&.small-border-radius {
border-radius: 4px;
}
&.children-bottom {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
mat-icon {
cursor: pointer;
}
}
}

View File

@ -1,15 +1,38 @@
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import {
Component,
ElementRef,
EventEmitter,
HostBinding,
Input,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { FormControl } from '@angular/forms';
import { Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
/**
* Type declared to see, which values are possible for some inputs.
*/
export type Size = 'small' | 'medium' | 'large';
@Component({
selector: 'os-rounded-input',
templateUrl: './rounded-input.component.html',
styleUrls: ['./rounded-input.component.scss']
})
export class RoundedInputComponent implements OnInit, OnDestroy {
/**
* Binds the class to the parent-element.
*/
@HostBinding('class')
public get classes(): string {
return this.fullWidth ? 'full-width' : '';
}
/**
* Reference to the `<input />`-element.
*/
@ -45,7 +68,13 @@ export class RoundedInputComponent implements OnInit, OnDestroy {
* Defaults to `'medium'`.
*/
@Input()
public size: 'small' | 'medium' | 'large' = 'medium';
public size: Size = 'medium';
/**
* Whether this component should render over the full width.
*/
@Input()
public fullWidth = true;
/**
* Custom `FormControl`.
@ -59,6 +88,12 @@ export class RoundedInputComponent implements OnInit, OnDestroy {
@Input()
public autofocus = false;
/**
* Boolean, whether the input should keep the focus, even if it loses the focus.
*/
@Input()
public keepFocus = false;
/**
* Boolean, whether the input should fire the value-change-event after a specific time.
*/
@ -77,6 +112,20 @@ export class RoundedInputComponent implements OnInit, OnDestroy {
@Input()
public clearOnEscape = true;
/**
* Boolean to indicate, whether the input should have rounded borders at the bottom or not.
*/
@Input()
public hasChildren = false;
/**
* Boolean to indicate, whether the borders should be rounded with a smaller size.
*/
@Input()
public set typeBorderRadius(radius: Size) {
this._borderRadius = radius + '-border-radius';
}
/**
* EventHandler for the input-changes.
*/
@ -89,11 +138,24 @@ export class RoundedInputComponent implements OnInit, OnDestroy {
@Output()
public onkeyup: EventEmitter<KeyboardEvent> = new EventEmitter();
/**
* Getter to get the border-radius as a string.
*
* @returns {string} The border-radius as class.
*/
public get borderRadius(): string {
return this._borderRadius;
}
/**
* Subscription, that will handle the value-changes of the input.
*/
private subscription: Subscription;
/**
* Variable for the border-radius as class.
*/
private _borderRadius = 'large-border-radius';
/**
* Default constructor
*/
@ -107,6 +169,9 @@ export class RoundedInputComponent implements OnInit, OnDestroy {
* Overwrites `OnInit` - initializes the subscription.
*/
public ngOnInit(): void {
if (this.autofocus) {
this.focus();
}
this.subscription = this.modelForm.valueChanges
.pipe(debounceTime(this.lazyInput ? 250 : 0))
.subscribe(nextValue => {
@ -128,10 +193,26 @@ export class RoundedInputComponent implements OnInit, OnDestroy {
* Function to clear the input and refocus it.
*/
public clear(): void {
this.osInput.nativeElement.focus();
this.focus();
this.modelForm.setValue('');
}
/**
* Function to programmatically focus the input.
*/
public focus(): void {
this.osInput.nativeElement.focus();
}
/**
* Function called, if the input loses its focus.
*/
public blur(): void {
if (this.keepFocus) {
this.focus();
}
}
/**
* Function to handle typing.
* Useful to listen to special keys.

View File

@ -12,6 +12,9 @@
.action-buttons {
display: flex;
os-rounded-input {
margin: 0 10px;
}
}
.active-filter {
@ -74,7 +77,3 @@ span.right-with-margin {
height: 0px;
width: 0px;
}
os-rounded-input {
margin: 0 10px;
}

View File

@ -19,7 +19,7 @@ export class Mediafile extends BaseModelWithListOfSpeakers<Mediafile> {
public title: string;
public mediafile?: FileMetadata;
public media_url_prefix: string;
public filesize: string;
public filesize?: string;
public access_groups_id: number[];
public create_timestamp: string;
public parent_id: number | null;

View File

@ -97,6 +97,11 @@ import { ExtensionFieldComponent } from './components/extension-field/extension-
import { AttachmentControlComponent } from './components/attachment-control/attachment-control.component';
import { RoundedInputComponent } from './components/rounded-input/rounded-input.component';
import { ProgressSnackBarComponent } from './components/progress-snack-bar/progress-snack-bar.component';
import { SuperSearchComponent } from 'app/site/common/components/super-search/super-search.component';
import { OverlayComponent } from 'app/site/common/components/overlay/overlay.component';
import { PreviewComponent } from './components/preview/preview.component';
import { PdfViewerModule } from 'ng2-pdf-viewer';
import { GlobalSpinnerComponent } from 'app/site/common/components/global-spinner/global-spinner.component';
/**
* Share Module for all "dumb" components and pipes.
@ -157,7 +162,8 @@ import { ProgressSnackBarComponent } from './components/progress-snack-bar/progr
ScrollingModule,
PblNgridModule,
PblNgridMaterialModule,
PblNgridTargetEventsModule
PblNgridTargetEventsModule,
PdfViewerModule
],
exports: [
FormsModule,
@ -197,6 +203,7 @@ import { ProgressSnackBarComponent } from './components/progress-snack-bar/progr
NgxFileDropModule,
TranslateModule,
OpenSlidesTranslateModule,
PdfViewerModule,
PermsDirective,
IsSuperAdminDirective,
DomChangeDirective,
@ -236,7 +243,10 @@ import { ProgressSnackBarComponent } from './components/progress-snack-bar/progr
ListViewTableComponent,
AgendaContentObjectFormComponent,
ExtensionFieldComponent,
RoundedInputComponent
RoundedInputComponent,
GlobalSpinnerComponent,
OverlayComponent,
PreviewComponent
],
declarations: [
PermsDirective,
@ -276,7 +286,11 @@ import { ProgressSnackBarComponent } from './components/progress-snack-bar/progr
ExtensionFieldComponent,
AttachmentControlComponent,
RoundedInputComponent,
ProgressSnackBarComponent
ProgressSnackBarComponent,
GlobalSpinnerComponent,
SuperSearchComponent,
OverlayComponent,
PreviewComponent
],
providers: [
{ provide: DateAdapter, useClass: OpenSlidesDateAdapter },
@ -294,7 +308,8 @@ import { ProgressSnackBarComponent } from './components/progress-snack-bar/progr
PromptDialogComponent,
ChoiceDialogComponent,
ProjectionDialogComponent,
ProgressSnackBarComponent
ProgressSnackBarComponent,
SuperSearchComponent
]
})
export class SharedModule {}

View File

@ -127,7 +127,7 @@ export class ViewAssignment extends BaseViewModelWithAgendaItemAndListOfSpeakers
}
public formatForSearch(): SearchRepresentation {
return [this.title];
return { properties: [{ key: 'Title', value: this.getTitle() }], searchValue: [this.getTitle()] };
}
public getDetailStateURL(): string {

View File

@ -16,6 +16,14 @@ export function isSearchable(object: any): object is Searchable {
export interface Searchable extends DetailNavigable {
/**
* Should return strings that represents the object.
*
* The result contains two properties: The `searchValue`, `properties` and optional `type`.
*
* `searchValue` is an array as summary of the properties.
*
* `properties` is an array of key-value pair.
*
* `type` - in case of mediafiles - describes, which type the mediafile has.
*/
formatForSearch: () => SearchRepresentation;
}

View File

@ -4,7 +4,6 @@ import { RouterModule, Routes } from '@angular/router';
import { ErrorComponent } from './components/error/error.component';
import { LegalNoticeComponent } from './components/legal-notice/legal-notice.component';
import { PrivacyPolicyComponent } from './components/privacy-policy/privacy-policy.component';
import { SearchComponent } from './components/search/search.component';
import { StartComponent } from './components/start/start.component';
const routes: Routes = [
@ -22,10 +21,6 @@ const routes: Routes = [
path: 'privacypolicy',
component: PrivacyPolicyComponent
},
{
path: 'search',
component: SearchComponent
},
{
path: 'error',
component: ErrorComponent

View File

@ -1,11 +1,12 @@
<div
*ngIf="isVisible"
class="global-spinner-component">
<div class="spinner-container">
<div>
<div class="spinner"></div>
<div class="text">{{ text }}</div>
</div>
<os-overlay *ngIf="isVisible">
<div>
<mat-progress-spinner
[mode]="mode"
[diameter]="diameter"
[strokeWidth]="stroke"
[value]="value"
class="spinner"
></mat-progress-spinner>
<div class="text">{{ text }}</div>
</div>
<div class="backdrop"></div>
</div>
</os-overlay>

View File

@ -1,83 +1,13 @@
@import '~@angular/material/theming';
@mixin os-global-spinner-theme($theme) {
$primary: map-get($theme, primary);
$accent: map-get($theme, accent);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
$contrast-primary: map-get($primary, contrast);
$contrast-accent: map-get($accent, contrast);
.global-spinner-component,
.backdrop,
.spinner-container {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 999;
.spinner {
display: inline-block;
}
.global-spinner-component {
position: fixed;
.spinner-container {
display: flex;
justify-content: center;
align-items: center;
.spinner {
position: absolute;
top: 50%;
left: 50%;
margin: -136px 0 0 -53px;
height: 100px;
width: 100px;
border: 6px solid #000;
border-radius: 100%;
opacity: 0.2;
animation: rotation 1s infinite linear;
&:before {
position: absolute;
top: -6px;
left: -6px;
content: '';
display: block;
height: 100%;
width: 100%;
border-radius: 100%;
border-style: solid;
border-width: 6px;
border-color: white transparent transparent;
}
@keyframes rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(359deg);
}
}
}
.text {
text-align: center;
color: white;
font-size: 1.4rem;
}
}
.backdrop {
z-index: 899;
background-color: #303030;
opacity: 0.8;
}
.text {
text-align: center;
color: white;
font-size: 1.4rem;
}
}

View File

@ -1,12 +1,11 @@
// External imports
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ProgressSpinnerMode } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { SpinnerService } from 'app/core/ui-services/spinner.service';
// Internal imports
import { OverlayService, SpinnerConfig } from 'app/core/ui-services/overlay.service';
/**
* Component for the global spinner.
@ -17,6 +16,26 @@ import { SpinnerService } from 'app/core/ui-services/spinner.service';
styleUrls: ['./global-spinner.component.scss']
})
export class GlobalSpinnerComponent implements OnInit, OnDestroy {
/**
* Defines the mode of the spinner. In `'determinate'-mode` a value can be passed to the spinner.
*/
public mode: ProgressSpinnerMode = 'indeterminate';
/**
* Defines the diameter of the spinner. Defaults to `140`.
*/
public diameter = 140;
/**
* Defines the stroke-width of the spinner. Defaults to `10`.
*/
public stroke = 10;
/**
* If the `'determinate'-mode` is applied, a value can be given to the spinner to indicate a progress.
*/
public value: number;
/**
* Text, which will be shown if the spinner is shown.
*/
@ -39,12 +58,12 @@ export class GlobalSpinnerComponent implements OnInit, OnDestroy {
/**
*
* @param spinnerService Reference to the service for this spinner.
* @param overlayService Reference to the service for this spinner.
* @param translate Service to get translations for the messages.
* @param cd Service to manual initiate a change of the UI.
*/
public constructor(
private spinnerService: SpinnerService,
private overlayService: OverlayService,
protected translate: TranslateService,
private cd: ChangeDetectorRef
) {}
@ -53,14 +72,17 @@ export class GlobalSpinnerComponent implements OnInit, OnDestroy {
* Init method
*/
public ngOnInit(): void {
this.spinnerSubscription = this.spinnerService // subscribe to the service.
.getVisibility()
.subscribe((value: { isVisible: boolean; text: string }) => {
this.spinnerSubscription = this.overlayService // subscribe to the service.
.getSpinner()
.subscribe((value: { isVisible: boolean; text: string; config?: SpinnerConfig }) => {
this.isVisible = value.isVisible;
this.text = this.translate.instant(value.text);
if (!this.text) {
this.text = this.LOADING;
}
if (value.config) {
this.setConfig(value.config);
}
this.cd.detectChanges();
});
}
@ -77,4 +99,16 @@ export class GlobalSpinnerComponent implements OnInit, OnDestroy {
}
this.spinnerSubscription = null;
}
/**
* Function to set properties to the spinner.
*
* @param config The `SpinnerConfig`.
*/
private setConfig(config?: SpinnerConfig): void {
this.mode = config.mode || this.mode;
this.diameter = config.diameter || this.diameter;
this.stroke = config.stroke || this.stroke;
this.value = config.value || this.value;
}
}

View File

@ -0,0 +1,6 @@
<div class="overlay-component stretch-to-fill-parent">
<div class="overlay-content" [ngClass]="position">
<ng-content></ng-content>
</div>
<div class="overlay-backdrop stretch-to-fill-parent" (click)="backdrop.emit()"></div>
</div>

View File

@ -0,0 +1,33 @@
.overlay-component {
z-index: 999;
}
.overlay-component {
display: flex;
justify-content: center;
align-items: center;
.overlay-content {
z-index: 900;
position: absolute;
transition: all 0.25s ease;
&.top {
top: 20%;
}
&.right {
right: 32px;
}
&.left {
left: 32px;
}
&.bottom {
bottom: 20%;
}
}
.overlay-backdrop {
z-index: 899;
background-color: #303030;
opacity: 0.8;
}
}

View File

@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { OverlayComponent } from './overlay.component';
describe('OverlayComponent', () => {
let component: OverlayComponent;
let fixture: ComponentFixture<OverlayComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [OverlayComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(OverlayComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,52 @@
import { Component, EventEmitter, HostListener, Input, OnInit, Output } from '@angular/core';
@Component({
selector: 'os-overlay',
templateUrl: './overlay.component.html',
styleUrls: ['./overlay.component.scss']
})
export class OverlayComponent implements OnInit {
/**
* Optional set the position of the component overlying on this overlay.
*
* Defaults to `'center'`.
*/
@Input()
public position: 'center' | 'left' | 'top' | 'right' | 'bottom' = 'center';
/**
* EventEmitter to handle a click on the backdrop.
*/
@Output()
public backdrop = new EventEmitter<void>();
/**
* EventEmitter to handle clicking `escape`.
*/
@Output()
public escape = new EventEmitter<void>();
/**
* Default constructor
*/
public constructor() {}
/**
* OnInit
*/
public ngOnInit(): void {}
/**
* Listens to keyboard inputs.
*
* If the user presses `escape`, the EventEmitter will emit a signal.
*
* @param event `KeyboardEvent`.
*/
@HostListener('document:keydown', ['$event'])
public keyListener(event: KeyboardEvent): void {
if (event.code === 'Escape') {
this.escape.emit();
}
}
}

View File

@ -1,80 +0,0 @@
<os-head-bar [nav]="false" [goBack]="true">
<!-- Title -->
<div class="title-slot"><h2 translate>Search results</h2></div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="menu"><mat-icon>more_vert</mat-icon></button>
</div>
</os-head-bar>
<!-- search-field -->
<div class="search-container">
<div class="search-field">
<mat-form-field appearance="outline" class="search-component">
<mat-label>{{ 'Search' | translate }}</mat-label>
<input matInput [formControl]="searchForm" />
<mat-icon matPrefix>search</mat-icon>
<button *ngIf="searchForm.value !== ''" mat-icon-button matSuffix (click)="searchForm.setValue('')">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
</div>
<mat-menu #menu="matMenu">
<button
mat-menu-item
*ngFor="let registeredModel of registeredModels"
(click)="toggleModel($event, registeredModel)"
>
<mat-checkbox [checked]="registeredModel.enabled" (click)="$event.preventDefault()">
<span>{{ registeredModel.verboseNamePlural | translate }}</span>
</mat-checkbox>
</button>
<mat-divider></mat-divider>
<h4 matSubheader>{{ 'Sort' | translate }}</h4>
<mat-list-item *ngFor="let option of sortingOptionsList" (click)="toggleSorting($event, option.option)">
<button mat-menu-item>
<mat-icon matListIcon>{{ sortingProperty === option.option ? 'check' : '' }}</mat-icon>
<span>{{ option.label | translate }}</span>
</button>
</mat-list-item>
</mat-menu>
<div *ngIf="searchResults.length > 0">
<div class="os-card">
{{ searchResultCount }}
<span *ngIf="searchResultCount === 1" translate>result</span>
<span *ngIf="searchResultCount !== 1" translate>results</span>
</div>
<div class="os-card">
<div class="noSearchResults" *ngIf="searchResultCount === 0 && query !== ''">
<span translate>No search result found for</span> "{{ query }}"
</div>
<ng-container *ngFor="let searchResult of searchResults">
<mat-card *ngIf="searchResult.models.length > 0">
<mat-card-title>
{{ searchResult.models.length }} {{ searchResult.verboseName | translate }}
</mat-card-title>
<mat-card-content>
<mat-list>
<mat-list-item *ngFor="let model of searchResult.models">
<a *ngIf="!searchResult.openInNewTab" [routerLink]="model.getDetailStateURL()">
{{ model.getTitle() }}
</a>
<a
*ngIf="searchResult.openInNewTab"
[routerLink]="model.getDetailStateURL()"
target="_blank"
>
{{ model.getTitle() }}
</a>
</mat-list-item>
</mat-list>
</mat-card-content>
</mat-card>
</ng-container>
</div>
</div>
</div>

View File

@ -1,42 +0,0 @@
// Variables
$border: 1px solid rgba(0, 0, 0, 0.125);
// Definitions
.search-field {
display: flex;
max-width: 50%;
margin: 15px auto;
mat-form-field.search-component {
width: 100%;
}
}
@media screen and (max-width: 400px) {
.search-container {
padding: 0 8px;
}
.search-field {
display: block;
mat-form-field.search-sort {
width: 100%;
margin: 0;
}
}
}
mat-list-item {
height: auto !important;
&:not(:first-child) {
margin-top: 8px;
}
&:not(:last-child) {
margin-bottom: 8px;
}
}
mat-card {
margin-bottom: 10px;
}

View File

@ -1,26 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
import { SearchComponent } from './search.component';
describe('SearchComponent', () => {
let component: SearchComponent;
let fixture: ComponentFixture<SearchComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [SearchComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,137 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { auditTime, debounceTime } from 'rxjs/operators';
import { DataStoreService } from 'app/core/core-services/data-store.service';
import { SearchModel, SearchResult, SearchService } from 'app/core/ui-services/search.service';
import { BaseViewComponent } from '../../../base/base-view';
type SearchModelEnabled = SearchModel & { enabled: boolean };
/**
* Component for the full search text.
*/
@Component({
selector: 'os-search',
templateUrl: './search.component.html',
styleUrls: ['./search.component.scss']
})
export class SearchComponent extends BaseViewComponent implements OnInit {
/**
* List with all options for sorting by.
*/
public sortingOptionsList = [{ option: 'title', label: 'Title' }, { option: 'id', label: 'ID' }];
/**
* the search term
*/
public query = '';
/**
* The amout of search results.
*/
public searchResultCount: number;
/**
* The search results for the ui
*/
public searchResults: SearchResult[] = [];
/**
* A list of models, that are registered to be searched. Used for
* enable and disable these models.
*/
public registeredModels: (SearchModelEnabled)[];
/**
* Property to decide what to sort by.
*/
public sortingProperty: 'id' | 'title' = 'title';
/**
* Form-control for the input-field.
*/
public searchForm = new FormControl('');
/**
* Inits the quickSearchForm, gets the registered models from the search service
* and watches the data store for any changes to initiate a new search if models changes.
*
* @param title
* @param translate
* @param matSnackBar
* @param DS DataStorService
* @param searchService For searching in the models
*/
public constructor(
title: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private DS: DataStoreService,
private searchService: SearchService
) {
super(title, translate, matSnackBar);
this.registeredModels = this.searchService.getRegisteredModels().map(rm => ({ ...rm, enabled: true }));
this.DS.modifiedObservable.pipe(auditTime(100)).subscribe(() => this.search());
this.searchForm.valueChanges.pipe(debounceTime(250)).subscribe(query => {
this.query = query;
this.search();
});
}
/**
* Take the search query from the URL and does the initial search.
*/
public ngOnInit(): void {
super.setTitle('Search');
}
/**
* Searches for the query in `this.query` or the query given.
*/
public search(): void {
if (!this.query || this.query === '') {
this.searchResults = [];
} else {
// Just search for enabled models.
const collectionStrings = this.registeredModels.filter(rm => rm.enabled).map(rm => rm.collectionString);
// Get all results
this.searchResults = this.searchService.search(this.query, collectionStrings, this.sortingProperty);
// Because the results are per model, we need to accumulate the total number of all search results.
this.searchResultCount = this.searchResults
.map(sr => sr.models.length)
.reduce((acc, current) => acc + current, 0);
}
}
/**
* Toggles a model, if it should be used during the search. Initiates a new search afterwards.
*
* @param registeredModel The model to toggle
*/
public toggleModel(event: MouseEvent, registeredModel: SearchModelEnabled): void {
event.stopPropagation();
registeredModel.enabled = !registeredModel.enabled;
this.search();
}
/**
* Function to switch between sorting-options.
*
* @param event The `MouseEvent`
* @param option The sorting-option
*/
public toggleSorting(event: MouseEvent, option: 'id' | 'title'): void {
event.stopPropagation();
this.sortingProperty = option;
this.search();
}
}

View File

@ -0,0 +1,96 @@
<div class="query-container">
<div class="super-search-input">
<os-rounded-input
[autofocus]="true"
placeholder="{{ 'Search' | translate }}"
[modelForm]="searchForm"
[keepFocus]="true"
></os-rounded-input>
<button mat-icon-button [matMenuTriggerFor]="filterMenu"><mat-icon>filter_list</mat-icon></button>
<mat-menu #filterMenu="matMenu">
<button
mat-menu-item
*ngFor="let model of registeredModels"
(click)="setCollection(model.verboseNamePlural)"
>
<mat-icon>{{ model.verboseNamePlural === searchCollection ? 'checked' : '' }}</mat-icon>
{{ model.verboseNamePlural | translate }}
</button>
</mat-menu>
</div>
<h4 *ngIf="searchResultCount > 0" class="result-count">
{{ searchResultCount }} {{ (searchResultCount === 1 ? 'result' : 'results') | translate }}
</h4>
<mat-divider></mat-divider>
<div class="result-view" *ngIf="searchResults.length > 0">
<div class="result-list">
<mat-accordion [displayMode]="'flat'">
<ng-container *ngFor="let result of searchResults">
<mat-expansion-panel
*ngIf="result.models.length > 0"
[expanded]="selectedCollection === result.collectionString"
>
<mat-expansion-panel-header>
<mat-panel-title>
{{ result.verboseName | translate }}
<mat-basic-chip
class="lightblue filter-count"
disableRipple
matTooltip="{{ (result.models.length === 1 ? 'result' : 'results') | translate }}"
>{{ result.models.length }}</mat-basic-chip
>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<mat-list role="list">
<mat-list-item
[ngClass]="selectedModel === model ? 'selected' : ''"
role="listitem"
(click)="changeModel(model)"
(dblclick)="viewResult(model)"
*ngFor="let model of result.models"
>
<div class="flex-1 ellipsis-overflow">
{{ model.getTitle() }}
</div>
<div *ngIf="selectedModel === model">
<button
*ngIf="!vp.isMobile"
mat-icon-button
(click)="showPreview = !showPreview"
matTooltip="{{ 'Show preview' | translate }}"
>
<mat-icon>
{{ showPreview ? 'visibility_off' : 'visibility_on' }}
</mat-icon>
</button>
<button
mat-icon-button
(click)="viewResult(model)"
matTooltip="{{ 'View' | translate }}"
>
<mat-icon>
present_to_all
</mat-icon>
</button>
</div>
<mat-divider></mat-divider>
</mat-list-item>
</mat-list>
</ng-template>
</mat-expansion-panel>
</ng-container>
</mat-accordion>
<div class="no-results" *ngIf="!selectedModel && searchString.length > 0">
<span translate>No search result found for</span> "{{ searchString }}"
<span *ngIf="searchCollection">in {{ searchCollection | translate }}</span>
</div>
</div>
<mat-divider [vertical]="true"></mat-divider>
<div *ngIf="showPreview" class="result-preview flex-1">
<div *ngIf="!!selectedModel">
<os-preview [viewModel]="selectedModel"> </os-preview>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,97 @@
@import '~@angular/material/theming';
@mixin os-super-search-style($theme) {
$primary: map-get($theme, primary);
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
.super-search-container > mat-dialog-container {
padding: 0;
}
.query-container {
display: block;
.super-search-input {
display: flex;
align-items: center;
padding: 8px;
os-rounded-input {
margin-right: 8px;
}
}
.result-count {
margin: 8px;
text-align: center;
}
.result-view {
position: relative;
display: flex;
background: mat-color($background, background);
max-height: calc(90vh - 93px);
.filter-count {
margin-left: 8px;
}
.result-list {
flex: 1;
overflow-y: auto;
.result-model-name {
position: sticky;
top: 0;
padding: 12px 16px;
background: darkgray;
z-index: 2;
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
}
mat-list,
mat-selection-list {
padding: 0;
}
mat-list-item {
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.025);
}
&.selected,
.mat-list-item-content.selected {
&,
mat-icon {
color: white;
}
background: mat-color($primary);
}
}
mat-icon {
color: mat-color($foreground, icon);
}
}
.flex-1 {
flex: 1;
}
.flex-2 {
flex: 2;
}
.result-preview {
overflow-y: auto;
transition: all 0.25s ease;
}
.no-results {
padding: 8px;
}
}
}
}

View File

@ -0,0 +1,25 @@
import { async, TestBed } from '@angular/core/testing';
// import { SuperSearchComponent } from './super-search.component';
import { E2EImportsModule } from 'e2e-imports.module';
fdescribe('SuperSearchComponent', () => {
// let component: SuperSearchComponent;
// let fixture: ComponentFixture<SuperSearchComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
// fixture = TestBed.createComponent(SuperSearchComponent);
// component = fixture.componentInstance;
// fixture.detectChanges();
});
it('should create', () => {
// expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,320 @@
import { Component, HostListener, Inject, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { auditTime, debounceTime } from 'rxjs/operators';
import { DataStoreService } from 'app/core/core-services/data-store.service';
import { StorageService } from 'app/core/core-services/storage.service';
import { SearchModel, SearchResult, SearchService, TranslatedCollection } from 'app/core/ui-services/search.service';
import { ViewportService } from 'app/core/ui-services/viewport.service';
import { BaseViewModel } from 'app/site/base/base-view-model';
import { Searchable } from 'app/site/base/searchable';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
@Component({
selector: 'os-super-search',
templateUrl: './super-search.component.html',
styleUrls: ['./super-search.component.scss']
})
export class SuperSearchComponent implements OnInit {
/**
* The reference to the form-control used for the `rounded-input.component`.
*/
public searchForm = new FormControl('');
/**
* The user's input as query: `string`.
*/
public searchString = '';
/**
* Variable to hold the verbose name of a specific collection.
*/
public searchCollection = '';
/**
* Holds the collection-string of the specific collection.
*/
private specificCollectionString: string = null;
/**
* The results for the given query.
*
* An array of `SearchResult`.
*/
public searchResults: SearchResult[] = [];
/**
* Number of all found results.
*/
public searchResultCount = 0;
/**
* The model, the user selected to see its preview.
*/
public selectedModel: (BaseViewModel & Searchable) | null = null;
/**
* The current collection of the selected model.
*/
public selectedCollection: string;
/**
* Boolean to indicate, if the preview should be shown.
*/
public showPreview = false;
/**
* All registered model-collections.
*/
public registeredModels: SearchModel[];
/**
* Stores all the collectionStrings registered by the `search.service`.
*/
private collectionStrings: string[];
/**
* Stores all the collections with translated names.
*/
private translatedCollectionStrings: TranslatedCollection[];
/**
* Key to store the query in the local-storage.
*/
private storageKey = 'SuperSearchQuery';
/**
* Constructor
*
* @param vp The viewport-service.
* @param overlayService Service to handle the overlaying background.
* @param searchService Service required for searching events.
* @param DS Reference to the `DataStore`.
* @param router Reference to the `Router`.
* @param store The reference to the storage-service.
* @param dialogRef Reference for the material-dialog.
*/
public constructor(
public vp: ViewportService,
private searchService: SearchService,
private DS: DataStoreService,
private router: Router,
private store: StorageService,
public dialogRef: MatDialogRef<SuperSearchComponent>,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
/**
* OnInit-function.
*
* Initializes the collections and the translated ones.
*/
public ngOnInit(): void {
this.DS.modifiedObservable.pipe(auditTime(100)).subscribe(() => this.search());
this.registeredModels = this.searchService.getRegisteredModels();
this.collectionStrings = this.registeredModels.map(rm => rm.collectionString);
this.translatedCollectionStrings = this.searchService.getTranslatedCollectionStrings();
this.searchForm.valueChanges.pipe(debounceTime(250)).subscribe(value => {
if (value.trim() === '') {
this.clearResults();
} else {
this.specificCollectionString = this.searchSpecificCollection(value.trim());
}
this.search();
});
this.restoreQueryFromStorage();
}
/**
* The main function to search through all collections.
*/
private search(): void {
if (this.searchString !== '') {
// Local variable to check, if the user searches for a specific id.
let dedicatedId: number;
const query = this.searchString;
// Looks, if the query matches variations of 'nr.' followed by at least one digit.
// If so, the user searches for a specific id in some collections.
// Everything not case-sensitive.
if (query.match(/n\w*r\.?\:?\s*\d+/gi)) {
// If so, this expression finds the number.
dedicatedId = +query.match(/\d+/g);
}
this.searchResults = this.searchService.search(
query,
this.specificCollectionString ? [this.specificCollectionString] : this.collectionStrings,
'title',
dedicatedId
);
this.selectFirstResult();
} else {
this.searchResults = [];
}
this.searchResultCount = this.searchResults
.map(result => result.models.length)
.reduce((acc, current) => acc + current, 0);
}
/**
* This function test, if the query matches some of the `collectionStrings`.
*
* That indicates, that the user looks for items in a specific collection.
*
* @returns { { collection: string, query: string[] } | null } Either an object containing the found collection and the query
* or null, if there exists none.
*/
private searchSpecificCollection(query: string): string | null {
// The query is splitted by the first whitespace or the first ':'.
const splittedQuery = query.split(/\s*(?::|\s+)\s*/);
const nextCollection = this.translatedCollectionStrings.find(item =>
// The value of the item should match the query plus any further
// characters (useful for splitted words in the query).
// This will look, if the user searches in a specific collection.
// Flag 'i' tells, that cases are ignored.
new RegExp(item.value, 'i').test(splittedQuery[0])
);
if (!!nextCollection) {
this.searchString = splittedQuery.slice(1).join(' ');
this.searchCollection = splittedQuery[0];
return nextCollection.collection;
} else {
this.searchString = query;
this.searchCollection = '';
return null;
}
}
/**
* Function to search through the result-list and select the first valid result to display.
*
* Otherwise the model is set to 'null'.
*/
private selectFirstResult(): void {
for (const result of this.searchResults) {
if (result.models.length > 0) {
this.changeModel(result.models[0]);
return;
}
}
// If this code is reached, there are no results for the query!
this.selectedModel = null;
}
/**
* Function to go through the whole list of results.
*
* @param up If the user presses the `ArrowUp`.
*/
private selectNextResult(up: boolean): void {
const tmp = this.searchResults.flatMap((result: SearchResult) => result.models);
this.changeModel(tmp[(tmp.indexOf(this.selectedModel) + (up ? -1 : 1)).modulo(tmp.length)]);
}
/**
* Function to set a specific collection and search through it.
*
* @param collectionName The `verboseName` of the selected collection.
*/
public setCollection(collectionName: string): void {
this.searchCollection = this.searchCollection === collectionName ? '' : collectionName;
if (this.searchCollection !== '') {
this.searchForm.setValue(this.searchCollection + ': ' + this.searchString);
} else {
this.searchForm.setValue(this.searchString);
}
}
/**
* Function to change the selected model.
*
* Ensures, that the preview-window's size is reset to the default one.
*
* @param model The model, the user selected. Typeof `BaseViewModel & Searchable`.
*/
public changeModel(model: BaseViewModel & Searchable): void {
this.selectedModel = model;
this.selectedCollection = model.collectionString;
}
/**
* Function to go to the detailed view of the model.
*
* @param model The model, the user selected.
*/
public viewResult(model: BaseViewModel & Searchable): void {
if (model.collectionString === 'mediafiles/mediafile' && !(<ViewMediafile>model).is_directory) {
window.open(model.getDetailStateURL(), '_blank');
} else {
this.router.navigateByUrl(model.getDetailStateURL());
}
this.hideOverlay();
this.saveQueryToStorage(this.searchForm.value);
}
/**
* Hides the overlay, so the search will disappear.
*/
public hideOverlay(): void {
this.clearResults();
this.dialogRef.close();
}
/**
* Clears the whole search with results and preview.
*/
private clearResults(): void {
this.searchResults = [];
this.selectedModel = null;
this.searchCollection = '';
this.searchString = '';
this.saveQueryToStorage(null);
}
/**
* Function to save an entered query.
*
* @param query The query to store.
*/
private saveQueryToStorage(query: string | null): void {
this.store.set(this.storageKey, query);
}
/**
* Function to restore a previous entered query.
* Once loaded, the result is passed as value to the form-control.
*/
private restoreQueryFromStorage(): void {
this.store.get<string>(this.storageKey).then(value => {
if (value) {
this.searchForm.setValue(value);
}
}, null);
}
/**
* Function to open the global `super-search.component`.
*
* @param event KeyboardEvent to listen to keyboard-inputs.
*/
@HostListener('document:keydown', ['$event']) public onKeyNavigation(event: KeyboardEvent): void {
if (event.key === 'Enter') {
this.viewResult(this.selectedModel);
}
if (event.key === 'ArrowUp') {
this.selectNextResult(true);
}
if (event.key === 'ArrowDown') {
this.selectNextResult(false);
}
if (event.altKey && event.shiftKey && event.key === 'V') {
this.showPreview = !this.showPreview;
}
}
}

View File

@ -6,19 +6,11 @@ import { CountUsersComponent } from './components/count-users/count-users.compon
import { ErrorComponent } from './components/error/error.component';
import { LegalNoticeComponent } from './components/legal-notice/legal-notice.component';
import { PrivacyPolicyComponent } from './components/privacy-policy/privacy-policy.component';
import { SearchComponent } from './components/search/search.component';
import { SharedModule } from '../../shared/shared.module';
import { StartComponent } from './components/start/start.component';
@NgModule({
imports: [CommonModule, CommonRoutingModule, SharedModule],
declarations: [
PrivacyPolicyComponent,
StartComponent,
LegalNoticeComponent,
SearchComponent,
CountUsersComponent,
ErrorComponent
]
declarations: [PrivacyPolicyComponent, StartComponent, LegalNoticeComponent, CountUsersComponent, ErrorComponent]
})
export class OsCommonModule {}

View File

@ -10,7 +10,7 @@ import { Subscription } from 'rxjs';
import { AuthService } from 'app/core/core-services/auth.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { LoginDataService } from 'app/core/ui-services/login-data.service';
import { SpinnerService } from 'app/core/ui-services/spinner.service';
import { OverlayService } from 'app/core/ui-services/overlay.service';
import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
import { BaseViewComponent } from 'app/site/base/base-view';
@ -62,7 +62,7 @@ export class LoginMaskComponent extends BaseViewComponent implements OnInit, OnD
* @param httpService used to get information before the login
* @param OpenSlides The Service for OpenSlides
* @param loginDataService provide information about the legal notice and privacy policy
* @param spinnerService Service to show the spinner when the user is signing in
* @param overlayService Service to show the spinner when the user is signing in
*/
public constructor(
title: Title,
@ -74,11 +74,10 @@ export class LoginMaskComponent extends BaseViewComponent implements OnInit, OnD
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private loginDataService: LoginDataService,
private spinnerService: SpinnerService
private overlayService: OverlayService
) {
super(title, translate, matSnackBar);
// Hide the spinner if the user is at `login-mask`
spinnerService.setVisibility(false);
this.createForm();
}
@ -138,7 +137,7 @@ export class LoginMaskComponent extends BaseViewComponent implements OnInit, OnD
this.loginErrorMsg = '';
try {
await this.authService.login(this.loginForm.value.username, this.loginForm.value.password, () => {
this.spinnerService.setVisibility(true, this.translate.instant('Loading data. Please wait ...'));
this.overlayService.setSpinner(true, this.translate.instant('Loading data. Please wait...'));
this.clearOperatorSubscription(); // We take control, not the subscription.
});
} catch (e) {

View File

@ -51,11 +51,7 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers<Mediafile>
}
public get title(): string {
if (this.is_directory) {
return this.mediafile.path;
} else {
return this.mediafile.title;
}
return this.filename;
}
public get filename(): string {
@ -78,7 +74,7 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers<Mediafile>
return !this.is_directory;
}
public get size(): string {
public get size(): string | null {
return this.mediafile.filesize;
}
@ -90,7 +86,7 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers<Mediafile>
return this.mediafile.url;
}
public get type(): string {
public get type(): string | null {
return this.mediafile.mediafile ? this.mediafile.mediafile.type : '';
}
@ -103,11 +99,23 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers<Mediafile>
}
public formatForSearch(): SearchRepresentation {
return [this.title, this.path];
const type = this.is_directory ? 'directory' : this.type;
const properties = [
{ key: 'Title', value: this.getTitle() },
{ key: 'Path', value: this.path },
{ key: 'Type', value: type },
{ key: 'Timestamp', value: this.timestamp },
{ key: 'Size', value: this.size ? this.size : '0' }
];
return {
properties,
searchValue: properties.map(property => property.value),
type: type
};
}
public getDetailStateURL(): string {
return this.url;
return this.is_directory ? ('/mediafiles/files/' + this.path).slice(0, -1) : this.url;
}
public getSlide(): ProjectorElementBuildDeskriptor {

View File

@ -107,7 +107,10 @@ export class ViewCategory extends BaseViewModel<Category> implements CategoryTit
}
public formatForSearch(): SearchRepresentation {
return [this.name, this.prefix];
return {
properties: [{ key: 'Name', value: this.name }, { key: 'Prefix', value: this.prefix }],
searchValue: [this.name, this.prefix]
};
}
public getDetailStateURL(): string {

View File

@ -42,7 +42,7 @@ export class ViewMotionBlock extends BaseViewModelWithAgendaItemAndListOfSpeaker
* @override
*/
public formatForSearch(): SearchRepresentation {
return [this.title];
return { properties: [{ key: 'Title', value: this.getTitle() }], searchValue: [this.getTitle()] };
}
/**

View File

@ -1,6 +1,6 @@
import { _ } from 'app/core/translate/translation-marker';
import { ConfigService } from 'app/core/ui-services/config.service';
import { SearchRepresentation } from 'app/core/ui-services/search.service';
import { SearchProperty, SearchRepresentation } from 'app/core/ui-services/search.service';
import { Motion, MotionComment } from 'app/shared/models/motions/motion';
import { PersonalNoteContent } from 'app/shared/models/users/personal-note';
import { TitleInformationWithAgendaItem } from 'app/site/base/base-view-model-with-agenda-item';
@ -351,24 +351,50 @@ export class ViewMotion extends BaseViewModelWithAgendaItemAndListOfSpeakers<Mot
* @override
*/
public formatForSearch(): SearchRepresentation {
let searchValues = [this.title, this.text, this.reason];
const properties: SearchProperty[] = [];
properties.push({ key: 'Title', value: this.getTitle() });
properties.push({ key: 'Submitters', value: this.submittersAsUsers.map(user => user.full_name).join(', ') });
properties.push({ key: 'Text', value: this.text, trusted: true });
properties.push({ key: 'Reason', value: this.reason, trusted: true });
if (this.amendment_paragraphs) {
searchValues = searchValues.concat(this.amendment_paragraphs.filter(x => !!x));
properties.push({
key: 'Amendments',
value: this.amendment_paragraphs.filter(x => !!x).join('\n'),
trusted: true
});
}
searchValues = searchValues.concat(this.submittersAsUsers.map(user => user.full_name));
searchValues = searchValues.concat(this.supporters.map(user => user.full_name));
searchValues = searchValues.concat(this.tags.map(tag => tag.getTitle()));
searchValues = searchValues.concat(this.motion.comments.map(comment => comment.comment));
properties.push({ key: 'Tags', value: this.tags.map(tag => tag.getTitle()).join(', ') });
properties.push({
key: 'Comments',
value: this.motion.comments.map(comment => comment.comment).join('\n'),
trusted: true
});
properties.push({ key: 'Supporters', value: this.supporters.map(user => user.full_name).join(', ') });
// A property with block-value to unify the meta-info.
const metaData: SearchProperty = {
key: null,
value: null,
blockProperties: []
};
if (this.motion_block) {
searchValues.push(this.motion_block.getTitle());
metaData.blockProperties.push({ key: 'Motion block', value: this.motion_block.getTitle() });
}
if (this.category) {
searchValues.push(this.category.getTitle());
metaData.blockProperties.push({ key: 'Category', value: this.category.getTitle() });
}
if (this.state) {
searchValues.push(this.state.name);
metaData.blockProperties.push({ key: 'State', value: this.state.name });
}
return searchValues;
properties.push(metaData);
return {
properties,
searchValue: properties.map(property =>
property.key ? property.value : property.blockProperties.join(',')
)
};
}
public getDetailStateURL(): string {

View File

@ -36,7 +36,7 @@ export class ViewStatuteParagraph extends BaseViewModel<StatuteParagraph>
}
public formatForSearch(): SearchRepresentation {
return [this.title];
return { properties: [{ key: 'Title', value: this.getTitle() }], searchValue: [this.getTitle()] };
}
public getDetailStateURL(): string {

View File

@ -15,7 +15,7 @@ import { WorkflowRepositoryService } from 'app/core/repositories/motions/workflo
import { TagRepositoryService } from 'app/core/repositories/tags/tag-repository.service';
import { OsFilterOptionCondition } from 'app/core/ui-services/base-filter-list.service';
import { ConfigService } from 'app/core/ui-services/config.service';
import { SpinnerService } from 'app/core/ui-services/spinner.service';
import { OverlayService } from 'app/core/ui-services/overlay.service';
import { ColumnRestriction } from 'app/shared/components/list-view-table/list-view-table.component';
import { infoDialogSettings, largeDialogSettings } from 'app/shared/utils/dialog-settings';
import { BaseListViewComponent } from 'app/site/base/base-list-view';
@ -214,7 +214,7 @@ export class MotionListComponent extends BaseListViewComponent<ViewMotion> imple
public multiselectService: MotionMultiselectService,
public perms: LocalPermissionsService,
private motionExport: MotionExportService,
private spinnerService: SpinnerService
private overlayService: OverlayService
) {
super(titleService, translate, matSnackBar, storage);
this.canMultiSelect = true;
@ -380,7 +380,7 @@ export class MotionListComponent extends BaseListViewComponent<ViewMotion> imple
} catch (e) {
this.raiseError(e);
} finally {
this.spinnerService.setVisibility(false);
this.overlayService.setSpinner(false);
}
}

View File

@ -11,9 +11,9 @@ import { WorkflowRepositoryService } from 'app/core/repositories/motions/workflo
import { TagRepositoryService } from 'app/core/repositories/tags/tag-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ChoiceService } from 'app/core/ui-services/choice.service';
import { OverlayService } from 'app/core/ui-services/overlay.service';
import { PersonalNoteService } from 'app/core/ui-services/personal-note.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { SpinnerService } from 'app/core/ui-services/spinner.service';
import { TreeService } from 'app/core/ui-services/tree.service';
import { ChoiceDialogOptions } from 'app/shared/components/choice-dialog/choice-dialog.component';
import { Identifiable } from 'app/shared/models/base/identifiable';
@ -45,7 +45,7 @@ export class MotionMultiselectService {
* @param httpService
* @param treeService
* @param personalNoteService
* @param spinnerService to show a spinner when http-requests are made.
* @param overlayService to show a spinner when http-requests are made.
*/
public constructor(
private repo: MotionRepositoryService,
@ -61,7 +61,7 @@ export class MotionMultiselectService {
private httpService: HttpService,
private treeService: TreeService,
private personalNoteService: PersonalNoteService,
private spinnerService: SpinnerService
private overlayService: OverlayService
) {}
/**
@ -81,10 +81,10 @@ export class MotionMultiselectService {
`\n${i} ` +
this.translate.instant('of') +
` ${motions.length}`;
this.spinnerService.setVisibility(true, message);
this.overlayService.setSpinner(true, message);
await this.repo.delete(motion);
}
this.spinnerService.setVisibility(false);
this.overlayService.setSpinner(false);
}
}
@ -118,8 +118,13 @@ export class MotionMultiselectService {
const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice) {
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
this.overlayService.setSpinner(true, message);
await this.repo.setMultiState(motions, selectedChoice.items as number);
// .catch(error => {
// this.overlayService.setSpinner(false);
// throw error;
// });
// this.overlayService.setSpinner(false);
}
}
@ -146,10 +151,15 @@ export class MotionMultiselectService {
}));
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
this.overlayService.setSpinner(true, message);
await this.httpService.post('/rest/motions/motion/manage_multiple_recommendation/', {
motions: requestData
});
// .catch(error => {
// this.overlayService.setSpinner(false);
// throw error;
// });
// this.overlayService.setSpinner(false);
}
}
@ -170,8 +180,13 @@ export class MotionMultiselectService {
);
if (selectedChoice) {
const message = this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
this.overlayService.setSpinner(true, message);
await this.repo.setMultiCategory(motions, selectedChoice.items as number);
// .catch(error => {
// this.overlayService.setSpinner(false);
// throw error;
// });
// this.overlayService.setSpinner(false);
}
}
@ -210,8 +225,9 @@ export class MotionMultiselectService {
}
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
this.overlayService.setSpinner(true, message);
await this.httpService.post('/rest/motions/motion/manage_multiple_submitters/', { motions: requestData });
// this.overlayService.setSpinner(false);
}
}
@ -260,8 +276,9 @@ export class MotionMultiselectService {
}
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
this.overlayService.setSpinner(true, message);
await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData });
// this.overlayService.setSpinner(false);
}
}
@ -282,9 +299,14 @@ export class MotionMultiselectService {
);
if (selectedChoice) {
const message = this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
this.overlayService.setSpinner(true, message);
const blockId = selectedChoice.action ? null : (selectedChoice.items as number);
await this.repo.setMultiMotionBlock(motions, blockId);
// .catch(error => {
// this.overlayService.setSpinner(false);
// throw error;
// });
// this.overlayService.setSpinner(false);
}
}
@ -347,8 +369,9 @@ export class MotionMultiselectService {
if (selectedChoice && motions.length) {
const message = this.translate.instant(`I have ${motions.length} favorite motions. Please wait ...`);
const star = (selectedChoice.items as number) === choices[0].id;
this.spinnerService.setVisibility(true, message);
this.overlayService.setSpinner(true, message);
await this.personalNoteService.bulkSetStar(motions, star);
// this.overlayService.setSpinner(false);
}
}
}

View File

@ -90,13 +90,7 @@
</a>
</span>
<mat-divider></mat-divider>
<a
[@navItemAnim]
mat-list-item
routerLink="/search"
routerLinkActive="active"
(click)="mobileAutoCloseNav()"
>
<a [@navItemAnim] *ngIf="vp.isMobile" mat-list-item routerLinkActive="active" (click)="toggleSearch()">
<mat-icon>search</mat-icon>
<span translate>Search</span>
</a>
@ -156,3 +150,5 @@
</div>
</mat-sidenav-content>
</mat-sidenav-container>
<os-global-spinner></os-global-spinner>

View File

@ -1,4 +1,4 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { Component, HostListener, OnInit, ViewChild } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { MatSidenav } from '@angular/material/sidenav';
@ -12,6 +12,7 @@ import { filter } from 'rxjs/operators';
import { navItemAnim, pageTransition } from '../shared/animations';
import { OfflineService } from 'app/core/core-services/offline.service';
import { ConfigService } from 'app/core/ui-services/config.service';
import { OverlayService } from 'app/core/ui-services/overlay.service';
import { UpdateService } from 'app/core/ui-services/update.service';
import { langToLocale } from 'app/shared/utils/lang-to-locale';
import { AuthService } from '../core/core-services/auth.service';
@ -100,9 +101,11 @@ export class SiteComponent extends BaseComponent implements OnInit {
public mainMenuService: MainMenuService,
public OSStatus: OpenSlidesStatusService,
public timeTravel: TimeTravelService,
private matSnackBar: MatSnackBar
private matSnackBar: MatSnackBar,
private overlayService: OverlayService
) {
super(title, translate);
overlayService.setSpinner(true, translate.instant('Loading data. Please wait...'));
this.operator.getViewUserObservable().subscribe(user => {
if (user) {
@ -208,6 +211,15 @@ export class SiteComponent extends BaseComponent implements OnInit {
this.sideNav.toggle();
}
/**
* Shows the `super-search.component`,
* only if the user is on a mobile device.
*/
public toggleSearch(): void {
this.overlayService.showSearch();
this.mobileAutoCloseNav();
}
/**
* Automatically close the navigation in while navigating in mobile mode
*/
@ -250,6 +262,7 @@ export class SiteComponent extends BaseComponent implements OnInit {
*/
public logout(): void {
this.authService.logout();
this.overlayService.logout();
}
/**
@ -292,15 +305,6 @@ export class SiteComponent extends BaseComponent implements OnInit {
}
}
/**
* Handler for the search bar
*/
public search(): void {
const query = this.searchform.get('query').value;
this.searchform.reset();
this.router.navigate(['/search'], { queryParams: { query: query } });
}
/**
* Get the timestamp for the current point in history mode.
* Tries to detect the ideal timestamp format using the translation service
@ -310,4 +314,15 @@ export class SiteComponent extends BaseComponent implements OnInit {
public getHistoryTimestamp(): string {
return this.OSStatus.getHistoryTimeStamp(langToLocale(this.translate.currentLang));
}
/**
* Function to open the global `super-search.component`.
*
* @param event KeyboardEvent to listen to keyboard-inputs.
*/
@HostListener('document:keydown', ['$event']) public onKeyNavigation(event: KeyboardEvent): void {
if (event.altKey && event.shiftKey && event.code === 'KeyF') {
this.overlayService.showSearch();
}
}
}

View File

@ -27,7 +27,7 @@ export class ViewTag extends BaseViewModel<Tag> implements TagTitleInformation,
}
public formatForSearch(): SearchRepresentation {
return [this.name];
return { properties: [{ key: 'Name', value: this.name }], searchValue: [this.name] };
}
public getDetailStateURL(): string {

View File

@ -46,7 +46,10 @@ export class ViewTopic extends BaseViewModelWithAgendaItemAndListOfSpeakers impl
* @override
*/
public formatForSearch(): SearchRepresentation {
return [this.title, this.text];
return {
properties: [{ key: 'Title', value: this.getTitle() }, { key: 'Text', value: this.text }],
searchValue: [this.getTitle(), this.text]
};
}
public getDetailStateURL(): string {

View File

@ -127,7 +127,14 @@ export class ViewUser extends BaseProjectableViewModel<User> implements UserTitl
* @override
*/
public formatForSearch(): SearchRepresentation {
return [this.title, this.first_name, this.last_name, this.structure_level, this.number];
const properties = [
{ key: 'Title', value: this.getTitle() },
{ key: 'First name', value: this.first_name },
{ key: 'Last name', value: this.last_name },
{ key: 'Structure level', value: this.structure_level },
{ key: 'Number', value: this.number }
];
return { properties, searchValue: properties.map(property => property.value) };
}
public getDetailStateURL(): string {

View File

@ -1,5 +1,5 @@
<div *ngIf="data">
<div *ngIf="isImage" [ngClass]="data.element.fullscreen ? 'fullscreen' : 'nofullscreen'" >
<div *ngIf="isImage" [ngClass]="data.element.fullscreen ? 'fullscreen' : 'nofullscreen'">
<img [src]="url" alt="" />
</div>
<div *ngIf="isPdf" class="fullscreen">
@ -11,6 +11,7 @@
[page]="data.element.page || 1"
[zoom]="zoom"
[src]="url"
style="display: block;"></pdf-viewer>
style="display: block;"
></pdf-viewer>
</div>
</div>

View File

@ -1,14 +1,12 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { PdfViewerModule } from 'ng2-pdf-viewer';
import { SharedModule } from 'app/shared/shared.module';
import { SLIDE } from 'app/slides/slide-token';
import { MediafileSlideComponent } from './mediafile-slide.component';
@NgModule({
imports: [CommonModule, SharedModule, PdfViewerModule],
imports: [CommonModule, SharedModule],
declarations: [MediafileSlideComponent],
providers: [{ provide: SLIDE, useValue: MediafileSlideComponent }],
entryComponents: [MediafileSlideComponent]

View File

@ -21,6 +21,8 @@
@import './app/shared/components/icon-container/icon-container.component.scss';
@import './app/site/common/components/start/start.component.scss';
@import './app/site/mediafiles/components/mediafile-list/mediafile-list.component.scss-theme.scss';
@import './app/site/common/components/super-search/super-search.component.scss';
@import './app/shared/components/rounded-input/rounded-input.component.scss';
/** fonts */
@import './assets/styles/fonts.scss';
@ -42,6 +44,8 @@ $narrow-spacing: (
@include os-global-spinner-theme($theme);
@include os-tile-style($theme);
@include os-mediafile-list-theme($theme);
@include os-super-search-style($theme);
@include os-rounded-input-style($theme);
}
/** Load projector specific SCSS values */