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 00000000..db9a9f92 Binary files /dev/null and b/frontend/src/assets/forms/diaspora-check.png differ diff --git a/frontend/src/assets/forms/down.png b/frontend/src/assets/forms/down.png new file mode 100644 index 00000000..7ecec987 Binary files /dev/null and b/frontend/src/assets/forms/down.png differ diff --git a/frontend/src/assets/forms/icon-delete.png b/frontend/src/assets/forms/icon-delete.png new file mode 100644 index 00000000..3f85350c Binary files /dev/null and b/frontend/src/assets/forms/icon-delete.png differ 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 00000000..f2c739aa Binary files /dev/null and b/frontend/src/assets/forms/icon-flag-diabled.png differ 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 00000000..359151bc Binary files /dev/null and b/frontend/src/assets/forms/icon-flag-enabled.png differ diff --git a/frontend/src/assets/forms/icon-reorder.png b/frontend/src/assets/forms/icon-reorder.png new file mode 100644 index 00000000..40b5eed5 Binary files /dev/null and b/frontend/src/assets/forms/icon-reorder.png differ 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 00000000..ae0efd38 Binary files /dev/null and b/frontend/src/assets/forms/icon-text-disabled.png differ 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 00000000..2773d362 Binary files /dev/null and b/frontend/src/assets/forms/icon-text-enabled.png differ diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index ec85ab6a..3c87c0e5 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -14,6 +14,31 @@ "IS_URBAN_AREA": "Is it an urban area", "STATION": "Polling Station", "FORM": "Form", + "FORM_EDIT": "Edit form", + "FORM_SAVE_DRAFT": "Save as draft", + "FORM_PUBLISH": "Publish", + "FORMS": "Forms", + "FORM_TITLE": "Form title", + "FORM_TITLE_PLACEHOLDER": "Type form title", + "FORM_CODE": "Form code", + "FORM_CODE_PLACEHOLDER": "Type form code", + "FORM_DESCRIPTION": "Form description", + "FORM_DESCRIPTION_PLACEHOLDER": "Describe this form", + "FORM_ONLY_DIASPORA": "Only for diaspora", + + "SECTION_ADD": "Add section", + "SECTION_DELETE": "Delete section", + "SECTION_TITLE": "Section title", + "SECTION_TITLE_PLACEHOLDER": "Type section title", + "SECTION_CODE": "Section code", + "SECTION_CODE_PLACEHOLDER": "Type section code", + "SECTION_DESCRIPTION": "Section description", + "SECTION_DESCRIPTION_PLACEHOLDER": "Describe this section", + + "QUESTION_ADD": "Add question", + + "OPTION_ADD": "Add option", + "NOTHING_SELECTED": "Nothing selected.", "SHOW": "Show", "HIDE": "Hide", @@ -48,5 +73,12 @@ "FROM": "From", "TO": "To", "ANSWERS_DOWNLOAD_CONFIRMATION": "Are you sure you want to launch a data download request?", - "NOTIFICATION_SEND_CONFIRMATION": "Are you sure you want to notify %d observer(s)?" + "NOTIFICATION_SEND_CONFIRMATION": "Are you sure you want to notify %d observer(s)?", + "BACK": "Back", + + "INDEX": "Index", + "ACTIONS": "Actions", + "NEW_FORM": "New form", + "DIASPORA": "Diaspora", + "DRAFT": "Draft" } diff --git a/frontend/src/assets/i18n/ro.json b/frontend/src/assets/i18n/ro.json index a985b632..9fecd96b 100644 --- a/frontend/src/assets/i18n/ro.json +++ b/frontend/src/assets/i18n/ro.json @@ -14,6 +14,31 @@ "IS_URBAN_AREA": "Este zona urbana", "STATION": "Sectia", "FORM": "Formular", + "FORM_SAVE_DRAFT": "Salvează ca draft", + "FORM_PUBLISH": "Publică", + "FORM_EDIT": "Editare formular", + "FORMS": "Formulare", + "FORM_TITLE": "Titlu formular", + "FORM_TITLE_PLACEHOLDER": "Introdu titlu formular", + "FORM_CODE": "Cod formular", + "FORM_CODE_PLACEHOLDER": "Introdu cod formular", + "FORM_DESCRIPTION": "Descriere formular", + "FORM_DESCRIPTION_PLACEHOLDER": "Descrie acest formular", + "FORM_ONLY_DIASPORA": "Doar pentru Diaspora", + + "SECTION_ADD": "Adaugă o secţiune", + "SECTION_DELETE": "Şterge secţiune", + "SECTION_TITLE": "Titlu secţiune", + "SECTION_TITLE_PLACEHOLDER": "Introdu titlu secţiune", + "SECTION_CODE": "Cod secţiune", + "SECTION_CODE_PLACEHOLDER": "Introdu cod secţiune", + "SECTION_DESCRIPTION": "Introdu descriere secţiune", + "SECTION_DESCRIPTION_PLACEHOLDER": "Descrie această secţiune", + + "QUESTION_ADD": "Adaugă o întrebare", + + "OPTION_ADD": "Adaugă o opţiune", + "NOTHING_SELECTED": "Nu ati selectat nimic", "SHOW": "Vezi", "HIDE": "Ascunde", @@ -30,5 +55,12 @@ "FROM": "De la", "TO": "Pana la", "ANSWERS_DOWNLOAD_CONFIRMATION": "Esti sigur ca vrei sa lansezi cererea de descarcare a datelor?", - "NOTIFICATION_SEND_CONFIRMATION": "Esti sigur ca vrei sa trimiti notificarea la %d observator(i)?" + "NOTIFICATION_SEND_CONFIRMATION": "Esti sigur ca vrei sa trimiti notificarea la %d observator(i)?", + "BACK": "Înapoi", + + "INDEX": "Nr.", + "ACTIONS": "Acţiuni", + "NEW_FORM": "Formular nou", + "DIASPORA": "Diaspora", + "DRAFT": "Draft" } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index eae700e0..8c330074 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -122,6 +122,13 @@ resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-9.1.12.tgz#1c9c1a792be4b52b196cab1e5c88bd319b60716d" integrity sha512-tphpf9QHnOPoL2Jl7KpR+R5aHNW3oifLEmRUTajJYJGvo1uzdUDE82+V9OGOinxJsYseCth9gYJhN24aYTB9NA== +"@angular/cdk@^9.1.12": + version "9.2.4" + resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-9.2.4.tgz#8413958bd275e4c34be3b96f56444671dd30ba93" + integrity sha512-iw2+qHMXHYVC6K/fttHeNHIieSKiTEodVutZoOEcBu9rmRTGbLB26V/CRsfIRmA1RBk+uFYWc6UQZnMC3RdnJQ== + optionalDependencies: + parse5 "^5.0.0" + "@angular/cli@^9.1.12": version "9.1.12" resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-9.1.12.tgz#c6e41c80c387200766fc52a6b42fde869dcc0cef" @@ -6513,6 +6520,11 @@ parse5@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" +parse5@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" + integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== + parseqs@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"