import { ChangeDetectorRef, Inject, Injectable } from '@angular/core'; import { ActivatedRoute, ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { Answer } from '../enums/answer.enum'; import { Choice } from '../models/choice.model'; import { Poll } from '../models/poll.model'; import { User } from '../models/user.model'; import { ApiService } from './api.service'; import { ToastService } from './toast.service'; import { UserService } from './user.service'; import { UuidService } from './uuid.service'; import { HttpClient } from '@angular/common/http'; import { environment } from '../../../environments/environment'; import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { DateChoice, defaultTimeOfDay, otherDefaultDates } from '../../features/old-stuff/config/defaultConfigs'; import { DateUtilities } from '../../features/old-stuff/config/DateUtilities'; import { DOCUMENT } from '@angular/common'; @Injectable({ providedIn: 'root', }) export class PollService implements Resolve { private _poll: BehaviorSubject = new BehaviorSubject(undefined); public readonly poll: Observable = this._poll.asObservable(); public form: FormGroup; public startDateInterval: string; public endDateInterval: string; public intervalDays: any; public intervalDaysDefault = 7; public dateList: DateChoice[] = otherDefaultDates; // sets of days as strings, config to set identical time for days in a special days poll public timeList: any = defaultTimeOfDay; // ranges of time expressed as strings public previousRouteName: string = '/administration'; public nextRouteName: string = '/administration/step/2'; public step_current: number = 1; public step_max: number = 5; public round: Function; public urlPrefix: string = window.location.origin + '/participation/'; public advancedDisplayEnabled = false; public showDateInterval = false; public allowSeveralHours = false; public richTextMode = false; constructor( private http: HttpClient, private router: Router, private apiService: ApiService, private userService: UserService, private uuidService: UuidService, private toastService: ToastService, public dateUtilities: DateUtilities, public route: ActivatedRoute, @Inject(DOCUMENT) private document: any, private fb: FormBuilder ) { this.createFormGroup(); if (environment.poll.autoFillDemo) { this.setDemoValues(); } } /** * add example values to the form */ setDemoValues(): void { this.addChoice('orange'); this.addChoice('raisin'); this.addChoice('abricot'); this.form.patchValue({ title: 'mon titre', description: 'répondez SVP <3 ! *-* ', custom_url: this.uuidService.getUUID(), creatorPseudo: 'Chuck Norris', creatorEmail: 'chucknorris@example.com', isAboutDate: true, whoModifiesAnswers: 'everybody', whoCanChangeAnswers: 'everybody', isProtectedByPassword: false, isOwnerNotifiedByEmailOnNewVote: false, isOwnerNotifiedByEmailOnNewComment: false, isMaybeAnswerAvailable: false, richTextMode: false, areResultsPublic: true, expiracyNumberOfDays: 60, }); this.automaticSlug(); } /** * set the poll slug from other data of the poll */ automaticSlug() { this.form.patchValue({ custom_url: this.makeSlug(this.form) }); } public createFormGroup() { let form = this.fb.group({ title: ['', [Validators.required, Validators.minLength(12)]], creatorPseudo: ['', [Validators.required]], created_at: [new Date(), [Validators.required]], creatorEmail: ['', [Validators.required]], custom_url: [this.uuidService.getUUID(), [Validators.required]], description: ['', [Validators.required]], choices: new FormArray([]), whoModifiesAnswers: ['', [Validators.required]], whoCanChangeAnswers: ['', [Validators.required]], isAboutDate: [true, [Validators.required]], startDateInterval: ['', [Validators.required]], endDateInterval: ['', [Validators.required]], isProtectedByPassword: [false, [Validators.required]], isOwnerNotifiedByEmailOnNewVote: [false, [Validators.required]], isOwnerNotifiedByEmailOnNewComment: [false, [Validators.required]], isMaybeAnswerAvailable: [false, [Validators.required]], areResultsPublic: [true, [Validators.required]], richTextMode: [false, [Validators.required]], expiracyNumberOfDays: [60, [Validators.required, Validators.min(0)]], }); this.form = form; return form; } /** * auto fetch a poll when route is looking for one in the administration pattern * @param route * @param state */ public async resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise { const segments: string[] = state.url.split('/'); const wantedSlug: string = segments.includes('poll') ? segments[segments.indexOf('poll') + 1] : ''; if (!wantedSlug && state.url.includes('administration')) { // creation of new poll const poll = new Poll(this.userService.getCurrentUser(), this.uuidService.getUUID(), ''); this._poll.next(poll); this.router.navigate(['poll/' + poll.slug + '/administration']); } if (!this._poll.getValue() || !this._poll.getValue().slug || this._poll.getValue().slug !== wantedSlug) { await this.loadPollBySlug(wantedSlug); } if (this._poll.getValue()) { return this._poll.getValue(); } else { this.router.navigate(['page-not-found']); return; } } getAllAvailablePolls() { const baseHref = environment.api.version.apiV1.baseHref; console.log('getAllAvailablePolls baseHref', baseHref); const headers = ApiService.makeHeaders(); console.log('getAllAvailablePolls headers', headers); try { this.http.get(`${baseHref}/poll`, headers).subscribe((res: Observable) => { console.log('getAllAvailablePolls res', res); }); } catch (e) { console.log('getAllAvailablePolls e', e); } } public async loadPollBySlug(slug: string): Promise { console.log('slug', slug); if (slug) { const poll: Poll | undefined = await this.apiService.getPollBySlug(slug); console.log({ loadPollBySlugResponse: poll }); this.updateCurrentPoll(poll); } } public updateCurrentPoll(poll: Poll): void { this._poll.next(poll); } /** * add all the dates between the start and end dates in the interval section */ addIntervalOfDates(): void { const newIntervalArray = this.dateUtilities.getDatesInRange( this.dateUtilities.parseInputDateToDateObject(this.startDateInterval), this.dateUtilities.parseInputDateToDateObject(this.endDateInterval), 1 ); const converted = []; newIntervalArray.forEach((element) => { converted.push({ literal: element.literal, date_object: element.date_object, timeList: [], }); }); this.dateList = [...new Set(converted)]; // add only dates that are not already present with a Set of unique items console.log('this.dateList', this.dateList); this.showDateInterval = false; this.form.patchValue({ choices: this.dateList }); this.toastService.display(`les dates ont été ajoutées aux réponses possibles.`); } /** * handle keyboard shortcuts * @param $event * @param choice_number */ keyOnChoice($event: KeyboardEvent, choice_number: number): void { $event.preventDefault(); console.log('this.choices.length', this.choices.length); console.log('choice_number', choice_number); const lastChoice = this.choices.length - 1 === choice_number; // reset field with Ctrl + D // add a field with Ctrl + N // go to previous choice with arrow up // go to next choice with arrow down console.log('$event', $event); if ($event.key == 'ArrowUp' && choice_number > 0) { this.focusOnChoice(choice_number - 1); } if ($event.key == 'ArrowDown') { // add a field if we are on the last choice if (lastChoice) { this.addChoice(); this.toastService.display('choix ajouté par raccourci "flèche bas"'); } else { this.focusOnChoice(choice_number + 1); } } if ($event.ctrlKey && $event.key == 'Backspace') { this.deleteChoiceField(choice_number); this.toastService.display('choix supprimé par raccourci "Ctrl + retour"'); this.focusOnChoice(Math.min(choice_number - 1, 0)); } if ($event.ctrlKey && $event.key == 'Enter') { // go to other fields const elem = this.document.querySelector('#creatorEmail'); if (elem) { elem.focus(); } } } /** * change time spans */ addTime() { this.timeList.push({ literal: '', timeList: [], date_object: new Date(), }); } removeAllTimes() { this.timeList = []; } resetTimes() { this.timeList = otherDefaultDates; } /** * add a time period to a specific date choice, * focus on the new input * @param config * @param id */ addTimeToDate(config: any, id: number) { this.timeList.push({ literal: '', timeList: [], date_object: new Date(), }); const selector = '[ng-reflect-choice_label="dateTime_' + id + '_Choices_' + (this.timeList.length - 1) + '"]'; const elem = this.document.querySelector(selector); if (elem) { elem.focus(); } } public createPoll(): void { console.log('this.form', this.form); const newpoll = this.newPollFromForm(this.form); console.log('newpoll', newpoll); this.apiService.createPoll(newpoll); } /** * default interval of dates proposed is from today to 7 days more */ setDefaultDatesForInterval(): void { const dateCurrent = new Date(); const dateJson = dateCurrent.toISOString(); this.startDateInterval = dateJson.substring(0, 10); this.endDateInterval = this.dateUtilities .addDaysToDate(this.intervalDaysDefault, dateCurrent) .toISOString() .substring(0, 10); this.form.patchValue({ startDateInterval: this.startDateInterval, endDateInterval: this.endDateInterval, }); this.countDays(); } askInitFormDefault(): void { this.initFormDefault(false); this.toastService.display('formulaire réinitialisé'); } countDays(): void { this.intervalDays = this.dateUtilities.countDays( this.dateUtilities.parseInputDateToDateObject(this.startDateInterval), this.dateUtilities.parseInputDateToDateObject(this.endDateInterval) ); } focusOnChoice(index): void { const selector = '#choice_label_' + index; const elem = this.document.querySelector(selector); if (elem) { elem.focus(); } } deleteChoiceField(index: number): void { if (this.choices.length !== 1) { this.choices.removeAt(index); } } initFormDefault(showDemoValues = true): void { this.form = this.createFormGroup(); console.log('this.form ', this.form); this.setDefaultDatesForInterval(); if (showDemoValues) { this.setDemoValues(); } } get choices(): FormArray { return this.form.get('choices') as FormArray; } reinitChoices(): void { this.choices.setValue([]); } addChoice(optionalLabel = ''): void { const newControlGroup = this.fb.group({ label: this.fb.control('', [Validators.required]), imageUrl: ['', [Validators.required]], }); if (optionalLabel) { newControlGroup.patchValue({ label: optionalLabel, imageUrl: 'mon url', }); } this.choices.push(newControlGroup); console.log('this.choices.length', this.choices.length); this.focusOnChoice(this.choices.length - 1); } /** * make a uniq slug for the current poll creation * @param form */ makeSlug(form: FormGroup): string { let str = ''; str = form.value.created_at.getFullYear() + '_' + (form.value.created_at.getMonth() + 1) + '_' + form.value.created_at.getDate() + '_' + form.value.creatorPseudo + '_' + form.value.title; str = str.replace(/^\s+|\s+$/g, ''); // trim str = str.toLowerCase(); // remove accents, swap ñ for n, etc const from = 'àáäâèéëêìíïîòóöôùúüûñç·/_,:;'; const to = 'aaaaeeeeiiiioooouuuunc------'; for (let i = 0, l = from.length; i < l; i++) { str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i)); } str = str .replace(/[^a-z0-9 -]/g, '') // remove invalid chars .replace(/\s+/g, '-') // collapse whitespace and replace by - .replace(/-+/g, '-'); // collapse dashes return str + '-' + this.uuidService.getUUID(); } public async saveCurrentPoll(): Promise { const pollUrl: Subscription = await this.apiService.createPoll(this._poll.getValue()); // TODO: Maybe handle the url to update currentPoll according to backend response if (pollUrl) { console.log('pollUrl', pollUrl); this.toastService.display('Le sondage a été enregistré.'); } else { this.toastService.display('Le sondage n’a été correctement enregistré, veuillez ré-essayer.'); } } public saveParticipation(choice: Choice, user: User, response: Answer): void { const currentPoll = this._poll.getValue(); currentPoll.choices.find((c) => c.label === choice.label)?.updateParticipation(user, response); this.updateCurrentPoll(currentPoll); this.apiService.createParticipation(currentPoll.slug, choice.label, user.pseudo, response); this.toastService.display('Votre participation au sondage a été enregistrée.'); } public async deleteAllAnswers(): Promise { await this.apiService.deletePollAnswers(this._poll.getValue().slug); this.toastService.display('Les participations des votants à ce sondage ont été supprimées.'); } public async addComment(comment: string): Promise { await this.apiService.createComment(this._poll.getValue().slug, comment); this.toastService.display('Votre commentaire a été enregistré.'); } public async deleteComments(): Promise { await this.apiService.deletePollComments(this._poll.getValue().slug); this.toastService.display('Les commentaires de ce sondage ont été supprimés.'); } public buildAnswersByChoiceLabelByPseudo(poll: Poll): Map> { const pseudos: Set = new Set(); poll.choices.forEach((choice: Choice) => { choice.participants.forEach((users: Set) => { users.forEach((user: User) => { pseudos.add(user.pseudo); }); }); }); const list = new Map>(); pseudos.forEach((pseudo: string) => { list.set( pseudo, new Map( poll.choices.map((choice: Choice) => { return [choice.label, undefined]; }) ) ); }); poll.choices.forEach((choice: Choice) => { choice.participants.forEach((users: Set, answer: Answer) => { users.forEach((user: User) => { list.get(user.pseudo).set(choice.label, answer); }); }); }); return list; } newPollFromForm(form: any): any { const newpoll = new Poll( this.userService.getCurrentUser(), this.uuidService.getUUID(), form.controls.title.value ); /** * convert to API version 1 config poll */ const apiV1Poll = { menuVisible: true, expiracyDateDefaultInDays: newpoll.configuration.expiresDaysDelay, deletionDateAfterLastModification: newpoll.configuration.expiracyAfterLastModificationInDays, pollType: newpoll.configuration.isAboutDate ? 'dates' : 'classic', // classic or dates title: newpoll.title, description: newpoll.description, myName: newpoll.owner.pseudo, myComment: '', isAdmin: true, // when we create a poll, we are admin on it myVoteStack: {}, myTempVoteStack: 0, myEmail: newpoll.owner.email, myPolls: [], // list of retrieved polls from the backend api /* date specific poll, we have the choice to setup different hours (timeList) for all possible dates (dateList), or use the same hours for all dates */ allowSeveralHours: 'true', // access visibility: newpoll.configuration.areResultsPublic, // visible to one with the link: voteChoices: newpoll.configuration.isMaybeAnswerAvailable ? 'yes, maybe, no' : 'yes', // possible answers to a vote choice: only "yes", "yes, maybe, no" created_at: new Date(), expirationDate: '', // expiracy date voteStackId: null, // id of the vote stack to update pollId: null, // id of the current poll when created. data given by the backend api pollSlug: null, // id of the current poll when created. data given by the backend api currentPoll: null, // current poll selected with createPoll or getPoll of ConfigService passwordAccess: newpoll.configuration.isProtectedByPassword, password: newpoll.configuration.password, customUrl: newpoll.slug, // custom slug in the url, must be unique customUrlIsUnique: null, // given by the backend urlSlugPublic: null, urlPublic: null, urlAdmin: null, adminKey: '', // key to change config of the poll owner_modifier_token: '', // key to change a vote stack canModifyAnswers: newpoll.configuration.isAllowingtoChangeOwnAnswers, // bool for the frontend selector whoModifiesAnswers: newpoll.configuration.whoCanChangeAnswers, // everybody, self, nobody (: just admin) whoCanChangeAnswers: newpoll.configuration.whoCanChangeAnswers, // everybody, self, nobody (: just admin) dateList: newpoll.dateChoices, // sets of days as strings, config to set identical time for days in a special days poll timeList: newpoll.timeChoices, // ranges of time expressed as strings answers: newpoll.choices, // modals displayConfirmVoteModalAdmin: false, }; console.log('apiV1Poll', apiV1Poll); return apiV1Poll; } }