adding full search component
This commit is contained in:
parent
656753c2a7
commit
f5ee0daf00
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
16
client/src/app/core/services/search.service.spec.ts
Normal file
16
client/src/app/core/services/search.service.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
131
client/src/app/core/services/search.service.ts
Normal file
131
client/src/app/core/services/search.service.ts
Normal 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
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
9
client/src/app/shared/models/base/detail-navigable.ts
Normal file
9
client/src/app/shared/models/base/detail-navigable.ts
Normal 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;
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
21
client/src/app/shared/models/base/searchable.ts
Normal file
21
client/src/app/shared/models/base/searchable.ts
Normal 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;
|
||||
}
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
@ -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 = [];
|
||||
|
@ -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 {
|
||||
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -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 {}
|
||||
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
143
client/src/app/site/common/components/search/search.component.ts
Normal file
143
client/src/app/site/common/components/search/search.component.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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',
|
||||
|
@ -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: [
|
||||
{
|
||||
|
@ -51,6 +51,7 @@ const routes: Routes = [
|
||||
path: 'history',
|
||||
loadChildren: './history/history.module#HistoryModule'
|
||||
}
|
||||
|
||||
],
|
||||
canActivateChild: [AuthGuard]
|
||||
}
|
||||
|
@ -2,16 +2,21 @@
|
||||
<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 }}
|
||||
@ -21,20 +26,20 @@
|
||||
<mat-icon>language</mat-icon>
|
||||
<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>
|
||||
@ -42,24 +47,43 @@
|
||||
</mat-expansion-panel>
|
||||
<!-- 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'>
|
||||
<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 === '/'}">
|
||||
<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>
|
||||
|
@ -36,6 +36,10 @@ mat-sidenav-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-nav form {
|
||||
margin: 0 1em;
|
||||
}
|
||||
|
||||
.relax {
|
||||
position: initial;
|
||||
padding-bottom: 70px;
|
||||
|
@ -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 } });
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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 }
|
||||
],
|
||||
|
Loading…
Reference in New Issue
Block a user