diff --git a/client/package-lock.json b/client/package-lock.json index eb8bf9398..3e88d00f8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -4645,12 +4645,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4665,17 +4667,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -4792,7 +4797,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -4804,6 +4810,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4818,6 +4825,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -4825,12 +4833,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -4849,6 +4859,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -4929,7 +4940,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -4941,6 +4953,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -5062,6 +5075,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts index 48e5c9661..28352a857 100644 --- a/client/src/app/core/core.module.ts +++ b/client/src/app/core/core.module.ts @@ -7,10 +7,11 @@ import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { AuthGuard } from './services/auth-guard.service'; import { AuthService } from './services/auth.service'; import { AutoupdateService } from './services/autoupdate.service'; -import { DataStoreService } from './services/dataStore.service'; +import { DataStoreService } from './services/data-store.service'; import { OperatorService } from './services/operator.service'; import { WebsocketService } from './services/websocket.service'; import { AddHeaderInterceptor } from './http-interceptor'; +import { DataSendService } from './services/data-send.service'; /** Global Core Module. Contains all global (singleton) services * @@ -23,6 +24,7 @@ import { AddHeaderInterceptor } from './http-interceptor'; AuthService, AutoupdateService, DataStoreService, + DataSendService, OperatorService, WebsocketService, { diff --git a/client/src/app/core/services/autoupdate.service.ts b/client/src/app/core/services/autoupdate.service.ts index 60d5bf363..c60c9fb5b 100644 --- a/client/src/app/core/services/autoupdate.service.ts +++ b/client/src/app/core/services/autoupdate.service.ts @@ -68,7 +68,13 @@ export class AutoupdateService extends OpenSlidesComponent { storeResponse(socketResponse): void { socketResponse.forEach(jsonObj => { const targetClass = this.getClassFromCollectionString(jsonObj.collection); - this.DS.add(new targetClass().deserialize(jsonObj.data)); + if (jsonObj.action === 'deleted') { + console.log('storeResponse detect delete'); + + this.DS.remove(jsonObj.collection, jsonObj.id); + } else { + this.DS.add(new targetClass().deserialize(jsonObj.data)); + } }); } diff --git a/client/src/app/core/services/data-send.service.spec.ts b/client/src/app/core/services/data-send.service.spec.ts new file mode 100644 index 000000000..4d66e43f8 --- /dev/null +++ b/client/src/app/core/services/data-send.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { DataSendService } from './data-send.service'; + +describe('DataSendService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DataSendService] + }); + }); + + it('should be created', inject([DataSendService], (service: DataSendService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/core/services/data-send.service.ts b/client/src/app/core/services/data-send.service.ts new file mode 100644 index 000000000..4fbaad5c2 --- /dev/null +++ b/client/src/app/core/services/data-send.service.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { BaseModel } from '../../shared/models/base.model'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +/** + * Send data back to server + * + * Contrast to dataStore service + */ +@Injectable({ + providedIn: 'root' +}) +export class DataSendService { + /** + * Construct a DataSendService + * + * @param http The HTTP Client + */ + constructor(private http: HttpClient) {} + + /** + * Save motion in the server + * + * @return Observable from + */ + saveModel(model: BaseModel): Observable { + if (!model.id) { + return this.http.post('rest/' + model.collectionString + '/', model).pipe( + tap( + response => { + // TODO: Message, Notify, Etc + console.log('New Model added. Response : ', response); + }, + error => console.log('error. ', error) + ) + ); + } else { + return this.http.put('rest/' + model.collectionString + '/' + model.id, model).pipe( + tap( + response => { + console.log('Update model. Response : ', response); + }, + error => console.log('error. ', error) + ) + ); + } + } + + /** + * Deletes the given model on the server + * + * @param model the BaseModel that shall be removed + * @return Observable of BaseModel + * + * TODO Not tested + */ + delete(model: BaseModel): Observable { + if (model.id) { + return this.http.delete('rest/' + model.collectionString + '/' + model.id).pipe( + tap( + response => { + // TODO: Message, Notify, Etc + console.log('the response: ', response); + }, + error => console.error('error during delete: ', error) + ) + ); + } else { + console.error('No model ID to delete'); + } + } +} diff --git a/client/src/app/core/services/dataStore.service.spec.ts b/client/src/app/core/services/data-store.service.spec.ts similarity index 78% rename from client/src/app/core/services/dataStore.service.spec.ts rename to client/src/app/core/services/data-store.service.spec.ts index 70f76d8fe..d5269ab50 100644 --- a/client/src/app/core/services/dataStore.service.spec.ts +++ b/client/src/app/core/services/data-store.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed, inject } from '@angular/core/testing'; -import { DataStoreService } from './dataStore.service'; +import { DataStoreService } from './data-store.service'; describe('DS', () => { beforeEach(() => { diff --git a/client/src/app/core/services/dataStore.service.ts b/client/src/app/core/services/data-store.service.ts similarity index 75% rename from client/src/app/core/services/dataStore.service.ts rename to client/src/app/core/services/data-store.service.ts index 3183a5be2..fb98490c8 100644 --- a/client/src/app/core/services/dataStore.service.ts +++ b/client/src/app/core/services/data-store.service.ts @@ -1,7 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpResponse, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; -import { Observable, of, BehaviorSubject } from 'rxjs'; -import { tap, map } from 'rxjs/operators'; +import { Observable, BehaviorSubject } from 'rxjs'; import { ImproperlyConfiguredError } from 'app/core/exceptions'; import { BaseModel, ModelId } from 'app/shared/models/base.model'; @@ -29,6 +27,12 @@ interface Storage { * Use this.DS in an OpenSlides Component to Access the store. * Used by a lot of components, classes and services. * Changes can be observed + * + * FIXME: The injector does not init the HttpClient Service. + * Either remove it from DataStore and make an own Service + * fix it somehow + * or just do-not let the OpenSlidesComponent inject DataStore to it's + * children. */ @Injectable({ providedIn: 'root' @@ -49,7 +53,7 @@ export class DataStoreService { * Empty constructor for dataStore * @param http use HttpClient to send models back to the server */ - constructor(private http: HttpClient) {} + constructor() {} /** * Read one, multiple or all ID's from dataStore @@ -145,54 +149,25 @@ export class DataStoreService { * @param ...ids An or multiple IDs or a list of IDs of BaseModels. use spread operator ("...") for arrays * @example this.DS.remove(User, myUser.id, 3, 4) */ - remove(Type, ...ids: ModelId[]): void { + remove(collectionType, ...ids: ModelId[]): void { + console.log('remove from DS: collection', collectionType); + console.log('remove from DS: collection', ids); + + let collectionString: string; + if (typeof collectionType === 'string') { + collectionString = collectionType; + } else { + const tempObject = new collectionType(); + collectionString = tempObject.collectionString; + } + ids.forEach(id => { - const tempObject = new Type(); - if (DataStoreService.store[tempObject.collectionString]) { - delete DataStoreService.store[tempObject.collectionString][id]; - console.log(`did remove "${id}" from Datastore "${tempObject.collectionString}"`); + if (DataStoreService.store[collectionString]) { + delete DataStoreService.store[collectionString][id]; } }); } - /** - * Saves the given model on the server - * @param model the BaseModel that shall be removed - * @return Observable of BaseModel - */ - save(model: BaseModel): Observable { - if (!model.id) { - throw new ImproperlyConfiguredError('The model must have an id!'); - } - - // TODO not tested - return this.http.post(model.collectionString + '/', model).pipe( - tap(response => { - console.log('the response: ', response); - this.add(model); - }) - ); - } - - /** - * Deletes the given model on the server - * @param model the BaseModel that shall be removed - * @return Observable of BaseModel - */ - delete(model: BaseModel): Observable { - if (!model.id) { - throw new ImproperlyConfiguredError('The model must have an id!'); - } - - // TODO not tested - return this.http.post(model.collectionString + '/', model).pipe( - tap(response => { - console.log('the response: ', response); - this.remove(model, model.id); - }) - ); - } - /** * Observe the dataStore for changes. * @return an observable behaviorSubject diff --git a/client/src/app/core/services/operator.service.ts b/client/src/app/core/services/operator.service.ts index 9655a8b17..30d25ee31 100644 --- a/client/src/app/core/services/operator.service.ts +++ b/client/src/app/core/services/operator.service.ts @@ -4,6 +4,7 @@ import { HttpClient } from '@angular/common/http'; import { tap, catchError, share } from 'rxjs/operators'; import { OpenSlidesComponent } from 'app/openslides.component'; import { Group } from 'app/shared/models/users/group'; +import { User } from '../../shared/models/users/user'; /** * The operator represents the user who is using OpenSlides. @@ -38,6 +39,8 @@ export class OperatorService extends OpenSlidesComponent { username: string; logged_in: boolean; + private _user: User; + /** * The subject that can be observed by other instances using observing functions. */ @@ -65,7 +68,7 @@ export class OperatorService extends OpenSlidesComponent { } } - // observe the datastore now to avoid race conditions. Ensures to + // observe the DataStore now to avoid race conditions. Ensures to // find the groups in time this.observeDataStore(); } @@ -77,7 +80,7 @@ export class OperatorService extends OpenSlidesComponent { return this.http.get('/users/whoami/').pipe( tap(whoami => { if (whoami && whoami.user) { - this.storeUser(whoami.user); + this.storeUser(whoami.user as User); } }), catchError(this.handleError()) @@ -87,8 +90,11 @@ export class OperatorService extends OpenSlidesComponent { /** * Store the user Information in the operator, the localStorage and update the Observable * @param user usually a http response that represents a user. + * + * Todo: Could be refractored to use the actual User Object. + * Operator is older than user, so this is still a traditional JS way */ - public storeUser(user: any): void { + public storeUser(user: User): void { // store in file this.about_me = user.about_me; this.comment = user.comment; @@ -106,6 +112,7 @@ export class OperatorService extends OpenSlidesComponent { this.structure_level = user.structure_level; this.title = user.title; this.username = user.username; + // also store in localstorrage this.updateLocalStorage(); // update mode to inform observers @@ -187,6 +194,10 @@ export class OperatorService extends OpenSlidesComponent { if (newModel instanceof Group) { this.addGroup(newModel); } + + if (newModel instanceof User && this.id === newModel.id) { + this._user = newModel; + } }); } @@ -243,4 +254,11 @@ export class OperatorService extends OpenSlidesComponent { this.setObservable(newGroup); } } + + /** + * get the user that corresponds to operator. + */ + get user(): User { + return this._user; + } } diff --git a/client/src/app/openslides.component.ts b/client/src/app/openslides.component.ts index 951646758..c78939e0e 100644 --- a/client/src/app/openslides.component.ts +++ b/client/src/app/openslides.component.ts @@ -1,7 +1,8 @@ import { Injector } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; import { Observable, of } from 'rxjs'; -import { DataStoreService } from 'app/core/services/dataStore.service'; +import { DataStoreService } from './core/services/data-store.service'; /** * injects the {@link DataStoreService} to all its children and provides a generic function to catch errors diff --git a/client/src/app/shared/directives/os-perms.directive.ts b/client/src/app/shared/directives/os-perms.directive.ts index 95f107f94..02e896325 100644 --- a/client/src/app/shared/directives/os-perms.directive.ts +++ b/client/src/app/shared/directives/os-perms.directive.ts @@ -78,7 +78,6 @@ export class OsPermsDirective extends OpenSlidesComponent { private updateView(): void { if (this.checkPermissions()) { // will just render the page normally - console.log('do show: ', this.template, ' - ', this.viewContainer); this.viewContainer.createEmbeddedView(this.template); } else { // will remove the content of the container diff --git a/client/src/app/shared/models/motions/motion-version.ts b/client/src/app/shared/models/motions/motion-version.ts index 522d6c84f..171b26f5d 100644 --- a/client/src/app/shared/models/motions/motion-version.ts +++ b/client/src/app/shared/models/motions/motion-version.ts @@ -26,10 +26,10 @@ export class MotionVersion implements Deserializable { this.id = id; this.version_number = version_number; this.creation_time = creation_time; - this.title = title; - this.text = text; - this.amendment_paragraphs = amendment_paragraphs; - this.reason = reason; + this.title = title || ''; + this.text = text || ''; + this.amendment_paragraphs = amendment_paragraphs || ''; + this.reason = reason || ''; } deserialize(input: any): this { diff --git a/client/src/app/shared/models/motions/motion.ts b/client/src/app/shared/models/motions/motion.ts index 955b76776..f78e69542 100644 --- a/client/src/app/shared/models/motions/motion.ts +++ b/client/src/app/shared/models/motions/motion.ts @@ -6,6 +6,7 @@ import { Config } from '../core/config'; import { Workflow } from './workflow'; import { User } from '../users/user'; import { Category } from './category'; +import { WorkflowState } from './workflow-state'; /** * Representation of Motion. @@ -36,6 +37,15 @@ export class Motion extends BaseModel { agenda_item_id: number; log_messages: MotionLog[]; + // read from config + workflow_id: number; + // by the config above + workflow: Workflow; + + // for request + title: string; + text: string; + constructor( id?: number, identifier?: string, @@ -60,49 +70,116 @@ export class Motion extends BaseModel { super(); this._collectionString = 'motions/motion'; this.id = id; - this.identifier = identifier; - this.versions = versions; + this.identifier = identifier || ''; + this.versions = versions || [new MotionVersion()]; this.active_version = active_version; this.parent_id = parent_id; this.category_id = category_id; this.motion_block_id = motion_block_id; - this.origin = origin; - this.submitters = submitters; + this.origin = origin || ''; + this.submitters = submitters || []; this.supporters_id = supporters_id; this.comments = comments; this.state_id = state_id; - this.state_required_permission_to_see = state_required_permission_to_see; + this.state_required_permission_to_see = state_required_permission_to_see || ''; this.recommendation_id = recommendation_id; this.tags_id = tags_id; this.attachments_id = attachments_id; this.polls = polls; this.agenda_item_id = agenda_item_id; - this.log_messages = log_messages; + this.log_messages = log_messages || []; + + this.initDataStoreValues(); + } + + /** + * update the values of the motion with new values + */ + patchValues(update: object) { + Object.assign(this, update); + } + + /** + * sets the workflow_id and the workflow + */ + initDataStoreValues() { + const motionsWorkflowConfig = this.DS.filter(Config, config => config.key === 'motions_workflow')[0] as Config; + if (motionsWorkflowConfig) { + this.workflow_id = +motionsWorkflowConfig.value; + } else { + this.DS.getObservable().subscribe(newConfig => { + if (newConfig instanceof Config && newConfig.key === 'motions_workflow') { + this.workflow_id = +newConfig.value; + } + }); + } + + this.workflow = this.DS.get(Workflow, this.workflow_id) as Workflow; + if (!this.workflow.id) { + this.DS.getObservable().subscribe(newModel => { + if (newModel instanceof Workflow && newModel.id === this.workflow_id) { + this.workflow = newModel; + } + }); + } + } + + /** add a new motionSubmitter from user-object */ + addSubmitter(user: User) { + const newSubmitter = new MotionSubmitter(null, user.id); + this.submitters.push(newSubmitter); + console.log('did addSubmitter. this.submitters: ', this.submitters); } /** * returns the most current title from versions */ - get currentTitle() { - if (this.versions[0]) { + get currentTitle(): string { + if (this.versions && this.versions[0]) { return this.versions[0].title; } else { return ''; } } + set currentTitle(newTitle: string) { + if (this.versions[0]) { + this.versions[0].title = newTitle; + } + } + /** * returns the most current motion text from versions */ get currentText() { - return this.versions[0].text; + if (this.versions) { + return this.versions[0].text; + } else { + return null; + } + } + + set currentText(newText: string) { + this.versions[0].text = newText; } /** * returns the most current motion reason text from versions */ get currentReason() { - return this.versions[0].reason; + if (this.versions) { + return this.versions[0].reason; + } else { + return null; + } + } + + /** + * Update the current reason. + * TODO: ignores motion versions. Should make a new one. + */ + set currentReason(newReason: string) { + this.versions[0].reason = newReason; } /** @@ -110,76 +187,64 @@ export class Motion extends BaseModel { */ get submitterAsUser() { const submitterIds = []; - this.submitters.forEach(submitter => { - submitterIds.push(submitter.user_id); - }); - const users = this.DS.get(User, ...submitterIds); - return users; - } - - /** - * returns the name of the first submitter - */ - get submitterName() { - const mainSubmitter = this.DS.get(User, this.submitters[0].user_id) as User; - if (mainSubmitter) { - return mainSubmitter.username; + if (this.submitters && this.submitters.length > 0) { + this.submitters.forEach(submitter => { + submitterIds.push(submitter.user_id); + }); + const users = this.DS.get(User, ...submitterIds); + return users; } else { - return ''; + return null; } } /** * get the category of a motion as object */ - get category() { + get category(): any { if (this.category_id) { const motionCategory = this.DS.get(Category, this.category_id); - return motionCategory; - } else { - return 'none'; - } - } - - /** - * return the workflow state - * - * Right now only the default workflow is assumed - * TODO: Motion workflow needs to be specific on the server - */ - get stateName() { - //get the default workflow - const motionsWorkflowConfig = this.DS.filter(Config, config => config.key === 'motions_workflow')[0] as Config; - //make sure this is a number - const workflowId = +motionsWorkflowConfig.value; - //get the workflow for out motion - const selectedWorkflow = this.DS.get(Workflow, workflowId) as Workflow; - const stateName = selectedWorkflow.getStateNameById(this.state_id); - if (stateName !== 'NULL') { - return stateName; + return motionCategory as Category; } else { return ''; } } - get possibleStates() { - return ''; + /** + * Set the category in the motion + */ + set category(newCategory: any) { + this.category_id = newCategory.id; + } + + /** + * return the workflow state + */ + get state(): any { + if (this.state_id && this.workflow && this.workflow.id) { + const state = this.workflow.state_by_id(this.state_id); + return state; + } else { + return ''; + } + } + + /** + * returns possible states for the motion + */ + get possible_states(): WorkflowState[] { + return this.workflow.states; } /** * Returns the name of the recommendation. * - * Right now only the default workflow is assumed * TODO: Motion workflow needs to be specific on the server */ - get recommendation() { - //get the default workflow - const motionsWorkflowConfig = this.DS.filter(Config, config => config.key === 'motions_workflow')[0] as Config; - const workflowId = +motionsWorkflowConfig.value; - const selectedWorkflow = this.DS.get(Workflow, workflowId) as Workflow; - const stateName = selectedWorkflow.getStateNameById(this.recommendation_id); - if (stateName !== 'NULL') { - return stateName; + get recommendation(): any { + if (this.recommendation_id && this.workflow && this.workflow.id) { + const state = this.workflow.state_by_id(this.recommendation_id); + return state; } else { return ''; } @@ -193,8 +258,13 @@ export class Motion extends BaseModel { Config, config => config.key === 'motions_recommendations_by' )[0] as Config; - const recomByString = motionsRecommendationsByConfig.value; - return recomByString; + + if (motionsRecommendationsByConfig) { + const recomByString = motionsRecommendationsByConfig.value; + return recomByString; + } else { + return null; + } } deserialize(input: any): this { diff --git a/client/src/app/shared/models/motions/workflow-state.ts b/client/src/app/shared/models/motions/workflow-state.ts index 8cd689202..acc85b614 100644 --- a/client/src/app/shared/models/motions/workflow-state.ts +++ b/client/src/app/shared/models/motions/workflow-state.ts @@ -83,4 +83,8 @@ export class WorkflowState implements Deserializable { Object.assign(this, input); return this; } + + public toString = (): string => { + return this.name; + }; } diff --git a/client/src/app/shared/models/motions/workflow.ts b/client/src/app/shared/models/motions/workflow.ts index 0487fb07b..f1f8bb20f 100644 --- a/client/src/app/shared/models/motions/workflow.ts +++ b/client/src/app/shared/models/motions/workflow.ts @@ -21,14 +21,14 @@ export class Workflow extends BaseModel { this.first_state = first_state; } - getStateNameById(id: number): string { - let stateName = 'NULL'; + state_by_id(id: number): WorkflowState { + let targetState; this.states.forEach(state => { if (id === state.id) { - stateName = state.name; + targetState = state; } }); - return stateName; + return targetState as WorkflowState; } deserialize(input: any): this { diff --git a/client/src/app/shared/models/users/user.ts b/client/src/app/shared/models/users/user.ts index 31240e015..135f30412 100644 --- a/client/src/app/shared/models/users/user.ts +++ b/client/src/app/shared/models/users/user.ts @@ -64,4 +64,39 @@ export class User extends BaseModel { getGroups(): BaseModel | BaseModel[] { return this.DS.get('users/group', ...this.groups_id); } + + //TODO get full_name + + // TODO read config values for "users_sort_by" + get short_name(): string { + const title = this.title.trim(); + const firstName = this.first_name.trim(); + const lastName = this.last_name.trim(); + let shortName = ''; + + // TODO need DS adjustment first first + // if (this.DS.getConfig('users_sort_by').value === 'last_name') { + // if (lastName && firstName) { + // shortName += `${lastName}, ${firstName}`; + // } else { + // shortName += lastName || firstName; + // } + // } + + shortName += `${firstName} ${lastName}`; + + if (shortName.trim() === '') { + shortName = this.username; + } + + if (title) { + shortName = `${title} ${shortName}`; + } + + return shortName.trim(); + } + + public toString = (): string => { + return this.short_name; + }; } diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 886a37795..f596c455f 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -20,6 +20,8 @@ import { MatDialogModule } from '@angular/material/dialog'; import { MatListModule } from '@angular/material/list'; import { MatExpansionModule } from '@angular/material/expansion'; import { MatMenuModule } from '@angular/material/menu'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; // FontAwesome modules import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; @@ -48,6 +50,8 @@ library.add(fas); imports: [ CommonModule, FormsModule, + MatFormFieldModule, + MatSelectModule, ReactiveFormsModule, MatButtonModule, MatCheckboxModule, @@ -68,6 +72,8 @@ library.add(fas); ], exports: [ FormsModule, + MatFormFieldModule, + MatSelectModule, ReactiveFormsModule, MatButtonModule, MatCheckboxModule, diff --git a/client/src/app/site/motions/motion-detail/motion-detail.component.html b/client/src/app/site/motions/motion-detail/motion-detail.component.html index 5437fb598..0a8e475d1 100644 --- a/client/src/app/site/motions/motion-detail/motion-detail.component.html +++ b/client/src/app/site/motions/motion-detail/motion-detail.component.html @@ -1,90 +1,148 @@ - - -
- Motion {{motion.identifier}}: {{motion.currentTitle}} + New + Motion + {{motion.identifier}} + {{metaInfoForm.get('identifier').value}} + : + {{motion.currentTitle}} + {{contentForm.get('currentTitle').value}}
-
- by {{motion.submitterName}} +
+ by {{motion.submitterAsUser}}
+ + + + + + + + + + - - + + + + Meta information -
-
-

Submitters

- {{motion.submitterName}} -
+ +
-
-

translate>Supporters

- -
- -
-

- - Status - - -

- {{motion.stateName}} -
- -
-

- - {{motion.recomBy}} - - -

- {{motion.recommendation}} -
- -
-

- - Category - - -

- {{motion.category}} -
- -
-

- Origin - -

- {{motion.origin}} - -
- - + +
+ +
+

Identifier

+ {{motion.identifier}} +
+ +
-
-

Voting

+ +
+

Submitters

+ {{motion.submitterAsUser}}
-
+ + +
+

Supporters

+ +
+ + +
+
+

State

+ {{motion.state.name}} +
+ + + {{state}} + + + + Reset State + + + +
+ + + +
+
+

{{motion.recomBy}}

+ {{motion.recommendation.name}} +
+ + + {{state}} + + + + Reset recommendation + + + +
+ + +
+
+

Category

+ {{motion.category}} +
+ + + None + + {{cat}} + + +
+ + +
+
+

Origin

+ {{motion.origin}} +
+ + + +
+ + + + - + + @@ -94,7 +152,9 @@ TEST - + + + @@ -102,23 +162,39 @@ -

The assembly may decide:

-
+
-

Reason

-
+ +
+
+

{{motion.currentTitle}}

+
+ + + +
+ + +

The assembly may decide:

+
+
+
+ + + + + +
+
+

Reason

+
+
+ + + +
+ +
- - - - - - - - - - - - \ No newline at end of file diff --git a/client/src/app/site/motions/motion-detail/motion-detail.component.scss b/client/src/app/site/motions/motion-detail/motion-detail.component.scss index 9909432a8..4043eaee2 100644 --- a/client/src/app/site/motions/motion-detail/motion-detail.component.scss +++ b/client/src/app/site/motions/motion-detail/motion-detail.component.scss @@ -2,6 +2,14 @@ span { margin: 0; } +.save-button { + background-color: rgb(77, 243, 86); +} + +.deleteMotionButton { + color: red; +} + .motion-title { padding-left: 20px; line-height: 100%; @@ -52,17 +60,33 @@ mat-panel-title { margin-left: 5px; } } +} - .meta-info-panel-body { +.wide-text { + width: 95%; + + textarea { + height: 200px; + } +} + +mat-expansion-panel { + .expansion-panel-custom-body { padding-left: 55px; } } .content-panel { + h2 { + display: block; + font-weight: bold; + font-size: 120%; + } + h3 { + display: block; font-weight: initial; font-size: 100%; - display: block; } h4 { diff --git a/client/src/app/site/motions/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/motion-detail/motion-detail.component.ts index 409cb469c..cf9e99ab0 100644 --- a/client/src/app/site/motions/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/motions/motion-detail/motion-detail.component.ts @@ -1,45 +1,187 @@ -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { OpenSlidesComponent } from '../../../openslides.component'; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; import { BaseComponent } from '../../../base.component'; import { Motion } from '../../../shared/models/motions/motion'; +import { Category } from '../../../shared/models/motions/category'; +import { FormGroup, FormBuilder } from '@angular/forms'; +import { MatExpansionPanel } from '@angular/material'; +import { DataSendService } from '../../../core/services/data-send.service'; +/** + * Component for the motion detail view + */ @Component({ selector: 'app-motion-detail', templateUrl: './motion-detail.component.html', styleUrls: ['./motion-detail.component.scss'] }) export class MotionDetailComponent extends BaseComponent implements OnInit { + /** + * MatExpansionPanel for the meta info + */ + @ViewChild('metaInfoPanel') metaInfoPanel: MatExpansionPanel; + + /** + * MatExpansionPanel for the content panel + */ + @ViewChild('contentPanel') contentPanel: MatExpansionPanel; + + /** + * Target motion. Might be new or old + */ motion: Motion; - constructor(private route: ActivatedRoute) { + /** + * Motions meta-info + */ + metaInfoForm: FormGroup; + + /** + * Motion content. Can be a new version + */ + contentForm: FormGroup; + + /** + * Determine if the motion is edited + */ + editMotion = false; + + /** + * Determine if the motion is new + */ + newMotion = false; + + /** + * Constuct the detail view. + * + * + * @param route determine if this is a new or an existing motion + * @param formBuilder For reactive forms. Form Group and Form Control + */ + constructor( + private router: Router, + private route: ActivatedRoute, + private formBuilder: FormBuilder, + private dataSend: DataSendService + ) { super(); - this.route.params.subscribe(params => { - console.log(params.id); + this.createForm(); - // has the motion of the DataStore was initialized before. - // Otherwise we need to observe DS - this.motion = this.DS.get(Motion, params.id) as Motion; + if (route.snapshot.url[0].path === 'new') { + this.newMotion = true; + this.editMotion = true; + this.motion = new Motion(); + } else { + // load existing motion + this.route.params.subscribe(params => { + // has the motion of the DataStore was initialized before. + this.motion = this.DS.get(Motion, params.id) as Motion; - // Observe motion to get the motion in the parameter and also get the changes - this.DS.getObservable().subscribe(newModel => { - if (newModel instanceof Motion) { - if (newModel.id === +params.id) { - this.motion = newModel as Motion; - console.log('this.motion = ', this.motion); - // console.log('motion state name: ', this.motion.stateName); + // Observe motion to get the motion in the parameter and also get the changes + this.DS.getObservable().subscribe(newModel => { + if (newModel instanceof Motion) { + if (newModel.id === +params.id) { + this.motion = newModel as Motion; + } } - } + }); }); + } + } + + /** + * Async load the values of the motion in the Form. + */ + patchForm() { + this.metaInfoForm.patchValue({ + category_id: this.motion.category.id, + state_id: this.motion.state.id, + recommendation_id: this.motion.recommendation.id, + identifier: this.motion.identifier, + origin: this.motion.origin + }); + this.contentForm.patchValue({ + currentTitle: this.motion.currentTitle, + currentText: this.motion.currentText, + currentReason: this.motion.currentReason }); } - ngOnInit() { - console.log('(init)the motion: ', this.motion); - console.log('motion state name: ', this.motion.stateName); + /** + * Creates the forms for the Motion and the MotionVersion + * + * TODO: Build a custom form validator + */ + createForm() { + this.metaInfoForm = this.formBuilder.group({ + identifier: [''], + category_id: [''], + state_id: [''], + recommendation_id: [''], + origin: [''] + }); + this.contentForm = this.formBuilder.group({ + currentTitle: [''], + currentText: [''], + currentReason: [''] + }); } - downloadSingleMotionButton() { - console.log('Download this motion'); + /** + * Save a motion. Calls the "patchValues" function in the MotionObject + * + * http:post the motion to the server. + * The AutoUpdate-Service should see a change once it arrives and show it + * in the list view automatically + */ + saveMotion() { + const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value }; + this.motion.patchValues(newMotionValues); + + // TODO: This is DRAFT. Reads out Motion version directly. Potentially insecure. + this.motion.title = this.motion.currentTitle; + this.motion.text = this.motion.currentText; + + this.dataSend.saveModel(this.motion).subscribe(answer => { + if (answer && answer.id && this.newMotion) { + this.router.navigate(['./motions/' + answer.id]); + } + }); } + + /** + * return all Categories. + */ + getMotionCategories(): Category[] { + const categories = this.DS.get(Category); + return categories as Category[]; + } + + /** + * Click on the edit button (pen-symbol) + */ + editMotionButton() { + this.editMotion ? (this.editMotion = false) : (this.editMotion = true); + if (this.editMotion) { + this.patchForm(); + this.metaInfoPanel.open(); + this.contentPanel.open(); + } else { + this.saveMotion(); + } + } + + /** + * Trigger to delete the motion + */ + deleteMotionButton() { + this.dataSend.delete(this.motion).subscribe(answer => { + this.router.navigate(['./motions/']); + }); + } + + /** + * Init. Does nothing here. + */ + ngOnInit() {} } diff --git a/client/src/app/site/motions/motion-list/motion-list.component.html b/client/src/app/site/motions/motion-list/motion-list.component.html index 4a60dbbaf..9a2868567 100644 --- a/client/src/app/site/motions/motion-list/motion-list.component.html +++ b/client/src/app/site/motions/motion-list/motion-list.component.html @@ -1,6 +1,6 @@ - @@ -43,7 +43,7 @@
by - {{motion.submitterAsUser.username}} + {{motion.submitterAsUser}}
@@ -53,8 +53,8 @@ State -
- +
+
@@ -63,4 +63,4 @@ - \ No newline at end of file + diff --git a/client/src/app/site/motions/motion-list/motion-list.component.ts b/client/src/app/site/motions/motion-list/motion-list.component.ts index 51200d068..456ea6356 100644 --- a/client/src/app/site/motions/motion-list/motion-list.component.ts +++ b/client/src/app/site/motions/motion-list/motion-list.component.ts @@ -7,6 +7,9 @@ import { Motion } from '../../../shared/models/motions/motion'; import { MatTable, MatPaginator, MatSort, MatTableDataSource } from '@angular/material'; import { Workflow } from '../../../shared/models/motions/workflow'; +/** + * Component that displays all the motions in a Table using DataSource. + */ @Component({ selector: 'app-motion-list', templateUrl: './motion-list.component.html', @@ -28,14 +31,26 @@ export class MotionListComponent extends BaseComponent implements OnInit { */ dataSource: MatTableDataSource; + /** + * The table itself. + */ @ViewChild(MatTable) table: MatTable; + + /** + * Pagination. Might be turned off to all motions at once. + */ @ViewChild(MatPaginator) paginator: MatPaginator; + + /** + * Sort the Table + */ @ViewChild(MatSort) sort: MatSort; /** * Use for minimal width */ columnsToDisplayMinWidth = ['identifier', 'title', 'state']; + /** * Use for maximal width */ @@ -43,13 +58,16 @@ export class MotionListComponent extends BaseComponent implements OnInit { /** * Constructor implements title and translation Module. - * @param titleService - * @param translate + * + * @param titleService Title + * @param translate Translation + * @param router Router + * @param route Current route */ constructor( - public router: Router, - titleService: Title, + protected titleService: Title, protected translate: TranslateService, + private router: Router, private route: ActivatedRoute ) { super(titleService, translate); @@ -70,35 +88,43 @@ export class MotionListComponent extends BaseComponent implements OnInit { // The alternative approach is to put the observable as DataSource to the table this.DS.getObservable().subscribe(newModel => { if (newModel instanceof Motion) { - this.motionArray.push(newModel as Motion); + this.motionArray = this.DS.get(Motion) as Motion[]; this.dataSource.data = this.motionArray; } }); } + /** + * Select a motion from list. Executed via click. + * + * @param motion The row the user clicked at + */ selectMotion(motion) { - console.log('clicked a row, :', motion); - this.router.navigate(['./' + motion.id], { relativeTo: this.route }); } /** * Get the icon to the coresponding Motion Status * TODO Needs to be more accessible (Motion workflow needs adjustment on the server) - * @param stateName the name of the state + * @param state the name of the state */ - getStateIcon(stateName) { + getStateIcon(state) { + const stateName = state.name; if (stateName === 'accepted') { return 'thumbs-up'; } else if (stateName === 'rejected') { return 'thumbs-down'; } else if (stateName === 'not decided') { return 'question'; + } else { + return ''; } } /** * Download all motions As PDF and DocX + * + * TODO: Currently does nothing */ downloadMotionsButton() { console.log('Download Motions Button'); diff --git a/client/src/app/site/motions/motions-routing.module.ts b/client/src/app/site/motions/motions-routing.module.ts index e85a13e36..f74ec93e0 100644 --- a/client/src/app/site/motions/motions-routing.module.ts +++ b/client/src/app/site/motions/motions-routing.module.ts @@ -5,7 +5,7 @@ import { MotionDetailComponent } from './motion-detail/motion-detail.component'; const routes: Routes = [ { path: '', component: MotionListComponent }, - { path: 'dummy', component: MotionDetailComponent }, + { path: 'new', component: MotionDetailComponent }, { path: ':id', component: MotionDetailComponent } ]; diff --git a/client/src/app/site/site.component.ts b/client/src/app/site/site.component.ts index 9401e0daa..f9fd31883 100644 --- a/client/src/app/site/site.component.ts +++ b/client/src/app/site/site.component.ts @@ -65,12 +65,12 @@ export class SiteComponent extends BaseComponent implements OnInit { } }); - //get a translation via code: use the translation service - this.translate.get('Motions').subscribe((res: string) => { - console.log('translation of motions in the target language: ' + res); - }); + // get a translation via code: use the translation service + // this.translate.get('Motions').subscribe((res: string) => { + // console.log('translation of motions in the target language: ' + res); + // }); - //start autoupdate if the user is logged in: + // start autoupdate if the user is logged in: this.operator.whoAmI().subscribe(resp => { if (resp.user) { this.autoupdateService.startAutoupdate(); diff --git a/client/src/app/site/start/start.component.ts b/client/src/app/site/start/start.component.ts index fcf6abb50..cc4db77d3 100644 --- a/client/src/app/site/start/start.component.ts +++ b/client/src/app/site/start/start.component.ts @@ -64,7 +64,6 @@ export class StartComponent extends BaseComponent implements OnInit { if (welcomeTextConfig) { this.welcomeText = welcomeTextConfig.value as string; } - console.log(this.DS.filter(Config, config => config.key === 'general_event_welcome_title')); // observe title and text in DS this.DS.getObservable().subscribe(newModel => {