Merge pull request #3991 from Fadiabb/full-text-search

adding full search component
This commit is contained in:
Jochen Saalfeld 2018-12-13 13:16:01 +01:00 committed by GitHub
commit 2c16d1893e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 823 additions and 104 deletions

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { plugins } from '../../../plugins'; import { plugins } from '../../../plugins';
import { CommonAppConfig } from '../../site/common/common.config'; import { CommonAppConfig } from '../../site/common/common.config';
import { AppConfig } from '../../site/base/app-config'; import { AppConfig, SearchableModelEntry, ModelEntry } from '../../site/base/app-config';
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service'; import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
import { MediafileAppConfig } from '../../site/mediafiles/mediafile.config'; import { MediafileAppConfig } from '../../site/mediafiles/mediafile.config';
import { MotionsAppConfig } from '../../site/motions/motions.config'; import { MotionsAppConfig } from '../../site/motions/motions.config';
@ -12,6 +12,8 @@ import { UsersAppConfig } from '../../site/users/users.config';
import { TagAppConfig } from '../../site/tags/tag.config'; import { TagAppConfig } from '../../site/tags/tag.config';
import { MainMenuService } from './main-menu.service'; import { MainMenuService } from './main-menu.service';
import { HistoryAppConfig } from 'app/site/history/history.config'; import { HistoryAppConfig } from 'app/site/history/history.config';
import { SearchService } from './search.service';
import { isSearchable } from '../../shared/models/base/searchable';
/** /**
* A list of all app configurations of all delivered apps. * A list of all app configurations of all delivered apps.
@ -29,15 +31,23 @@ const appConfigs: AppConfig[] = [
]; ];
/** /**
* Handles all incoming and outgoing notify messages via {@link WebsocketService}. * Handles loading of all apps during the bootup process.
*/ */
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class AppLoadService { export class AppLoadService {
/**
* Constructor.
*
* @param modelMapper
* @param mainMenuService
* @param searchService
*/
public constructor( public constructor(
private modelMapper: CollectionStringModelMapperService, private modelMapper: CollectionStringModelMapperService,
private mainMenuService: MainMenuService private mainMenuService: MainMenuService,
private searchService: SearchService
) {} ) {}
public async loadApps(): Promise<void> { public async loadApps(): Promise<void> {
@ -52,6 +62,9 @@ export class AppLoadService {
if (config.models) { if (config.models) {
config.models.forEach(entry => { config.models.forEach(entry => {
this.modelMapper.registerCollectionElement(entry.collectionString, entry.model); this.modelMapper.registerCollectionElement(entry.collectionString, entry.model);
if (this.isSearchableModelEntry(entry)) {
this.searchService.registerModel(entry.collectionString, entry.model, entry.searchOrder);
}
}); });
} }
if (config.mainMenuEntries) { if (config.mainMenuEntries) {
@ -59,4 +72,17 @@ export class AppLoadService {
} }
}); });
} }
private isSearchableModelEntry(entry: ModelEntry | SearchableModelEntry): entry is SearchableModelEntry {
if ((<SearchableModelEntry>entry).searchOrder !== undefined) {
// We need to double check, because Typescipt cannot check contructors. If typescript could differentiate
// between (ModelConstructor<BaseModel>) and (new (...args: any[]) => (BaseModel & Searchable)), we would not have
// to check if the result of the contructor (the model instance) is really a searchable.
if (!isSearchable(new entry.model())) {
throw Error(`Wrong configuration for ${entry.collectionString}: you gave a searchOrder, but the model is not searchable.`);
}
return true;
}
return false;
}
} }

View File

