diff --git a/app/components/create-options-dates.js b/app/components/create-options-dates.js index 32c5f19..6d42a58 100644 --- a/app/components/create-options-dates.js +++ b/app/components/create-options-dates.js @@ -9,17 +9,9 @@ export default class CreateOptionsDates extends Component { this.selectedDays.length >= 1 ? this.selectedDays[0] : DateTime.local(); get selectedDays() { - // Options may contain the same date multiple times with different ime - // Must filter out those duplicates as otherwise unselect would only - // remove one entry but not all duplicates. - return Array.from( - // using Set to remove duplicate values - new Set( - this.args.options.map(({ value }) => - DateTime.fromISO(value).toISODate(), - ), - ), - ).map((isoDate) => DateTime.fromISO(isoDate)); + return Array.from(this.args.options).map(({ value }) => + DateTime.fromISO(value), + ); } get calendarCenterNext() { @@ -34,53 +26,8 @@ export default class CreateOptionsDates extends Component { return; } - // A date has either been added or removed. If it has been removed, we must - // remove all options for that date. It may be multiple options with - // different times at that date. - - // If any date received as an input argument is _not_ yet in the list of - // dates, it has been added. - const dateAdded = newDatesAsLuxonDateTime.find((newDateAsLuxonDateTime) => { - return !this.selectedDays.some( - (selectedDay) => - selectedDay.toISODate() === newDateAsLuxonDateTime.toISODate(), - ); - }); - - if (dateAdded) { - this.args.updateOptions( - [ - ...this.args.options.map(({ value }) => value), - dateAdded.toISODate(), - ].sort(), - ); - return; - } - - // If no date has been added, one date must have been removed. It has been - // removed if there is one date in current selectedDays but not in the new - // dates received as input argument to the function. - const dateRemoved = this.selectedDays.find((selectedDay) => { - return !newDatesAsLuxonDateTime.some( - (newDateAsLuxonDateTime) => - newDateAsLuxonDateTime.toISODate() === selectedDay.toISODate(), - ); - }); - - if (dateRemoved) { - this.args.updateOptions( - this.args.options - .filter( - ({ value }) => - DateTime.fromISO(value).toISODate() !== dateRemoved.toISODate(), - ) - .map(({ value }) => value), - ); - return; - } - - throw new Error( - 'No date has been added or removed. This cannot be the case. Something spooky is going on.', + this.args.updateOptions( + newDatesAsLuxonDateTime.map((datetime) => datetime.toISODate()), ); } diff --git a/app/components/create-options-datetime.hbs b/app/components/create-options-datetime.hbs index 3fcbc8f..3c2b2f4 100644 --- a/app/components/create-options-datetime.hbs +++ b/app/components/create-options-datetime.hbs @@ -14,117 +14,110 @@ as |form| >
- {{#each this.formData.options as |option index|}} - {{! - show summarized validation state for all times in a day - }} -
- -
- `. - }} - {{on "focusin" (fn this.updateInputValidation option)}} - {{on "keyup" (fn this.updateInputValidation option)}} - id={{el.id}} - /> -
- - - - {{t "create.options.button.delete.label"}} - - -
-
- + - - {{t - "create.options.button.add.label" - }} - -
-
- {{/each}} +
+ `. + }} + {{on "focusin" (fn this.updateInputValidation timeOption)}} + {{on "keyup" (fn this.updateInputValidation timeOption)}} + id={{el.id}} + /> +
+ + + + {{t "create.options.button.delete.label"}} + + +
+
+ + + + {{t + "create.options.button.add.label" + }} + + +
+ {{/each}} + {{/each-in}} {{#if this.formData.hasMultipleDays}} @@ -145,7 +138,7 @@
- +
diff --git a/app/components/create-options-datetime.js b/app/components/create-options-datetime.js index 4145a1f..330c874 100644 --- a/app/components/create-options-datetime.js +++ b/app/components/create-options-datetime.js @@ -2,15 +2,15 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; -import { TrackedArray } from 'tracked-built-ins'; +import { TrackedMap, TrackedSet } from 'tracked-built-ins'; import { DateTime } from 'luxon'; import IntlMessage from '../utils/intl-message'; -class FormDataOption { +class FormDataTimeOption { formData; // ISO 8601 date string: YYYY-MM-DD - day; + date; // ISO 8601 time string without seconds: HH:mm @tracked time; @@ -31,11 +31,11 @@ class FormDataOption { // 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, day } = this; - const optionsForThisDay = formData.optionsGroupedByDay[day]; - const isDuplicate = optionsForThisDay - .slice(0, optionsForThisDay.indexOf(this)) - .some((option) => option.time == this.time); + 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'); } @@ -44,8 +44,8 @@ class FormDataOption { } get datetime() { - const { day, time } = this; - const isoString = time === null ? day : `${day}T${time}`; + const { date, time } = this; + const isoString = time === null ? date : `${date}T${time}`; return DateTime.fromISO(isoString); } @@ -59,66 +59,61 @@ class FormDataOption { return timeValidation === null; } - constructor(formData, { day, time }) { + 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, { date, time }) { this.formData = formData; - this.day = day; + this.date = date; this.time = time; } } class FormData { - @tracked options; + @tracked datetimes; get optionsValidation() { - const { options } = this; - const allOptionsAreValid = options.every((option) => option.isValid); - if (!allOptionsAreValid) { + const { datetimes } = this; + const allTimeOptionsAreValid = Array.from(datetimes.values()).every( + (timeOptionsForDate) => + Array.from(timeOptionsForDate).every( + (timeOption) => timeOption.isValid, + ), + ); + if (!allTimeOptionsAreValid) { return IntlMessage('create.options-datetime.error.invalidTime'); } return null; } - get optionsGroupedByDay() { - const { options } = this; - const groupedOptions = {}; - - for (const option of options) { - const { day } = option; - - if (!groupedOptions[day]) { - groupedOptions[day] = []; - } - - groupedOptions[day].push(option); - } - - return groupedOptions; - } - get hasMultipleDays() { - return Object.keys(this.optionsGroupedByDay).length > 1; + return this.datetimes.size > 1; } @action - addOption(position, day) { - this.options.splice( - position + 1, - 0, - new FormDataOption(this, { day, time: null }), - ); + addOption(date) { + this.datetimes + .get(date) + .add(new FormDataTimeOption(this, { date, time: null })); } /* - * removes target option if it's not the only date for this day + * removes target option if it's not the only time for this date * otherwise it deletes time for this date */ @action deleteOption(option) { - const optionsForThisDay = this.optionsGroupedByDay[option.day]; + const timeOptionsForDate = this.datetimes.get(option.date); - if (optionsForThisDay.length > 1) { - this.options.splice(this.options.indexOf(option), 1); + if (timeOptionsForDate.size > 1) { + timeOptionsForDate.delete(option); } else { option.time = null; } @@ -126,42 +121,53 @@ class FormData { @action adoptTimesOfFirstDay() { - const { optionsGroupedByDay } = this; - const days = Object.keys(optionsGroupedByDay).sort(); - const firstDay = days[0]; - const optionsForFirstDay = optionsGroupedByDay[firstDay]; - - const timesForFirstDayAreValid = optionsForFirstDay.every( - (option) => option.isValid, + const timeOptionsForFirstDay = Array.from( + Array.from(this.datetimes.values())[0], + ); + const timesForFirstDayAreValid = timeOptionsForFirstDay.every( + (timeOption) => timeOption.isValid, ); if (!timesForFirstDayAreValid) { return false; } - const timesForFirstDay = optionsForFirstDay.map((option) => option.time); - - this.options = new TrackedArray( - days - .map((day) => - timesForFirstDay.map( - (time) => new FormDataOption(this, { day, time }), + 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 }), ), - ) - .flat(), - ); + ), + ); + } } - constructor(options) { - this.options = new TrackedArray( - options.map(({ day, time }) => new FormDataOption(this, { day, time })), - ); + constructor({ dates, times }) { + const datetimes = new Map(); + + for (const date of dates) { + const timesForDate = times.has(date) + ? Array.from(times.get(date)) + : [null]; + datetimes.set( + date, + new TrackedSet( + timesForDate.map( + (time) => new FormDataTimeOption(this, { date, time }), + ), + ), + ); + } + + this.datetimes = new TrackedMap(datetimes); } } export default class CreateOptionsDatetime extends Component { @service router; - formData = new FormData(this.args.dates); + formData = new FormData({ dates: this.args.dates, times: this.args.times }); @tracked errorMesage = null; @@ -208,7 +214,25 @@ export default class CreateOptionsDatetime extends Component { @action handleTransition(transition) { if (transition.from?.name === 'create.options-datetime') { - this.args.updateOptions(this.formData.options); + this.args.updateOptions( + // FormData.datetimes Map has a Set of FormDataTime object as values + // We need to transform it to a Set of plain time strings + new Map( + Array.from(this.formData.datetimes.entries()) + .map(([key, timeOptions]) => [ + key, + new Set( + Array.from(timeOptions) + .map(({ time }) => time) + // There might be FormDataTime objects without a time, which + // we need to filter out + .filter((time) => time !== null), + ), + ]) + // There might be dates without any time, which we need to filter out + .filter(([, times]) => times.size > 0), + ), + ); this.router.off('routeWillChange', this.handleTransition); } } diff --git a/app/components/create-options.js b/app/components/create-options.js index c8f9cb9..897ba34 100644 --- a/app/components/create-options.js +++ b/app/components/create-options.js @@ -79,10 +79,12 @@ class FormData { constructor({ options }, { defaultOptionCount }) { const normalizedOptions = - options.length === 0 && defaultOptionCount > 0 ? ['', ''] : options; + options.size === 0 && defaultOptionCount > 0 + ? ['', ''] + : Array.from(options); this.options = new TrackedArray( - normalizedOptions.map(({ title }) => new FormDataOption(this, title)), + normalizedOptions.map((value) => new FormDataOption(this, value)), ); } } diff --git a/app/controllers/create.js b/app/controllers/create.js index c0a6ce9..da36361 100644 --- a/app/controllers/create.js +++ b/app/controllers/create.js @@ -23,12 +23,16 @@ export default class CreateController extends Controller { get canEnterOptionsDatetimeStep() { return ( this.visitedSteps.has('options-datetime') && - this.model.options.length >= 1 + this.model.dateOptions.size >= 1 ); } get canEnterSettingsStep() { - return this.visitedSteps.has('settings') && this.model.options.length >= 1; + const { model, visitedSteps } = this; + const { dateOptions, freetextOptions, pollType } = model; + const options = pollType === 'FindADate' ? dateOptions : freetextOptions; + + return visitedSteps.has('settings') && options.size >= 1; } get isFindADate() { diff --git a/app/controllers/create/options-datetime.js b/app/controllers/create/options-datetime.js index 86976b0..b0e0d8f 100644 --- a/app/controllers/create/options-datetime.js +++ b/app/controllers/create/options-datetime.js @@ -1,6 +1,5 @@ import Controller from '@ember/controller'; import { action } from '@ember/object'; -import { DateTime } from 'luxon'; import { inject as service } from '@ember/service'; export default class CreateOptionsDatetimeController extends Controller { @@ -18,14 +17,7 @@ export default class CreateOptionsDatetimeController extends Controller { } @action - updateOptions(options) { - this.model.options = options - .map(({ day, time }) => - time ? DateTime.fromISO(`${day}T${time}`).toISO() : day, - ) - .sort() - .map((isoString) => { - return this.store.createFragment('option', { title: isoString }); - }); + updateOptions(datetimes) { + this.model.timesForDateOptions = new Map(datetimes.entries()); } } diff --git a/app/controllers/create/options.js b/app/controllers/create/options.js index 2567ed0..ec45ef8 100644 --- a/app/controllers/create/options.js +++ b/app/controllers/create/options.js @@ -1,6 +1,7 @@ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import { TrackedSet } from 'tracked-built-ins/.'; export default class CreateOptionsController extends Controller { @service router; @@ -8,9 +9,9 @@ export default class CreateOptionsController extends Controller { @action nextPage() { - const { isFindADate } = this.model; + const { pollType } = this.model; - if (isFindADate) { + if (pollType === 'FindADate') { this.router.transitionTo('create.options-datetime'); } else { this.router.transitionTo('create.settings'); @@ -24,8 +25,13 @@ export default class CreateOptionsController extends Controller { @action updateOptions(newOptions) { - this.model.options = newOptions.map(({ value }) => - this.store.createFragment('option', { title: value }), - ); + const { pollType } = this.model; + const options = newOptions.map(({ value }) => value); + + if (pollType === 'FindADate') { + this.model.dateOptions = new TrackedSet(options.sort()); + } else { + this.model.freetextOptions = new TrackedSet(options); + } } } diff --git a/app/controllers/create/settings.js b/app/controllers/create/settings.js index 4f7abc5..0bae887 100644 --- a/app/controllers/create/settings.js +++ b/app/controllers/create/settings.js @@ -9,6 +9,7 @@ export default class CreateSettings extends Controller { @service flashMessages; @service intl; @service router; + @service store; get anonymousUser() { return this.model.anonymousUser; @@ -77,9 +78,9 @@ export default class CreateSettings extends Controller { @action previousPage() { - let { isFindADate } = this.model; + let { pollType } = this.model; - if (isFindADate) { + if (pollType === 'FindADate') { this.router.transitionTo('create.options-datetime'); } else { this.router.transitionTo('create.options'); @@ -88,19 +89,68 @@ export default class CreateSettings extends Controller { @action async submit() { - const { model: poll } = this; + const { model } = this; + const { + anonymousUser, + answerType, + description, + expirationDate, + forceAnswer, + freetextOptions, + dateOptions, + timesForDateOptions, + pollType, + title, + } = model; + let options = []; + + if (pollType === 'FindADate') { + // merge date with times + for (const date of dateOptions) { + if (timesForDateOptions.has(date)) { + for (const time of timesForDateOptions.get(date)) { + const [hour, minute] = time.split(':'); + options.push( + DateTime.fromISO(date) + .set({ + hour, + minute, + second: 0, + millisecond: 0, + }) + .toISO(), + ); + } + } else { + options.push(date); + } + } + } else { + options.push(...freetextOptions); + } + + const poll = this.store.createRecord('poll', { + anonymousUser, + answerType, + creationDate: new Date().toISOString(), + description, + expirationDate, + forceAnswer, + options: options.map((option) => + this.store.createFragment('option', { title: option }), + ), + pollType, + title, + }); // set timezone if there is atleast one option with time if ( - poll.isFindADate && - poll.options.toArray().some((option) => { - return option.hasTime; + pollType === 'FindADate' && + Array.from(timesForDateOptions.values()).some((timesForDateOptions) => { + return timesForDateOptions.size > 0; }) ) { - this.set( - 'model.timezone', - Intl.DateTimeFormat().resolvedOptions().timeZone, - ); + poll.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } // save poll diff --git a/app/modifiers/autofocus.js b/app/modifiers/autofocus.js index 867e5be..714e960 100644 --- a/app/modifiers/autofocus.js +++ b/app/modifiers/autofocus.js @@ -1,13 +1,20 @@ -import { modifier } from 'ember-modifier'; +import Modifier from 'ember-modifier'; -export default modifier(function autofocus( - element, - params, - { enabled = true }, -) { - if (!enabled) { - return; +export default class AutofocusModifier extends Modifier { + isInstalled = false; + + modify(element, positional, { enabled = true }) { + // element should be only autofocused on initial render + // not when `enabled` option is invalidated + if (this.isInstalled) { + return; + } + this.isInstalled = true; + + if (!enabled) { + return; + } + + element.focus(); } - - element.focus(); -}); +} diff --git a/app/routes/create.js b/app/routes/create.js index 02a3753..5371e29 100644 --- a/app/routes/create.js +++ b/app/routes/create.js @@ -1,10 +1,22 @@ -import classic from 'ember-classic-decorator'; import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; -import config from 'croodle/config/environment'; import { DateTime } from 'luxon'; +import { tracked } from '@glimmer/tracking'; +import { TrackedSet } from 'tracked-built-ins'; + +class PollData { + @tracked anonymousUser = false; + @tracked answerType = 'YesNo'; + @tracked description = ''; + @tracked expirationDate = DateTime.local().plus({ months: 3 }).toISO(); + @tracked forceAnswer = true; + @tracked freetextOptions = new TrackedSet(); + @tracked dateOptions = new TrackedSet(); + @tracked timesForDateOptions = new Map(); + @tracked pollType = 'FindADate'; + @tracked title = ''; +} -@classic export default class CreateRoute extends Route { @service encryption; @service router; @@ -21,17 +33,7 @@ export default class CreateRoute extends Route { } model() { - // create empty poll - return this.store.createRecord('poll', { - answerType: 'YesNo', - creationDate: new Date(), - forceAnswer: true, - anonymousUser: false, - pollType: 'FindADate', - timezone: null, - expirationDate: DateTime.local().plus({ months: 3 }).toISO(), - version: config.APP.version, - }); + return new PollData(); } activate() { diff --git a/app/templates/create/options-datetime.hbs b/app/templates/create/options-datetime.hbs index 4ed6ae1..8779ebe 100644 --- a/app/templates/create/options-datetime.hbs +++ b/app/templates/create/options-datetime.hbs @@ -1,5 +1,6 @@ { - this.set( - 'poll', - this.store.createRecord('poll', { - pollType: 'FindADate', - options: [{ title: '2015-01-01' }], - }), - ); - }); - await render(hbs``); + this.set('dates', new Set(['2015-01-01'])); + this.set('times', new Map()); + await render( + hbs``, + ); assert.equal( findAll('.days .form-group input').length, @@ -48,20 +37,11 @@ module('Integration | Component | create options datetime', function (hooks) { }); test('it generates input field for options iso 8601 datetime string (with time)', async function (assert) { - // validation is based on validation of every option fragment - // which validates according to poll model it belongs to - // therefore each option needs to be pushed to poll model to have it as - // it's owner - run(() => { - this.set( - 'poll', - this.store.createRecord('poll', { - pollType: 'FindADate', - options: [{ title: '2015-01-01T11:11:00.000Z' }], - }), - ); - }); - await render(hbs``); + this.set('dates', new Set(['2015-01-01'])); + this.set('times', new Map([['2015-01-01', new Set(['11:11'])]])); + await render( + hbs``, + ); assert.equal( findAll('.days .form-group input').length, @@ -70,30 +50,17 @@ module('Integration | Component | create options datetime', function (hooks) { ); assert.equal( find('.days .form-group input').value, - DateTime.fromISO('2015-01-01T11:11:00.000Z').toFormat('HH:mm'), + '11:11', 'it has time in option as value', ); }); test('it hides repeated labels', async function (assert) { - // validation is based on validation of every option fragment - // which validates according to poll model it belongs to - // therefore each option needs to be pushed to poll model to have it as - // it's owner - run(() => { - this.set( - 'poll', - this.store.createRecord('poll', { - pollType: 'FindADate', - options: [ - { title: DateTime.fromISO('2015-01-01T10:11').toISO() }, - { title: DateTime.fromISO('2015-01-01T22:22').toISO() }, - { title: '2015-02-02' }, - ], - }), - ); - }); - await render(hbs``); + this.set('dates', new Set(['2015-01-01', '2015-02-02'])); + this.set('times', new Map([['2015-01-01', new Set(['11:11', '22:22'])]])); + await render( + hbs``, + ); assert.equal( findAll('.days label').length, @@ -126,19 +93,11 @@ module('Integration | Component | create options datetime', function (hooks) { }); test('allows to add another option', async function (assert) { - // validation is based on validation of every option fragment - // which validates according to poll model it belongs to - // therefore each option needs to be pushed to poll model to have it as - // it's owner - run(() => { - this.set( - 'poll', - this.store.createRecord('poll', { - options: [{ title: '2015-01-01' }, { title: '2015-02-02' }], - }), - ); - }); - await render(hbs``); + this.set('dates', new Set(['2015-01-01', '2015-02-02'])); + this.set('times', new Map()); + await render( + hbs``, + ); assert.equal( findAll('.days .form-group input').length, @@ -166,23 +125,11 @@ module('Integration | Component | create options datetime', function (hooks) { }); test('allows to delete an option', async function (assert) { - // validation is based on validation of every option fragment - // which validates according to poll model it belongs to - // therefore each option needs to be pushed to poll model to have it as - // it's owner - run(() => { - this.set( - 'poll', - this.store.createRecord('poll', { - pollType: 'FindADate', - options: [ - { title: DateTime.fromISO('2015-01-01T11:11').toISO() }, - { title: DateTime.fromISO('2015-01-01T22:22').toISO() }, - ], - }), - ); - }); - await render(hbs``); + this.set('dates', new Set(['2015-01-01'])); + this.set('times', new Map([['2015-01-01', new Set(['11:11', '22:22'])]])); + await render( + hbs``, + ); assert.equal( findAll('.days input').length, diff --git a/tests/integration/components/create-options-test.js b/tests/integration/components/create-options-test.js index 7fc7f80..ae9ba00 100644 --- a/tests/integration/components/create-options-test.js +++ b/tests/integration/components/create-options-test.js @@ -1,8 +1,8 @@ -import { run } from '@ember/runloop'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { render, findAll, blur, fillIn, focus } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; +import { TrackedSet } from 'tracked-built-ins'; module('Integration | Component | create options', function (hooks) { setupRenderingTest(hooks); @@ -14,25 +14,14 @@ module('Integration | Component | create options', function (hooks) { test('shows validation errors if options are not unique (makeAPoll)', async function (assert) { assert.expect(5); - // validation is based on validation of every option fragment - // which validates according to poll model it belongs to - // therefore each option needs to be pushed to poll model to have it as - // it's owner - let poll; - run(() => { - poll = this.store.createRecord('poll', { - pollType: 'MakeAPoll', - }); - }); - this.set('poll', poll); - this.set('options', poll.get('options')); + this.set('options', new TrackedSet()); await render(hbs` `); @@ -64,25 +53,14 @@ module('Integration | Component | create options', function (hooks) { }); test('shows validation errors if option is empty (makeAPoll)', async function (assert) { - // validation is based on validation of every option fragment - // which validates according to poll model it belongs to - // therefore each option needs to be pushed to poll model to have it as - // it's owner - let poll; - run(() => { - poll = this.store.createRecord('poll', { - pollType: 'MakeAPoll', - }); - }); - this.set('poll', poll); - this.set('options', poll.get('options')); + this.set('options', new TrackedSet()); await render(hbs` `); diff --git a/tests/integration/modifiers/autofocus-test.js b/tests/integration/modifiers/autofocus-test.js index 61fe7e3..7c36592 100644 --- a/tests/integration/modifiers/autofocus-test.js +++ b/tests/integration/modifiers/autofocus-test.js @@ -25,4 +25,12 @@ module('Integration | Modifier | autofocus', function (hooks) { assert.dom('input').isNotFocused(); }); + + test('it does not focus the element if `enabled` changes', async function (assert) { + this.set('enabled', false); + + await render(hbs``); + this.set('enabled', true); + assert.dom('input').isNotFocused(); + }); });