From 5f29732e261e9c735c704bb70ec7b11a905b1250 Mon Sep 17 00:00:00 2001 From: GabrielMeyer Date: Thu, 11 Jul 2019 14:06:01 +0200 Subject: [PATCH] Implements the global 'super-search.component' - Moves the 'spinner.component' to 'site.component' - Replaces also the 'spinner.service' with an 'overlay.service', that handles all request to show an element on an overlay. --- client/src/app/app.component.html | 1 - client/src/app/app.component.ts | 35 +- client/src/app/app.module.ts | 3 +- .../core/ui-services/overlay.service.spec.ts | 12 + .../app/core/ui-services/overlay.service.ts | 121 +++++++ .../app/core/ui-services/search.service.ts | 106 +++++- .../core/ui-services/spinner.service.spec.ts | 18 - .../app/core/ui-services/spinner.service.ts | 41 --- .../head-bar/head-bar.component.html | 5 + .../components/head-bar/head-bar.component.ts | 11 +- .../components/preview/preview.component.html | 41 +++ .../components/preview/preview.component.scss | 18 + .../preview/preview.component.spec.ts | 26 ++ .../components/preview/preview.component.ts | 61 ++++ .../rounded-input.component.html | 5 +- .../rounded-input.component.scss | 109 +++--- .../rounded-input/rounded-input.component.ts | 87 ++++- .../sort-filter-bar.component.scss | 7 +- .../app/shared/models/mediafiles/mediafile.ts | 2 +- client/src/app/shared/shared.module.ts | 23 +- .../assignments/models/view-assignment.ts | 2 +- client/src/app/site/base/searchable.ts | 8 + .../app/site/common/common-routing.module.ts | 5 - .../global-spinner.component.html | 21 +- .../global-spinner.component.scss | 82 +---- .../global-spinner.component.ts | 50 ++- .../components/overlay/overlay.component.html | 6 + .../components/overlay/overlay.component.scss | 33 ++ .../overlay/overlay.component.spec.ts | 24 ++ .../components/overlay/overlay.component.ts | 52 +++ .../components/search/search.component.html | 80 ----- .../components/search/search.component.scss | 42 --- .../search/search.component.spec.ts | 26 -- .../components/search/search.component.ts | 137 -------- .../super-search/super-search.component.html | 96 ++++++ .../super-search/super-search.component.scss | 97 ++++++ .../super-search.component.spec.ts | 25 ++ .../super-search/super-search.component.ts | 320 ++++++++++++++++++ .../src/app/site/common/os-common.module.ts | 10 +- .../login-mask/login-mask.component.ts | 9 +- .../site/mediafiles/models/view-mediafile.ts | 26 +- .../app/site/motions/models/view-category.ts | 5 +- .../site/motions/models/view-motion-block.ts | 2 +- .../app/site/motions/models/view-motion.ts | 48 ++- .../motions/models/view-statute-paragraph.ts | 2 +- .../motion-list/motion-list.component.ts | 6 +- .../services/motion-multiselect.service.ts | 47 ++- client/src/app/site/site.component.html | 10 +- client/src/app/site/site.component.ts | 37 +- client/src/app/site/tags/models/view-tag.ts | 2 +- .../src/app/site/topics/models/view-topic.ts | 5 +- client/src/app/site/users/models/view-user.ts | 9 +- .../mediafile/mediafile-slide.component.html | 5 +- .../mediafile/mediafile-slide.module.ts | 4 +- client/src/styles.scss | 4 + 55 files changed, 1474 insertions(+), 595 deletions(-) create mode 100644 client/src/app/core/ui-services/overlay.service.spec.ts create mode 100644 client/src/app/core/ui-services/overlay.service.ts delete mode 100644 client/src/app/core/ui-services/spinner.service.spec.ts delete mode 100644 client/src/app/core/ui-services/spinner.service.ts create mode 100644 client/src/app/shared/components/preview/preview.component.html create mode 100644 client/src/app/shared/components/preview/preview.component.scss create mode 100644 client/src/app/shared/components/preview/preview.component.spec.ts create mode 100644 client/src/app/shared/components/preview/preview.component.ts create mode 100644 client/src/app/site/common/components/overlay/overlay.component.html create mode 100644 client/src/app/site/common/components/overlay/overlay.component.scss create mode 100644 client/src/app/site/common/components/overlay/overlay.component.spec.ts create mode 100644 client/src/app/site/common/components/overlay/overlay.component.ts delete mode 100644 client/src/app/site/common/components/search/search.component.html delete mode 100644 client/src/app/site/common/components/search/search.component.scss delete mode 100644 client/src/app/site/common/components/search/search.component.spec.ts delete mode 100644 client/src/app/site/common/components/search/search.component.ts create mode 100644 client/src/app/site/common/components/super-search/super-search.component.html create mode 100644 client/src/app/site/common/components/super-search/super-search.component.scss create mode 100644 client/src/app/site/common/components/super-search/super-search.component.spec.ts create mode 100644 client/src/app/site/common/components/super-search/super-search.component.ts diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html index be412ce9e..e7f953e2e 100644 --- a/client/src/app/app.component.html +++ b/client/src/app/app.component.html @@ -1,4 +1,3 @@
-
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 89004fea8..79b198010 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -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 { 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); } } } diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index 9bc00e7b2..751f45fa8 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -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, diff --git a/client/src/app/core/ui-services/overlay.service.spec.ts b/client/src/app/core/ui-services/overlay.service.spec.ts new file mode 100644 index 000000000..1aea11e37 --- /dev/null +++ b/client/src/app/core/ui-services/overlay.service.spec.ts @@ -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(); + }); +}); diff --git a/client/src/app/core/ui-services/overlay.service.ts b/client/src/app/core/ui-services/overlay.service.ts new file mode 100644 index 000000000..3d7ab98fc --- /dev/null +++ b/client/src/app/core/ui-services/overlay.service.ts @@ -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 = 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; + } +} diff --git a/client/src/app/core/ui-services/search.service.ts b/client/src/app/core/ui-services/search.service.ts index 23d5165af..9dd09f186 100644 --- a/client/src/app/core/ui-services/search.service.ts +++ b/client/src/app/core/ui-services/search.service.ts @@ -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; + } } diff --git a/client/src/app/core/ui-services/spinner.service.spec.ts b/client/src/app/core/ui-services/spinner.service.spec.ts deleted file mode 100644 index 3e634e102..000000000 --- a/client/src/app/core/ui-services/spinner.service.spec.ts +++ /dev/null @@ -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(); - })); -}); diff --git a/client/src/app/core/ui-services/spinner.service.ts b/client/src/app/core/ui-services/spinner.service.ts deleted file mode 100644 index c3c4e1197..000000000 --- a/client/src/app/core/ui-services/spinner.service.ts +++ /dev/null @@ -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; - } -} diff --git a/client/src/app/shared/components/head-bar/head-bar.component.html b/client/src/app/shared/components/head-bar/head-bar.component.html index fb0ca493c..ef4842467 100644 --- a/client/src/app/shared/components/head-bar/head-bar.component.html +++ b/client/src/app/shared/components/head-bar/head-bar.component.html @@ -28,6 +28,11 @@
+ + +
diff --git a/client/src/app/shared/components/head-bar/head-bar.component.ts b/client/src/app/shared/components/head-bar/head-bar.component.ts index 54fdd4d2d..8b2200bdc 100644 --- a/client/src/app/shared/components/head-bar/head-bar.component.ts +++ b/client/src/app/shared/components/head-bar/head-bar.component.ts @@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, 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'; @@ -141,7 +142,8 @@ export class HeadBarComponent { private menu: MainMenuService, private router: Router, private route: ActivatedRoute, - private routingState: RoutingStateService + private routingState: RoutingStateService, + private overlayService: OverlayService ) {} /** @@ -172,6 +174,13 @@ export class HeadBarComponent { 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. diff --git a/client/src/app/shared/components/preview/preview.component.html b/client/src/app/shared/components/preview/preview.component.html new file mode 100644 index 000000000..4a8283546 --- /dev/null +++ b/client/src/app/shared/components/preview/preview.component.html @@ -0,0 +1,41 @@ + +
+
+
+ +
+
+ +
+
+
+ +
{{ entry.key | translate }}
+
{{ entry.value }}
+
+
+
+
+
+
+ +
{{ entry.key | translate }}
+
{{ entry.value }}
+
+
+ +
+
{{ property.key | translate }}
+
{{ property.value }}
+
+
+
+
+
+
diff --git a/client/src/app/shared/components/preview/preview.component.scss b/client/src/app/shared/components/preview/preview.component.scss new file mode 100644 index 000000000..737a0eed0 --- /dev/null +++ b/client/src/app/shared/components/preview/preview.component.scss @@ -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; + } +} diff --git a/client/src/app/shared/components/preview/preview.component.spec.ts b/client/src/app/shared/components/preview/preview.component.spec.ts new file mode 100644 index 000000000..ccbaf3753 --- /dev/null +++ b/client/src/app/shared/components/preview/preview.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PreviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/preview/preview.component.ts b/client/src/app/shared/components/preview/preview.component.ts new file mode 100644 index 000000000..bdbf1c3d3 --- /dev/null +++ b/client/src/app/shared/components/preview/preview.component.ts @@ -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); + } +} diff --git a/client/src/app/shared/components/rounded-input/rounded-input.component.html b/client/src/app/shared/components/rounded-input/rounded-input.component.html index 29d5ca83d..fa0da23e2 100644 --- a/client/src/app/shared/components/rounded-input/rounded-input.component.html +++ b/client/src/app/shared/components/rounded-input/rounded-input.component.html @@ -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()" />
- close + close
diff --git a/client/src/app/shared/components/rounded-input/rounded-input.component.scss b/client/src/app/shared/components/rounded-input/rounded-input.component.scss index f0bcbba45..11a717e6e 100644 --- a/client/src/app/shared/components/rounded-input/rounded-input.component.scss +++ b/client/src/app/shared/components/rounded-input/rounded-input.component.scss @@ -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; + } } } diff --git a/client/src/app/shared/components/rounded-input/rounded-input.component.ts b/client/src/app/shared/components/rounded-input/rounded-input.component.ts index b0e18750f..73e8c256f 100644 --- a/client/src/app/shared/components/rounded-input/rounded-input.component.ts +++ b/client/src/app/shared/components/rounded-input/rounded-input.component.ts @@ -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 ``-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 = 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. diff --git a/client/src/app/shared/components/sort-filter-bar/sort-filter-bar.component.scss b/client/src/app/shared/components/sort-filter-bar/sort-filter-bar.component.scss index 861afd2f9..f2e252961 100644 --- a/client/src/app/shared/components/sort-filter-bar/sort-filter-bar.component.scss +++ b/client/src/app/shared/components/sort-filter-bar/sort-filter-bar.component.scss @@ -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; -} diff --git a/client/src/app/shared/models/mediafiles/mediafile.ts b/client/src/app/shared/models/mediafiles/mediafile.ts index 39077343d..d8495d421 100644 --- a/client/src/app/shared/models/mediafiles/mediafile.ts +++ b/client/src/app/shared/models/mediafiles/mediafile.ts @@ -19,7 +19,7 @@ export class Mediafile extends BaseModelWithListOfSpeakers { 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; diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 144440517..45ea895ff 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -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 {} diff --git a/client/src/app/site/assignments/models/view-assignment.ts b/client/src/app/site/assignments/models/view-assignment.ts index 28a4de0b4..540e15552 100644 --- a/client/src/app/site/assignments/models/view-assignment.ts +++ b/client/src/app/site/assignments/models/view-assignment.ts @@ -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 { diff --git a/client/src/app/site/base/searchable.ts b/client/src/app/site/base/searchable.ts index a8cbfd8cc..b96e5ff04 100644 --- a/client/src/app/site/base/searchable.ts +++ b/client/src/app/site/base/searchable.ts @@ -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; } diff --git a/client/src/app/site/common/common-routing.module.ts b/client/src/app/site/common/common-routing.module.ts index e271fea74..88516ad5c 100644 --- a/client/src/app/site/common/common-routing.module.ts +++ b/client/src/app/site/common/common-routing.module.ts @@ -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 diff --git a/client/src/app/site/common/components/global-spinner/global-spinner.component.html b/client/src/app/site/common/components/global-spinner/global-spinner.component.html index 6e1791159..de29fe8af 100644 --- a/client/src/app/site/common/components/global-spinner/global-spinner.component.html +++ b/client/src/app/site/common/components/global-spinner/global-spinner.component.html @@ -1,11 +1,12 @@ -
-
-
-
-
{{ text }}
-
+ +
+ +
{{ text }}
-
-
+ diff --git a/client/src/app/site/common/components/global-spinner/global-spinner.component.scss b/client/src/app/site/common/components/global-spinner/global-spinner.component.scss index 213c9e135..9cadf9c96 100644 --- a/client/src/app/site/common/components/global-spinner/global-spinner.component.scss +++ b/client/src/app/site/common/components/global-spinner/global-spinner.component.scss @@ -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; } } diff --git a/client/src/app/site/common/components/global-spinner/global-spinner.component.ts b/client/src/app/site/common/components/global-spinner/global-spinner.component.ts index 629a35c9b..99e7ae751 100644 --- a/client/src/app/site/common/components/global-spinner/global-spinner.component.ts +++ b/client/src/app/site/common/components/global-spinner/global-spinner.component.ts @@ -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; + } } diff --git a/client/src/app/site/common/components/overlay/overlay.component.html b/client/src/app/site/common/components/overlay/overlay.component.html new file mode 100644 index 000000000..33f1b84f9 --- /dev/null +++ b/client/src/app/site/common/components/overlay/overlay.component.html @@ -0,0 +1,6 @@ +
+
+ +
+
+
diff --git a/client/src/app/site/common/components/overlay/overlay.component.scss b/client/src/app/site/common/components/overlay/overlay.component.scss new file mode 100644 index 000000000..3d481731a --- /dev/null +++ b/client/src/app/site/common/components/overlay/overlay.component.scss @@ -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; + } +} diff --git a/client/src/app/site/common/components/overlay/overlay.component.spec.ts b/client/src/app/site/common/components/overlay/overlay.component.spec.ts new file mode 100644 index 000000000..ac5d75616 --- /dev/null +++ b/client/src/app/site/common/components/overlay/overlay.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [OverlayComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(OverlayComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/common/components/overlay/overlay.component.ts b/client/src/app/site/common/components/overlay/overlay.component.ts new file mode 100644 index 000000000..8217b96b6 --- /dev/null +++ b/client/src/app/site/common/components/overlay/overlay.component.ts @@ -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(); + + /** + * EventEmitter to handle clicking `escape`. + */ + @Output() + public escape = new EventEmitter(); + + /** + * 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(); + } + } +} diff --git a/client/src/app/site/common/components/search/search.component.html b/client/src/app/site/common/components/search/search.component.html deleted file mode 100644 index e7e1f1e57..000000000 --- a/client/src/app/site/common/components/search/search.component.html +++ /dev/null @@ -1,80 +0,0 @@ - - -

Search results

- - - -
- - -
-
- - {{ 'Search' | translate }} - - search - - -
- - - - -

{{ 'Sort' | translate }}

- - - -
- -
-
- {{ searchResultCount }} - result - results -
-
-
- No search result found for "{{ query }}" -
- - - - - {{ searchResult.models.length }} {{ searchResult.verboseName | translate }} - - - - - - {{ model.getTitle() }} - - - {{ model.getTitle() }} - - - - - - -
-
-
diff --git a/client/src/app/site/common/components/search/search.component.scss b/client/src/app/site/common/components/search/search.component.scss deleted file mode 100644 index fe6949fa9..000000000 --- a/client/src/app/site/common/components/search/search.component.scss +++ /dev/null @@ -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; -} diff --git a/client/src/app/site/common/components/search/search.component.spec.ts b/client/src/app/site/common/components/search/search.component.spec.ts deleted file mode 100644 index 5d8c952fa..000000000 --- a/client/src/app/site/common/components/search/search.component.spec.ts +++ /dev/null @@ -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; - - 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(); - }); -}); diff --git a/client/src/app/site/common/components/search/search.component.ts b/client/src/app/site/common/components/search/search.component.ts deleted file mode 100644 index 381258b29..000000000 --- a/client/src/app/site/common/components/search/search.component.ts +++ /dev/null @@ -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(); - } -} diff --git a/client/src/app/site/common/components/super-search/super-search.component.html b/client/src/app/site/common/components/super-search/super-search.component.html new file mode 100644 index 000000000..2120330e8 --- /dev/null +++ b/client/src/app/site/common/components/super-search/super-search.component.html @@ -0,0 +1,96 @@ +
+
+ + + + + +
+

+ {{ searchResultCount }} {{ (searchResultCount === 1 ? 'result' : 'results') | translate }} +

+ +
+
+ + + + + + {{ result.verboseName | translate }} + {{ result.models.length }} + + + + + +
+ {{ model.getTitle() }} +
+
+ + +
+ +
+
+
+
+
+
+
+ No search result found for "{{ searchString }}" + in {{ searchCollection | translate }} +
+
+ +
+
+ +
+
+
+
diff --git a/client/src/app/site/common/components/super-search/super-search.component.scss b/client/src/app/site/common/components/super-search/super-search.component.scss new file mode 100644 index 000000000..2a2a099af --- /dev/null +++ b/client/src/app/site/common/components/super-search/super-search.component.scss @@ -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; + } + } + } +} diff --git a/client/src/app/site/common/components/super-search/super-search.component.spec.ts b/client/src/app/site/common/components/super-search/super-search.component.spec.ts new file mode 100644 index 000000000..702b6dfa2 --- /dev/null +++ b/client/src/app/site/common/components/super-search/super-search.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + // fixture = TestBed.createComponent(SuperSearchComponent); + // component = fixture.componentInstance; + // fixture.detectChanges(); + }); + + it('should create', () => { + // expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/common/components/super-search/super-search.component.ts b/client/src/app/site/common/components/super-search/super-search.component.ts new file mode 100644 index 000000000..3a2f11adb --- /dev/null +++ b/client/src/app/site/common/components/super-search/super-search.component.ts @@ -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, + @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' && !(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(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; + } + } +} diff --git a/client/src/app/site/common/os-common.module.ts b/client/src/app/site/common/os-common.module.ts index b5605532c..1df31d094 100644 --- a/client/src/app/site/common/os-common.module.ts +++ b/client/src/app/site/common/os-common.module.ts @@ -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 {} diff --git a/client/src/app/site/login/components/login-mask/login-mask.component.ts b/client/src/app/site/login/components/login-mask/login-mask.component.ts index 776820c58..0e37c6ecc 100644 --- a/client/src/app/site/login/components/login-mask/login-mask.component.ts +++ b/client/src/app/site/login/components/login-mask/login-mask.component.ts @@ -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) { diff --git a/client/src/app/site/mediafiles/models/view-mediafile.ts b/client/src/app/site/mediafiles/models/view-mediafile.ts index e2d9dca68..e4c873771 100644 --- a/client/src/app/site/mediafiles/models/view-mediafile.ts +++ b/client/src/app/site/mediafiles/models/view-mediafile.ts @@ -51,11 +51,7 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers } 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 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 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 } 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 { diff --git a/client/src/app/site/motions/models/view-category.ts b/client/src/app/site/motions/models/view-category.ts index 43db386aa..013622e0a 100644 --- a/client/src/app/site/motions/models/view-category.ts +++ b/client/src/app/site/motions/models/view-category.ts @@ -107,7 +107,10 @@ export class ViewCategory extends BaseViewModel 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 { diff --git a/client/src/app/site/motions/models/view-motion-block.ts b/client/src/app/site/motions/models/view-motion-block.ts index 86cab032b..3e5e15571 100644 --- a/client/src/app/site/motions/models/view-motion-block.ts +++ b/client/src/app/site/motions/models/view-motion-block.ts @@ -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()] }; } /** diff --git a/client/src/app/site/motions/models/view-motion.ts b/client/src/app/site/motions/models/view-motion.ts index 456adf8bb..ba6bb8456 100644 --- a/client/src/app/site/motions/models/view-motion.ts +++ b/client/src/app/site/motions/models/view-motion.ts @@ -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 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 { diff --git a/client/src/app/site/motions/models/view-statute-paragraph.ts b/client/src/app/site/motions/models/view-statute-paragraph.ts index 1634f9ae0..42fe87a96 100644 --- a/client/src/app/site/motions/models/view-statute-paragraph.ts +++ b/client/src/app/site/motions/models/view-statute-paragraph.ts @@ -36,7 +36,7 @@ export class ViewStatuteParagraph extends BaseViewModel } public formatForSearch(): SearchRepresentation { - return [this.title]; + return { properties: [{ key: 'Title', value: this.getTitle() }], searchValue: [this.getTitle()] }; } public getDetailStateURL(): string { diff --git a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts index 42b069f0d..549a33ac5 100644 --- a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts +++ b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts @@ -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'; @@ -206,7 +206,7 @@ export class MotionListComponent extends BaseListViewComponent imple public multiselectService: MotionMultiselectService, public perms: LocalPermissionsService, private motionExport: MotionExportService, - private spinnerService: SpinnerService + private overlayService: OverlayService ) { super(titleService, translate, matSnackBar, storage); this.canMultiSelect = true; @@ -372,7 +372,7 @@ export class MotionListComponent extends BaseListViewComponent imple } catch (e) { this.raiseError(e); } finally { - this.spinnerService.setVisibility(false); + this.overlayService.setSpinner(false); } } diff --git a/client/src/app/site/motions/services/motion-multiselect.service.ts b/client/src/app/site/motions/services/motion-multiselect.service.ts index 24e008dec..a246a79e2 100644 --- a/client/src/app/site/motions/services/motion-multiselect.service.ts +++ b/client/src/app/site/motions/services/motion-multiselect.service.ts @@ -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); } } } diff --git a/client/src/app/site/site.component.html b/client/src/app/site/site.component.html index 6a92429ef..0c2e47f68 100644 --- a/client/src/app/site/site.component.html +++ b/client/src/app/site/site.component.html @@ -90,13 +90,7 @@ - + search Search @@ -156,3 +150,5 @@
+ + diff --git a/client/src/app/site/site.component.ts b/client/src/app/site/site.component.ts index ac9adbf41..1633b4e04 100644 --- a/client/src/app/site/site.component.ts +++ b/client/src/app/site/site.component.ts @@ -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(); + } + } } diff --git a/client/src/app/site/tags/models/view-tag.ts b/client/src/app/site/tags/models/view-tag.ts index ce58726e8..a7b6d21a4 100644 --- a/client/src/app/site/tags/models/view-tag.ts +++ b/client/src/app/site/tags/models/view-tag.ts @@ -27,7 +27,7 @@ export class ViewTag extends BaseViewModel implements TagTitleInformation, } public formatForSearch(): SearchRepresentation { - return [this.name]; + return { properties: [{ key: 'Name', value: this.name }], searchValue: [this.name] }; } public getDetailStateURL(): string { diff --git a/client/src/app/site/topics/models/view-topic.ts b/client/src/app/site/topics/models/view-topic.ts index 0361f9e84..2f4eeb37b 100644 --- a/client/src/app/site/topics/models/view-topic.ts +++ b/client/src/app/site/topics/models/view-topic.ts @@ -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 { diff --git a/client/src/app/site/users/models/view-user.ts b/client/src/app/site/users/models/view-user.ts index 77ff6e49e..b39a0b258 100644 --- a/client/src/app/site/users/models/view-user.ts +++ b/client/src/app/site/users/models/view-user.ts @@ -127,7 +127,14 @@ export class ViewUser extends BaseProjectableViewModel 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 { diff --git a/client/src/app/slides/mediafiles/mediafile/mediafile-slide.component.html b/client/src/app/slides/mediafiles/mediafile/mediafile-slide.component.html index 618db8aa4..644c82779 100644 --- a/client/src/app/slides/mediafiles/mediafile/mediafile-slide.component.html +++ b/client/src/app/slides/mediafiles/mediafile/mediafile-slide.component.html @@ -1,5 +1,5 @@
-
+
@@ -11,6 +11,7 @@ [page]="data.element.page || 1" [zoom]="zoom" [src]="url" - style="display: block;"> + style="display: block;" + >
diff --git a/client/src/app/slides/mediafiles/mediafile/mediafile-slide.module.ts b/client/src/app/slides/mediafiles/mediafile/mediafile-slide.module.ts index 023bb26c1..f936094d2 100644 --- a/client/src/app/slides/mediafiles/mediafile/mediafile-slide.module.ts +++ b/client/src/app/slides/mediafiles/mediafile/mediafile-slide.module.ts @@ -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] diff --git a/client/src/styles.scss b/client/src/styles.scss index 23bc5a234..4fddcb300 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -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 */