import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; import { TrackedMap, TrackedSet } from 'tracked-built-ins'; import { DateTime } from 'luxon'; import IntlMessage from '../utils/intl-message'; import type RouterService from '@ember/routing/router-service'; import type Transition from '@ember/routing/transition'; import type { CreateOptionsDatetimeRouteModel } from 'croodle/routes/create/options-datetime'; class FormDataTimeOption { formData; // ISO 8601 date string: YYYY-MM-DD date: string; // ISO 8601 time string without seconds: HH:mm @tracked time: string | null; // helper property set by modifiers to track if input element is invalid // because user only entered the time partly (e.g. "10:--"). @tracked isPartiallyFilled = false; get timeValidation() { const { isPartiallyFilled } = this; if (isPartiallyFilled) { return new IntlMessage( 'create.options-datetime.error.partiallyFilledTime', ); } // The same time must not be entered twice for a day. // It should show a validation error if the same time has been entered for // the same day already before. Only the second input field containing the // duplicated time should show the validation error. const { formData, date } = this; const timesForThisDate = Array.from(formData.datetimes.get(date)!); const isDuplicate = timesForThisDate .slice(0, timesForThisDate.indexOf(this)) .some((timeOption) => timeOption.time == this.time); if (isDuplicate) { return new IntlMessage('create.options-datetime.error.duplicatedDate'); } return null; } get datetime() { const { date, time } = this; const isoString = time === null ? date : `${date}T${time}`; return DateTime.fromISO(isoString); } get jsDate() { const { datetime } = this; return datetime.toJSDate(); } get isValid() { const { timeValidation } = this; return timeValidation === null; } get isFirstTimeOnFirstDate() { const { formData, date } = this; const { datetimes } = formData; return ( Array.from(datetimes.keys())[0] === date && Array.from(datetimes.get(date)!)[0] === this ); } constructor( formData: FormData, { date, time }: { date: string; time: string | null }, ) { this.formData = formData; this.date = date; this.time = time; } } class FormData { @tracked datetimes: Map>; get optionsValidation() { const { datetimes } = this; const allTimeOptionsAreValid = Array.from(datetimes.values()).every( (timeOptionsForDate) => Array.from(timeOptionsForDate).every( (timeOption) => timeOption.isValid, ), ); if (!allTimeOptionsAreValid) { return new IntlMessage('create.options-datetime.error.invalidTime'); } return null; } get hasMultipleDays() { return this.datetimes.size > 1; } get validationStatePerDate() { const validationState: Map = new Map(); for (const [date, timeOptions] of this.datetimes.entries()) { validationState.set( date, Array.from(timeOptions).every((time) => time.isValid), ); } return validationState; } @action addOption(date: string) { this.datetimes .get(date)! .add(new FormDataTimeOption(this, { date, time: null })); } /* * removes target option if it's not the only time for this date * otherwise it deletes time for this date */ @action deleteOption(option: FormDataTimeOption) { const timeOptionsForDate = this.datetimes.get(option.date)!; if (timeOptionsForDate.size > 1) { timeOptionsForDate.delete(option); } else { option.time = null; } } @action adoptTimesOfFirstDay() { const timeOptionsForFirstDay = Array.from( Array.from(this.datetimes.values())[0]!, ) as FormDataTimeOption[]; const timesForFirstDayAreValid = timeOptionsForFirstDay.every( (timeOption) => timeOption.isValid, ); if (!timesForFirstDayAreValid) { return false; } for (const date of Array.from(this.datetimes.keys()).slice(1)) { this.datetimes.set( date, new TrackedSet( timeOptionsForFirstDay.map( ({ time }) => new FormDataTimeOption(this, { date, time }), ), ), ); } } constructor({ dates, times, }: { dates: Set; times: Map>; }) { const datetimes = new Map(); for (const date of dates) { const timesForDate = times.has(date) ? Array.from(times.get(date) as Set) : [null]; datetimes.set( date, new TrackedSet( timesForDate.map( (time) => new FormDataTimeOption(this, { date, time }), ), ), ); } this.datetimes = new TrackedMap(datetimes); } } export interface CreateOptoinsDatetimeSignature { Args: { poll: CreateOptionsDatetimeRouteModel; }; } export default class CreateOptionsDatetime extends Component { @service declare router: RouterService; formData = new FormData({ dates: this.args.poll.dateOptions, times: this.args.poll.timesForDateOptions, }); @tracked errorMessage: string | null = null; @action adoptTimesOfFirstDay() { const { formData } = this; const successful = formData.adoptTimesOfFirstDay(); if (!successful) { this.errorMessage = 'create.options-datetime.fix-validation-errors-first-day'; } } @action previousPage() { this.router.transitionTo('create.options'); } @action submit() { this.router.transitionTo('create.settings'); } // validate input field for being partially filled @action validateInput(option: FormDataTimeOption, event: Event) { const element = event.target as HTMLInputElement; // update partially filled time validation error option.isPartiallyFilled = !element.checkValidity(); } // remove partially filled validation error if user fixed it @action updateInputValidation(option: FormDataTimeOption, event: Event) { const element = event.target as HTMLInputElement; if (element.checkValidity() && option.isPartiallyFilled) { option.isPartiallyFilled = false; } } @action handleTransition(transition: Transition) { if (transition.from?.name === 'create.options-datetime') { this.args.poll.timesForDateOptions = new Map( // FormData.datetimes Map has a Set of FormDataTime object as values // We need to transform it to a Set of plain time strings Array.from(this.formData.datetimes.entries()) .map(([key, timeOptions]): [string, Set] => { return [ key, new Set( Array.from(timeOptions) .map(({ time }: FormDataTimeOption) => time) // There might be FormDataTime objects without a time, which // we need to filter out .filter((time) => time !== null), ) as Set, ]; }) // There might be dates without any time, which we need to filter out .filter(([, times]) => times.size > 0), ); this.router.off('routeWillChange', this.handleTransition); } } constructor(owner: unknown, args: CreateOptoinsDatetimeSignature['Args']) { super(owner, args); this.router.on('routeWillChange', this.handleTransition); } } declare module '@glint/environment-ember-loose/registry' { export default interface Registry { CreateOptionsDatetime: typeof CreateOptionsDatetime; } }