adding full search component

This commit is contained in:
Fadi Abbud 2018-11-07 08:43:48 +01:00 committed by FinnStutzenstein
parent 656753c2a7
commit f5ee0daf00
48 changed files with 823 additions and 104 deletions

View File

@ -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<void> {
@ -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 ((<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.
*/
private changedSubject: Subject<BaseModel> = new Subject<BaseModel>();
private readonly changedSubject: Subject<BaseModel> = new Subject<BaseModel>();
/**
* Observe the datastore for changes.
@ -83,7 +83,7 @@ export class DataStoreService {
/**
* 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.
@ -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<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.
*/
@ -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.

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 constructor(input?: any) {
super('agenda/item', input);
super('agenda/item', 'Item', input);
}
public deserialize(input: any): void {

View File

@ -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';
}

View File

@ -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.

View File

@ -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;
}

View File

@ -20,6 +20,20 @@ export abstract class BaseModel<T = object> 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<T = object> 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<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 {
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
}
}
/**

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';
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 {}
/**
* @returns the projector title.
*/
public getProjectorTitle(): string {
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 constructor(input?: any) {
super('core/chat-message', input);
super('core/chat-message', 'Chatmessage', input);
}
public getTitle(): string {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

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

View File

@ -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<Tag> {
export class Tag extends BaseModel<Tag> 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';
}
}

View File

@ -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();
}
}

View File

@ -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<Category> {
export class Category extends BaseModel<Category> 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';
}
}

View File

@ -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
*

View File

@ -17,7 +17,7 @@ export class MotionChangeReco extends BaseModel<MotionChangeReco> {
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 {

View File

@ -11,7 +11,7 @@ export class MotionCommentSection extends BaseModel<MotionCommentSection> {
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 {

View File

@ -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}`;
}

View File

@ -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<StatuteParagraph> {
export class StatuteParagraph extends BaseModel<StatuteParagraph> 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';
}
}

View File

@ -12,7 +12,7 @@ export class Workflow extends BaseModel<Workflow> {
public first_state: number;
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 { 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}`;
}

View File

@ -10,7 +10,7 @@ export class Group extends BaseModel<Group> {
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 = [];

View File

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

View File

@ -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];
}
}

View File

@ -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',

View File

@ -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',

View File

@ -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<BaseModel>;
}
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<BaseModel>;
}[];
models?: (ModelEntry | SearchableModelEntry)[];
/**
* 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 { 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
}
];

View File

@ -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 {}

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 = {
name: 'mediafiles',
models: [{ collectionString: 'mediafiles/mediafile', model: Mediafile }],
models: [{ collectionString: 'mediafiles/mediafile', model: Mediafile, searchOrder: 5 }],
mainMenuEntries: [
{
route: '/mediafiles',

View File

@ -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: [
{

View File

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

View File

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

View File

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

View File

@ -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 } });
}
}

View File

@ -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',

View File

@ -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 }
],