From 4b7a5e1575b1a3271d0bef25ed257389f7595116 Mon Sep 17 00:00:00 2001 From: Robert-Andrei Damian Date: Fri, 2 Oct 2020 11:13:36 +0200 Subject: [PATCH] Implemented forms tab (#246) --- frontend/angular.json | 3 + frontend/package.json | 1 + frontend/src/app/app.module.ts | 4 +- .../answer-details.component.html | 4 +- .../answer-details.component.ts | 26 ++- .../answer-form-list.component.html | 2 +- .../answer-form-list.component.ts | 15 +- .../src/app/components/components.module.ts | 19 ++- .../form-create/form-create.component.html | 64 ++++++++ .../form-create/form-create.component.scss | 48 ++++++ .../form-create/form-create.component.spec.ts | 25 +++ .../form-create/form-create.component.ts | 148 ++++++++++++++++++ .../components/forms/form-groups-builder.ts | 35 +++++ .../app/components/forms/forms.component.html | 55 +++++++ .../app/components/forms/forms.component.scss | 22 +++ .../components/forms/forms.component.spec.ts | 25 +++ .../app/components/forms/forms.component.ts | 62 ++++++++ .../forms/option/option.component.html | 18 +++ .../forms/option/option.component.scss | 15 ++ .../forms/option/option.component.spec.ts | 25 +++ .../forms/option/option.component.ts | 17 ++ .../forms/question/question.component.html | 52 ++++++ .../forms/question/question.component.scss | 60 +++++++ .../forms/question/question.component.spec.ts | 25 +++ .../forms/question/question.component.ts | 53 +++++++ .../forms/section/section.component.html | 59 +++++++ .../forms/section/section.component.scss | 63 ++++++++ .../forms/section/section.component.spec.ts | 25 +++ .../forms/section/section.component.ts | 40 +++++ .../components/header/header.component.html | 5 + .../observers/observers.component.ts | 3 +- frontend/src/app/components/utils.ts | 12 ++ frontend/src/app/models/form.info.model.ts | 4 +- frontend/src/app/models/form.model.ts | 27 +++- .../src/app/models/form.question.model.ts | 27 +++- frontend/src/app/routing/app.routes.ts | 17 ++ frontend/src/app/services/forms.service.ts | 65 ++++++++ .../icon-toggle-input.component.html | 4 + .../icon-toggle-input.component.scss | 0 .../icon-toggle-input.component.ts | 44 ++++++ frontend/src/app/shared/shared.module.ts | 24 +-- frontend/src/app/store/form/form.actions.ts | 75 +++++++-- frontend/src/app/store/form/form.effects.ts | 123 ++++++++++----- frontend/src/app/store/form/form.reducer.ts | 36 ++++- frontend/src/app/store/store.module.ts | 4 + frontend/src/assets/delete.svg | 4 + frontend/src/assets/forms/diaspora-check.png | Bin 0 -> 271 bytes frontend/src/assets/forms/down.png | Bin 0 -> 232 bytes frontend/src/assets/forms/icon-delete.png | Bin 0 -> 385 bytes .../src/assets/forms/icon-flag-diabled.png | Bin 0 -> 347 bytes .../src/assets/forms/icon-flag-enabled.png | Bin 0 -> 376 bytes frontend/src/assets/forms/icon-reorder.png | Bin 0 -> 348 bytes .../src/assets/forms/icon-text-disabled.png | Bin 0 -> 319 bytes .../src/assets/forms/icon-text-enabled.png | Bin 0 -> 328 bytes frontend/src/assets/i18n/en.json | 34 +++- frontend/src/assets/i18n/ro.json | 34 +++- frontend/yarn.lock | 12 ++ 57 files changed, 1470 insertions(+), 94 deletions(-) create mode 100644 frontend/src/app/components/forms/form-create/form-create.component.html create mode 100644 frontend/src/app/components/forms/form-create/form-create.component.scss create mode 100644 frontend/src/app/components/forms/form-create/form-create.component.spec.ts create mode 100644 frontend/src/app/components/forms/form-create/form-create.component.ts create mode 100644 frontend/src/app/components/forms/form-groups-builder.ts create mode 100644 frontend/src/app/components/forms/forms.component.html create mode 100644 frontend/src/app/components/forms/forms.component.scss create mode 100644 frontend/src/app/components/forms/forms.component.spec.ts create mode 100644 frontend/src/app/components/forms/forms.component.ts create mode 100644 frontend/src/app/components/forms/option/option.component.html create mode 100644 frontend/src/app/components/forms/option/option.component.scss create mode 100644 frontend/src/app/components/forms/option/option.component.spec.ts create mode 100644 frontend/src/app/components/forms/option/option.component.ts create mode 100644 frontend/src/app/components/forms/question/question.component.html create mode 100644 frontend/src/app/components/forms/question/question.component.scss create mode 100644 frontend/src/app/components/forms/question/question.component.spec.ts create mode 100644 frontend/src/app/components/forms/question/question.component.ts create mode 100644 frontend/src/app/components/forms/section/section.component.html create mode 100644 frontend/src/app/components/forms/section/section.component.scss create mode 100644 frontend/src/app/components/forms/section/section.component.spec.ts create mode 100644 frontend/src/app/components/forms/section/section.component.ts create mode 100644 frontend/src/app/components/utils.ts create mode 100644 frontend/src/app/services/forms.service.ts create mode 100644 frontend/src/app/shared/icon-toggle-input/icon-toggle-input.component.html create mode 100644 frontend/src/app/shared/icon-toggle-input/icon-toggle-input.component.scss create mode 100644 frontend/src/app/shared/icon-toggle-input/icon-toggle-input.component.ts create mode 100644 frontend/src/assets/delete.svg create mode 100644 frontend/src/assets/forms/diaspora-check.png create mode 100644 frontend/src/assets/forms/down.png create mode 100644 frontend/src/assets/forms/icon-delete.png create mode 100644 frontend/src/assets/forms/icon-flag-diabled.png create mode 100644 frontend/src/assets/forms/icon-flag-enabled.png create mode 100644 frontend/src/assets/forms/icon-reorder.png create mode 100644 frontend/src/assets/forms/icon-text-disabled.png create mode 100644 frontend/src/assets/forms/icon-text-enabled.png diff --git a/frontend/angular.json b/frontend/angular.json index 3756be6d..5ca8f4cb 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -187,6 +187,9 @@ }, "@schematics/angular:directive": { "prefix": "app" + }, + "@nrwl/schematics:component": { + "styleext": "scss" } }, "cli": { diff --git a/frontend/package.json b/frontend/package.json index cf4c4bcd..bf67a763 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "private": true, "dependencies": { "@angular/animations": "^9.1.12", + "@angular/cdk": "^9.1.12", "@angular/cli": "^9.1.12", "@angular/common": "^9.1.12", "@angular/compiler": "^9.1.12", diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 486cdb98..ea917edc 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -16,6 +16,7 @@ import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import { NotificationsService } from './services/notifications.service'; import { AnswersService } from './services/answers.service'; import { ModalModule } from 'ngx-bootstrap/modal'; +import {FormsService} from './services/forms.service'; export function HttpLoaderFactory(httpClient: HttpClient) { return new TranslateHttpLoader(httpClient); @@ -44,7 +45,8 @@ export function HttpLoaderFactory(httpClient: HttpClient) { providers: [ ObserversService, NotificationsService, - AnswersService + AnswersService, + FormsService ], bootstrap: [AppComponent] diff --git a/frontend/src/app/components/answer/answer-details/answer-details.component.html b/frontend/src/app/components/answer/answer-details/answer-details.component.html index 8b5e3887..95b9ffef 100644 --- a/frontend/src/app/components/answer/answer-details/answer-details.component.html +++ b/frontend/src/app/components/answer/answer-details/answer-details.component.html @@ -12,9 +12,9 @@ - + {{form.description}} - diff --git a/frontend/src/app/components/answer/answer-details/answer-details.component.ts b/frontend/src/app/components/answer/answer-details/answer-details.component.ts index 118f5f81..83e38399 100644 --- a/frontend/src/app/components/answer/answer-details/answer-details.component.ts +++ b/frontend/src/app/components/answer/answer-details/answer-details.component.ts @@ -8,6 +8,10 @@ import {AnswerState} from '../../../store/answer/answer.reducer'; import {Component, OnDestroy, OnInit} from '@angular/core'; import * as _ from 'lodash'; import {CompletedQuestion} from '../../../models/completed.question.model'; +import {TabDirective} from 'ngx-bootstrap/tabs'; +import {FormDetails} from '../../../models/form.info.model'; +import {FullyLoadFormAction} from '../../../store/form/form.actions'; +import {Form} from '../../../models/form.model'; @Component({ selector: 'app-answer-details', @@ -60,10 +64,16 @@ export class AnswerDetailsComponent implements OnInit, OnDestroy { this.subs = [ this.store.select(s => s.answer).subscribe(s => this.answerState = s), - this.store.select(s => s.form).subscribe(s => this.formState = s), + this.store.select(s => s.form).subscribe(s => { + this.formState = s; + if (s.items.length > 0) { + this.onTabSelected(s.items[0]); + } + }), this.store.select(s => s.note).subscribe(s => this.noteState = s) ]; } + ngOnDestroy() { _.map(this.subs, sub => sub.unsubscribe()); } @@ -71,4 +81,18 @@ export class AnswerDetailsComponent implements OnInit, OnDestroy { retry() { this.store.dispatch(new LoadAnswerDetailsAction(this.answerState.observerId, this.answerState.sectionId)); } + + onTabSelected(form: FormDetails) { + // if the form is already loaded don't launch another action + if (this.formState.fullyLoaded[form.id]) { + return ; + } + + this.store.dispatch(new FullyLoadFormAction(form.id)); + } + + getDataForForm(form: FormDetails): Form { + const fullyLoaded = this.formState.fullyLoaded[form.id]; + return fullyLoaded ? fullyLoaded : Form.fromMetaData(form); + } } diff --git a/frontend/src/app/components/answer/answer-form-list/answer-form-list.component.html b/frontend/src/app/components/answer/answer-form-list/answer-form-list.component.html index 1bf5d629..6b028c45 100644 --- a/frontend/src/app/components/answer/answer-form-list/answer-form-list.component.html +++ b/frontend/src/app/components/answer/answer-form-list/answer-form-list.component.html @@ -1,4 +1,4 @@ -
+

{{section.description}}

diff --git a/frontend/src/app/components/answer/answer-form-list/answer-form-list.component.ts b/frontend/src/app/components/answer/answer-form-list/answer-form-list.component.ts index b5a3b00a..ab37c99f 100644 --- a/frontend/src/app/components/answer/answer-form-list/answer-form-list.component.ts +++ b/frontend/src/app/components/answer/answer-form-list/answer-form-list.component.ts @@ -1,10 +1,10 @@ -import { Note } from '../../../models/note.model'; -import { AnswerThread } from '../../../models/answer.thread.model'; -import { BaseQuestion } from '../../../models/base.question.model'; -import { CompletedQuestion } from '../../../models/completed.question.model'; -import { Form } from '../../../models/form.model'; -import { Component, Input, OnInit } from '@angular/core'; +import {Note} from '../../../models/note.model'; +import {BaseQuestion} from '../../../models/base.question.model'; +import {CompletedQuestion} from '../../../models/completed.question.model'; +import {Component, Input, OnInit} from '@angular/core'; import * as _ from 'lodash'; +import {FormDetails} from '../../../models/form.info.model'; +import {Form} from '../../../models/form.model'; @Component({ selector: 'app-answer-form-list', @@ -26,7 +26,7 @@ export class AnswerFormListComponent implements OnInit { return _.find(this.completedQuestions, value => value.id === question.id); } notesForQuestion(question: BaseQuestion) { - if (!this.notes || !this.notes.length){ + if (!this.notes || !this.notes.length) { return undefined; } return this.notes.filter(note => note.questionId === question.id); @@ -34,7 +34,6 @@ export class AnswerFormListComponent implements OnInit { constructor() { } - ngOnInit() { } diff --git a/frontend/src/app/components/components.module.ts b/frontend/src/app/components/components.module.ts index a7fb8d43..c0d5a384 100644 --- a/frontend/src/app/components/components.module.ts +++ b/frontend/src/app/components/components.module.ts @@ -19,6 +19,12 @@ import { OberverRowComponent } from './observers/oberver-row/oberver-row.compone import { ObserverProfileComponent } from './observers/observer-profile/observer-profile.component'; import { NotificationsComponent } from './notifications/notifications.component'; import { NgMultiSelectDropDownModule } from 'ng-multiselect-dropdown'; +import {FormCreateComponent} from './forms/form-create/form-create.component'; +import {SectionComponent} from './forms/section/section.component'; +import {QuestionComponent} from './forms/question/question.component'; +import {OptionComponent} from './forms/option/option.component'; +import {FormsComponent} from './forms/forms.component'; +import {DragDropModule} from '@angular/cdk/drag-drop'; export let components = [ AnswerComponent, @@ -32,19 +38,28 @@ export let components = [ ObserverCardComponent, OberverRowComponent, ObserverProfileComponent, + FormsComponent, + FormCreateComponent, + SectionComponent, + QuestionComponent, + OptionComponent, HeaderComponent, StatisticsComponent, StatisticsCardComponent, StatisticsDetailsComponent, StatisticsValueComponent, NotificationsComponent, - LoginComponent + LoginComponent, ]; @NgModule({ declarations: components, exports: components, - imports: [SharedModule, NgMultiSelectDropDownModule.forRoot()] + imports: [ + SharedModule, + NgMultiSelectDropDownModule.forRoot(), + DragDropModule + ] }) export class ComponentsModule { diff --git a/frontend/src/app/components/forms/form-create/form-create.component.html b/frontend/src/app/components/forms/form-create/form-create.component.html new file mode 100644 index 00000000..6d3be055 --- /dev/null +++ b/frontend/src/app/components/forms/form-create/form-create.component.html @@ -0,0 +1,64 @@ +
+
← {{ 'BACK' | translate}}
+ +
+ {{'FORM_EDIT' | translate }} +
+ + +
+
+ +
+
+
+
+
+
+ {{'FORM_TITLE' | translate}} +
+
+ +
+
+
+
+ {{'FORM_CODE' | translate}} +
+
+ +
+
+
+
+ {{'FORM_ONLY_DIASPORA' | translate}} +
+
+ +
+
+
+
+ + + + + + + + +
+
+
+
+ +
+
+ + +
+
+ +
+ {{'SECTION_ADD' | translate}}
+
diff --git a/frontend/src/app/components/forms/form-create/form-create.component.scss b/frontend/src/app/components/forms/form-create/form-create.component.scss new file mode 100644 index 00000000..10eabe27 --- /dev/null +++ b/frontend/src/app/components/forms/form-create/form-create.component.scss @@ -0,0 +1,48 @@ +.form-create { + .back-button { + font-weight: bold; + cursor: pointer; + margin-bottom: 1.2em; + } + + .add-section-button { + color: rebeccapurple; + cursor: pointer; + font-weight: bold; + } + + .form-labeled-field { + margin-bottom: 10px; + + &-checkbox { + @extend .form-labeled-field; + + display: flex; + flex-direction: row; + justify-content: space-between; + + .checkbox-label { + display: flex; + flex-direction: column; + justify-content: space-around; + } + + .checkbox-field { + flex-basis: 16px; + } + } + } + + .header-row { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 20px; + + .header-text { + margin: auto 0; + font-size: larger; + font-weight: bold; + } + } +} diff --git a/frontend/src/app/components/forms/form-create/form-create.component.spec.ts b/frontend/src/app/components/forms/form-create/form-create.component.spec.ts new file mode 100644 index 00000000..92f79bdf --- /dev/null +++ b/frontend/src/app/components/forms/form-create/form-create.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FormCreateComponent } from './form-create.component'; + +describe('FormCreateComponent', () => { + let component: FormCreateComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ FormCreateComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FormCreateComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/forms/form-create/form-create.component.ts b/frontend/src/app/components/forms/form-create/form-create.component.ts new file mode 100644 index 00000000..d5a79cba --- /dev/null +++ b/frontend/src/app/components/forms/form-create/form-create.component.ts @@ -0,0 +1,148 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; +import {Form} from '../../../models/form.model'; +import {Location} from '@angular/common'; +import {FormSection} from '../../../models/form.section.model'; +import {AppState} from '../../../store/store.module'; +import {Store} from '@ngrx/store'; +import {FormUploadAction, FormUploadPublishAction, FullyLoadFormAction} from '../../../store/form/form.actions'; +import {Subscription} from 'rxjs'; +import {CdkDragDrop} from '@angular/cdk/drag-drop'; +import {FormArray, FormBuilder, FormGroup} from '@angular/forms'; +import {moveItemInFormArray} from '../../utils'; +import {initFormFormGroup, initOptionFormGroup, initQuestionFormGroup, initSectionFormGroup} from '../form-groups-builder'; +import {FormQuestion} from '../../../models/form.question.model'; +import {BaseAnswer} from '../../../models/base.answer.model'; + +@Component({ + selector: 'app-form-create', + templateUrl: './form-create.component.html', + styleUrls: ['./form-create.component.scss'] +}) +export class FormCreateComponent implements OnInit, OnDestroy { + + readonly FORM_ID_URL_PARAM = 'formId'; + + title: string; + + formDetailsFormGroup: FormGroup; + + formDataSubscription: Subscription; + + constructor(private location: Location, + private store: Store, + private activatedRoute: ActivatedRoute, + private formBuilder: FormBuilder) { } + + ngOnInit() { + this.formDetailsFormGroup = initFormFormGroup(this.formBuilder); + this.title = 'Adauga formular nou'; + + this.activatedRoute.paramMap.subscribe(params => { + const hasFormId = params.has(this.FORM_ID_URL_PARAM); + if (!hasFormId) { + return ; + } + + const targetFormId = +params.get(this.FORM_ID_URL_PARAM); + this.loadFormForEditing(targetFormId); + this.handleLoadedFormData(targetFormId); + }); + } + + get sectionsArray() { + return this.formDetailsFormGroup.get('formSections') as FormArray; + } + + get sectionFormGroupsArray(): FormGroup[] { + return this.sectionsArray.controls as FormGroup[]; + } + + private initSectionFormGroupWithValues(formSection: FormSection) { + return this.initFormGroupWithValues(initSectionFormGroup, formSection); + } + + private initQuestionFormGroupWithValues(formQuestion: FormQuestion) { + return this.initFormGroupWithValues(initQuestionFormGroup, formQuestion); + } + + private initOptionFormGroupWithValues(formOption: BaseAnswer) { + return this.initFormGroupWithValues(initOptionFormGroup, formOption); + } + + private initFormGroupWithValues(formGroupGenerator: (formBuilder: FormBuilder) => FormGroup, value: any) { + const formGroup = formGroupGenerator(this.formBuilder); + formGroup.patchValue(value); + return formGroup; + } + + private loadFormForEditing(formId: number) { + this.store.dispatch(new FullyLoadFormAction(formId)); + } + + private handleLoadedFormData(formId: number) { + this.formDataSubscription = this.store + .select(state => state.form.fullyLoaded) + .subscribe(loadedForms => { + const correspondingForm = loadedForms[formId]; + if (!correspondingForm) { + return ; + } + + this.patchReactiveForm(correspondingForm); + }); + } + + private patchReactiveForm(form: Form) { + this.formDetailsFormGroup.patchValue(form); + form.formSections.forEach(s => { + const sectionFormGroup = this.initSectionFormGroupWithValues(s); + s.questions.forEach(q => { + const questionFormGroup = this.initQuestionFormGroupWithValues(q); + + q.optionsToQuestions.forEach(o => { + const optionFormGroup = this.initOptionFormGroupWithValues(o); + + const optionsArray = questionFormGroup.controls.optionsToQuestions as FormArray; + optionsArray.push(optionFormGroup); + }); + + const questionsArray = sectionFormGroup.controls.questions as FormArray; + questionsArray.push(questionFormGroup); + }); + this.sectionsArray.push(sectionFormGroup); + }); + } + + public onBackPressed() { + this.location.back(); + } + + addFormSection() { + this.sectionsArray.push(initSectionFormGroup(this.formBuilder)); + } + + saveForm() { + const form = this.formDetailsFormGroup.value as Form; + this.store.dispatch(new FormUploadAction(form)); + } + + saveAndPublishForm() { + const form = this.formDetailsFormGroup.value as Form; + this.store.dispatch(new FormUploadPublishAction(form)); + } + + onSectionDelete(index: number) { + this.sectionsArray.removeAt(index); + } + + ngOnDestroy(): void { + if (this.formDataSubscription) { + this.formDataSubscription.unsubscribe(); + } + } + + onReorder(event: CdkDragDrop) { + moveItemInFormArray(this.sectionsArray, event.previousIndex, event.currentIndex); + } +} diff --git a/frontend/src/app/components/forms/form-groups-builder.ts b/frontend/src/app/components/forms/form-groups-builder.ts new file mode 100644 index 00000000..f0ddb997 --- /dev/null +++ b/frontend/src/app/components/forms/form-groups-builder.ts @@ -0,0 +1,35 @@ +import {FormBuilder, Validators} from '@angular/forms'; + + +export function initFormFormGroup(formBuilder: FormBuilder) { + return formBuilder.group({ + description: formBuilder.control(''), + code: formBuilder.control(''), + diaspora: formBuilder.control(false), + formSections: formBuilder.array([], Validators.required) + }); +} + +export function initSectionFormGroup(formBuilder: FormBuilder) { + return formBuilder.group({ + questions: formBuilder.array([]), + description: formBuilder.control(''), + code: formBuilder.control('') + }); +} + +export function initQuestionFormGroup(formBuilder: FormBuilder) { + return formBuilder.group({ + optionsToQuestions: formBuilder.array([]), + text: formBuilder.control(''), + code: formBuilder.control(''), + questionType: formBuilder.control(0) + }); +} + +export function initOptionFormGroup(formBuilder: FormBuilder) { + return formBuilder.group({ + text: formBuilder.control(''), + isFreeText: formBuilder.control(false) + }); +} diff --git a/frontend/src/app/components/forms/forms.component.html b/frontend/src/app/components/forms/forms.component.html new file mode 100644 index 00000000..792c7f60 --- /dev/null +++ b/frontend/src/app/components/forms/forms.component.html @@ -0,0 +1,55 @@ +
+ +
+ {{'FORMS' | translate }} +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
{{'INDEX' | translate}}{{'FORM_CODE' | translate}}{{'FORM_TITLE' | translate}}{{'DIASPORA' | translate}}{{'DRAFT' | translate}}{{'ACTION' | translate}}
+ {{ (page - 1) * pageSize + formsList.indexOf(form) + 1 }} + + {{ form.code }} + + {{ form.description }} + + + + + + +
+
+
+ diff --git a/frontend/src/app/components/forms/forms.component.scss b/frontend/src/app/components/forms/forms.component.scss new file mode 100644 index 00000000..8c208fec --- /dev/null +++ b/frontend/src/app/components/forms/forms.component.scss @@ -0,0 +1,22 @@ +.forms-component { + .footer-pagination { + margin: 8px auto; + } + + .dropdown-item { + margin: 4px; + } + + .header-row { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 20px; + + .header-text { + margin: auto 0; + font-size: larger; + font-weight: bold; + } + } +} diff --git a/frontend/src/app/components/forms/forms.component.spec.ts b/frontend/src/app/components/forms/forms.component.spec.ts new file mode 100644 index 00000000..006d4ce8 --- /dev/null +++ b/frontend/src/app/components/forms/forms.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FormsComponent } from './forms.component'; + +describe('FormsComponent', () => { + let component: FormsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ FormsComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FormsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/forms/forms.component.ts b/frontend/src/app/components/forms/forms.component.ts new file mode 100644 index 00000000..f4509c28 --- /dev/null +++ b/frontend/src/app/components/forms/forms.component.ts @@ -0,0 +1,62 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {FormDetails} from '../../models/form.info.model'; +import {AppState} from '../../store/store.module'; +import {select, Store} from '@ngrx/store'; +import {map, take} from 'rxjs/operators'; +import {Subscription} from 'rxjs'; +import {FormState} from '../../store/form/form.reducer'; +import {FormDeleteAction, FormLoadAction} from '../../store/form/form.actions'; +import {CdkDragDrop, moveItemInArray} from '@angular/cdk/drag-drop'; +import {cloneDeep} from 'lodash'; + +@Component({ + selector: 'app-forms', + templateUrl: './forms.component.html', + styleUrls: ['./forms.component.scss'] +}) +export class FormsComponent implements OnInit, OnDestroy { + formsList: FormDetails[]; + pageSize = 10; + totalCount = 0; + page = 1; + + formsSubscription: Subscription; + + constructor(private store: Store) { } + + ngOnInit() { + this.loadForms(1, this.pageSize); + this.handleFormsData(); + } + + private loadForms(pageNo: number, pageSize: number) { + this.store + .pipe( + select(s => s.form), + take(1), + map((storeItem: FormState) => new FormLoadAction()) + ) + .subscribe(action => this.store.dispatch(action)); + } + + private handleFormsData() { + this.formsSubscription = this.store + .select(state => state.form) + .subscribe(formState => { + this.formsList = cloneDeep(formState.items); + this.totalCount = this.formsList.length; + }); + } + + public deleteForm(form: FormDetails) { + this.store.dispatch(new FormDeleteAction(form.id)); + } + + ngOnDestroy(): void { + this.formsSubscription.unsubscribe(); + } + + onReorder(event: CdkDragDrop) { + moveItemInArray(this.formsList, event.previousIndex, event.currentIndex); + } +} diff --git a/frontend/src/app/components/forms/option/option.component.html b/frontend/src/app/components/forms/option/option.component.html new file mode 100644 index 00000000..1ad9bd4f --- /dev/null +++ b/frontend/src/app/components/forms/option/option.component.html @@ -0,0 +1,18 @@ +
+ +
+ + +
+ + +
+ +
+ +
+
+
+ diff --git a/frontend/src/app/components/forms/option/option.component.scss b/frontend/src/app/components/forms/option/option.component.scss new file mode 100644 index 00000000..16877dbc --- /dev/null +++ b/frontend/src/app/components/forms/option/option.component.scss @@ -0,0 +1,15 @@ +.option-container { + display: flex; + flex-direction: row; + margin: 4px auto; + + .icon-holder { + display: flex; + flex-direction: column; + justify-content: space-around; + + cursor: pointer; + + margin: auto 4px; + } +} diff --git a/frontend/src/app/components/forms/option/option.component.spec.ts b/frontend/src/app/components/forms/option/option.component.spec.ts new file mode 100644 index 00000000..bd615f94 --- /dev/null +++ b/frontend/src/app/components/forms/option/option.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OptionComponent } from './option.component'; + +describe('OptionComponent', () => { + let component: OptionComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ OptionComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(OptionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/forms/option/option.component.ts b/frontend/src/app/components/forms/option/option.component.ts new file mode 100644 index 00000000..1ab33d30 --- /dev/null +++ b/frontend/src/app/components/forms/option/option.component.ts @@ -0,0 +1,17 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {BaseAnswer} from '../../../models/base.answer.model'; +import {FormGroup} from '@angular/forms'; + +@Component({ + selector: 'app-option', + templateUrl: './option.component.html', + styleUrls: ['./option.component.scss'] +}) + +export class OptionComponent { + @Input() optionFormGroup: FormGroup; + + @Output() optionDeleteEventEmitter = new EventEmitter(); + + constructor() { } +} diff --git a/frontend/src/app/components/forms/question/question.component.html b/frontend/src/app/components/forms/question/question.component.html new file mode 100644 index 00000000..96cac9f7 --- /dev/null +++ b/frontend/src/app/components/forms/question/question.component.html @@ -0,0 +1,52 @@ + +
+
+ + ▶ + + + ▼ + +
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+
+ + + +
+ +
+
+
+ +
+ {{'OPTION_ADD' | translate}}
+
+
+
diff --git a/frontend/src/app/components/forms/question/question.component.scss b/frontend/src/app/components/forms/question/question.component.scss new file mode 100644 index 00000000..bb106a4f --- /dev/null +++ b/frontend/src/app/components/forms/question/question.component.scss @@ -0,0 +1,60 @@ +.question-component { + + display: flex; + flex-direction: row; + + .add-option-button { + color: rebeccapurple; + cursor: pointer; + font-weight: bold; + } + + .options-panel { + background-color: #EDEDED; + padding: 8px; + + .options-list { + display: flex; + flex-direction: column; + + .option-line { + display: flex; + flex-direction: row; + + .option { + flex-grow: 1; + } + } + + } + } + + .expandable-container { + display: flex; + flex-direction: column; + + flex-grow: 1; + + padding: 4px; + } + + .main-fields-container { + display: flex; + justify-content: space-evenly; + flex-direction: row; + + border: 1px solid #EDEDED; + } + + .icon-holder { + display: flex; + flex-direction: column; + justify-content: space-around; + + cursor: pointer; + } + + .field { + margin: 16px auto; + } +} diff --git a/frontend/src/app/components/forms/question/question.component.spec.ts b/frontend/src/app/components/forms/question/question.component.spec.ts new file mode 100644 index 00000000..0a695f5d --- /dev/null +++ b/frontend/src/app/components/forms/question/question.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QuestionComponent } from './question.component'; + +describe('QuestionComponent', () => { + let component: QuestionComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ QuestionComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(QuestionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/forms/question/question.component.ts b/frontend/src/app/components/forms/question/question.component.ts new file mode 100644 index 00000000..425fd24c --- /dev/null +++ b/frontend/src/app/components/forms/question/question.component.ts @@ -0,0 +1,53 @@ +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {QUESTION_TYPES, QuestionType} from '../../../models/form.question.model'; +import {BaseAnswer} from '../../../models/base.answer.model'; +import {CdkDragDrop} from '@angular/cdk/drag-drop'; +import {FormArray, FormBuilder, FormGroup} from '@angular/forms'; +import {initOptionFormGroup} from '../form-groups-builder'; +import {moveItemInFormArray} from '../../utils'; + +@Component({ + selector: 'app-question', + templateUrl: './question.component.html', + styleUrls: ['./question.component.scss'] +}) + +export class QuestionComponent implements OnInit { + hideOptions = false; + + @Input() questionFormGroup: FormGroup; + @Output() questionDeleteEventEmitter = new EventEmitter(); + + questionTypes: QuestionType[]; + + constructor(private formBuilder: FormBuilder) {} + + + ngOnInit() { + this.questionTypes = QUESTION_TYPES; + } + + get optionsArray(): FormArray { + return this.questionFormGroup.controls.optionsToQuestions as FormArray; + } + + get optionFormGroupsArray(): FormGroup[] { + return this.optionsArray.controls as FormGroup[]; + } + + addOption() { + this.optionsArray.push(initOptionFormGroup(this.formBuilder)); + } + + toggleOptions() { + this.hideOptions = !this.hideOptions; + } + + onOptionDelete(index: number) { + this.optionsArray.removeAt(index); + } + + onReorder(event: CdkDragDrop) { + moveItemInFormArray(this.optionsArray, event.previousIndex, event.currentIndex); + } +} diff --git a/frontend/src/app/components/forms/section/section.component.html b/frontend/src/app/components/forms/section/section.component.html new file mode 100644 index 00000000..7f79bf8b --- /dev/null +++ b/frontend/src/app/components/forms/section/section.component.html @@ -0,0 +1,59 @@ + +
+ +
+ {{ sectionFormGroup.controls.description.value }} + +
+ +
+
+ +
+
+
+
+
+
+ {{'SECTION_TITLE' | translate}} +
+
+ +
+
+
+
+ {{'SECTION_CODE' | translate}} +
+
+ +
+
+
+
+ + + + + + + + +
+
+ +
+
+ + + +
+ +
+
+
+
+ {{'QUESTION_ADD' | translate}}
+
+
+
diff --git a/frontend/src/app/components/forms/section/section.component.scss b/frontend/src/app/components/forms/section/section.component.scss new file mode 100644 index 00000000..c18e4a2e --- /dev/null +++ b/frontend/src/app/components/forms/section/section.component.scss @@ -0,0 +1,63 @@ +.section-create { + + .section-details { + display: flex; + flex-direction: column; + } + + .add-question-button { + color: rebeccapurple; + cursor: pointer; + font-weight: bold; + } + + .form-labeled-field { + margin-bottom: 10px; + } + + .header-row { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 20px; + + .header-text { + margin: auto 0; + font-size: larger; + font-weight: bold; + } + + .btn-delete { + border: 1px solid; + color: red; + } + } + + .question-list { + display: flex; + flex-direction: column; + + .question-line { + display: flex; + flex-direction: row; + + .question { + flex-grow: 1; + } + } + } + + .icon-holder { + display: flex; + flex-direction: column; + justify-content: space-around; + + cursor: pointer; + + margin: auto 4px; + } + + .icon-reorder { + cursor: move; + } +} diff --git a/frontend/src/app/components/forms/section/section.component.spec.ts b/frontend/src/app/components/forms/section/section.component.spec.ts new file mode 100644 index 00000000..9b599545 --- /dev/null +++ b/frontend/src/app/components/forms/section/section.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SectionComponent } from './section.component'; + +describe('SectionComponent', () => { + let component: SectionComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SectionComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/forms/section/section.component.ts b/frontend/src/app/components/forms/section/section.component.ts new file mode 100644 index 00000000..c0cee71b --- /dev/null +++ b/frontend/src/app/components/forms/section/section.component.ts @@ -0,0 +1,40 @@ +import {Component, EventEmitter, Input, Output, } from '@angular/core'; +import {FormQuestion} from '../../../models/form.question.model'; +import {CdkDragDrop} from '@angular/cdk/drag-drop'; +import {FormArray, FormBuilder, FormGroup} from '@angular/forms'; +import {moveItemInFormArray} from '../../utils'; +import {initQuestionFormGroup} from '../form-groups-builder'; + +@Component({ + selector: 'app-section', + templateUrl: './section.component.html', + styleUrls: ['./section.component.scss'] +}) +export class SectionComponent { + @Input() sectionFormGroup: FormGroup; + + @Output() sectionDeleteEventEmitter = new EventEmitter(); + + constructor(private formBuilder: FormBuilder) { + } + + addQuestion() { + this.questionsArray.push(initQuestionFormGroup(this.formBuilder)); + } + + get questionsArray(): FormArray { + return this.sectionFormGroup.get('questions') as FormArray; + } + + get questionFormGroupsArray(): FormGroup[] { + return this.questionsArray.controls as FormGroup[]; + } + + onQuestionDelete(index: number) { + this.questionsArray.removeAt(index); + } + + onReorder(event: CdkDragDrop) { + moveItemInFormArray(this.questionsArray, event.previousIndex, event.currentIndex); + } +} diff --git a/frontend/src/app/components/header/header.component.html b/frontend/src/app/components/header/header.component.html index 1cf9ac70..6a5e9ba7 100644 --- a/frontend/src/app/components/header/header.component.html +++ b/frontend/src/app/components/header/header.component.html @@ -42,6 +42,11 @@ {{'OBSERVER_GUIDE' | translate}} +
  • + + {{'FORMS' | translate}} + +
  • {{'NOTIFICATIONS' | translate}} diff --git a/frontend/src/app/components/observers/observers.component.ts b/frontend/src/app/components/observers/observers.component.ts index 611e1a92..b375563d 100644 --- a/frontend/src/app/components/observers/observers.component.ts +++ b/frontend/src/app/components/observers/observers.component.ts @@ -44,7 +44,8 @@ export class ObserversComponent implements OnInit, OnDestroy { }; observerToEdit: Observer; - constructor(private http: ApiService, private store: Store, + constructor(private http: ApiService, + private store: Store, private observersService: ObserversService, private toastrService: ToastrService, private modalService: BsModalService) { diff --git a/frontend/src/app/components/utils.ts b/frontend/src/app/components/utils.ts new file mode 100644 index 00000000..db7cae62 --- /dev/null +++ b/frontend/src/app/components/utils.ts @@ -0,0 +1,12 @@ +import {FormArray} from '@angular/forms'; + +export function moveItemInFormArray(source: FormArray, from: number, to: number) { + const dir = to > from ? 1 : -1; + + const temp = source.at(from); + for (let i = from; i * dir < to * dir; i = i + dir) { + const current = source.at(i + dir); + source.setControl(i, current); + } + source.setControl(to, temp); +} diff --git a/frontend/src/app/models/form.info.model.ts b/frontend/src/app/models/form.info.model.ts index 824b13ad..70dd359a 100644 --- a/frontend/src/app/models/form.info.model.ts +++ b/frontend/src/app/models/form.info.model.ts @@ -6,5 +6,7 @@ export interface FormDetails { id: number; code: string; description: string; - ver: number; + currentVersion: number; + diaspora: boolean; + draft: boolean; } diff --git a/frontend/src/app/models/form.model.ts b/frontend/src/app/models/form.model.ts index 81fab0e4..63b3d9f5 100644 --- a/frontend/src/app/models/form.model.ts +++ b/frontend/src/app/models/form.model.ts @@ -1,6 +1,27 @@ import { FormSection } from './form.section.model'; -export class Form { - idFormular: number; - sections: FormSection[]; +import {FormDetails} from './form.info.model'; + +export class Form implements FormDetails { + id: number; + formSections: FormSection[]; description: string; + code: string; + diaspora: boolean; + draft: boolean; + currentVersion: number; + + public static fromMetaData(formDetails: FormDetails) { + const result = new Form(); + result.inheritMetaData(formDetails); + return result; + } + + public inheritMetaData(formDetails: FormDetails) { + this.id = formDetails.id; + this.description = formDetails.description; + this.code = formDetails.code; + this.diaspora = formDetails.diaspora; + this.currentVersion = formDetails.currentVersion; + this.draft = formDetails.draft; + } } diff --git a/frontend/src/app/models/form.question.model.ts b/frontend/src/app/models/form.question.model.ts index 5104a217..b96812b9 100644 --- a/frontend/src/app/models/form.question.model.ts +++ b/frontend/src/app/models/form.question.model.ts @@ -1,6 +1,7 @@ import { BaseAnswer } from './base.answer.model'; +import {BaseQuestion} from './base.question.model'; -export class FormQuestion { +export class FormQuestion extends BaseQuestion { id: number; formCode: string; code: string; @@ -10,3 +11,27 @@ export class FormQuestion { hint: string; optionsToQuestions: BaseAnswer[]; } + +export const QUESTION_TYPES = [ + { + id: 0, + name: 'MULTIPLE_CHOICE' + }, + { + id: 1, + name: 'SINGLE_CHOICE' + }, + { + id: 2, + name: 'SINGLE_CHOICE_TEXT' + }, + { + id: 3, + name: 'MULTIPLE_CHOICE_TEXT' + } +]; + +export interface QuestionType { + id: number; + name: string; +} diff --git a/frontend/src/app/routing/app.routes.ts b/frontend/src/app/routing/app.routes.ts index 9ed988f4..e4e0b6b4 100644 --- a/frontend/src/app/routing/app.routes.ts +++ b/frontend/src/app/routing/app.routes.ts @@ -13,6 +13,8 @@ import { AnswerComponent } from '..//components/answer/answer.component'; import { Routes } from '@angular/router'; import { ObserverProfileComponent } from 'app/components/observers/observer-profile/observer-profile.component'; import { NotificationsComponent } from 'app/components/notifications/notifications.component'; +import {FormsComponent} from '../components/forms/forms.component'; +import {FormCreateComponent} from '../components/forms/form-create/form-create.component'; export let appRoutes: Routes = [ { @@ -77,6 +79,21 @@ export let appRoutes: Routes = [ component: StatisticsDetailsComponent, canActivate: [AuthGuard, LoadStatisticsGuard] }, + { + path: 'formulare', + component: FormsComponent, + canActivate: [AuthGuard] + }, + { + path: 'formulare/nou', + component: FormCreateComponent, + canActivate: [AuthGuard] + }, + { + path: 'formulare/:formId', + component: FormCreateComponent, + canActivate: [AuthGuard] + }, { path: 'login', canActivate: [AnonGuard], diff --git a/frontend/src/app/services/forms.service.ts b/frontend/src/app/services/forms.service.ts new file mode 100644 index 00000000..6535ae48 --- /dev/null +++ b/frontend/src/app/services/forms.service.ts @@ -0,0 +1,65 @@ +import {ApiService} from '../core/apiService/api.service'; +import {Injectable} from '@angular/core'; +import {FormDetails, FormInfo} from '../models/form.info.model'; +import {Location} from '@angular/common'; +import {Form} from '../models/form.model'; +import {FormSection} from '../models/form.section.model'; +import {environment} from '../../environments/environment'; +import {cloneDeep} from 'lodash'; +import {HttpParams} from '@angular/common/http'; + + +@Injectable() +export class FormsService { + private baseUrl: string; + + constructor(private http: ApiService) { + this.baseUrl = Location.joinWithSlash(environment.apiUrl, '/api/v1/form'); + } + + public loadForms() { + return this.http.get(this.baseUrl).pipe(); + } + + public searchForms(name: string, pageNo?: number, pageSize?: number) { + // TODO: enable search forms after BE is implemented + // let url: string = Location.joinWithSlash(this.baseUrl, `/api/v1/form/search?Description=${name}`); + // + // if (pageNo > 0 && pageSize > 0) { + // url = Location.joinWithSlash(this.baseUrl, `/api/v1/form/search?Description=${name}&Page=${pageNo}&PageSize=${pageSize}`); + // } + return this.http.get(this.baseUrl).pipe(); + } + + public getForm(formId: number) { + const url: string = Location.joinWithSlash(this.baseUrl, `/${formId}`); + return this.http.get(url); + } + + public saveForm(form: Form) { + const formClone = cloneDeep(form); + formClone.draft = true; + + return this.uploadForm(formClone); + } + + public saveAndPublishForm(form: Form) { + const formClone = cloneDeep(form); + formClone.draft = false; + + return this.uploadForm(formClone); + } + + private uploadForm(form: Form) { + if (!form.currentVersion) { + form.currentVersion = 1; + } + + return this.http.post(this.baseUrl, form); + } + + public deleteForm(formId: number) { + const params = new HttpParams({fromObject: {formId: String(formId)}}); + return this.http.delete(this.baseUrl, {params}); + } +} diff --git a/frontend/src/app/shared/icon-toggle-input/icon-toggle-input.component.html b/frontend/src/app/shared/icon-toggle-input/icon-toggle-input.component.html new file mode 100644 index 00000000..b4773682 --- /dev/null +++ b/frontend/src/app/shared/icon-toggle-input/icon-toggle-input.component.html @@ -0,0 +1,4 @@ +
    + + +
    diff --git a/frontend/src/app/shared/icon-toggle-input/icon-toggle-input.component.scss b/frontend/src/app/shared/icon-toggle-input/icon-toggle-input.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/shared/icon-toggle-input/icon-toggle-input.component.ts b/frontend/src/app/shared/icon-toggle-input/icon-toggle-input.component.ts new file mode 100644 index 00000000..f6aa0439 --- /dev/null +++ b/frontend/src/app/shared/icon-toggle-input/icon-toggle-input.component.ts @@ -0,0 +1,44 @@ +import {Component, forwardRef, Input} from '@angular/core'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; + +@Component({ + selector: 'app-icon-toggle-input', + templateUrl: './icon-toggle-input.component.html', + styleUrls: ['./icon-toggle-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => IconToggleInputComponent), + multi: true + } + ] +}) +export class IconToggleInputComponent implements ControlValueAccessor { + + @Input() value = false; + + @Input() enabledIcon: string; + @Input() disabledIcon: string; + + onChange = (value: boolean) => {}; + onTouched = () => {}; + + constructor() { } + + toggleValue() { + this.value = !this.value; + this.onChange(this.value); + } + + registerOnChange(fn: (value: boolean) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + writeValue(value: boolean): void { + this.value = value; + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 2c911a0a..11280dcf 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -8,6 +8,7 @@ import { RouterModule } from '@angular/router'; import { TabsModule } from 'ngx-bootstrap/tabs'; import { TranslateModule } from '@ngx-translate/core'; import {CollapseModule} from 'ngx-bootstrap/collapse'; +import { IconToggleInputComponent } from './icon-toggle-input/icon-toggle-input.component'; @NgModule({ imports: [ FormsModule, @@ -19,18 +20,19 @@ import {CollapseModule} from 'ngx-bootstrap/collapse'; TranslateModule ], exports: [ - FormsModule, - ReactiveFormsModule, - CommonModule, - CollapseModule, - TabsModule, - RouterModule, - PaginationComponent, - LoadingIndicatorComponent, - ErrorIndicatorComponent, - TranslateModule + FormsModule, + ReactiveFormsModule, + CommonModule, + CollapseModule, + TabsModule, + RouterModule, + PaginationComponent, + LoadingIndicatorComponent, + ErrorIndicatorComponent, + TranslateModule, + IconToggleInputComponent ], - declarations: [PaginationComponent, LoadingIndicatorComponent, ErrorIndicatorComponent], + declarations: [PaginationComponent, LoadingIndicatorComponent, ErrorIndicatorComponent, IconToggleInputComponent], providers: [] }) export class SharedModule { diff --git a/frontend/src/app/store/form/form.actions.ts b/frontend/src/app/store/form/form.actions.ts index c3f0b5b4..530187d2 100644 --- a/frontend/src/app/store/form/form.actions.ts +++ b/frontend/src/app/store/form/form.actions.ts @@ -1,14 +1,22 @@ -import { actionType } from '../util'; -import { Form } from '../../models/form.model'; -import { Action } from '@ngrx/store'; -export class FormActionTypes{ - static readonly LOAD = actionType('[Form] LOAD'); - static readonly LOAD_COMPLETE = actionType('[Form] LOAD_COMPLETE'); - static readonly ERROR = actionType('form/error'); - static readonly CLEAR = actionType('form/clear all'); +import {actionType} from '../util'; +import {Form} from '../../models/form.model'; +import {Action} from '@ngrx/store'; +import {FormDetails} from '../../models/form.info.model'; + +export class FormActionTypes { + static readonly LOAD_ALL_FORMS_META = actionType('[Form] LOAD_ALL_FORMS_META'); + static readonly LOAD_ONE_FORM_FULLY = actionType('[Form] LOAD_ONE_FORM_FULLY'); + static readonly LOAD_ONE_FORM_FULLY_COMPLETE = actionType('[Form] LOAD_ONE_FORM_FULLY_COMPLETE'); + static readonly LOAD_ALL_FORMS_META_COMPLETE = actionType('[Form] LOAD_ALL_FORMS_META_COMPLETE'); + static readonly ERROR = actionType('form/error'); + static readonly CLEAR = actionType('form/clear all'); + static readonly UPLOAD = actionType('[Form] UPLOAD'); + static readonly UPLOAD_PUBLISH = actionType('[Form] UPLOAD PUBLISH'); + static readonly UPLOAD_COMPLETE = actionType('[Form] UPLOAD_COMPLETE'); + static readonly DELETE = actionType('[Form] DELETE'); } export class FormLoadAction implements Action { - readonly type = FormActionTypes.LOAD; + readonly type = FormActionTypes.LOAD_ALL_FORMS_META; constructor() { } @@ -17,14 +25,55 @@ export class FormErrorAction implements Action { readonly type = FormActionTypes.ERROR; } export class FormLoadCompletedAction implements Action { - readonly type = FormActionTypes.LOAD_COMPLETE; - payload: Form[]; + readonly type = FormActionTypes.LOAD_ALL_FORMS_META_COMPLETE; + payload: FormDetails[]; - constructor(forms: Form[]) { + constructor(forms: FormDetails[]) { this.payload = forms; } } export class FormClearAll implements Action { readonly type = FormActionTypes.CLEAR; } -export type FormActions = FormLoadAction | FormLoadCompletedAction | FormClearAll; + +export class FormUploadAction implements Action { + readonly type = FormActionTypes.UPLOAD; + + constructor(public form: Form) {} +} + +export class FormUploadPublishAction implements Action { + readonly type = FormActionTypes.UPLOAD_PUBLISH; + + constructor(public form: Form) {} +} + +export class FormUploadCompleteAction implements Action { + readonly type = FormActionTypes.UPLOAD_COMPLETE; +} + +export class FormDeleteAction implements Action { + readonly type = FormActionTypes.DELETE; + + constructor(public formId: number) {} +} + +export class FullyLoadFormAction implements Action { + readonly type = FormActionTypes.LOAD_ONE_FORM_FULLY; + + constructor(public formId: number) {} +} + +export class FullyLoadFormCompleteAction implements Action { + readonly type = FormActionTypes.LOAD_ONE_FORM_FULLY_COMPLETE; + + constructor(public payload: Form) {} +} + +export type FormActions = + FormLoadAction | + FormLoadCompletedAction | + FormClearAll | + FullyLoadFormAction | + FullyLoadFormCompleteAction | + FormUploadAction ; diff --git a/frontend/src/app/store/form/form.effects.ts b/frontend/src/app/store/form/form.effects.ts index 118bd37f..ca456502 100644 --- a/frontend/src/app/store/form/form.effects.ts +++ b/frontend/src/app/store/form/form.effects.ts @@ -1,53 +1,98 @@ +import {of as observableOf} from 'rxjs'; -import {of as observableOf, Observable } from 'rxjs'; - -import {catchError, concatMap, map, switchMap} from 'rxjs/operators'; -import { Form } from '../../models/form.model'; -import { FormActionTypes, FormErrorAction, FormLoadAction, FormLoadCompletedAction } from './form.actions'; +import {catchError, map, switchMap, take, tap} from 'rxjs/operators'; +import { + FormActionTypes, + FormClearAll, + FormDeleteAction, + FormErrorAction, + FormLoadAction, + FormLoadCompletedAction, + FormUploadAction, + FormUploadCompleteAction, + FullyLoadFormAction, + FullyLoadFormCompleteAction +} from './form.actions'; import {Actions, Effect, ofType} from '@ngrx/effects'; -import { ApiService } from '../../core/apiService/api.service'; -import { Injectable } from '@angular/core'; -import { FormSection } from '../../models/form.section.model'; -import { Location } from '@angular/common'; -import { environment } from 'environments/environment'; -import { FormInfo } from 'app/models/form.info.model'; +import {Injectable} from '@angular/core'; +import {FormSection} from '../../models/form.section.model'; +import {FormsService} from '../../services/forms.service'; +import {Router} from '@angular/router'; +import {Form} from '../../models/form.model'; @Injectable() export class FormEffects { - private baseUrl: string; - constructor(private http: ApiService, private actions: Actions) { - this.baseUrl = environment.apiUrl; - } + constructor(private formsService: FormsService, + private actions: Actions, + private router: Router) {} @Effect() loadFormAction = this.actions - .pipe(ofType(FormActionTypes.LOAD)).pipe( - switchMap(_ => this.getAvailableForms()), - switchMap(r => r.formVersions), - map(f => { - return { id: f.id, description: f.description}; - }), - concatMap((x: { id: number, description: string }) => this.getForm(x.id, x.description)), - map(form => new FormLoadCompletedAction([form])), + .pipe(ofType(FormActionTypes.LOAD_ALL_FORMS_META)).pipe( + switchMap(_ => this.formsService.loadForms()), + map(formInfo => new FormLoadCompletedAction(formInfo.formVersions)), catchError(() => observableOf(new FormErrorAction())), ); - private getForm(id: number, description: string): Observable
    { - const formsUrl: string = Location.joinWithSlash(this.baseUrl, `/api/v1/form/${id}`); - - return this.http.get(formsUrl).pipe( - map(sections => { + @Effect() + fullyLoadFormAction = this.actions + .pipe( + ofType(FormActionTypes.LOAD_ONE_FORM_FULLY), + map((a: FullyLoadFormAction) => a.formId), + switchMap(formId => + this.formsService.getForm(formId) + .pipe( + map((sections: FormSection[]) => { const form = new Form(); - form.idFormular = id; - form.sections = sections; - form.description = description; - return form; - })); - } - private getAvailableForms(): Observable { - const formsUrl: string = Location.joinWithSlash(this.baseUrl, '/api/v1/form/'); - - return this.http.get(formsUrl); - } + form.id = formId; + form.formSections = sections; + + return new FullyLoadFormCompleteAction(form); + }) + ) + ), + catchError(() => observableOf(new FormErrorAction())) + ); + + @Effect() + formUpload = this.actions + .pipe( + ofType(FormActionTypes.UPLOAD), + switchMap((a: FormUploadAction) => + this.formsService.saveForm(a.form).pipe( + map(_ => new FormUploadCompleteAction()) + )), + catchError(() => observableOf(new FormErrorAction())) + ); + + @Effect() + formUploadPublish = this.actions + .pipe( + ofType(FormActionTypes.UPLOAD_PUBLISH), + switchMap((a: FormUploadAction) => + this.formsService.saveAndPublishForm(a.form).pipe( + map(_ => new FormUploadCompleteAction()) + )), + catchError(() => observableOf(new FormErrorAction())) + ); + @Effect() + formUploadSuccess = this.actions + .pipe( + ofType(FormActionTypes.UPLOAD_COMPLETE), + take(1), + tap(_ => this.router.navigate(['formulare'])) + ); + + @Effect() + formDelete = this.actions + .pipe( + ofType(FormActionTypes.DELETE), + take(1), + switchMap((a: FormDeleteAction) => + this.formsService.deleteForm(a.formId).pipe( + map(_ => new FormLoadAction()), + )), + catchError(() => observableOf(new FormErrorAction())) + ); } diff --git a/frontend/src/app/store/form/form.reducer.ts b/frontend/src/app/store/form/form.reducer.ts index cd706648..aa088c1f 100644 --- a/frontend/src/app/store/form/form.reducer.ts +++ b/frontend/src/app/store/form/form.reducer.ts @@ -1,24 +1,44 @@ -import { FormActions, FormActionTypes, FormLoadCompletedAction } from './form.actions'; -import { Form } from '../../models/form.model'; -import * as _ from 'lodash'; - +import {FormActions, FormActionTypes} from './form.actions'; +import {Form} from '../../models/form.model'; +import {FormDetails} from '../../models/form.info.model'; +import {cloneDeep} from 'lodash'; export class FormState { - items: Form[]; + items: FormDetails[]; + fullyLoaded: { + [key: number]: Form; + }; } const formsInitialState: FormState = { - items: [] + items: [], + fullyLoaded: {} }; export function formReducer(state = formsInitialState, $action: FormActions) { switch ($action.type) { - case FormActionTypes.LOAD_COMPLETE: + case FormActionTypes.LOAD_ALL_FORMS_META_COMPLETE: return { - items: state.items.concat($action.payload) + ...state, + items: $action.payload }; case FormActionTypes.CLEAR: return { + fullyLoaded: [], items: [] }; + case FormActionTypes.LOAD_ONE_FORM_FULLY_COMPLETE: + const fullyLoaded = state.fullyLoaded; + const loadedForm = $action.payload; + const formDetails = state.items.find(f => f.id === loadedForm.id); + + const mutableLoadedForm: Form = cloneDeep(loadedForm); + mutableLoadedForm.inheritMetaData(formDetails); + + const allFullyLoadedForms = {...fullyLoaded}; + allFullyLoadedForms[mutableLoadedForm.id] = mutableLoadedForm; + return { + ...state, + fullyLoaded: allFullyLoadedForms + }; default: return state; } diff --git a/frontend/src/app/store/store.module.ts b/frontend/src/app/store/store.module.ts index be312da7..cee58afc 100644 --- a/frontend/src/app/store/store.module.ts +++ b/frontend/src/app/store/store.module.ts @@ -41,6 +41,10 @@ const moduleImports = [ ObserversCountEffects, NoteEffects ]), + StoreDevtoolsModule.instrument({ + maxAge: 25, // Retains last 25 states + logOnly: environment.production, // Restrict extension to log-only mode + }), ]; if (!environment.production) { moduleImports.push(StoreDevtoolsModule.instrument({ diff --git a/frontend/src/assets/delete.svg b/frontend/src/assets/delete.svg new file mode 100644 index 00000000..f4123283 --- /dev/null +++ b/frontend/src/assets/delete.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/forms/diaspora-check.png b/frontend/src/assets/forms/diaspora-check.png new file mode 100644 index 0000000000000000000000000000000000000000..db9a9f929f16af27a185ec2e5845ba7a63c9e68f GIT binary patch literal 271 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O)#C7v#hAr*{oC++5KP!MqN=YD?Soar2waDyH4DRsKl2!50+;yiaWwh#K5^BgYPlw6mC7~i zBH2YOxfRYlDP666=*iy@BXve$OUneU>r9om&Rs9CtXna2?VRS=Pnr*uqIZf3=r<$^ zcKs>J+Wm&_qE_d$&o={~WH5Ahd`$nD%+Sf;`cEoA$zfu(P2LU0%{$YVEj4;+-u6{1-oD!MY!ENrBHsNBiLrni-su%qxzB^0FFqr9{%Z1G z?nf4adQR!OSB%%1{$9HNLdk=r95sgL4m~;jucGjRTc2omeZrfD_`fI8gOz?{2=(5& dvdh1Qd6jWok-lM@2r$GLJYD@<);T3K0RZ@Rm-+wz literal 0 HcmV?d00001 diff --git a/frontend/src/assets/forms/icon-flag-diabled.png b/frontend/src/assets/forms/icon-flag-diabled.png new file mode 100644 index 0000000000000000000000000000000000000000..f2c739aa6b0397982bbafabeac7a255534a412b6 GIT binary patch literal 347 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{s&JN$B+ufvy;vWHW>)Ca@wcsHu61T3V+G) z_JB~vK`$T98;*Hr7>po`@5^}P;J|*oxz3n7((HFzj+~*&oX@3~P1v1IxIL-8 zA6_xH_I~;?>5hsenXhhbJG(K)-%OyWHKIu)RZelwy4T+v1!8hOUfi@Mz@+=EhD_@E z_6`9(CAYVlT!x|b96SjDOPg)ZeYSBqe9q+T)SPcApJihcn&vPDS58X}k#T&w?4#;_ zaf!1IJS#N1CdcT1YP56l_lp#CE_lMcp;mLl74`*Lx2K)`dr(MzN`dati>oXfId}1w o{CgrLd))KYE-`b5UjCho%ifeaZGNUw5A-{Or>mdKI;Vst09hu2djJ3c literal 0 HcmV?d00001 diff --git a/frontend/src/assets/forms/icon-flag-enabled.png b/frontend/src/assets/forms/icon-flag-enabled.png new file mode 100644 index 0000000000000000000000000000000000000000..359151bcdf596cfc06846b6aa862f9bd1de4972b GIT binary patch literal 376 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBIbKf}$B+ufvy*It4jTxxmWM0yusZiWxFoxa zai`-0rU%M15*M^}Fx$vXl9o60~k?D_p=2+BER;ujXVtvbh*7A0P zP$Ap$KQ}9#c&1NgbAD)8Q_m}B9HBgi=lFWTAZ&&Qa~&}3U$;c%DP@P*#LA6NdCS*~Ed5^L)5?=r(urZrC; zZiTOAy#2&Mv-RQRHJlp^*(3I)c$|Cs-Bmcjv)eM_%EX$*l7aSfs=SX`+z3owt&_C! Uf%ZFJU_dc=y85}Sb4q9e0Dqs7BLDyZ literal 0 HcmV?d00001 diff --git a/frontend/src/assets/forms/icon-reorder.png b/frontend/src/assets/forms/icon-reorder.png new file mode 100644 index 0000000000000000000000000000000000000000..40b5eed5ca6c12c2681dfd005fd59e76ea243656 GIT binary patch literal 348 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`j)FbFd;%$g$s6l5$8 za(7}_cTVOdki(Mh=zSu-NR(i*DwbLPbipOIX(}gs&vh^QVEKZr z!+YNAUEfV}1phlt4T_U!^FJ6FIW2uxM%QI`O?R2&&nDRh&$*STbyI_Do9ryP1uvxo z1@brE6;1sdwBQwU-?4`+7c&i={>kxdtnp8LobY&N=8iX?m)G9!R@LcC*3I!Ub9}yd rzJb_|CN93T+yIlcudPJeH;AuyogMUbU*Kz?=NUX*{an^LB{Ts5phku; literal 0 HcmV?d00001 diff --git a/frontend/src/assets/forms/icon-text-disabled.png b/frontend/src/assets/forms/icon-text-disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..ae0efd38659628547ed6ad7348f205bb2407ea7b GIT binary patch literal 319 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{uNIb$B+ufx04R?HXHD`zGWzPOJLDrOyA|` zo}vDNOLpPi2HsN~a~heI4V>ODSYq$7P;ix0)VjYxDz|^t6^aPwip*Y+IV(m}bZw@P zlg%U*&2)#`+t^L+zqEdP*<@{vtf!`Pn%W$>%v6~Mx#iI_xPzO`-Q{Lx{$<^IC_}x< zHk;|j)?MpNbuOKs!RRP3<*>n#1!7OGi3)4{uwE`KtWj%M$&!0TK4I-9-9KL?|I6I& zZee}3NoTk2-ha_rpIOf?JZZ{tK<=T{0gFEC^Ph`$vvpJ#)?E}{v^|3B|J&FTK>sm# My85}Sb4q9e0QYBeRsaA1 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/forms/icon-text-enabled.png b/frontend/src/assets/forms/icon-text-enabled.png new file mode 100644 index 0000000000000000000000000000000000000000..2773d362493061557ed35e69dfb0b69af662813f GIT binary patch literal 328 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{#{QO$B+ufx09^-nj8e$($hS1Y?-DW^viEB z|G;qUIHym|mv459%pH=~yFcJ45z_8+$UNM~&A3TOSz~Rz(agE_o8y=LV9es$eEdMv zZH|<%pGPJg{#;vlma+NTGJOvH53K?z=RZzgH<731>=piyowx2;yzP5-bJZD+8$Ilc z8Or+q)lA}5&YsOslAn<)!(zf3G=up_^SVufr5+sYUM?!%by%6X*XNk;Yk2&Nqh;1F z<}DhprM8s^r@U(rQ*ry^xBceA9oc)9fAZS8EUj16AYiXj!%A+2x8jpm*jVg;!D*#u WJD>gk|4^Vu89ZJ6T-G@yGywoL