@ -69,7 +69,7 @@ export class DataStoreService {
/** /**
* Observable subject for changed models in the datastore. * Observable subject for changed models in the datastore.
*/ */
private changedSubject: Subject<BaseModel> = new Subject<BaseModel>(); private readonly changedSubject: Subject<BaseModel> = new Subject<BaseModel>();
/** /**
* Observe the datastore for changes. * Observe the datastore for changes.
@ -83,7 +83,7 @@ export class DataStoreService {
/** /**
* Observable subject for changed models in the datastore. * Observable subject for changed models in the datastore.
*/ */
private deletedSubject: Subject<DeletedInformation> = new Subject<DeletedInformation>(); private readonly deletedSubject: Subject<DeletedInformation> = new Subject<DeletedInformation>();
/** /**
* Observe the datastore for deletions. * Observe the datastore for deletions.
@ -94,6 +94,20 @@ export class DataStoreService {
return this.deletedSubject.asObservable(); return this.deletedSubject.asObservable();
} }
/**
* Observable subject for changed or deleted models in the datastore.
*/
private readonly changedOrDeletedSubject: Subject<BaseModel | DeletedInformation> = new Subject<BaseModel | DeletedInformation>();
/**
* Observe the datastore for changes and deletions.
*
* @return an observable for changed and deleted objects.
*/
public get changedOrDeletedObservable(): Observable<BaseModel | DeletedInformation> {
return this.changedOrDeletedSubject.asObservable();
}
/** /**
* The maximal change id from this DataStore. * The maximal change id from this DataStore.
*/ */
@ -137,7 +151,7 @@ export class DataStoreService {
// update observers // update observers
Object.keys(this.modelStore).forEach(collection => { Object.keys(this.modelStore).forEach(collection => {
Object.keys(this.modelStore[collection]).forEach(id => { Object.keys(this.modelStore[collection]).forEach(id => {
this.changedSubject.next(this.modelStore[collection][id]); this.publishChangedInformation(this.modelStore[collection][id]);
}); });
}); });
} else { } else {
@ -293,7 +307,7 @@ export class DataStoreService {
this.jsonStore[collection] = {}; this.jsonStore[collection] = {};
} }
this.jsonStore[collection][model.id] = JSON.stringify(model); this.jsonStore[collection][model.id] = JSON.stringify(model);
this.changedSubject.next(model); this.publishChangedInformation(model);
}); });
if (changeId) { if (changeId) {
await this.flushToStorage(changeId); await this.flushToStorage(changeId);
@ -317,7 +331,7 @@ export class DataStoreService {
if (this.jsonStore[collectionString]) { if (this.jsonStore[collectionString]) {
delete this.jsonStore[collectionString][id]; delete this.jsonStore[collectionString][id];
} }
this.deletedSubject.next({ this.publishDeletedInformation({
collection: collectionString, collection: collectionString,
id: id id: id
}); });
@ -340,9 +354,9 @@ export class DataStoreService {
// Inform about the deletion // Inform about the deletion
Object.keys(modelStoreReference).forEach(collectionString => { Object.keys(modelStoreReference).forEach(collectionString => {
Object.keys(modelStoreReference[collectionString]).forEach(id => { Object.keys(modelStoreReference[collectionString]).forEach(id => {
this.deletedSubject.next({ this.publishDeletedInformation({
collection: collectionString, collection: collectionString,
id: +id id: +id // needs casting, because Objects.keys gives all keys as strings...
}); });
}) })
}); });
@ -351,6 +365,26 @@ export class DataStoreService {
} }
} }
/**
* Informs the changed and changedOrDeleted subject about a change.
*
* @param model The model to publish
*/
private publishChangedInformation(model: BaseModel): void {
this.changedSubject.next(model);
this.changedOrDeletedSubject.next(model);
}
/**
* Informs the deleted and changedOrDeleted subject about a deletion.
*
* @param information The information about the deleted model
*/
private publishDeletedInformation(information: DeletedInformation): void {
this.deletedSubject.next(information);
this.changedOrDeletedSubject.next(information);
}
/** /**
* Updates the cache by inserting the serialized DataStore. Also changes the chageId, if it's larger * Updates the cache by inserting the serialized DataStore. Also changes the chageId, if it's larger
* @param changeId The changeId from the update. If it's the highest change id seen, it will be set into the cache. * @param changeId The changeId from the update. If it's the highest change id seen, it will be set into the cache.

View File

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

View File

@ -0,0 +1,131 @@
import { Injectable } from '@angular/core';
import { BaseModel } from '../../shared/models/base/base-model';
import { DataStoreService } from './data-store.service';
import { Searchable } from '../../shared/models/base/searchable';
/**
* The representation every searchable model should use to represent their data.
*/
export type SearchRepresentation = string[];
/**
* Our representation of a searchable model for external use.
*/
export interface SearchModel {
/**
* The collection string.
*/
collectionString: string;
/**
* The singular verbose name of the model.
*/
verboseNameSingular: string;
/**
* The plural verbose name of the model.
*/
verboseNamePlural: string;
}
/**
* A search result has the model's collectionstring, a verbose name and the actual models.
*/
export interface SearchResult {
/**
* The collection string.
*/
collectionString: string;
/**
* This verbodeName 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;
/**
* All matched models.
*/
models: (BaseModel & Searchable)[];
}
/**
* This service cares about searching the DataStore and managing models, that are searchable.
*/
@Injectable({
providedIn: 'root'
})
export class SearchService {
/**
* All searchable models in our own representation.
*/
private searchModels: {
collectionString: string;
ctor: new (...args: any[]) => Searchable & BaseModel;
verboseNameSingular: string;
verboseNamePlural: string;
displayOrder: number;
}[] = [];
/**
* @param DS The DataStore to search in.
*/
public constructor(private DS: DataStoreService) {}
/**
* Registers a model by the given attributes.
*
* @param collectionString The colelction string of the model
* @param ctor The model constructor
* @param displayOrder The order in which the elements should be displayed.
*/
public registerModel(
collectionString: string,
ctor: new (...args: any[]) => Searchable & BaseModel,
displayOrder: number
): void {
const instance = new ctor();
this.searchModels.push({
collectionString: collectionString,
ctor: ctor,
verboseNameSingular: instance.getVerboseName(),
verboseNamePlural: instance.getVerboseName(true),
displayOrder: displayOrder
});
this.searchModels.sort((a, b) => a.displayOrder - b.displayOrder);
}
/**
* @returns all registered models for the UI.
*/
public getRegisteredModels(): SearchModel[] {
return this.searchModels.map(searchModel => ({
collectionString: searchModel.collectionString,
verboseNameSingular: searchModel.verboseNameSingular,
verboseNamePlural: searchModel.verboseNamePlural
}));
}
/**
* Does the actual searching.
*
* @param query The search query
* @param inCollectionStrings All connection strings which should be used for searching.
* @returns All search results.
*/
public search(query: string, inCollectionStrings: string[]): SearchResult[] {
query = query.toLowerCase();
return this.searchModels
.filter(s => inCollectionStrings.includes(s.collectionString))
.map(searchModel => {
const results = this.DS.filter(searchModel.ctor, model =>
model.formatForSearch().some(text => text.toLowerCase().includes(query))
);
return {
collectionString: searchModel.collectionString,
verboseName: results.length === 1 ? searchModel.verboseNameSingular : searchModel.verboseNamePlural,
models: results
};
});
}
}

View File

@ -41,7 +41,7 @@ export class Item extends ProjectableBaseModel {
public parent_id: number; public parent_id: number;
public constructor(input?: any) { public constructor(input?: any) {
super('agenda/item', input); super('agenda/item', 'Item', input);
} }
public deserialize(input: any): void { public deserialize(input: any): void {

View File

@ -1,6 +1,7 @@
import { AssignmentUser } from './assignment-user'; import { AssignmentUser } from './assignment-user';
import { Poll } from './poll'; import { Poll } from './poll';
import { AgendaBaseModel } from '../base/agenda-base-model'; import { AgendaBaseModel } from '../base/agenda-base-model';
import { SearchRepresentation } from '../../../core/services/search.service';
/** /**
* Representation of an assignment. * Representation of an assignment.
@ -19,7 +20,7 @@ export class Assignment extends AgendaBaseModel {
public tags_id: number[]; public tags_id: number[];
public constructor(input?: any) { public constructor(input?: any) {
super('assignments/assignment', 'Assignment', input); super('assignments/assignment', 'Election', input);
} }
public get candidateIds(): number[] { public get candidateIds(): number[] {
@ -52,6 +53,10 @@ export class Assignment extends AgendaBaseModel {
return this.title; return this.title;
} }
public formatForSearch(): SearchRepresentation {
return [this.title, this.description];
}
public getDetailStateURL(): string { public getDetailStateURL(): string {
return 'TODO'; return 'TODO';
} }

View File

@ -1,34 +1,43 @@
import { AgendaInformation } from './agenda-information'; import { AgendaInformation } from './agenda-information';
import { ProjectableBaseModel } from './projectable-base-model'; import { ProjectableBaseModel } from './projectable-base-model';
import { Searchable } from './searchable';
import { SearchRepresentation } from '../../../core/services/search.service';
/** /**
* A base model for models, that can be content objects in the agenda. Provides title and navigation * A base model for models, that can be content objects in the agenda. Provides title and navigation
* information for the agenda. * information for the agenda.
*/ */
export abstract class AgendaBaseModel extends ProjectableBaseModel implements AgendaInformation { export abstract class AgendaBaseModel extends ProjectableBaseModel implements AgendaInformation, Searchable {
protected verboseName: string;
/** /**
* A Model that inherits from this class should provide a verbose name. It's used by creating * A model that can be a content object of an agenda item.
* the agenda title with type.
* @param collectionString * @param collectionString
* @param verboseName * @param verboseName
* @param input * @param input
*/ */
protected constructor(collectionString: string, verboseName: string, input?: any) { protected constructor(collectionString: string, verboseName: string, input?: any) {
super(collectionString, input); super(collectionString, verboseName, input);
this.verboseName = verboseName;
} }
/**
* @returns the agenda title
*/
public getAgendaTitle(): string { public getAgendaTitle(): string {
return this.getTitle(); return this.getTitle();
} }
/**
* @return the agenda title with the verbose name of the content object
*/
public getAgendaTitleWithType(): string { public getAgendaTitleWithType(): string {
// Return the agenda title with the model's verbose name appended // Return the agenda title with the model's verbose name appended
return this.getAgendaTitle() + ' (' + this.verboseName + ')'; return this.getAgendaTitle() + ' (' + this.getVerboseName() + ')';
} }
/**
* Should return a string representation of the object, so there can be searched for.
*/
public abstract formatForSearch(): SearchRepresentation;
/** /**
* Should return the URL to the detail view. Used for the agenda, that the * Should return the URL to the detail view. Used for the agenda, that the
* user can navigate to the content object. * user can navigate to the content object.

View File

@ -1,7 +1,9 @@
import { DetailNavigable } from "./detail-navigable";
/** /**
* An Interface for all extra information needed for content objects of items. * An Interface for all extra information needed for content objects of items.
*/ */
export interface AgendaInformation { export interface AgendaInformation extends DetailNavigable {
/** /**
* Should return the title for the agenda list view. * Should return the title for the agenda list view.
*/ */
@ -11,9 +13,4 @@ export interface AgendaInformation {
* Should return the title for the list of speakers view. * Should return the title for the list of speakers view.
*/ */
getAgendaTitleWithType(): string; getAgendaTitleWithType(): string;
/**
* Get the url for the detail view, so in the agenda the user can navigate to it.
*/
getDetailStateURL(): string;
} }

View File

@ -20,6 +20,20 @@ export abstract class BaseModel<T = object> extends OpenSlidesComponent
*/ */
protected _collectionString: string; protected _collectionString: string;
/**
* returns the collectionString.
*
* The server and the dataStore use it to identify the collection.
*/
public get collectionString(): string {
return this._collectionString;
}
/**
* Children should also have a verbose name for generic display purposes
*/
protected _verboseName: string;
/** /**
* force children of BaseModel to have an id * force children of BaseModel to have an id
*/ */
@ -27,10 +41,15 @@ export abstract class BaseModel<T = object> extends OpenSlidesComponent
/** /**
* constructor that calls super from parent class * constructor that calls super from parent class
*
* @param collectionString
* @param verboseName
* @param input
*/ */
protected constructor(collectionString: string, input?: any) { protected constructor(collectionString: string, verboseName: string, input?: any) {
super(); super();
this._collectionString = collectionString; this._collectionString = collectionString;
this._verboseName = verboseName;
if (input) { if (input) {
this.changeNullValuesToUndef(input); this.changeNullValuesToUndef(input);
@ -68,12 +87,19 @@ export abstract class BaseModel<T = object> extends OpenSlidesComponent
} }
/** /**
* returns the collectionString. * Returns the verbose name. Makes it plural by adding a 's'.
* *
* The server and the dataStore use it to identify the collection. * @param plural If the name should be plural
* @returns the verbose name of the model
*/ */
public get collectionString(): string { public getVerboseName(plural: boolean = false): string {
return this._collectionString; if (plural) {
return this._verboseName + 's'; // I love english. This works for all our models (participantS, electionS,
// topicS, motionS, (media)fileS, motion blockS, commentS, personal noteS, projectorS, messageS, countdownS, ...)
// Just categorIES need to overwrite this...
} else {
return this._verboseName
}
} }
/** /**

View File

@ -0,0 +1,9 @@
/**
* One can navigate to the detail page of every object implementing this interface.
*/
export interface DetailNavigable {
/**
* Get the url for the detail view, so the user can navigate to it.
*/
getDetailStateURL(): string;
}

View File

@ -2,8 +2,15 @@ import { BaseModel } from './base-model';
import { Projectable } from './projectable'; import { Projectable } from './projectable';
export abstract class ProjectableBaseModel extends BaseModel<ProjectableBaseModel> implements Projectable { export abstract class ProjectableBaseModel extends BaseModel<ProjectableBaseModel> implements Projectable {
protected constructor(collectionString: string, input?: any) { /**
super(collectionString, input); * A model which can be projected. This class give basic implementation for the projector.
*
* @param collectionString
* @param verboseName
* @param input
*/
protected constructor(collectionString: string, verboseName: string, input?: any) {
super(collectionString, verboseName, input);
} }
/** /**
@ -11,6 +18,9 @@ export abstract class ProjectableBaseModel extends BaseModel<ProjectableBaseMode
*/ */
public project(): void {} public project(): void {}
/**
* @returns the projector title.
*/
public getProjectorTitle(): string { public getProjectorTitle(): string {
return this.getTitle(); return this.getTitle();
} }

View File

@ -0,0 +1,21 @@
import { DetailNavigable } from './detail-navigable';
import { SearchRepresentation } from '../../../core/services/search.service';
/**
* Asserts, if the given object is searchable.
*
* @param object The object to check
*/
export function isSearchable(object: any): object is Searchable {
return (<Searchable>object).formatForSearch !== undefined;
}
/**
* One can search for every object implementing this interface.
*/
export interface Searchable extends DetailNavigable {
/**
* Should return strings that represents the object.
*/
formatForSearch: () => SearchRepresentation;
}

View File

@ -11,7 +11,7 @@ export class ChatMessage extends BaseModel<ChatMessage> {
public user_id: number; public user_id: number;
public constructor(input?: any) { public constructor(input?: any) {
super('core/chat-message', input); super('core/chat-message', 'Chatmessage', input);
} }
public getTitle(): string { public getTitle(): string {

View File

@ -10,7 +10,7 @@ export class Config extends BaseModel {
public value: Object; public value: Object;
public constructor(input?: any) { public constructor(input?: any) {
super('core/config', input); super('core/config', 'Config', input);
} }
public getTitle(): string { public getTitle(): string {

View File

@ -12,7 +12,7 @@ export class Countdown extends ProjectableBaseModel {
public running: boolean; public running: boolean;
public constructor(input?: any) { public constructor(input?: any) {
super('core/countdown'); super('core/countdown', 'Countdown', input);
} }
public getTitle(): string { public getTitle(): string {

View File

@ -30,7 +30,7 @@ export class History extends BaseModel {
} }
public constructor(input?: any) { public constructor(input?: any) {
super('core/history', input); super('core/history', 'History', input);
} }
public getTitle(): string { public getTitle(): string {

View File

@ -9,7 +9,7 @@ export class ProjectorMessage extends ProjectableBaseModel {
public message: string; public message: string;
public constructor(input?: any) { public constructor(input?: any) {
super('core/projector-message', input); super('core/projector-message', 'Message', input);
} }
public getTitle(): string { public getTitle(): string {

View File

@ -16,7 +16,7 @@ export class Projector extends BaseModel<Projector> {
public projectiondefaults: Object[]; public projectiondefaults: Object[];
public constructor(input?: any) { public constructor(input?: any) {
super('core/projector', input); super('core/projector', 'Projector', input);
} }
public getTitle(): string { public getTitle(): string {

View File

@ -1,18 +1,27 @@
import { BaseModel } from '../base/base-model'; import { BaseModel } from '../base/base-model';
import { Searchable } from '../base/searchable';
/** /**
* Representation of a tag. * Representation of a tag.
* @ignore * @ignore
*/ */
export class Tag extends BaseModel<Tag> { export class Tag extends BaseModel<Tag> implements Searchable {
public id: number; public id: number;
public name: string; public name: string;
public constructor(input?: any) { public constructor(input?: any) {
super('core/tag', input); super('core/tag', 'Tag', input);
} }
public getTitle(): string { public getTitle(): string {
return this.name; return this.name;
} }
public formatForSearch(): string[] {
return [this.name];
}
public getDetailStateURL(): string {
return '/tags';
}
} }

View File

@ -1,11 +1,12 @@
import { File } from './file'; import { File } from './file';
import { ProjectableBaseModel } from '../base/projectable-base-model'; import { ProjectableBaseModel } from '../base/projectable-base-model';
import { Searchable } from '../base/searchable';
/** /**
* Representation of MediaFile. Has the nested property "File" * Representation of MediaFile. Has the nested property "File"
* @ignore * @ignore
*/ */
export class Mediafile extends ProjectableBaseModel { export class Mediafile extends ProjectableBaseModel implements Searchable {
public id: number; public id: number;
public title: string; public title: string;
public mediafile: File; public mediafile: File;
@ -16,7 +17,7 @@ export class Mediafile extends ProjectableBaseModel {
public timestamp: string; public timestamp: string;
public constructor(input?: any) { public constructor(input?: any) {
super('mediafiles/mediafile', input); super('mediafiles/mediafile', 'Mediafile', input);
} }
public deserialize(input: any): void { public deserialize(input: any): void {
@ -36,4 +37,12 @@ export class Mediafile extends ProjectableBaseModel {
public getTitle(): string { public getTitle(): string {
return this.title; return this.title;
} }
public formatForSearch(): string[] {
return [this.title, this.mediafile.name];
}
public getDetailStateURL(): string {
return this.getDownloadUrl();
}
} }

View File

@ -1,19 +1,52 @@
import { BaseModel } from '../base/base-model'; import { BaseModel } from '../base/base-model';
import { Searchable } from '../base/searchable';
import { SearchRepresentation } from '../../../core/services/search.service';
/** /**
* Representation of a motion category. Has the nested property "File" * Representation of a motion category. Has the nested property "File"
* @ignore * @ignore
*/ */
export class Category extends BaseModel<Category> { export class Category extends BaseModel<Category> implements Searchable {
public id: number; public id: number;
public name: string; public name: string;
public prefix: string; public prefix: string;
public constructor(input?: any) { public constructor(input?: any) {
super('motions/category', input); super('motions/category', 'Category', input);
} }
public getTitle(): string { public getTitle(): string {
return this.prefix + ' - ' + this.name; return this.prefix + ' - ' + this.name;
} }
/**
* Returns the verbose name of this model.
*
* @override
* @param plural If the name should be plural
* @param The verbose name
*/
public getVerboseName(plural: boolean = false): string {
if (plural) {
return 'Categories';
} else {
return this._verboseName;
}
}
/**
* Formats the category for search
*
* @override
*/
public formatForSearch(): SearchRepresentation {
return [this.getTitle()];
}
/**
* TODO: add an id as url parameter, so the category auto-opens.
*/
public getDetailStateURL(): string {
return '/motions/category';
}
} }

View File

@ -1,4 +1,5 @@
import { AgendaBaseModel } from '../base/agenda-base-model'; import { AgendaBaseModel } from '../base/agenda-base-model';
import { SearchRepresentation } from '../../../core/services/search.service';
/** /**
* Representation of a motion block. * Representation of a motion block.
@ -17,6 +18,15 @@ export class MotionBlock extends AgendaBaseModel {
return this.title; return this.title;
} }
/**
* Formats the category for search
*
* @override
*/
public formatForSearch(): SearchRepresentation {
return [this.title];
}
/** /**
* Get the URL to the motion block * Get the URL to the motion block
* *

View File

@ -17,7 +17,7 @@ export class MotionChangeReco extends BaseModel<MotionChangeReco> {
public creation_time: string; public creation_time: string;
public constructor(input?: any) { public constructor(input?: any) {
super('motions/motion-change-recommendation', input); super('motions/motion-change-recommendation', 'Change recommendation', input);
} }
public getTitle(): string { public getTitle(): string {

View File

@ -11,7 +11,7 @@ export class MotionCommentSection extends BaseModel<MotionCommentSection> {
public write_groups_id: number[]; public write_groups_id: number[];
public constructor(input?: any) { public constructor(input?: any) {
super('motions/motion-comment-section', input); super('motions/motion-comment-section', 'Comment section', input);
} }
public getTitle(): string { public getTitle(): string {

View File

@ -2,6 +2,7 @@ import { MotionSubmitter } from './motion-submitter';
import { MotionLog } from './motion-log'; import { MotionLog } from './motion-log';
import { MotionComment } from './motion-comment'; import { MotionComment } from './motion-comment';
import { AgendaBaseModel } from '../base/agenda-base-model'; import { AgendaBaseModel } from '../base/agenda-base-model';
import { SearchRepresentation } from '../../../core/services/search.service';
/** /**
* Representation of Motion. * Representation of Motion.
@ -75,10 +76,23 @@ export class Motion extends AgendaBaseModel {
if (this.identifier) { if (this.identifier) {
return 'Motion ' + this.identifier; return 'Motion ' + this.identifier;
} else { } else {
return this.getTitle() + ' (' + this.verboseName + ')'; return this.getTitle() + ' (' + this.getVerboseName() + ')';
} }
} }
/**
* Formats the category for search
*
* @override
*/
public formatForSearch(): SearchRepresentation {
let searchValues = [this.title, this.text, this.reason]
if (this.amendment_paragraphs) {
searchValues = searchValues.concat(this.amendment_paragraphs.filter(x => !!x));
}
return searchValues;
}
public getDetailStateURL(): string { public getDetailStateURL(): string {
return `/motions/${this.id}`; return `/motions/${this.id}`;
} }

View File

@ -1,20 +1,38 @@
import { BaseModel } from '../base/base-model'; import { BaseModel } from '../base/base-model';
import { Searchable } from '../base/searchable';
import { SearchRepresentation } from '../../../core/services/search.service';
/** /**
* Representation of a statute paragraph. * Representation of a statute paragraph.
* @ignore * @ignore
*/ */
export class StatuteParagraph extends BaseModel<StatuteParagraph> { export class StatuteParagraph extends BaseModel<StatuteParagraph> implements Searchable {
public id: number; public id: number;
public title: string; public title: string;
public text: string; public text: string;
public weight: number; public weight: number;
public constructor(input?: any) { public constructor(input?: any) {
super('motions/statute-paragraph', input); super('motions/statute-paragraph', 'Statute paragraph', input);
} }
public getTitle(): string { public getTitle(): string {
return this.title; return this.title;
} }
/**
* Formats the category for search
*
* @override
*/
public formatForSearch(): SearchRepresentation {
return [this.title, this.text];
}
/**
* TODO: add an id as url parameter, so the statute paragraph auto-opens.
*/
public getDetailStateURL(): string {
return '/motions/statute-paragraphs';
}
} }

View File

@ -12,7 +12,7 @@ export class Workflow extends BaseModel<Workflow> {
public first_state: number; public first_state: number;
public constructor(input?: any) { public constructor(input?: any) {
super('motions/workflow', input); super('motions/workflow', 'Workflow', input);
} }
/** /**

View File

@ -1,4 +1,5 @@
import { AgendaBaseModel } from '../base/agenda-base-model'; import { AgendaBaseModel } from '../base/agenda-base-model';
import { SearchRepresentation } from '../../../core/services/search.service';
/** /**
* Representation of a topic. * Representation of a topic.
@ -24,6 +25,15 @@ export class Topic extends AgendaBaseModel {
return this.getAgendaTitle(); return this.getAgendaTitle();
} }
/**
* Formats the category for search
*
* @override
*/
public formatForSearch(): SearchRepresentation {
return [this.title, this.text];
}
public getDetailStateURL(): string { public getDetailStateURL(): string {
return `/agenda/topics/${this.id}`; return `/agenda/topics/${this.id}`;
} }

View File

@ -10,7 +10,7 @@ export class Group extends BaseModel<Group> {
public permissions: string[]; public permissions: string[];
public constructor(input?: any) { public constructor(input?: any) {
super('users/group', input); super('users/group', 'Group', input);
if (!input) { if (!input) {
// permissions are required for new groups // permissions are required for new groups
this.permissions = []; this.permissions = [];

View File

@ -54,7 +54,7 @@ export class PersonalNote extends BaseModel<PersonalNote> implements PersonalNot
public notes: PersonalNotesFormat; public notes: PersonalNotesFormat;
public constructor(input: any) { public constructor(input: any) {
super('users/personal-note', input); super('users/personal-note', 'Personal note', input);
} }
public getTitle(): string { public getTitle(): string {

View File

@ -1,10 +1,12 @@
import { ProjectableBaseModel } from '../base/projectable-base-model'; import { ProjectableBaseModel } from '../base/projectable-base-model';
import { Searchable } from '../base/searchable';
import { SearchRepresentation } from '../../../core/services/search.service';
/** /**
* Representation of a user in contrast to the operator. * Representation of a user in contrast to the operator.
* @ignore * @ignore
*/ */
export class User extends ProjectableBaseModel { export class User extends ProjectableBaseModel implements Searchable {
public id: number; public id: number;
public username: string; public username: string;
public title: string; public title: string;
@ -23,7 +25,7 @@ export class User extends ProjectableBaseModel {
public default_password: string; public default_password: string;
public constructor(input?: any) { public constructor(input?: any) {
super('users/user', input); super('users/user', 'Participant', input);
} }
public get full_name(): string { public get full_name(): string {
@ -88,4 +90,17 @@ export class User extends ProjectableBaseModel {
public getListViewTitle(): string { public getListViewTitle(): string {
return this.short_name; return this.short_name;
} }
public getDetailStateURL(): string {
return `/users/${this.id}`;
}
/**
* Formats the category for search
*
* @override
*/
public formatForSearch(): SearchRepresentation {
return [this.title, this.first_name, this.last_name, this.structure_level, this.number];
}
} }

View File

@ -4,7 +4,7 @@ import { Topic } from '../../shared/models/topics/topic';
export const AgendaAppConfig: AppConfig = { export const AgendaAppConfig: AppConfig = {
name: 'agenda', name: 'agenda',
models: [{ collectionString: 'agenda/item', model: Item }, { collectionString: 'topics/topic', model: Topic }], models: [{ collectionString: 'agenda/item', model: Item }, { collectionString: 'topics/topic', model: Topic, searchOrder: 1 }],
mainMenuEntries: [ mainMenuEntries: [
{ {
route: '/agenda', route: '/agenda',

View File

@ -3,7 +3,7 @@ import { Assignment } from '../../shared/models/assignments/assignment';
export const AssignmentsAppConfig: AppConfig = { export const AssignmentsAppConfig: AppConfig = {
name: 'assignments', name: 'assignments',
models: [{ collectionString: 'assignments/assignment', model: Assignment }], models: [{ collectionString: 'assignments/assignment', model: Assignment, searchOrder: 3 }],
mainMenuEntries: [ mainMenuEntries: [
{ {
route: '/assignments', route: '/assignments',

View File

@ -1,5 +1,17 @@
import { ModelConstructor, BaseModel } from '../../shared/models/base/base-model'; import { ModelConstructor, BaseModel } from '../../shared/models/base/base-model';
import { MainMenuEntry } from '../../core/services/main-menu.service'; import { MainMenuEntry } from '../../core/services/main-menu.service';
import { Searchable } from '../../shared/models/base/searchable';
export interface ModelEntry {
collectionString: string;
model: ModelConstructor<BaseModel>;
}
export interface SearchableModelEntry {
collectionString: string;
model: new (...args: any[]) => (BaseModel & Searchable);
searchOrder: number;
}
/** /**
* The configuration of an app. * The configuration of an app.
@ -13,10 +25,7 @@ export interface AppConfig {
/** /**
* All models, that should be registered. * All models, that should be registered.
*/ */
models?: { models?: (ModelEntry | SearchableModelEntry)[];
collectionString: string;
model: ModelConstructor<BaseModel>;
}[];
/** /**
* Main menu entries. * Main menu entries.

View File

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

View File

@ -6,9 +6,10 @@ import { SharedModule } from '../../shared/shared.module';
import { PrivacyPolicyComponent } from './components/privacy-policy/privacy-policy.component'; import { PrivacyPolicyComponent } from './components/privacy-policy/privacy-policy.component';
import { StartComponent } from './components/start/start.component'; import { StartComponent } from './components/start/start.component';
import { LegalNoticeComponent } from './components/legal-notice/legal-notice.component'; import { LegalNoticeComponent } from './components/legal-notice/legal-notice.component';
import { SearchComponent } from './components/search/search.component';
@NgModule({ @NgModule({
imports: [AngularCommonModule, CommonRoutingModule, SharedModule], imports: [AngularCommonModule, CommonRoutingModule, SharedModule],
declarations: [PrivacyPolicyComponent, StartComponent, LegalNoticeComponent] declarations: [PrivacyPolicyComponent, StartComponent, LegalNoticeComponent, SearchComponent]
}) })
export class CommonModule {} export class CommonModule {}

View File

@ -0,0 +1,58 @@
<os-head-bar [nav]="false" [goBack]="true">
<!-- Title -->
<div class="title-slot"><h2 translate>Search results</h2></div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="menu"><mat-icon>more_vert</mat-icon></button>
</div>
</os-head-bar>
<!-- search-field -->
<div class="search-field">
<form [formGroup]="quickSearchform">
<mat-form-field>
<input matInput formControlName="query" (keyup)="quickSearch()" />
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
</form>
</div>
<mat-menu #menu="matMenu">
<button mat-menu-item *ngFor="let registeredModel of registeredModels" (click)="toggleModel(registeredModel)">
<mat-checkbox [checked]="registeredModel.enabled" (click)="$event.preventDefault()">
<span translate>{{ registeredModel.verboseNamePlural }}</span>
</mat-checkbox>
</button>
</mat-menu>
<div class="noSearchResults" *ngIf="searchResultCount === 0">
<span translate>No search result found for</span> "{{ query }}"
</div>
<ng-container *ngIf="searchResultCount > 0">
<h3>
<span translate>Found</span> {{ searchResultCount }}
<span *ngIf="searchResultCount === 1" translate>result</span>
<span *ngIf="searchResultCount > 1" translate>results</span>
</h3>
<ng-container *ngFor="let searchResult of searchResults">
<mat-card *ngIf="searchResult.models.length > 0">
<mat-card-title>
{{ searchResult.models.length }} {{ searchResult.verboseName | translate }}
</mat-card-title>
<mat-card-content>
<mat-list>
<mat-list-item *ngFor="let model of searchResult.models">
<a
[routerLink]="model.getDetailStateURL()"
target="{{ searchResult.collectionString === 'mediafiles/mediafile' ? '_blank' : '' }}"
>
{{ model.getTitle() }}
</a>
</mat-list-item>
</mat-list>
</mat-card-content>
</mat-card>
</ng-container>
</ng-container>

View File

@ -0,0 +1,20 @@
.search-field {
width: 100%;
form {
width: 80%;
margin: 8px auto;
}
mat-form-field {
width: 100%;
}
}
.noSearchResults {
text-align: center;
}
mat-card {
margin-bottom: 10px;
}

View File

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

View File

@ -0,0 +1,143 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormControl } from '@angular/forms';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material';
import { Subject } from 'rxjs';
import { auditTime, debounceTime } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { DataStoreService } from 'app/core/services/data-store.service';
import { SearchService, SearchModel, SearchResult } from 'app/core/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 {
/**
* the search term
*/
public query: string;
/**
* Holds the typed search query.
*/
public quickSearchform: FormGroup;
/**
* 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)[];
/**
* This subject is used for the quicksearch input. It is used to debounce the input.
*/
private quickSearchSubject = new Subject<string>();
/**
* 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 activatedRoute determine the search term from the URL
* @param router To change the query in the url
* @param searchService For searching in the models
*/
public constructor(
title: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private DS: DataStoreService,
private activatedRoute: ActivatedRoute,
private router: Router,
private searchService: SearchService
) {
super(title, translate, matSnackBar);
this.quickSearchform = new FormGroup({ query: new FormControl([]) });
this.registeredModels = this.searchService.getRegisteredModels().map(rm => ({...rm, enabled: true}));
this.DS.changedOrDeletedObservable.pipe(auditTime(1)).subscribe(() => this.search());
this.quickSearchSubject.pipe(debounceTime(250)).subscribe(query => this.search(query));
}
/**
* Take the search query from the URL and does the initial search.
*/
public ngOnInit(): void {
super.setTitle('Search');
this.query = this.activatedRoute.snapshot.queryParams.query;
this.quickSearchform.get('query').setValue(this.query);
this.search();
}
/**
* Searches for the query in `this.query` or the query given.
*
* @param query optional, if given, `this.query` will be set to this value
*/
public search(query?: string): void {
if (query) {
this.query = query;
}
if (!this.query) {
return;
}
// 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);
// 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);
// Update the URL.
this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams: { query: this.query },
replaceUrl: true
});
}
/**
* Handler for the quick search input. Emits the typed value to the `quickSearchSubject`.
*/
public quickSearch(): void {
this.quickSearchSubject.next(this.quickSearchform.get('query').value);
}
/**
* Toggles a model, if it should be used during the search. Initiates a new search afterwards.
*
* @param registeredModel The model to toggle
*/
public toggleModel(registeredModel: SearchModelEnabled): void {
registeredModel.enabled = !registeredModel.enabled;
this.search();
}
}

View File

@ -3,7 +3,7 @@ import { Mediafile } from '../../shared/models/mediafiles/mediafile';
export const MediafileAppConfig: AppConfig = { export const MediafileAppConfig: AppConfig = {
name: 'mediafiles', name: 'mediafiles',
models: [{ collectionString: 'mediafiles/mediafile', model: Mediafile }], models: [{ collectionString: 'mediafiles/mediafile', model: Mediafile, searchOrder: 5 }],
mainMenuEntries: [ mainMenuEntries: [
{ {
route: '/mediafiles', route: '/mediafiles',

View File

@ -10,13 +10,13 @@ import { StatuteParagraph } from '../../shared/models/motions/statute-paragraph'
export const MotionsAppConfig: AppConfig = { export const MotionsAppConfig: AppConfig = {
name: 'motions', name: 'motions',
models: [ models: [
{ collectionString: 'motions/motion', model: Motion }, { collectionString: 'motions/motion', model: Motion, searchOrder: 2 },
{ collectionString: 'motions/category', model: Category }, { collectionString: 'motions/category', model: Category, searchOrder: 6 },
{ collectionString: 'motions/workflow', model: Workflow }, { collectionString: 'motions/workflow', model: Workflow },
{ collectionString: 'motions/motion-comment-section', model: MotionCommentSection }, { collectionString: 'motions/motion-comment-section', model: MotionCommentSection },
{ collectionString: 'motions/motion-change-recommendation', model: MotionChangeReco }, { collectionString: 'motions/motion-change-recommendation', model: MotionChangeReco },
{ collectionString: 'motions/motion-block', model: MotionBlock }, { collectionString: 'motions/motion-block', model: MotionBlock, searchOrder: 7 },
{ collectionString: 'motions/statute-paragraph', model: StatuteParagraph } { collectionString: 'motions/statute-paragraph', model: StatuteParagraph, searchOrder: 9 }
], ],
mainMenuEntries: [ mainMenuEntries: [
{ {

View File

@ -51,6 +51,7 @@ const routes: Routes = [
path: 'history', path: 'history',
loadChildren: './history/history.module#HistoryModule' loadChildren: './history/history.module#HistoryModule'
} }
], ],
canActivateChild: [AuthGuard] canActivateChild: [AuthGuard]
} }

View File

@ -2,64 +2,88 @@
<span translate>You are using the history mode of OpenSlides. Changes will not be saved.</span> <span translate>You are using the history mode of OpenSlides. Changes will not be saved.</span>
<a (click)="timeTravel.resumeTime()" translate>Exit</a> <a (click)="timeTravel.resumeTime()" translate>Exit</a>
</div> </div>
<mat-sidenav-container #siteContainer class='main-container' (backdropClick)="toggleSideNav()"> <mat-sidenav-container #siteContainer class="main-container" (backdropClick)="toggleSideNav()">
<mat-sidenav #sideNav [mode]="vp.isMobile ? 'push' : 'side'" [opened]='!vp.isMobile' disableClose='!vp.isMobile' <mat-sidenav
class="side-panel"> #sideNav
<mat-toolbar class='nav-toolbar'> [mode]="vp.isMobile ? 'push' : 'side'"
[opened]="!vp.isMobile"
disableClose="!vp.isMobile"
class="side-panel"
>
<mat-toolbar class="nav-toolbar">
<!-- logo --> <!-- logo -->
<mat-toolbar-row class='os-logo-container' routerLink='/' (click)="toggleSideNav()"></mat-toolbar-row> <mat-toolbar-row class="os-logo-container" routerLink="/" (click)="toggleSideNav()"></mat-toolbar-row>
</mat-toolbar> </mat-toolbar>
<!-- User Menu --> <!-- User Menu -->
<mat-expansion-panel class='user-menu mat-elevation-z0'> <mat-expansion-panel class="user-menu mat-elevation-z0">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<!-- Get the username from operator --> <!-- Get the username from operator -->
{{username}} {{ username }}
</mat-expansion-panel-header> </mat-expansion-panel-header>
<mat-nav-list> <mat-nav-list>
<a mat-list-item [matMenuTriggerFor]="languageMenu"> <a mat-list-item [matMenuTriggerFor]="languageMenu">
<mat-icon>language</mat-icon> <mat-icon>language</mat-icon>
<span> {{getLangName(this.translate.currentLang)}} </span> <span> {{ getLangName(this.translate.currentLang) }} </span>
</a> </a>
<a *ngIf="isLoggedIn" (click)='editProfile()' mat-list-item> <a *ngIf="isLoggedIn" (click)="editProfile()" mat-list-item>
<mat-icon>person</mat-icon> <mat-icon>person</mat-icon>
<span translate>Edit profile</span> <span translate>Edit profile</span>
</a> </a>
<a *ngIf="isLoggedIn" (click)='changePassword()' mat-list-item> <a *ngIf="isLoggedIn" (click)="changePassword()" mat-list-item>
<mat-icon>vpn_key</mat-icon> <mat-icon>vpn_key</mat-icon>
<span translate>Change password</span> <span translate>Change password</span>
</a> </a>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<a *ngIf="isLoggedIn" (click)='logout()' mat-list-item> <a *ngIf="isLoggedIn" (click)="logout()" mat-list-item>
<mat-icon>exit_to_app</mat-icon> <mat-icon>exit_to_app</mat-icon>
<span translate>Logout</span> <span translate>Logout</span>
</a> </a>
<a *ngIf="!isLoggedIn" routerLink='/login' mat-list-item> <a *ngIf="!isLoggedIn" routerLink="/login" mat-list-item>
<mat-icon>exit_to_app</mat-icon> <mat-icon>exit_to_app</mat-icon>
<span translate>Login</span> <span translate>Login</span>
</a> </a>
</mat-nav-list> </mat-nav-list>
</mat-expansion-panel> </mat-expansion-panel>
<!-- TODO: Could use translate.getLangs() to fetch available languages--> <!-- TODO: Could use translate.getLangs() to fetch available languages -->
<mat-menu #languageMenu="matMenu"> <mat-menu #languageMenu="matMenu">
<button mat-menu-item (click)='selectLang("en")' translate>English</button> <button mat-menu-item (click)="selectLang('en')" translate>English</button>
<button mat-menu-item (click)='selectLang("de")' translate>German</button> <button mat-menu-item (click)="selectLang('de')" translate>German</button>
<button mat-menu-item (click)='selectLang("cs")' translate>Czech</button> <button mat-menu-item (click)="selectLang('cs')" translate>Czech</button>
</mat-menu> </mat-menu>
<!-- navigation --> <!-- navigation -->
<mat-nav-list class='main-nav'> <mat-nav-list class="main-nav">
<span *ngFor="let entry of mainMenuService.entries"> <form [formGroup]="searchform" (submit)="search()" *ngIf="showSearch">
<a [@navItemAnim] *osPerms="entry.permission" mat-list-item (click)='toggleSideNav()' [routerLink]='entry.route' <mat-form-field>
routerLinkActive='active' [routerLinkActiveOptions]="{exact: entry.route === '/'}"> <input matInput formControlName="query" placeholder="{{ 'Search' | translate }}" />
<mat-icon>{{ entry.icon }}</mat-icon> <button mat-icon-button matSuffix><mat-icon>search</mat-icon></button>
<span>{{ entry.displayName | translate}}</span> </mat-form-field>
</form>
<span *ngFor="let entry of mainMenuService.entries">
<a
[@navItemAnim]
*osPerms="entry.permission"
mat-list-item
(click)="toggleSideNav()"
[routerLink]="entry.route"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: entry.route === '/' }"
>
<mat-icon>{{ entry.icon }}</mat-icon>
<span>{{ entry.displayName | translate }}</span>
</a> </a>
</span> </span>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<a [@navItemAnim] *osPerms="'core.can_see_projector'" mat-list-item routerLink='/projector' <a
routerLinkActive='active' (click)='toggleSideNav()'> [@navItemAnim]
*osPerms="'core.can_see_projector'"
mat-list-item
routerLink="/projector"
routerLinkActive="active"
(click)="toggleSideNav()"
>
<mat-icon>videocam</mat-icon> <mat-icon>videocam</mat-icon>
<span translate>Projector</span> <span translate>Projector</span>
</a> </a>
@ -71,9 +95,7 @@
<main [@pageTransition]="o.isActivated ? o.activatedRoute : ''"> <main [@pageTransition]="o.isActivated ? o.activatedRoute : ''">
<router-outlet #o="outlet"></router-outlet> <router-outlet #o="outlet"></router-outlet>
</main> </main>
<footer> <footer><os-footer></os-footer></footer>
<os-footer></os-footer>
</footer>
</div> </div>
</div> </div>
</mat-sidenav-content> </mat-sidenav-content>

View File

@ -36,6 +36,10 @@ mat-sidenav-container {
width: 100%; width: 100%;
} }
.main-nav form {
margin: 0 1em;
}
.relax { .relax {
position: initial; position: initial;
padding-bottom: 70px; padding-bottom: 70px;

View File

@ -1,17 +1,18 @@
import { Component, OnInit, ViewChild } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router'; import { Router, NavigationEnd } from '@angular/router';
import { FormGroup, FormControl } from '@angular/forms';
import { AuthService } from 'app/core/services/auth.service'; import { MatDialog, MatSidenav } from '@angular/material';
import { OperatorService } from 'app/core/services/operator.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BaseComponent } from 'app/base.component';
import { pageTransition, navItemAnim } from 'app/shared/animations'; import { AuthService } from '../core/services/auth.service';
import { MatDialog, MatSidenav } from '@angular/material'; import { OperatorService } from '../core/services/operator.service';
import { BaseComponent } from '../base.component';
import { pageTransition, navItemAnim } from '../shared/animations';
import { ViewportService } from '../core/services/viewport.service'; import { ViewportService } from '../core/services/viewport.service';
import { MainMenuService } from '../core/services/main-menu.service'; import { MainMenuService } from '../core/services/main-menu.service';
import { OpenSlidesStatusService } from 'app/core/services/openslides-status.service'; import { OpenSlidesStatusService } from '../core/services/openslides-status.service';
import { TimeTravelService } from 'app/core/services/time-travel.service'; import { TimeTravelService } from '../core/services/time-travel.service';
@Component({ @Component({
selector: 'os-site', selector: 'os-site',
@ -46,6 +47,16 @@ export class SiteComponent extends BaseComponent implements OnInit {
*/ */
private swipeTime?: number; private swipeTime?: number;
/**
* Holds the typed search query.
*/
public searchform: FormGroup;
/**
* Flag, if the search bar shoud be shown.
*/
public showSearch: boolean;
/** /**
* Constructor * Constructor
* *
@ -80,6 +91,14 @@ export class SiteComponent extends BaseComponent implements OnInit {
} }
this.isLoggedIn = !!user; this.isLoggedIn = !!user;
}); });
this.searchform = new FormGroup({ query: new FormControl([]) });
this.router.events.subscribe(event => {
if (event instanceof NavigationEnd) {
this.showSearch = !this.router.url.startsWith('/search');
}
});
} }
/** /**
@ -184,4 +203,13 @@ 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 } });
}
} }

View File

@ -3,7 +3,7 @@ import { Tag } from '../../shared/models/core/tag';
export const TagAppConfig: AppConfig = { export const TagAppConfig: AppConfig = {
name: 'tag', name: 'tag',
models: [{ collectionString: 'core/tag', model: Tag }], models: [{ collectionString: 'core/tag', model: Tag, searchOrder: 8 }],
mainMenuEntries: [ mainMenuEntries: [
{ {
route: '/tags', route: '/tags',

View File

@ -6,7 +6,7 @@ import { PersonalNote } from '../../shared/models/users/personal-note';
export const UsersAppConfig: AppConfig = { export const UsersAppConfig: AppConfig = {
name: 'users', name: 'users',
models: [ models: [
{ collectionString: 'users/user', model: User }, { collectionString: 'users/user', model: User, searchOrder: 4 },
{ collectionString: 'users/group', model: Group }, { collectionString: 'users/group', model: Group },
{ collectionString: 'users/personal-note', model: PersonalNote } { collectionString: 'users/personal-note', model: PersonalNote }
], ],