diff --git a/client/src/app/core/services/app-load.service.ts b/client/src/app/core/services/app-load.service.ts index 662a6b1ba..3c66a230f 100644 --- a/client/src/app/core/services/app-load.service.ts +++ b/client/src/app/core/services/app-load.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { plugins } from '../../../plugins'; 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 { MediafileAppConfig } from '../../site/mediafiles/mediafile.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 { MainMenuService } from './main-menu.service'; 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. @@ -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({ providedIn: 'root' }) export class AppLoadService { + /** + * Constructor. + * + * @param modelMapper + * @param mainMenuService + * @param searchService + */ public constructor( private modelMapper: CollectionStringModelMapperService, - private mainMenuService: MainMenuService + private mainMenuService: MainMenuService, + private searchService: SearchService ) {} public async loadApps(): Promise { @@ -52,6 +62,9 @@ export class AppLoadService { if (config.models) { config.models.forEach(entry => { this.modelMapper.registerCollectionElement(entry.collectionString, entry.model); + if (this.isSearchableModelEntry(entry)) { + this.searchService.registerModel(entry.collectionString, entry.model, entry.searchOrder); + } }); } if (config.mainMenuEntries) { @@ -59,4 +72,17 @@ export class AppLoadService { } }); } + + private isSearchableModelEntry(entry: ModelEntry | SearchableModelEntry): entry is SearchableModelEntry { + if ((entry).searchOrder !== undefined) { + // We need to double check, because Typescipt cannot check contructors. If typescript could differentiate + // between (ModelConstructor) 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; + } } diff --git a/client/src/app/core/services/data-store.service.ts b/client/src/app/core/services/data-store.service.ts index bbd2537b7..c5dd73a0e 100644 --- a/client/src/app/core/services/data-store.service.ts +++ b/client/src/app/core/services/data-store.service.ts @@ -69,7 +69,7 @@ export class DataStoreService { /** * Observable subject for changed models in the datastore. */ - private changedSubject: Subject = new Subject(); + private readonly changedSubject: Subject = new Subject(); /** * Observe the datastore for changes. @@ -83,7 +83,7 @@ export class DataStoreService { /** * Observable subject for changed models in the datastore. */ - private deletedSubject: Subject = new Subject(); + private readonly deletedSubject: Subject = new Subject(); /** * Observe the datastore for deletions. @@ -94,6 +94,20 @@ export class DataStoreService { return this.deletedSubject.asObservable(); } + /** + * Observable subject for changed or deleted models in the datastore. + */ + private readonly changedOrDeletedSubject: Subject = new Subject(); + + /** + * Observe the datastore for changes and deletions. + * + * @return an observable for changed and deleted objects. + */ + public get changedOrDeletedObservable(): Observable { + return this.changedOrDeletedSubject.asObservable(); + } + /** * The maximal change id from this DataStore. */ @@ -137,7 +151,7 @@ export class DataStoreService { // update observers Object.keys(this.modelStore).forEach(collection => { Object.keys(this.modelStore[collection]).forEach(id => { - this.changedSubject.next(this.modelStore[collection][id]); + this.publishChangedInformation(this.modelStore[collection][id]); }); }); } else { @@ -293,7 +307,7 @@ export class DataStoreService { this.jsonStore[collection] = {}; } this.jsonStore[collection][model.id] = JSON.stringify(model); - this.changedSubject.next(model); + this.publishChangedInformation(model); }); if (changeId) { await this.flushToStorage(changeId); @@ -317,7 +331,7 @@ export class DataStoreService { if (this.jsonStore[collectionString]) { delete this.jsonStore[collectionString][id]; } - this.deletedSubject.next({ + this.publishDeletedInformation({ collection: collectionString, id: id }); @@ -340,9 +354,9 @@ export class DataStoreService { // Inform about the deletion Object.keys(modelStoreReference).forEach(collectionString => { Object.keys(modelStoreReference[collectionString]).forEach(id => { - this.deletedSubject.next({ + this.publishDeletedInformation({ 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 * @param changeId The changeId from the update. If it's the highest change id seen, it will be set into the cache. diff --git a/client/src/app/core/services/search.service.spec.ts b/client/src/app/core/services/search.service.spec.ts new file mode 100644 index 000000000..9c9787e01 --- /dev/null +++ b/client/src/app/core/services/search.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/search.service.ts b/client/src/app/core/services/search.service.ts new file mode 100644 index 000000000..51cd37e2c --- /dev/null +++ b/client/src/app/core/services/search.service.ts @@ -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 + }; + }); + } +} diff --git a/client/src/app/shared/models/agenda/item.ts b/client/src/app/shared/models/agenda/item.ts index 0bf57ee3f..2ce8f7270 100644 --- a/client/src/app/shared/models/agenda/item.ts +++ b/client/src/app/shared/models/agenda/item.ts @@ -41,7 +41,7 @@ export class Item extends ProjectableBaseModel { public parent_id: number; public constructor(input?: any) { - super('agenda/item', input); + super('agenda/item', 'Item', input); } public deserialize(input: any): void { diff --git a/client/src/app/shared/models/assignments/assignment.ts b/client/src/app/shared/models/assignments/assignment.ts index 40931e2b6..c5dc2208f 100644 --- a/client/src/app/shared/models/assignments/assignment.ts +++ b/client/src/app/shared/models/assignments/assignment.ts @@ -1,6 +1,7 @@ import { AssignmentUser } from './assignment-user'; import { Poll } from './poll'; import { AgendaBaseModel } from '../base/agenda-base-model'; +import { SearchRepresentation } from '../../../core/services/search.service'; /** * Representation of an assignment. @@ -19,7 +20,7 @@ export class Assignment extends AgendaBaseModel { public tags_id: number[]; public constructor(input?: any) { - super('assignments/assignment', 'Assignment', input); + super('assignments/assignment', 'Election', input); } public get candidateIds(): number[] { @@ -52,6 +53,10 @@ export class Assignment extends AgendaBaseModel { return this.title; } + public formatForSearch(): SearchRepresentation { + return [this.title, this.description]; + } + public getDetailStateURL(): string { return 'TODO'; } diff --git a/client/src/app/shared/models/base/agenda-base-model.ts b/client/src/app/shared/models/base/agenda-base-model.ts index 755a7bfa7..38abdb1c4 100644 --- a/client/src/app/shared/models/base/agenda-base-model.ts +++ b/client/src/app/shared/models/base/agenda-base-model.ts @@ -1,34 +1,43 @@ import { AgendaInformation } from './agenda-information'; 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 * information for the agenda. */ -export abstract class AgendaBaseModel extends ProjectableBaseModel implements AgendaInformation { - protected verboseName: string; - +export abstract class AgendaBaseModel extends ProjectableBaseModel implements AgendaInformation, Searchable { /** - * A Model that inherits from this class should provide a verbose name. It's used by creating - * the agenda title with type. + * A model that can be a content object of an agenda item. * @param collectionString * @param verboseName * @param input */ protected constructor(collectionString: string, verboseName: string, input?: any) { - super(collectionString, input); - this.verboseName = verboseName; + super(collectionString, verboseName, input); } + /** + * @returns the agenda title + */ public getAgendaTitle(): string { return this.getTitle(); } + /** + * @return the agenda title with the verbose name of the content object + */ public getAgendaTitleWithType(): string { // 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 * user can navigate to the content object. diff --git a/client/src/app/shared/models/base/agenda-information.ts b/client/src/app/shared/models/base/agenda-information.ts index 832f1bd45..bca537cf8 100644 --- a/client/src/app/shared/models/base/agenda-information.ts +++ b/client/src/app/shared/models/base/agenda-information.ts @@ -1,7 +1,9 @@ +import { DetailNavigable } from "./detail-navigable"; + /** * 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. */ @@ -11,9 +13,4 @@ export interface AgendaInformation { * Should return the title for the list of speakers view. */ getAgendaTitleWithType(): string; - - /** - * Get the url for the detail view, so in the agenda the user can navigate to it. - */ - getDetailStateURL(): string; } diff --git a/client/src/app/shared/models/base/base-model.ts b/client/src/app/shared/models/base/base-model.ts index 26d90f84d..e76283565 100644 --- a/client/src/app/shared/models/base/base-model.ts +++ b/client/src/app/shared/models/base/base-model.ts @@ -20,6 +20,20 @@ export abstract class BaseModel extends OpenSlidesComponent */ 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 */ @@ -27,10 +41,15 @@ export abstract class BaseModel extends OpenSlidesComponent /** * 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(); this._collectionString = collectionString; + this._verboseName = verboseName; if (input) { this.changeNullValuesToUndef(input); @@ -68,12 +87,19 @@ export abstract class BaseModel 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 { - return this._collectionString; + public getVerboseName(plural: boolean = false): string { + 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 + } } /** diff --git a/client/src/app/shared/models/base/detail-navigable.ts b/client/src/app/shared/models/base/detail-navigable.ts new file mode 100644 index 000000000..4a7949396 --- /dev/null +++ b/client/src/app/shared/models/base/detail-navigable.ts @@ -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; +} diff --git a/client/src/app/shared/models/base/projectable-base-model.ts b/client/src/app/shared/models/base/projectable-base-model.ts index afc57c66e..7ee0bb370 100644 --- a/client/src/app/shared/models/base/projectable-base-model.ts +++ b/client/src/app/shared/models/base/projectable-base-model.ts @@ -2,8 +2,15 @@ import { BaseModel } from './base-model'; import { Projectable } from './projectable'; export abstract class ProjectableBaseModel extends BaseModel 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 BaseModelobject).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; +} diff --git a/client/src/app/shared/models/core/chat-message.ts b/client/src/app/shared/models/core/chat-message.ts index c18835264..f13226cfb 100644 --- a/client/src/app/shared/models/core/chat-message.ts +++ b/client/src/app/shared/models/core/chat-message.ts @@ -11,7 +11,7 @@ export class ChatMessage extends BaseModel { public user_id: number; public constructor(input?: any) { - super('core/chat-message', input); + super('core/chat-message', 'Chatmessage', input); } public getTitle(): string { diff --git a/client/src/app/shared/models/core/config.ts b/client/src/app/shared/models/core/config.ts index 402235585..9f7d5ee74 100644 --- a/client/src/app/shared/models/core/config.ts +++ b/client/src/app/shared/models/core/config.ts @@ -10,7 +10,7 @@ export class Config extends BaseModel { public value: Object; public constructor(input?: any) { - super('core/config', input); + super('core/config', 'Config', input); } public getTitle(): string { diff --git a/client/src/app/shared/models/core/countdown.ts b/client/src/app/shared/models/core/countdown.ts index 309ebedee..66da83a9e 100644 --- a/client/src/app/shared/models/core/countdown.ts +++ b/client/src/app/shared/models/core/countdown.ts @@ -12,7 +12,7 @@ export class Countdown extends ProjectableBaseModel { public running: boolean; public constructor(input?: any) { - super('core/countdown'); + super('core/countdown', 'Countdown', input); } public getTitle(): string { diff --git a/client/src/app/shared/models/core/history.ts b/client/src/app/shared/models/core/history.ts index fc9e49018..fe1233b81 100644 --- a/client/src/app/shared/models/core/history.ts +++ b/client/src/app/shared/models/core/history.ts @@ -30,7 +30,7 @@ export class History extends BaseModel { } public constructor(input?: any) { - super('core/history', input); + super('core/history', 'History', input); } public getTitle(): string { diff --git a/client/src/app/shared/models/core/projector-message.ts b/client/src/app/shared/models/core/projector-message.ts index d1d4f7208..cdb086d28 100644 --- a/client/src/app/shared/models/core/projector-message.ts +++ b/client/src/app/shared/models/core/projector-message.ts @@ -9,7 +9,7 @@ export class ProjectorMessage extends ProjectableBaseModel { public message: string; public constructor(input?: any) { - super('core/projector-message', input); + super('core/projector-message', 'Message', input); } public getTitle(): string { diff --git a/client/src/app/shared/models/core/projector.ts b/client/src/app/shared/models/core/projector.ts index e3686278a..8164b35af 100644 --- a/client/src/app/shared/models/core/projector.ts +++ b/client/src/app/shared/models/core/projector.ts @@ -16,7 +16,7 @@ export class Projector extends BaseModel { public projectiondefaults: Object[]; public constructor(input?: any) { - super('core/projector', input); + super('core/projector', 'Projector', input); } public getTitle(): string { diff --git a/client/src/app/shared/models/core/tag.ts b/client/src/app/shared/models/core/tag.ts index 79b00d027..149e7cbd8 100644 --- a/client/src/app/shared/models/core/tag.ts +++ b/client/src/app/shared/models/core/tag.ts @@ -1,18 +1,27 @@ import { BaseModel } from '../base/base-model'; +import { Searchable } from '../base/searchable'; /** * Representation of a tag. * @ignore */ -export class Tag extends BaseModel { +export class Tag extends BaseModel implements Searchable { public id: number; public name: string; public constructor(input?: any) { - super('core/tag', input); + super('core/tag', 'Tag', input); } public getTitle(): string { return this.name; } + + public formatForSearch(): string[] { + return [this.name]; + } + + public getDetailStateURL(): string { + return '/tags'; + } } diff --git a/client/src/app/shared/models/mediafiles/mediafile.ts b/client/src/app/shared/models/mediafiles/mediafile.ts index f6161c1c6..97c90916c 100644 --- a/client/src/app/shared/models/mediafiles/mediafile.ts +++ b/client/src/app/shared/models/mediafiles/mediafile.ts @@ -1,11 +1,12 @@ import { File } from './file'; import { ProjectableBaseModel } from '../base/projectable-base-model'; +import { Searchable } from '../base/searchable'; /** * Representation of MediaFile. Has the nested property "File" * @ignore */ -export class Mediafile extends ProjectableBaseModel { +export class Mediafile extends ProjectableBaseModel implements Searchable { public id: number; public title: string; public mediafile: File; @@ -16,7 +17,7 @@ export class Mediafile extends ProjectableBaseModel { public timestamp: string; public constructor(input?: any) { - super('mediafiles/mediafile', input); + super('mediafiles/mediafile', 'Mediafile', input); } public deserialize(input: any): void { @@ -36,4 +37,12 @@ export class Mediafile extends ProjectableBaseModel { public getTitle(): string { return this.title; } + + public formatForSearch(): string[] { + return [this.title, this.mediafile.name]; + } + + public getDetailStateURL(): string { + return this.getDownloadUrl(); + } } diff --git a/client/src/app/shared/models/motions/category.ts b/client/src/app/shared/models/motions/category.ts index ffbaeaef3..aed2a86fb 100644 --- a/client/src/app/shared/models/motions/category.ts +++ b/client/src/app/shared/models/motions/category.ts @@ -1,19 +1,52 @@ 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" * @ignore */ -export class Category extends BaseModel { +export class Category extends BaseModel implements Searchable { public id: number; public name: string; public prefix: string; public constructor(input?: any) { - super('motions/category', input); + super('motions/category', 'Category', input); } public getTitle(): string { 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'; + } } diff --git a/client/src/app/shared/models/motions/motion-block.ts b/client/src/app/shared/models/motions/motion-block.ts index d2e6885bc..49b2b3256 100644 --- a/client/src/app/shared/models/motions/motion-block.ts +++ b/client/src/app/shared/models/motions/motion-block.ts @@ -1,4 +1,5 @@ import { AgendaBaseModel } from '../base/agenda-base-model'; +import { SearchRepresentation } from '../../../core/services/search.service'; /** * Representation of a motion block. @@ -17,6 +18,15 @@ export class MotionBlock extends AgendaBaseModel { return this.title; } + /** + * Formats the category for search + * + * @override + */ + public formatForSearch(): SearchRepresentation { + return [this.title]; + } + /** * Get the URL to the motion block * diff --git a/client/src/app/shared/models/motions/motion-change-reco.ts b/client/src/app/shared/models/motions/motion-change-reco.ts index 8abd10c19..bc1a4a6c5 100644 --- a/client/src/app/shared/models/motions/motion-change-reco.ts +++ b/client/src/app/shared/models/motions/motion-change-reco.ts @@ -17,7 +17,7 @@ export class MotionChangeReco extends BaseModel { public creation_time: string; public constructor(input?: any) { - super('motions/motion-change-recommendation', input); + super('motions/motion-change-recommendation', 'Change recommendation', input); } public getTitle(): string { diff --git a/client/src/app/shared/models/motions/motion-comment-section.ts b/client/src/app/shared/models/motions/motion-comment-section.ts index 7b38f191f..aa68240da 100644 --- a/client/src/app/shared/models/motions/motion-comment-section.ts +++ b/client/src/app/shared/models/motions/motion-comment-section.ts @@ -11,7 +11,7 @@ export class MotionCommentSection extends BaseModel { public write_groups_id: number[]; public constructor(input?: any) { - super('motions/motion-comment-section', input); + super('motions/motion-comment-section', 'Comment section', input); } public getTitle(): string { diff --git a/client/src/app/shared/models/motions/motion.ts b/client/src/app/shared/models/motions/motion.ts index a6b06d847..e7205f80c 100644 --- a/client/src/app/shared/models/motions/motion.ts +++ b/client/src/app/shared/models/motions/motion.ts @@ -2,6 +2,7 @@ import { MotionSubmitter } from './motion-submitter'; import { MotionLog } from './motion-log'; import { MotionComment } from './motion-comment'; import { AgendaBaseModel } from '../base/agenda-base-model'; +import { SearchRepresentation } from '../../../core/services/search.service'; /** * Representation of Motion. @@ -75,10 +76,23 @@ export class Motion extends AgendaBaseModel { if (this.identifier) { return 'Motion ' + this.identifier; } 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 { return `/motions/${this.id}`; } diff --git a/client/src/app/shared/models/motions/statute-paragraph.ts b/client/src/app/shared/models/motions/statute-paragraph.ts index 7fbb814a9..dabbf32e5 100644 --- a/client/src/app/shared/models/motions/statute-paragraph.ts +++ b/client/src/app/shared/models/motions/statute-paragraph.ts @@ -1,20 +1,38 @@ import { BaseModel } from '../base/base-model'; +import { Searchable } from '../base/searchable'; +import { SearchRepresentation } from '../../../core/services/search.service'; /** * Representation of a statute paragraph. * @ignore */ -export class StatuteParagraph extends BaseModel { +export class StatuteParagraph extends BaseModel implements Searchable { public id: number; public title: string; public text: string; public weight: number; public constructor(input?: any) { - super('motions/statute-paragraph', input); + super('motions/statute-paragraph', 'Statute paragraph', input); } public getTitle(): string { 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'; + } } diff --git a/client/src/app/shared/models/motions/workflow.ts b/client/src/app/shared/models/motions/workflow.ts index 866563fd9..babbd73ec 100644 --- a/client/src/app/shared/models/motions/workflow.ts +++ b/client/src/app/shared/models/motions/workflow.ts @@ -12,7 +12,7 @@ export class Workflow extends BaseModel { public first_state: number; public constructor(input?: any) { - super('motions/workflow', input); + super('motions/workflow', 'Workflow', input); } /** diff --git a/client/src/app/shared/models/topics/topic.ts b/client/src/app/shared/models/topics/topic.ts index 1c38938fb..f935455c6 100644 --- a/client/src/app/shared/models/topics/topic.ts +++ b/client/src/app/shared/models/topics/topic.ts @@ -1,4 +1,5 @@ import { AgendaBaseModel } from '../base/agenda-base-model'; +import { SearchRepresentation } from '../../../core/services/search.service'; /** * Representation of a topic. @@ -24,6 +25,15 @@ export class Topic extends AgendaBaseModel { return this.getAgendaTitle(); } + /** + * Formats the category for search + * + * @override + */ + public formatForSearch(): SearchRepresentation { + return [this.title, this.text]; + } + public getDetailStateURL(): string { return `/agenda/topics/${this.id}`; } diff --git a/client/src/app/shared/models/users/group.ts b/client/src/app/shared/models/users/group.ts index 14b790ab5..801baf359 100644 --- a/client/src/app/shared/models/users/group.ts +++ b/client/src/app/shared/models/users/group.ts @@ -10,7 +10,7 @@ export class Group extends BaseModel { public permissions: string[]; public constructor(input?: any) { - super('users/group', input); + super('users/group', 'Group', input); if (!input) { // permissions are required for new groups this.permissions = []; diff --git a/client/src/app/shared/models/users/personal-note.ts b/client/src/app/shared/models/users/personal-note.ts index 61bf6f5bc..4f60b558b 100644 --- a/client/src/app/shared/models/users/personal-note.ts +++ b/client/src/app/shared/models/users/personal-note.ts @@ -54,7 +54,7 @@ export class PersonalNote extends BaseModel implements PersonalNot public notes: PersonalNotesFormat; public constructor(input: any) { - super('users/personal-note', input); + super('users/personal-note', 'Personal note', input); } public getTitle(): string { diff --git a/client/src/app/shared/models/users/user.ts b/client/src/app/shared/models/users/user.ts index 11eed4b02..6c64b2abc 100644 --- a/client/src/app/shared/models/users/user.ts +++ b/client/src/app/shared/models/users/user.ts @@ -1,10 +1,12 @@ 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. * @ignore */ -export class User extends ProjectableBaseModel { +export class User extends ProjectableBaseModel implements Searchable { public id: number; public username: string; public title: string; @@ -23,7 +25,7 @@ export class User extends ProjectableBaseModel { public default_password: string; public constructor(input?: any) { - super('users/user', input); + super('users/user', 'Participant', input); } public get full_name(): string { @@ -88,4 +90,17 @@ export class User extends ProjectableBaseModel { public getListViewTitle(): string { 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]; + } } diff --git a/client/src/app/site/agenda/agenda.config.ts b/client/src/app/site/agenda/agenda.config.ts index 43c549bbb..ce86afa36 100644 --- a/client/src/app/site/agenda/agenda.config.ts +++ b/client/src/app/site/agenda/agenda.config.ts @@ -4,7 +4,7 @@ import { Topic } from '../../shared/models/topics/topic'; export const AgendaAppConfig: AppConfig = { 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: [ { route: '/agenda', diff --git a/client/src/app/site/assignments/assignments.config.ts b/client/src/app/site/assignments/assignments.config.ts index 1e161c217..12a0e672d 100644 --- a/client/src/app/site/assignments/assignments.config.ts +++ b/client/src/app/site/assignments/assignments.config.ts @@ -3,7 +3,7 @@ import { Assignment } from '../../shared/models/assignments/assignment'; export const AssignmentsAppConfig: AppConfig = { name: 'assignments', - models: [{ collectionString: 'assignments/assignment', model: Assignment }], + models: [{ collectionString: 'assignments/assignment', model: Assignment, searchOrder: 3 }], mainMenuEntries: [ { route: '/assignments', diff --git a/client/src/app/site/base/app-config.ts b/client/src/app/site/base/app-config.ts index e2ba1bd34..0323f50b1 100644 --- a/client/src/app/site/base/app-config.ts +++ b/client/src/app/site/base/app-config.ts @@ -1,5 +1,17 @@ import { ModelConstructor, BaseModel } from '../../shared/models/base/base-model'; import { MainMenuEntry } from '../../core/services/main-menu.service'; +import { Searchable } from '../../shared/models/base/searchable'; + +export interface ModelEntry { + collectionString: string; + model: ModelConstructor; +} + +export interface SearchableModelEntry { + collectionString: string; + model: new (...args: any[]) => (BaseModel & Searchable); + searchOrder: number; +} /** * The configuration of an app. @@ -13,10 +25,7 @@ export interface AppConfig { /** * All models, that should be registered. */ - models?: { - collectionString: string; - model: ModelConstructor; - }[]; + models?: (ModelEntry | SearchableModelEntry)[]; /** * Main menu entries. diff --git a/client/src/app/site/common/common-routing.module.ts b/client/src/app/site/common/common-routing.module.ts index 87f463e52..91eadfc70 100644 --- a/client/src/app/site/common/common-routing.module.ts +++ b/client/src/app/site/common/common-routing.module.ts @@ -3,6 +3,7 @@ import { Routes, RouterModule } from '@angular/router'; import { PrivacyPolicyComponent } from './components/privacy-policy/privacy-policy.component'; import { StartComponent } from './components/start/start.component'; import { LegalNoticeComponent } from './components/legal-notice/legal-notice.component'; +import { SearchComponent } from './components/search/search.component'; const routes: Routes = [ { @@ -16,6 +17,10 @@ const routes: Routes = [ { path: 'privacypolicy', component: PrivacyPolicyComponent + }, + { + path: 'search', + component: SearchComponent } ]; diff --git a/client/src/app/site/common/common.module.ts b/client/src/app/site/common/common.module.ts index b5fb513ce..ece0b0067 100644 --- a/client/src/app/site/common/common.module.ts +++ b/client/src/app/site/common/common.module.ts @@ -6,9 +6,10 @@ import { SharedModule } from '../../shared/shared.module'; import { PrivacyPolicyComponent } from './components/privacy-policy/privacy-policy.component'; import { StartComponent } from './components/start/start.component'; import { LegalNoticeComponent } from './components/legal-notice/legal-notice.component'; +import { SearchComponent } from './components/search/search.component'; @NgModule({ imports: [AngularCommonModule, CommonRoutingModule, SharedModule], - declarations: [PrivacyPolicyComponent, StartComponent, LegalNoticeComponent] + declarations: [PrivacyPolicyComponent, StartComponent, LegalNoticeComponent, SearchComponent] }) export class CommonModule {} diff --git a/client/src/app/site/common/components/search/search.component.html b/client/src/app/site/common/components/search/search.component.html new file mode 100644 index 000000000..d576ad985 --- /dev/null +++ b/client/src/app/site/common/components/search/search.component.html @@ -0,0 +1,58 @@ + + +

Search results

+ + +
+ + +
+
+ + + search + +
+
+ + + + + +
+ No search result found for "{{ query }}" +
+ + +

+ Found {{ searchResultCount }} + result + results +

+ + + + + {{ searchResult.models.length }} {{ searchResult.verboseName | translate }} + + + + + + {{ 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 new file mode 100644 index 000000000..5110c9545 --- /dev/null +++ b/client/src/app/site/common/components/search/search.component.scss @@ -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; +} 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 new file mode 100644 index 000000000..08e7aff64 --- /dev/null +++ b/client/src/app/site/common/components/search/search.component.spec.ts @@ -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; + + 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 new file mode 100644 index 000000000..50d4de558 --- /dev/null +++ b/client/src/app/site/common/components/search/search.component.ts @@ -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(); + + /** + * 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(); + } +} diff --git a/client/src/app/site/mediafiles/mediafile.config.ts b/client/src/app/site/mediafiles/mediafile.config.ts index 91c19130a..30e985dc1 100644 --- a/client/src/app/site/mediafiles/mediafile.config.ts +++ b/client/src/app/site/mediafiles/mediafile.config.ts @@ -3,7 +3,7 @@ import { Mediafile } from '../../shared/models/mediafiles/mediafile'; export const MediafileAppConfig: AppConfig = { name: 'mediafiles', - models: [{ collectionString: 'mediafiles/mediafile', model: Mediafile }], + models: [{ collectionString: 'mediafiles/mediafile', model: Mediafile, searchOrder: 5 }], mainMenuEntries: [ { route: '/mediafiles', diff --git a/client/src/app/site/motions/motions.config.ts b/client/src/app/site/motions/motions.config.ts index 59a042e6b..9bf4a76a3 100644 --- a/client/src/app/site/motions/motions.config.ts +++ b/client/src/app/site/motions/motions.config.ts @@ -10,13 +10,13 @@ import { StatuteParagraph } from '../../shared/models/motions/statute-paragraph' export const MotionsAppConfig: AppConfig = { name: 'motions', models: [ - { collectionString: 'motions/motion', model: Motion }, - { collectionString: 'motions/category', model: Category }, + { collectionString: 'motions/motion', model: Motion, searchOrder: 2 }, + { collectionString: 'motions/category', model: Category, searchOrder: 6 }, { collectionString: 'motions/workflow', model: Workflow }, { collectionString: 'motions/motion-comment-section', model: MotionCommentSection }, { collectionString: 'motions/motion-change-recommendation', model: MotionChangeReco }, - { collectionString: 'motions/motion-block', model: MotionBlock }, - { collectionString: 'motions/statute-paragraph', model: StatuteParagraph } + { collectionString: 'motions/motion-block', model: MotionBlock, searchOrder: 7 }, + { collectionString: 'motions/statute-paragraph', model: StatuteParagraph, searchOrder: 9 } ], mainMenuEntries: [ { diff --git a/client/src/app/site/site-routing.module.ts b/client/src/app/site/site-routing.module.ts index 9575dd338..94f829ac3 100644 --- a/client/src/app/site/site-routing.module.ts +++ b/client/src/app/site/site-routing.module.ts @@ -51,6 +51,7 @@ const routes: Routes = [ path: 'history', loadChildren: './history/history.module#HistoryModule' } + ], canActivateChild: [AuthGuard] } diff --git a/client/src/app/site/site.component.html b/client/src/app/site/site.component.html index 53a2e6072..6777e5ea6 100644 --- a/client/src/app/site/site.component.html +++ b/client/src/app/site/site.component.html @@ -2,64 +2,88 @@ You are using the history mode of OpenSlides. Changes will not be saved. Exit - - - + + + - + - + - {{username}} + {{ username }} language - {{getLangName(this.translate.currentLang)}} + {{ getLangName(this.translate.currentLang) }} - + person Edit profile - + vpn_key Change password - + exit_to_app Logout - + exit_to_app Login - + - - - + + + - - - - {{ entry.icon }} - {{ entry.displayName | translate}} + +
+ + + + +
+ +
+ {{ entry.icon }} + {{ entry.displayName | translate }}
- + videocam Projector @@ -71,9 +95,7 @@
-
- -
+ diff --git a/client/src/app/site/site.component.scss b/client/src/app/site/site.component.scss index fe739fe2e..e21e3ec33 100644 --- a/client/src/app/site/site.component.scss +++ b/client/src/app/site/site.component.scss @@ -36,6 +36,10 @@ mat-sidenav-container { width: 100%; } +.main-nav form { + margin: 0 1em; +} + .relax { position: initial; padding-bottom: 70px; diff --git a/client/src/app/site/site.component.ts b/client/src/app/site/site.component.ts index e91735d5e..a76aa7f90 100644 --- a/client/src/app/site/site.component.ts +++ b/client/src/app/site/site.component.ts @@ -1,17 +1,18 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { Router, NavigationEnd } from '@angular/router'; - -import { AuthService } from 'app/core/services/auth.service'; -import { OperatorService } from 'app/core/services/operator.service'; +import { FormGroup, FormControl } from '@angular/forms'; +import { MatDialog, MatSidenav } from '@angular/material'; import { TranslateService } from '@ngx-translate/core'; -import { BaseComponent } from 'app/base.component'; -import { pageTransition, navItemAnim } from 'app/shared/animations'; -import { MatDialog, MatSidenav } from '@angular/material'; + +import { AuthService } from '../core/services/auth.service'; +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 { MainMenuService } from '../core/services/main-menu.service'; -import { OpenSlidesStatusService } from 'app/core/services/openslides-status.service'; -import { TimeTravelService } from 'app/core/services/time-travel.service'; +import { OpenSlidesStatusService } from '../core/services/openslides-status.service'; +import { TimeTravelService } from '../core/services/time-travel.service'; @Component({ selector: 'os-site', @@ -46,6 +47,16 @@ export class SiteComponent extends BaseComponent implements OnInit { */ private swipeTime?: number; + /** + * Holds the typed search query. + */ + public searchform: FormGroup; + + /** + * Flag, if the search bar shoud be shown. + */ + public showSearch: boolean; + /** * Constructor * @@ -80,6 +91,14 @@ export class SiteComponent extends BaseComponent implements OnInit { } 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 } }); + } } diff --git a/client/src/app/site/tags/tag.config.ts b/client/src/app/site/tags/tag.config.ts index c44a41a80..3ba67fff8 100644 --- a/client/src/app/site/tags/tag.config.ts +++ b/client/src/app/site/tags/tag.config.ts @@ -3,7 +3,7 @@ import { Tag } from '../../shared/models/core/tag'; export const TagAppConfig: AppConfig = { name: 'tag', - models: [{ collectionString: 'core/tag', model: Tag }], + models: [{ collectionString: 'core/tag', model: Tag, searchOrder: 8 }], mainMenuEntries: [ { route: '/tags', diff --git a/client/src/app/site/users/users.config.ts b/client/src/app/site/users/users.config.ts index 956b42bc1..04dafea67 100644 --- a/client/src/app/site/users/users.config.ts +++ b/client/src/app/site/users/users.config.ts @@ -6,7 +6,7 @@ import { PersonalNote } from '../../shared/models/users/personal-note'; export const UsersAppConfig: AppConfig = { name: 'users', models: [ - { collectionString: 'users/user', model: User }, + { collectionString: 'users/user', model: User, searchOrder: 4 }, { collectionString: 'users/group', model: Group }, { collectionString: 'users/personal-note', model: PersonalNote } ],