Convert to TypeScript (#713)

* setup typescript

* covert to TypeScript
This commit is contained in:
Jeldrik Hanschke 2023-10-29 19:16:33 +01:00 committed by GitHub
parent a5d19c91f0
commit f0cff27e99
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 2405 additions and 7905 deletions

View file

@ -11,5 +11,5 @@
Setting `isTypeScriptProject` to true will force the blueprint generators to generate TypeScript Setting `isTypeScriptProject` to true will force the blueprint generators to generate TypeScript
rather than JavaScript by default, when a TypeScript version of a given blueprint is available. rather than JavaScript by default, when a TypeScript version of a given blueprint is available.
*/ */
"isTypeScriptProject": false "isTypeScriptProject": true
} }

View file

@ -2,18 +2,11 @@
module.exports = { module.exports = {
root: true, root: true,
parser: '@babel/eslint-parser', parser: '@typescript-eslint/parser',
parserOptions: { parserOptions: {
ecmaVersion: 'latest', ecmaVersion: 'latest',
sourceType: 'module',
requireConfigFile: false,
babelOptions: {
plugins: [
['@babel/plugin-proposal-decorators', { decoratorsBeforeExport: true }],
],
},
}, },
plugins: ['ember'], plugins: ['ember', '@typescript-eslint'],
extends: [ extends: [
'eslint:recommended', 'eslint:recommended',
'plugin:ember/recommended', 'plugin:ember/recommended',
@ -32,6 +25,15 @@ module.exports = {
'no-prototype-builtins': 'warn', 'no-prototype-builtins': 'warn',
}, },
overrides: [ overrides: [
// ts files
{
files: ['**/*.ts'],
extends: [
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {},
},
// node files // node files
{ {
files: [ files: [
@ -46,9 +48,6 @@ module.exports = {
'./lib/*/index.js', './lib/*/index.js',
'./server/**/*.js', './server/**/*.js',
], ],
parserOptions: {
sourceType: 'script',
},
env: { env: {
browser: false, browser: false,
node: true, node: true,

View file

@ -4,6 +4,8 @@ import loadInitializers from 'ember-load-initializers';
import config from 'croodle/config/environment'; import config from 'croodle/config/environment';
export default class App extends Application { export default class App extends Application {
LOG_TRANSITIONS = true;
modulePrefix = config.modulePrefix; modulePrefix = config.modulePrefix;
podModulePrefix = config.podModulePrefix; podModulePrefix = config.podModulePrefix;
Resolver = Resolver; Resolver = Resolver;

View file

@ -1,38 +0,0 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { DateTime } from 'luxon';
import { tracked } from '@glimmer/tracking';
export default class CreateOptionsDates extends Component {
@tracked calendarCenter =
this.selectedDays.length >= 1 ? this.selectedDays[0] : DateTime.local();
get selectedDays() {
return Array.from(this.args.options).map(({ value }) =>
DateTime.fromISO(value),
);
}
get calendarCenterNext() {
return this.calendarCenter.plus({ months: 1 });
}
@action
handleSelectedDaysChange({ datetime: newDatesAsLuxonDateTime }) {
if (!isArray(newDatesAsLuxonDateTime)) {
// special case: all options are unselected
this.args.updateOptions([]);
return;
}
this.args.updateOptions(
newDatesAsLuxonDateTime.map((datetime) => datetime.toISODate()),
);
}
@action
updateCalenderCenter(diff) {
this.calendarCenter = this.calendarCenter.add(diff, 'months');
}
}

View file

@ -0,0 +1,48 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { DateTime } from 'luxon';
import { tracked } from '@glimmer/tracking';
import type { TrackedSet } from 'tracked-built-ins';
import type { FormDataOption } from './create-options';
export interface CreateOptionsDatesSignature {
Args: {
options: TrackedSet<FormDataOption>;
updateOptions: (options: string[]) => void;
};
}
export default class CreateOptionsDates extends Component<CreateOptionsDatesSignature> {
@tracked calendarCenter =
this.selectedDays.length >= 1
? (this.selectedDays[0] as DateTime)
: DateTime.local();
get selectedDays(): DateTime[] {
return Array.from(this.args.options).map(
({ value }) => DateTime.fromISO(value) as DateTime,
);
}
get calendarCenterNext() {
return this.calendarCenter.plus({ months: 1 });
}
@action
handleSelectedDaysChange({
datetime: newDatesAsLuxonDateTime,
}: {
datetime: DateTime[];
}) {
if (!isArray(newDatesAsLuxonDateTime)) {
// special case: all options are unselected
this.args.updateOptions([]);
return;
}
this.args.updateOptions(
newDatesAsLuxonDateTime.map((datetime) => datetime.toISODate() as string),
);
}
}

View file

@ -5,15 +5,17 @@ import { tracked } from '@glimmer/tracking';
import { TrackedMap, TrackedSet } from 'tracked-built-ins'; import { TrackedMap, TrackedSet } from 'tracked-built-ins';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import IntlMessage from '../utils/intl-message'; import IntlMessage from '../utils/intl-message';
import type RouterService from '@ember/routing/router-service';
import type Transition from '@ember/routing/transition';
class FormDataTimeOption { class FormDataTimeOption {
formData; formData;
// ISO 8601 date string: YYYY-MM-DD // ISO 8601 date string: YYYY-MM-DD
date; date: string;
// ISO 8601 time string without seconds: HH:mm // ISO 8601 time string without seconds: HH:mm
@tracked time; @tracked time: string | null;
// helper property set by modifiers to track if input element is invalid // helper property set by modifiers to track if input element is invalid
// because user only entered the time partly (e.g. "10:--"). // because user only entered the time partly (e.g. "10:--").
@ -32,7 +34,7 @@ class FormDataTimeOption {
// the same day already before. Only the second input field containing the // the same day already before. Only the second input field containing the
// duplicated time should show the validation error. // duplicated time should show the validation error.
const { formData, date } = this; const { formData, date } = this;
const timesForThisDate = Array.from(formData.datetimes.get(date)); const timesForThisDate = Array.from(formData.datetimes.get(date)!);
const isDuplicate = timesForThisDate const isDuplicate = timesForThisDate
.slice(0, timesForThisDate.indexOf(this)) .slice(0, timesForThisDate.indexOf(this))
.some((timeOption) => timeOption.time == this.time); .some((timeOption) => timeOption.time == this.time);
@ -64,11 +66,14 @@ class FormDataTimeOption {
const { datetimes } = formData; const { datetimes } = formData;
return ( return (
Array.from(datetimes.keys())[0] === date && Array.from(datetimes.keys())[0] === date &&
Array.from(datetimes.get(date))[0] === this Array.from(datetimes.get(date)!)[0] === this
); );
} }
constructor(formData, { date, time }) { constructor(
formData: FormData,
{ date, time }: { date: string; time: string | null },
) {
this.formData = formData; this.formData = formData;
this.date = date; this.date = date;
this.time = time; this.time = time;
@ -76,7 +81,7 @@ class FormDataTimeOption {
} }
class FormData { class FormData {
@tracked datetimes; @tracked datetimes: Map<string, Set<FormDataTimeOption>>;
get optionsValidation() { get optionsValidation() {
const { datetimes } = this; const { datetimes } = this;
@ -87,7 +92,7 @@ class FormData {
), ),
); );
if (!allTimeOptionsAreValid) { if (!allTimeOptionsAreValid) {
return IntlMessage('create.options-datetime.error.invalidTime'); return new IntlMessage('create.options-datetime.error.invalidTime');
} }
return null; return null;
@ -98,9 +103,9 @@ class FormData {
} }
@action @action
addOption(date) { addOption(date: string) {
this.datetimes this.datetimes
.get(date) .get(date)!
.add(new FormDataTimeOption(this, { date, time: null })); .add(new FormDataTimeOption(this, { date, time: null }));
} }
@ -109,8 +114,8 @@ class FormData {
* otherwise it deletes time for this date * otherwise it deletes time for this date
*/ */
@action @action
deleteOption(option) { deleteOption(option: FormDataTimeOption) {
const timeOptionsForDate = this.datetimes.get(option.date); const timeOptionsForDate = this.datetimes.get(option.date)!;
if (timeOptionsForDate.size > 1) { if (timeOptionsForDate.size > 1) {
timeOptionsForDate.delete(option); timeOptionsForDate.delete(option);
@ -122,8 +127,8 @@ class FormData {
@action @action
adoptTimesOfFirstDay() { adoptTimesOfFirstDay() {
const timeOptionsForFirstDay = Array.from( const timeOptionsForFirstDay = Array.from(
Array.from(this.datetimes.values())[0], Array.from(this.datetimes.values())[0]!,
); ) as FormDataTimeOption[];
const timesForFirstDayAreValid = timeOptionsForFirstDay.every( const timesForFirstDayAreValid = timeOptionsForFirstDay.every(
(timeOption) => timeOption.isValid, (timeOption) => timeOption.isValid,
); );
@ -143,12 +148,18 @@ class FormData {
} }
} }
constructor({ dates, times }) { constructor({
dates,
times,
}: {
dates: Set<string>;
times: Map<string, Set<string>>;
}) {
const datetimes = new Map(); const datetimes = new Map();
for (const date of dates) { for (const date of dates) {
const timesForDate = times.has(date) const timesForDate = times.has(date)
? Array.from(times.get(date)) ? Array.from(times.get(date) as Set<string>)
: [null]; : [null];
datetimes.set( datetimes.set(
date, date,
@ -164,12 +175,22 @@ class FormData {
} }
} }
export default class CreateOptionsDatetime extends Component { export interface CreateOptoinsDatetimeSignature {
@service router; Args: {
dates: TrackedSet<string>;
onNextPage: () => void;
onPrevPage: () => void;
times: Map<string, Set<string>>;
updateOptions: (datetimes: Map<string, Set<string>>) => void;
};
}
export default class CreateOptionsDatetime extends Component<CreateOptoinsDatetimeSignature> {
@service declare router: RouterService;
formData = new FormData({ dates: this.args.dates, times: this.args.times }); formData = new FormData({ dates: this.args.dates, times: this.args.times });
@tracked errorMesage = null; @tracked errorMessage: string | null = null;
@action @action
adoptTimesOfFirstDay() { adoptTimesOfFirstDay() {
@ -177,7 +198,7 @@ export default class CreateOptionsDatetime extends Component {
const successful = formData.adoptTimesOfFirstDay(); const successful = formData.adoptTimesOfFirstDay();
if (!successful) { if (!successful) {
this.errorMesage = this.errorMessage =
'create.options-datetime.fix-validation-errors-first-day'; 'create.options-datetime.fix-validation-errors-first-day';
} }
} }
@ -194,8 +215,8 @@ export default class CreateOptionsDatetime extends Component {
// validate input field for being partially filled // validate input field for being partially filled
@action @action
validateInput(option, event) { validateInput(option: FormDataTimeOption, event: InputEvent) {
const element = event.target; const element = event.target as HTMLInputElement;
// update partially filled time validation error // update partially filled time validation error
option.isPartiallyFilled = !element.checkValidity(); option.isPartiallyFilled = !element.checkValidity();
@ -203,8 +224,8 @@ export default class CreateOptionsDatetime extends Component {
// remove partially filled validation error if user fixed it // remove partially filled validation error if user fixed it
@action @action
updateInputValidation(option, event) { updateInputValidation(option: FormDataTimeOption, event: InputEvent) {
const element = event.target; const element = event.target as HTMLInputElement;
if (element.checkValidity() && option.isPartiallyFilled) { if (element.checkValidity() && option.isPartiallyFilled) {
option.isPartiallyFilled = false; option.isPartiallyFilled = false;
@ -212,23 +233,25 @@ export default class CreateOptionsDatetime extends Component {
} }
@action @action
handleTransition(transition) { handleTransition(transition: Transition) {
if (transition.from?.name === 'create.options-datetime') { if (transition.from?.name === 'create.options-datetime') {
this.args.updateOptions( 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( 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()) Array.from(this.formData.datetimes.entries())
.map(([key, timeOptions]) => [ .map(([key, timeOptions]): [string, Set<string>] => {
key, return [
new Set( key,
Array.from(timeOptions) new Set(
.map(({ time }) => time) Array.from(timeOptions)
// There might be FormDataTime objects without a time, which .map(({ time }: FormDataTimeOption) => time)
// we need to filter out // There might be FormDataTime objects without a time, which
.filter((time) => time !== null), // we need to filter out
), .filter((time) => time !== null),
]) ) as Set<string>,
];
})
// There might be dates without any time, which we need to filter out // There might be dates without any time, which we need to filter out
.filter(([, times]) => times.size > 0), .filter(([, times]) => times.size > 0),
), ),
@ -237,8 +260,8 @@ export default class CreateOptionsDatetime extends Component {
} }
} }
constructor() { constructor(owner: unknown, args: CreateOptoinsDatetimeSignature['Args']) {
super(...arguments); super(owner, args);
this.router.on('routeWillChange', this.handleTransition); this.router.on('routeWillChange', this.handleTransition);
} }

View file

@ -1,11 +1,13 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { TrackedArray } from 'tracked-built-ins'; import { TrackedArray, TrackedSet } from 'tracked-built-ins';
import IntlMessage from '../utils/intl-message'; import IntlMessage from '../utils/intl-message';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import type RouterService from '@ember/routing/router-service';
import type Transition from '@ember/routing/transition';
class FormDataOption { export class FormDataOption {
@tracked value; @tracked value;
formData; formData;
@ -33,7 +35,7 @@ class FormDataOption {
return this.valueValidation === null; return this.valueValidation === null;
} }
constructor(formData, value) { constructor(formData: FormData, value: string) {
this.formData = formData; this.formData = formData;
this.value = value; this.value = value;
} }
@ -59,25 +61,28 @@ class FormData {
} }
@action @action
updateOptions(values) { updateOptions(values: string[]) {
this.options = new TrackedArray( this.options = new TrackedArray(
values.map((value) => new FormDataOption(this, value)), values.map((value) => new FormDataOption(this, value)),
); );
} }
@action @action
addOption(value, afterPosition = this.options.length - 1) { addOption(value: string, afterPosition = this.options.length - 1) {
const option = new FormDataOption(this, value); const option = new FormDataOption(this, value);
this.options.splice(afterPosition + 1, 0, option); this.options.splice(afterPosition + 1, 0, option);
} }
@action @action
deleteOption(option) { deleteOption(option: FormDataOption) {
this.options.splice(this.options.indexOf(option), 1); this.options.splice(this.options.indexOf(option), 1);
} }
constructor({ options }, { defaultOptionCount }) { constructor(
{ options }: { options: Set<string> },
{ defaultOptionCount }: { defaultOptionCount: number },
) {
const normalizedOptions = const normalizedOptions =
options.size === 0 && defaultOptionCount > 0 options.size === 0 && defaultOptionCount > 0
? ['', ''] ? ['', '']
@ -89,8 +94,18 @@ class FormData {
} }
} }
export default class CreateOptionsComponent extends Component { export interface CreateOptionsSignature {
@service router; Args: {
isMakeAPoll: boolean;
options: TrackedSet<string>;
onNextPage: () => void;
onPrevPage: () => void;
updateOptions: (options: { value: string }[]) => void;
};
}
export default class CreateOptionsComponent extends Component<CreateOptionsSignature> {
@service declare router: RouterService;
formData = new FormData( formData = new FormData(
{ options: this.args.options }, { options: this.args.options },
@ -108,15 +123,15 @@ export default class CreateOptionsComponent extends Component {
} }
@action @action
handleTransition(transition) { handleTransition(transition: Transition) {
if (transition.from?.name === 'create.options') { if (transition.from?.name === 'create.options') {
this.args.updateOptions(this.formData.options); this.args.updateOptions(this.formData.options);
this.router.off('routeWillChange', this.handleTransition); this.router.off('routeWillChange', this.handleTransition);
} }
} }
constructor() { constructor(owner: unknown, args: CreateOptionsSignature['Args']) {
super(...arguments); super(owner, args);
this.router.on('routeWillChange', this.handleTransition); this.router.on('routeWillChange', this.handleTransition);
} }

View file

@ -2,25 +2,26 @@ import Component from '@glimmer/component';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import localesMeta from 'croodle/locales/meta'; import localesMeta from 'croodle/locales/meta';
import { action } from '@ember/object'; import { action } from '@ember/object';
import type IntlService from 'ember-intl/services/intl';
import type PowerCalendarService from 'ember-power-calendar/services/power-calendar';
export default class LanguageSelect extends Component { export default class LanguageSelect extends Component {
@service intl; @service declare intl: IntlService;
@service powerCalendar; @service declare powerCalendar: PowerCalendarService;
get currentLocale() { get currentLocale() {
return this.intl.primaryLocale; return this.intl.primaryLocale;
} }
get locales() { locales = localesMeta;
return localesMeta;
}
@action @action
handleChange(event) { handleChange(event: Event) {
const locale = event.target.value; const selectElement = event.target as HTMLSelectElement;
const locale = selectElement.value as keyof typeof this.locales;
this.intl.locale = locale.includes('-') this.intl.locale = locale.includes('-')
? [locale, locale.split('-')[0]] ? [locale, locale.split('-')[0] as string]
: [locale]; : [locale];
this.powerCalendar.locale = locale; this.powerCalendar.locale = locale;

View file

@ -1,7 +1,14 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import type Poll from 'croodle/models/poll';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
export default class PollEvaluationParticipantsTable extends Component { export interface PollEvaluationParticipantsTableSignature {
Args: {
poll: Poll;
};
}
export default class PollEvaluationParticipantsTable extends Component<PollEvaluationParticipantsTableSignature> {
get optionsPerDay() { get optionsPerDay() {
const { poll } = this.args; const { poll } = this.args;

View file

@ -1,8 +1,19 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import type IntlService from 'ember-intl/services/intl';
import type Option from 'croodle/models/option';
import type User from 'croodle/models/user';
import type { Answer } from 'croodle/utils/answers-for-answer-type';
import type Poll from 'croodle/models/poll';
export default class PollEvaluationSummary extends Component { export interface PollEvaluationSummarySignature {
@service intl; Args: {
poll: Poll;
};
}
export default class PollEvaluationSummary extends Component<PollEvaluationSummarySignature> {
@service declare intl: IntlService;
get bestOptions() { get bestOptions() {
const { poll } = this.args; const { poll } = this.args;
@ -18,34 +29,41 @@ export default class PollEvaluationSummary extends Component {
return undefined; return undefined;
} }
let answers = poll.answers.reduce((answers, answer) => { const answers = poll.answers.reduce(
answers[answer.type] = 0; (answers: Record<string, number>, answer: Answer) => {
return answers; answers[answer.type] = 0;
}, {}); return answers;
let evaluation = options.map((option) => { },
{},
);
const evaluation: {
answers: Record<string, number>;
option: Option;
score: number;
}[] = options.map((option: Option) => {
return { return {
answers: { ...answers }, answers: { ...answers },
option, option,
score: 0, score: 0,
}; };
}); });
let bestOptions = []; const bestOptions = [];
users.forEach((user) => { users.forEach((user: User) => {
user.selections.forEach(({ type }, i) => { user.selections.forEach(({ type }, i) => {
evaluation[i].answers[type]++; evaluation[i]!.answers[type]++;
switch (type) { switch (type) {
case 'yes': case 'yes':
evaluation[i].score += 2; evaluation[i]!.score += 2;
break; break;
case 'maybe': case 'maybe':
evaluation[i].score += 1; evaluation[i]!.score += 1;
break; break;
case 'no': case 'no':
evaluation[i].score -= 2; evaluation[i]!.score -= 2;
break; break;
} }
}); });
@ -53,9 +71,9 @@ export default class PollEvaluationSummary extends Component {
evaluation.sort((a, b) => b.score - a.score); evaluation.sort((a, b) => b.score - a.score);
let bestScore = evaluation[0].score; const bestScore = evaluation[0]!.score;
for (let i = 0; i < evaluation.length; i++) { for (let i = 0; i < evaluation.length; i++) {
if (bestScore === evaluation[i].score) { if (bestScore === evaluation[i]!.score) {
bestOptions.push(evaluation[i]); bestOptions.push(evaluation[i]);
} else { } else {
break; break;

16
app/config/environment.d.ts vendored Normal file
View file

@ -0,0 +1,16 @@
/**
* Type declarations for
* import config from 'croodle/config/environment'
*/
declare const config: {
environment: string;
modulePrefix: string;
podModulePrefix: string;
locationType: 'history' | 'hash' | 'none';
rootURL: string;
APP: {
version: string;
};
};
export default config;

View file

@ -1,6 +1,7 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import type FlashMessagesService from 'ember-cli-flash/services/flash-messages';
export default class ApplicationController extends Controller { export default class ApplicationController extends Controller {
@service flashMessages; @service declare flashMessages: FlashMessagesService;
} }

View file

@ -2,16 +2,22 @@ import { inject as service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { TrackedSet } from 'tracked-built-ins'; import { TrackedSet } from 'tracked-built-ins';
import type RouterService from '@ember/routing/router-service';
import type { CreateRouteModel } from 'croodle/routes/create';
export default class CreateController extends Controller { export default class CreateController extends Controller {
@service router; @service declare router: RouterService;
declare model: CreateRouteModel;
visitedSteps = new TrackedSet();
get canEnterMetaStep() { get canEnterMetaStep() {
return this.visitedSteps.has('meta') && this.model.pollType; return this.visitedSteps.has('meta') && this.model.pollType;
} }
get canEnterOptionsStep() { get canEnterOptionsStep() {
let { title } = this.model; const { title } = this.model;
return ( return (
this.visitedSteps.has('options') && this.visitedSteps.has('options') &&
typeof title === 'string' && typeof title === 'string' &&
@ -40,18 +46,18 @@ export default class CreateController extends Controller {
@action @action
updateVisitedSteps() { updateVisitedSteps() {
let { currentRouteName } = this.router; const { currentRouteName } = this.router;
// currentRouteName might not be defined in some edge cases // currentRouteName might not be defined in some edge cases
if (!currentRouteName) { if (!currentRouteName) {
return; return;
} }
let step = currentRouteName.split('.').pop(); const step = currentRouteName.split('.').pop();
this.visitedSteps.add(step); this.visitedSteps.add(step);
} }
@action transitionTo(route) { @action transitionTo(route: string) {
this.router.transitionTo(route); this.router.transitionTo(route);
} }

View file

@ -1,9 +1,14 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import type RouterService from '@ember/routing/router-service';
import type Transition from '@ember/routing/transition';
import type { CreateRouteIndexModel } from 'croodle/routes/create/index';
export default class CreateIndex extends Controller { export default class CreateIndex extends Controller {
@service router; @service declare router: RouterService;
declare model: CreateRouteIndexModel;
@action @action
submit() { submit() {
@ -11,7 +16,7 @@ export default class CreateIndex extends Controller {
} }
@action @action
handleTransition(transition) { handleTransition(transition: Transition) {
if (transition.from?.name === 'create.index') { if (transition.from?.name === 'create.index') {
const { poll, formData } = this.model; const { poll, formData } = this.model;
@ -20,6 +25,7 @@ export default class CreateIndex extends Controller {
} }
constructor() { constructor() {
// eslint-disable-next-line prefer-rest-params
super(...arguments); super(...arguments);
this.router.on('routeWillChange', this.handleTransition); this.router.on('routeWillChange', this.handleTransition);

View file

@ -1,9 +1,14 @@
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import type RouterService from '@ember/routing/router-service';
import type Transition from '@ember/routing/transition';
import type { CreateMetaRouteModel } from 'croodle/routes/create/meta';
export default class CreateMetaController extends Controller { export default class CreateMetaController extends Controller {
@service router; @service declare router: RouterService;
declare model: CreateMetaRouteModel;
@action @action
previousPage() { previousPage() {
@ -16,7 +21,7 @@ export default class CreateMetaController extends Controller {
} }
@action @action
handleTransition(transition) { handleTransition(transition: Transition) {
if (transition.from?.name === 'create.meta') { if (transition.from?.name === 'create.meta') {
const { poll, formData } = this.model; const { poll, formData } = this.model;
@ -26,6 +31,7 @@ export default class CreateMetaController extends Controller {
} }
constructor() { constructor() {
// eslint-disable-next-line prefer-rest-params
super(...arguments); super(...arguments);
this.router.on('routeWillChange', this.handleTransition); this.router.on('routeWillChange', this.handleTransition);

View file

@ -1,9 +1,13 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import type { CreateOptionsDatetimeRouteModel } from 'croodle/routes/create/options-datetime';
import type RouterService from '@ember/routing/router-service';
export default class CreateOptionsDatetimeController extends Controller { export default class CreateOptionsDatetimeController extends Controller {
@service router; @service declare router: RouterService;
declare model: CreateOptionsDatetimeRouteModel;
@action @action
nextPage() { nextPage() {
@ -16,7 +20,7 @@ export default class CreateOptionsDatetimeController extends Controller {
} }
@action @action
updateOptions(datetimes) { updateOptions(datetimes: Map<string, Set<string>>) {
this.model.timesForDateOptions = new Map(datetimes.entries()); this.model.timesForDateOptions = new Map(datetimes.entries());
} }
} }

View file

@ -1,10 +1,14 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { TrackedSet } from 'tracked-built-ins/.'; import { TrackedSet } from 'tracked-built-ins';
import type RouterService from '@ember/routing/router-service';
import type { CreateOptionsRouteModel } from 'croodle/routes/create/options';
export default class CreateOptionsController extends Controller { export default class CreateOptionsController extends Controller {
@service router; @service declare router: RouterService;
declare model: CreateOptionsRouteModel;
@action @action
nextPage() { nextPage() {
@ -23,7 +27,7 @@ export default class CreateOptionsController extends Controller {
} }
@action @action
updateOptions(newOptions) { updateOptions(newOptions: { value: string }[]) {
const { pollType } = this.model; const { pollType } = this.model;
const options = newOptions.map(({ value }) => value); const options = newOptions.map(({ value }) => value);

View file

@ -5,11 +5,17 @@ import { action } from '@ember/object';
import { DateTime, Duration } from 'luxon'; import { DateTime, Duration } from 'luxon';
import Poll from '../../models/poll'; import Poll from '../../models/poll';
import { generatePassphrase } from '../../utils/encryption'; import { generatePassphrase } from '../../utils/encryption';
import type RouterService from '@ember/routing/router-service';
import type { CreateSettingsRouteModel } from 'croodle/routes/create/settings';
import type IntlService from 'ember-intl/services/intl';
import type FlashMessagesService from 'ember-cli-flash/services/flash-messages';
export default class CreateSettings extends Controller { export default class CreateSettings extends Controller {
@service flashMessages; @service declare flashMessages: FlashMessagesService;
@service intl; @service declare intl: IntlService;
@service router; @service declare router: RouterService;
declare model: CreateSettingsRouteModel;
get anonymousUser() { get anonymousUser() {
return this.model.anonymousUser; return this.model.anonymousUser;
@ -39,7 +45,7 @@ export default class CreateSettings extends Controller {
} }
set expirationDuration(value) { set expirationDuration(value) {
this.model.expirationDate = isPresent(value) this.model.expirationDate = isPresent(value)
? DateTime.local().plus(Duration.fromISO(value)).toISO() ? (DateTime.local().plus(Duration.fromISO(value)).toISO() as string)
: ''; : '';
} }
@ -78,7 +84,7 @@ export default class CreateSettings extends Controller {
@action @action
previousPage() { previousPage() {
let { pollType } = this.model; const { pollType } = this.model;
if (pollType === 'FindADate') { if (pollType === 'FindADate') {
this.router.transitionTo('create.options-datetime'); this.router.transitionTo('create.options-datetime');
@ -104,22 +110,22 @@ export default class CreateSettings extends Controller {
} = model; } = model;
// calculate options // calculate options
let options = []; const options: string[] = [];
if (pollType === 'FindADate') { if (pollType === 'FindADate') {
// merge date with times // merge date with times
for (const date of dateOptions) { for (const date of dateOptions) {
if (timesForDateOptions.has(date)) { if (timesForDateOptions.has(date)) {
for (const time of timesForDateOptions.get(date)) { for (const time of timesForDateOptions.get(date)!) {
const [hour, minute] = time.split(':'); const [hour, minute] = time.split(':') as [string, string];
options.push( options.push(
DateTime.fromISO(date) DateTime.fromISO(date)
.set({ .set({
hour, hour: parseInt(hour),
minute, minute: parseInt(minute),
second: 0, second: 0,
millisecond: 0, millisecond: 0,
}) })
.toISO(), .toISO() as string,
); );
} }
} else { } else {
@ -139,7 +145,6 @@ export default class CreateSettings extends Controller {
{ {
anonymousUser, anonymousUser,
answerType, answerType,
creationDate: new Date().toISOString(),
description, description,
expirationDate, expirationDate,
forceAnswer, forceAnswer,

View file

@ -4,11 +4,17 @@ import { isPresent, isEmpty } from '@ember/utils';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import type FlashMessagesService from 'ember-cli-flash/services/flash-messages';
import type IntlService from 'ember-intl/services/intl';
import type RouterService from '@ember/routing/router-service';
import type { PollRouteModel } from 'croodle/routes/poll';
export default class PollController extends Controller { export default class PollController extends Controller {
@service flashMessages; @service declare flashMessages: FlashMessagesService;
@service intl; @service declare intl: IntlService;
@service router; @service declare router: RouterService;
declare model: PollRouteModel;
queryParams = ['encryptionKey']; queryParams = ['encryptionKey'];
encryptionKey = ''; encryptionKey = '';
@ -61,8 +67,8 @@ export default class PollController extends Controller {
} }
@action @action
linkAction(type) { linkAction(type: 'copied' | 'selected') {
let flashMessages = this.flashMessages; const flashMessages = this.flashMessages;
switch (type) { switch (type) {
case 'copied': case 'copied':
flashMessages.success(`poll.link.copied`); flashMessages.success(`poll.link.copied`);

View file

@ -1,11 +1,20 @@
import Helper from '@ember/component/helper'; import Helper from '@ember/component/helper';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import type IntlService from 'ember-intl/services/intl';
type Positional = [date: Date | string];
export interface FormatDateRelativeHelperSignature {
Args: {
Positional: Positional;
};
}
export default class FormatDateRelativeHelper extends Helper { export default class FormatDateRelativeHelper extends Helper {
@service intl; @service declare intl: IntlService;
compute([date]) { compute([date]: Positional) {
if (date instanceof Date) { if (date instanceof Date) {
date = date.toISOString(); date = date.toISOString();
} }

View file

@ -0,0 +1,16 @@
import { helper } from '@ember/component/helper';
import { htmlSafe } from '@ember/template';
type Positional = [html: string];
export interface MarkAsSafeHtmlHelperSignature {
Args: {
Positional: Positional;
};
}
export default helper<MarkAsSafeHtmlHelperSignature>(function markAsSafeHtml([
html,
]) {
return htmlSafe(html);
});

View file

@ -2,23 +2,33 @@ import { helper } from '@ember/component/helper';
import { next } from '@ember/runloop'; import { next } from '@ember/runloop';
import { assert } from '@ember/debug'; import { assert } from '@ember/debug';
function elementIsNotVisible(element) { function elementIsNotVisible(element: Element) {
let elementPosition = element.getBoundingClientRect(); const elementPosition = element.getBoundingClientRect();
let windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
// an element is not visible if // check if the element is within current view port
return ( if (
false || // above current view port
// it's above the current view port
elementPosition.top <= 0 || elementPosition.top <= 0 ||
// it's below the current view port // below current view port
elementPosition.bottom >= windowHeight || elementPosition.bottom >= windowHeight
// it's in current view port but hidden by fixed navigation ) {
(getComputedStyle(document.querySelector('.cr-steps-bottom-nav')) return true;
.position === 'fixed' && }
elementPosition.bottom >=
windowHeight - // check if element is within current view port button hidden behind
document.querySelector('.cr-steps-bottom-nav').offsetHeight) // fixed bottom navigation bar
const bottomNavEl = document.querySelector(
'.cr-steps-bottom-nav',
) as HTMLElement | null;
if (!bottomNavEl) {
// bottom navigation bar can not overlay element if it does not exist
return false;
}
return (
getComputedStyle(bottomNavEl).position === 'fixed' &&
elementPosition.bottom >= windowHeight - bottomNavEl.offsetHeight
); );
} }
@ -27,9 +37,9 @@ export function scrollFirstInvalidElementIntoViewPort() {
// timing issue in Firefox causing the Browser not scrolling up far enough if doing so // timing issue in Firefox causing the Browser not scrolling up far enough if doing so
// delaying to next runloop therefore // delaying to next runloop therefore
next(function () { next(function () {
let invalidInput = document.querySelector( const invalidInput = document.querySelector(
'.form-control.is-invalid, .custom-control-input.is-invalid', '.form-control.is-invalid, .custom-control-input.is-invalid',
); ) as HTMLInputElement;
assert( assert(
'Atleast one form control must be marked as invalid if form submission was rejected as invalid', 'Atleast one form control must be marked as invalid if form submission was rejected as invalid',
invalidInput, invalidInput,
@ -46,7 +56,7 @@ export function scrollFirstInvalidElementIntoViewPort() {
// https://github.com/kaliber5/ember-bootstrap/issues/931 // https://github.com/kaliber5/ember-bootstrap/issues/931
// As a work-a-round we look the correct label up by a custom convention for the `id` of the // As a work-a-round we look the correct label up by a custom convention for the `id` of the
// inputs and the `for` of the input group `<label>` (which should be a `<legend>`). // inputs and the `for` of the input group `<label>` (which should be a `<legend>`).
let scrollTarget = const scrollTarget =
document.querySelector( document.querySelector(
`label[for="${invalidInput.id.substr( `label[for="${invalidInput.id.substr(
0, 0,

View file

@ -7,7 +7,7 @@ export default class Option {
// 1) ISO 8601 date string: `YYYY-MM-DD` // 1) ISO 8601 date string: `YYYY-MM-DD`
// 2) ISO 8601 datetime string: `YYYY-MM-DDTHH:mm:ss.0000+01:00` // 2) ISO 8601 datetime string: `YYYY-MM-DDTHH:mm:ss.0000+01:00`
// 3) Free text if poll type is MakeAPoll // 3) Free text if poll type is MakeAPoll
title; title: string;
get datetime() { get datetime() {
const { title } = this; const { title } = this;
@ -16,16 +16,18 @@ export default class Option {
return null; return null;
} }
return DateTime.fromISO(title); const datetime = DateTime.fromISO(title);
return datetime.isValid ? datetime : null;
} }
get isDate() { get isDate() {
const { datetime } = this; const { datetime } = this;
return datetime !== null && datetime.isValid; return datetime !== null;
} }
get day() { get day() {
if (!this.isDate) { if (!this.datetime) {
return null; return null;
} }
@ -33,6 +35,10 @@ export default class Option {
} }
get jsDate() { get jsDate() {
if (!this.datetime) {
return null;
}
return this.datetime.toJSDate(); return this.datetime.toJSDate();
} }
@ -41,14 +47,14 @@ export default class Option {
} }
get time() { get time() {
if (!this.isDate || !this.hasTime) { if (!this.datetime || !this.hasTime) {
return null; return null;
} }
return this.datetime.toISOTime().substring(0, 5); return this.datetime.toISOTime()?.substring(0, 5);
} }
constructor({ title }) { constructor({ title }: { title: string }) {
this.title = title; this.title = title;
} }
} }

View file

@ -6,48 +6,68 @@ import { decrypt, encrypt } from '../utils/encryption';
import answersForAnswerType from '../utils/answers-for-answer-type'; import answersForAnswerType from '../utils/answers-for-answer-type';
import fetch from 'fetch'; import fetch from 'fetch';
import config from 'croodle/config/environment'; import config from 'croodle/config/environment';
import type { SelectionInput } from './selection';
const DAY_STRING_LENGTH = 10; // 'YYYY-MM-DD'.length const DAY_STRING_LENGTH = 10; // 'YYYY-MM-DD'.length
export type AnswerType = 'YesNo' | 'YesNoMaybe' | 'FreeText';
type OptionInput = { title: string }[];
export type PollType = 'FindADate' | 'MakeAPoll';
type PollInput = {
anonymousUser: boolean;
answerType: AnswerType;
creationDate: string;
description: string;
expirationDate: string;
forceAnswer: boolean;
id: string;
options: OptionInput;
pollType: PollType;
timezone: string | null;
title: string;
users: User[];
version: string;
};
export default class Poll { export default class Poll {
// Is participation without user name possibile? // Is participation without user name possibile?
anonymousUser; anonymousUser: boolean;
// YesNo, YesNoMaybe or Freetext // YesNo, YesNoMaybe or Freetext
answerType; answerType: AnswerType;
// ISO-8601 combined date and time string in UTC // ISO-8601 combined date and time string in UTC
creationDate; creationDate: string;
// poll's description // poll's description
description; description: string;
// ISO 8601 date + time string in UTC // ISO 8601 date + time string in UTC
expirationDate; expirationDate: string;
// Must all options been answered? // Must all options been answered?
forceAnswer; forceAnswer: boolean;
// ID of the poll // ID of the poll
id; id: string;
// array of poll's options // array of poll's options
options; options: Option[];
// FindADate or MakeAPoll // FindADate or MakeAPoll
pollType; pollType: 'FindADate' | 'MakeAPoll';
// timezone poll got created in (like "Europe/Berlin") // timezone poll got created in (like "Europe/Berlin")
timezone; timezone: string | null;
// polls title // polls title
title; title: string;
// participants of the poll // participants of the poll
users; users: TrackedArray<User>;
// Croodle version poll got created with // Croodle version poll got created with
version; version: string;
get answers() { get answers() {
const { answerType } = this; const { answerType } = this;
@ -90,7 +110,7 @@ export default class Poll {
title, title,
users, users,
version, version,
}) { }: PollInput) {
this.anonymousUser = anonymousUser; this.anonymousUser = anonymousUser;
this.answerType = answerType; this.answerType = answerType;
this.creationDate = creationDate; this.creationDate = creationDate;
@ -106,7 +126,7 @@ export default class Poll {
this.version = version; this.version = version;
} }
static async load(id, passphrase) { static async load(id: string, passphrase: string) {
const url = apiUrl(`polls/${id}`); const url = apiUrl(`polls/${id}`);
// TODO: Handle network connectivity error // TODO: Handle network connectivity error
@ -125,26 +145,51 @@ export default class Poll {
} }
// TODO: Handle malformed server response // TODO: Handle malformed server response
const payload = await response.json(); const payload = (await response.json()) as {
poll: {
anonymousUser: string;
answerType: string;
creationDate: string;
description: string;
expirationDate: string;
forceAnswer: string;
id: string;
options: string;
pollType: string;
timezone: string;
title: string;
users: {
creationDate: string;
id: string;
name: string;
selections: string;
version: string;
}[];
version: string;
};
};
return new Poll({ return new Poll({
anonymousUser: decrypt(payload.poll.anonymousUser, passphrase), anonymousUser: decrypt(payload.poll.anonymousUser, passphrase) as boolean,
answerType: decrypt(payload.poll.answerType, passphrase), answerType: decrypt(payload.poll.answerType, passphrase) as AnswerType,
creationDate: decrypt(payload.poll.creationDate, passphrase), creationDate: decrypt(payload.poll.creationDate, passphrase) as string,
description: decrypt(payload.poll.description, passphrase), description: decrypt(payload.poll.description, passphrase) as string,
expirationDate: decrypt(payload.poll.expirationDate, passphrase), expirationDate: decrypt(
forceAnswer: decrypt(payload.poll.forceAnswer, passphrase), payload.poll.expirationDate,
passphrase,
) as string,
forceAnswer: decrypt(payload.poll.forceAnswer, passphrase) as boolean,
id: payload.poll.id, id: payload.poll.id,
options: decrypt(payload.poll.options, passphrase), options: decrypt(payload.poll.options, passphrase) as OptionInput,
pollType: decrypt(payload.poll.pollType, passphrase), pollType: decrypt(payload.poll.pollType, passphrase) as PollType,
timezone: decrypt(payload.poll.timezone, passphrase), timezone: decrypt(payload.poll.timezone, passphrase) as string,
title: decrypt(payload.poll.title, passphrase), title: decrypt(payload.poll.title, passphrase) as string,
users: payload.poll.users.map((user) => { users: payload.poll.users.map((user) => {
return new User({ return new User({
creationDate: decrypt(user.creationDate, passphrase), creationDate: decrypt(user.creationDate, passphrase) as string,
id: user.id, id: user.id,
name: decrypt(user.name, passphrase), name: decrypt(user.name, passphrase) as string,
selections: decrypt(user.selections, passphrase), selections: decrypt(user.selections, passphrase) as SelectionInput[],
version: user.version, version: user.version,
}); });
}), }),
@ -162,15 +207,24 @@ export default class Poll {
options, options,
pollType, pollType,
title, title,
}: {
anonymousUser: boolean;
answerType: AnswerType;
description: string;
expirationDate: string;
forceAnswer: boolean;
options: OptionInput;
pollType: PollType;
title: string;
}, },
passphrase, passphrase: string,
) { ) {
const creationDate = new Date().toISOString(); const creationDate = new Date().toISOString();
const version = config.APP.version; const version = config.APP.version;
const timezone = const timezone =
pollType === 'FindADate' && pollType === 'FindADate' &&
options.some(({ title }) => { options.some(({ title }) => {
return title >= 'YYYY-MM-DDTHH:mm'.length; return title.length >= 'YYYY-MM-DDTHH:mm'.length;
}) })
? Intl.DateTimeFormat().resolvedOptions().timeZone ? Intl.DateTimeFormat().resolvedOptions().timeZone
: null; : null;

View file

@ -1,13 +0,0 @@
export default class Answer {
icon;
label;
labelTranslation;
type;
constructor({ icon, label, labelTranslation, type }) {
this.icon = icon;
this.label = label;
this.labelTranslation = labelTranslation;
this.type = type;
}
}

20
app/models/selection.ts Normal file
View file

@ -0,0 +1,20 @@
export type SelectionInput = {
icon: string;
label: string;
labelTranslation: string;
type: string;
};
export default class Selection {
icon: string;
label: string;
labelTranslation: string;
type: string;
constructor({ icon, label, labelTranslation, type }: SelectionInput) {
this.icon = icon;
this.label = label;
this.labelTranslation = labelTranslation;
this.type = type;
}
}

View file

@ -1,26 +1,35 @@
import Selection from './selection'; import Selection, { type SelectionInput } from './selection';
import config from 'croodle/config/environment'; import config from 'croodle/config/environment';
import { encrypt } from '../utils/encryption'; import { encrypt } from '../utils/encryption';
import { apiUrl } from '../utils/api'; import { apiUrl } from '../utils/api';
import fetch from 'fetch'; import fetch from 'fetch';
import type Poll from './poll';
type UserInput = {
creationDate: string;
id: string;
name: string;
selections: SelectionInput[];
version: string;
};
export default class User { export default class User {
// ISO 8601 date + time string // ISO 8601 date + time string
creationDate; creationDate: string;
id; id: string;
// user name // user name
name; name: string;
// array of users selections // array of users selections
// must be in same order as options property of poll // must be in same order as options property of poll
selections; selections: Selection[];
// Croodle version user got created with // Croodle version user got created with
version; version: string;
constructor({ creationDate, id, name, selections, version }) { constructor({ creationDate, id, name, selections, version }: UserInput) {
this.creationDate = creationDate; this.creationDate = creationDate;
this.id = id; this.id = id;
this.name = name; this.name = name;
@ -28,7 +37,14 @@ export default class User {
this.version = version; this.version = version;
} }
static async create({ name, poll, selections }, passphrase) { static async create(
{
name,
poll,
selections,
}: { name: string; poll: Poll; selections: SelectionInput[] },
passphrase: string,
) {
const creationDate = new Date().toISOString(); const creationDate = new Date().toISOString();
const version = config.APP.version; const version = config.APP.version;

View file

@ -1,9 +1,20 @@
import Modifier from 'ember-modifier'; import Modifier from 'ember-modifier';
export default class AutofocusModifier extends Modifier { type Named = {
enabled: boolean;
};
interface AutofocusModifierSignature {
Element: HTMLInputElement;
Args: {
Named: Named;
};
}
export default class AutofocusModifier extends Modifier<AutofocusModifierSignature> {
isInstalled = false; isInstalled = false;
modify(element, positional, { enabled = true }) { modify(element: HTMLInputElement, _: [], { enabled = true }: Named) {
// element should be only autofocused on initial render // element should be only autofocused on initial render
// not when `enabled` option is invalidated // not when `enabled` option is invalidated
if (this.isInstalled) { if (this.isInstalled) {

View file

@ -17,5 +17,4 @@ Router.map(function () {
this.route('options-datetime'); this.route('options-datetime');
this.route('settings'); this.route('settings');
}); });
this.route('404');
}); });

View file

@ -1,43 +0,0 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
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 = '';
}
export default class CreateRoute extends Route {
@service router;
beforeModel(transition) {
// enforce that wizzard is started at create.index
if (transition.targetName !== 'create.index') {
this.router.transitionTo('create.index');
}
}
model() {
return new PollData();
}
activate() {
let controller = this.controllerFor(this.routeName);
controller.listenForStepChanges();
}
deactivate() {
let controller = this.controllerFor(this.routeName);
controller.clearListenerForStepChanges();
}
}

52
app/routes/create.ts Normal file
View file

@ -0,0 +1,52 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import RouterService from '@ember/routing/router-service';
import type Transition from '@ember/routing/transition';
import { DateTime } from 'luxon';
import { tracked } from '@glimmer/tracking';
import { TrackedSet } from 'tracked-built-ins';
import type CreateController from 'croodle/controllers/create';
import type { AnswerType, PollType } from 'croodle/models/poll';
class PollData {
@tracked anonymousUser: boolean = false;
@tracked answerType: AnswerType = 'YesNo';
@tracked description: string = '';
@tracked expirationDate: string = DateTime.local()
.plus({ months: 3 })
.toISO() as string;
@tracked forceAnswer: boolean = true;
@tracked freetextOptions: TrackedSet<string> = new TrackedSet();
@tracked dateOptions: TrackedSet<string> = new TrackedSet();
@tracked timesForDateOptions: Map<string, Set<string>> = new Map();
@tracked pollType: PollType = 'FindADate';
@tracked title: string = '';
}
export default class CreateRoute extends Route {
@service declare router: RouterService;
beforeModel(transition: Transition) {
// enforce that wizzard is started at create.index
if (transition.to.name !== 'create.index') {
this.router.transitionTo('create.index');
}
}
model() {
return new PollData();
}
activate() {
const controller = this.controllerFor(this.routeName) as CreateController;
controller.listenForStepChanges();
}
deactivate() {
const controller = this.controllerFor(this.routeName) as CreateController;
controller.clearListenerForStepChanges();
}
}
type Resolved<P> = P extends Promise<infer T> ? T : P;
export type CreateRouteModel = Resolved<ReturnType<CreateRoute['model']>>;

View file

@ -1,21 +0,0 @@
import Route from '@ember/routing/route';
import { tracked } from '@glimmer/tracking';
class FormData {
@tracked pollType;
constructor({ pollType }) {
this.pollType = pollType;
}
}
export default class IndexRoute extends Route {
model() {
const { pollType } = this.modelFor('create');
return {
formData: new FormData({ pollType }),
poll: this.modelFor('create'),
};
}
}

View file

@ -0,0 +1,28 @@
import Route from '@ember/routing/route';
import { tracked } from '@glimmer/tracking';
import type { PollType } from 'croodle/models/poll';
import type { CreateRouteModel } from '../create';
class FormData {
@tracked declare pollType;
constructor({ pollType }: { pollType: PollType }) {
this.pollType = pollType;
}
}
export default class CreateIndexRoute extends Route {
model() {
const { pollType } = this.modelFor('create') as CreateRouteModel;
return {
formData: new FormData({ pollType }),
poll: this.modelFor('create') as CreateRouteModel,
};
}
}
type Resolved<P> = P extends Promise<infer T> ? T : P;
export type CreateRouteIndexModel = Resolved<
ReturnType<CreateIndexRoute['model']>
>;

View file

@ -1,10 +1,11 @@
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import IntlMessage from '../../utils/intl-message'; import IntlMessage from '../../utils/intl-message';
import type { CreateRouteModel } from '../create';
class FormData { class FormData {
@tracked title; @tracked title: string;
@tracked description; @tracked description: string;
get titleValidation() { get titleValidation() {
const { title } = this; const { title } = this;
@ -22,19 +23,24 @@ class FormData {
return null; return null;
} }
constructor({ title, description }) { constructor({ title, description }: { title: string; description: string }) {
this.title = title; this.title = title;
this.description = description; this.description = description;
} }
} }
export default class MetaRoute extends Route { export default class CreateMetaRoute extends Route {
model() { model() {
const { title, description } = this.modelFor('create'); const { title, description } = this.modelFor('create') as CreateRouteModel;
return { return {
formData: new FormData({ title, description }), formData: new FormData({ title, description }),
poll: this.modelFor('create'), poll: this.modelFor('create') as CreateRouteModel,
}; };
} }
} }
type Resolved<P> = P extends Promise<infer T> ? T : P;
export type CreateMetaRouteModel = Resolved<
ReturnType<CreateMetaRoute['model']>
>;

View file

@ -1,7 +0,0 @@
import Route from '@ember/routing/route';
export default class OptionsDatetimeRoute extends Route {
model() {
return this.modelFor('create');
}
}

View file

@ -0,0 +1,13 @@
import Route from '@ember/routing/route';
import type { CreateRouteModel } from '../create';
export default class CreateOptionsDatetimeRoute extends Route {
model() {
return this.modelFor('create') as CreateRouteModel;
}
}
type Resolved<P> = P extends Promise<infer T> ? T : P;
export type CreateOptionsDatetimeRouteModel = Resolved<
ReturnType<CreateOptionsDatetimeRoute['model']>
>;

View file

@ -1,7 +0,0 @@
import Route from '@ember/routing/route';
export default class OptionsRoute extends Route {
model() {
return this.modelFor('create');
}
}

View file

@ -0,0 +1,13 @@
import Route from '@ember/routing/route';
import type { CreateRouteModel } from '../create';
export default class CreateOptionsRoute extends Route {
model() {
return this.modelFor('create') as CreateRouteModel;
}
}
type Resolved<P> = P extends Promise<infer T> ? T : P;
export type CreateOptionsRouteModel = Resolved<
ReturnType<CreateOptionsRoute['model']>
>;

View file

@ -1,7 +0,0 @@
import Route from '@ember/routing/route';
export default class SettingsRoute extends Route {
model() {
return this.modelFor('create');
}
}

View file

@ -0,0 +1,13 @@
import Route from '@ember/routing/route';
import type { CreateRouteModel } from '../create';
export default class CreateSettingsRoute extends Route {
model() {
return this.modelFor('create') as CreateRouteModel;
}
}
type Resolved<P> = P extends Promise<infer T> ? T : P;
export type CreateSettingsRouteModel = Resolved<
ReturnType<CreateSettingsRoute['model']>
>;

View file

@ -1,33 +0,0 @@
import Route from '@ember/routing/route';
import Poll from '../models/poll';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class PollRoute extends Route {
@service router;
@action
error(error) {
if (error && error.status === 404) {
return this.router.transitionTo('404');
}
return true;
}
model({ encryptionKey, poll_id: id }) {
return Poll.load(id, encryptionKey);
}
redirect(poll, transition) {
if (transition.targetName === 'poll.index') {
const { encryptionKey } = this.paramsFor(this.routeName);
this.router.transitionTo('poll.participation', poll, {
queryParams: {
encryptionKey,
},
});
}
}
}

36
app/routes/poll.ts Normal file
View file

@ -0,0 +1,36 @@
import Route from '@ember/routing/route';
import Poll from '../models/poll';
import { inject as service } from '@ember/service';
import type Transition from '@ember/routing/transition';
import RouterService from '@ember/routing/router-service';
export default class PollRoute extends Route {
@service declare router: RouterService;
model({
encryptionKey,
poll_id: id,
}: {
encryptionKey: string;
poll_id: string;
}) {
return Poll.load(id, encryptionKey);
}
redirect(poll: Poll, transition: Transition) {
if (transition.to.name === 'poll.index') {
const { encryptionKey } = this.paramsFor(this.routeName) as {
encryptionKey: string;
};
this.router.transitionTo('poll.participation', poll, {
queryParams: {
encryptionKey,
},
});
}
}
}
type Resolved<P> = P extends Promise<infer T> ? T : P;
export type PollRouteModel = Resolved<ReturnType<PollRoute['model']>>;

View file

@ -1,7 +1,9 @@
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { TrackedArray } from 'tracked-built-ins'; import { TrackedArray } from 'tracked-built-ins';
import IntlMessage from '../../utils/intl-message'; import IntlMessage from 'croodle/utils/intl-message';
import type Poll from 'croodle/models/poll';
import type Option from 'croodle/models/option';
class FormDataSelections { class FormDataSelections {
@tracked value = null; @tracked value = null;
@ -21,13 +23,13 @@ class FormDataSelections {
return this.valueValidation === null; return this.valueValidation === null;
} }
constructor(valueIsRequired) { constructor(valueIsRequired: boolean) {
this.valueIsRequired = valueIsRequired; this.valueIsRequired = valueIsRequired;
} }
} }
class FormData { class FormData {
@tracked name = null; @tracked name: null | string = null;
nameIsRequired; nameIsRequired;
namesTaken; namesTaken;
selections; selections;
@ -39,8 +41,7 @@ class FormData {
return new IntlMessage('poll.error.name.valueMissing'); return new IntlMessage('poll.error.name.valueMissing');
} }
// TODO: Validate that name is unique for this poll if (name && namesTaken.includes(name)) {
if (namesTaken.includes(name)) {
return new IntlMessage('poll.error.name.duplicate'); return new IntlMessage('poll.error.name.duplicate');
} }
@ -57,7 +58,18 @@ class FormData {
return null; return null;
} }
constructor(options, { nameIsRequired, namesTaken, selectionIsRequired }) { constructor(
options: Option[],
{
nameIsRequired,
namesTaken,
selectionIsRequired,
}: {
nameIsRequired: boolean;
namesTaken: string[];
selectionIsRequired: boolean;
},
) {
this.nameIsRequired = nameIsRequired; this.nameIsRequired = nameIsRequired;
this.namesTaken = namesTaken; this.namesTaken = namesTaken;
this.selections = new TrackedArray( this.selections = new TrackedArray(
@ -68,7 +80,7 @@ class FormData {
export default class ParticipationRoute extends Route { export default class ParticipationRoute extends Route {
model() { model() {
const poll = this.modelFor('poll'); const poll = this.modelFor('poll') as Poll;
const { anonymousUser, forceAnswer, options, users } = poll; const { anonymousUser, forceAnswer, options, users } = poll;
const formData = new FormData(options, { const formData = new FormData(options, {
nameIsRequired: !anonymousUser, nameIsRequired: !anonymousUser,

View file

@ -1,6 +0,0 @@
<div class="box">
<h2>poll could not be found</h2>
<p>
The poll with you url could not be found. Perhaps it got deleted?
</p>
</div>

View file

@ -1,6 +1,14 @@
import { assert } from '@ember/debug'; import { assert } from '@ember/debug';
export default function (answerType) { export type Answer = {
labelTranslation: string;
icon: string;
type: string;
};
export default function (
answerType: 'YesNo' | 'YesNoMaybe' | 'FreeText',
): Answer[] {
switch (answerType) { switch (answerType) {
case 'YesNo': case 'YesNo':
return [ return [

View file

@ -10,7 +10,7 @@ const baseUrl = window.location.pathname
// add api/index.php // add api/index.php
.concat('/api/index.php'); .concat('/api/index.php');
function apiUrl(path) { function apiUrl(path: string) {
return `${baseUrl}/${path}`; return `${baseUrl}/${path}`;
} }

View file

@ -1,14 +1,14 @@
import { decrypt as sjclDecrypt, encrypt as sjclEncrypt } from 'sjcl'; import { decrypt as sjclDecrypt, encrypt as sjclEncrypt } from 'sjcl';
function decrypt(encryptedValue, passphrase) { function decrypt(encryptedValue: string, passphrase: string): unknown {
return JSON.parse(sjclDecrypt(passphrase, encryptedValue)); return JSON.parse(sjclDecrypt(passphrase, encryptedValue));
} }
function encrypt(plainValue, passphrase) { function encrypt(plainValue: unknown, passphrase: string) {
return sjclEncrypt(passphrase, JSON.stringify(plainValue)); return sjclEncrypt(passphrase, JSON.stringify(plainValue));
} }
function generatePassphrase() { function generatePassphrase(): string {
const length = 40; const length = 40;
const possible = const possible =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
@ -18,7 +18,9 @@ function generatePassphrase() {
let passphrase = ''; let passphrase = '';
for (let j = length; j--; ) { for (let j = length; j--; ) {
passphrase += possible.charAt(Math.floor(randomArray[j] % possible.length)); passphrase += possible.charAt(
Math.floor(randomArray[j]! % possible.length),
);
} }
return passphrase; return passphrase;

View file

@ -2,7 +2,7 @@ export default class IntlMessage {
key; key;
options; options;
constructor(key, options) { constructor(key: string, options?: Record<string, string>) {
this.key = key; this.key = key;
this.options = options; this.options = options;
} }

View file

@ -11,8 +11,8 @@
"codemodsSource": "ember-app-codemods-manifest@1", "codemodsSource": "ember-app-codemods-manifest@1",
"isBaseBlueprint": true, "isBaseBlueprint": true,
"options": [ "options": [
"--yarn", "--ci-provider=github",
"--no-welcome" "--typescript"
] ]
} }
] ]

View file

@ -1,5 +1,6 @@
{ {
"application-template-wrapper": false, "application-template-wrapper": false,
"default-async-observers": true,
"jquery-integration": false, "jquery-integration": false,
"template-only-glimmer-components": true "template-only-glimmer-components": true
} }

View file

@ -31,7 +31,7 @@ module.exports = function (defaults) {
], ],
}, },
'ember-cli-babel': { 'ember-cli-babel': {
includePolyfill: true, enableTypeScriptTransform: true,
}, },
'ember-composable-helpers': { 'ember-composable-helpers': {
only: ['array', 'object-at', 'pick'], only: ['array', 'object-at', 'pick'],

9109
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -18,8 +18,9 @@
"lint:hbs": "ember-template-lint .", "lint:hbs": "ember-template-lint .",
"lint:hbs:fix": "ember-template-lint . --fix", "lint:hbs:fix": "ember-template-lint . --fix",
"lint:js": "eslint . --cache", "lint:js": "eslint . --cache",
"release": "release-it",
"lint:js:fix": "eslint . --fix", "lint:js:fix": "eslint . --fix",
"lint:types": "tsc --noEmit",
"release": "release-it",
"start": "ember serve", "start": "ember serve",
"test": "concurrently \"npm:lint\" \"npm:test:*\" --names \"lint,test:\"", "test": "concurrently \"npm:lint\" \"npm:test:*\" --names \"lint,test:\"",
"test:ember": "ember test", "test:ember": "ember test",
@ -28,14 +29,21 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.22.20", "@babel/core": "^7.22.20",
"@babel/eslint-parser": "^7.22.15",
"@babel/plugin-proposal-decorators": "^7.22.15",
"@ember/optional-features": "^2.0.0", "@ember/optional-features": "^2.0.0",
"@ember/string": "^3.1.1", "@ember/string": "^3.1.1",
"@ember/test-helpers": "^3.2.0", "@ember/test-helpers": "^3.2.0",
"@glimmer/component": "^1.1.2", "@glimmer/component": "^1.1.2",
"@glimmer/tracking": "^1.1.2", "@glimmer/tracking": "^1.1.2",
"@glint/environment-ember-loose": "^1.1.0",
"@glint/template": "^1.1.0",
"@release-it-plugins/lerna-changelog": "^6.0.0", "@release-it-plugins/lerna-changelog": "^6.0.0",
"@tsconfig/ember": "^3.0.1",
"@types/luxon": "^3.3.3",
"@types/qunit": "^2.19.6",
"@types/rsvp": "^4.0.4",
"@types/sjcl": "^1.0.32",
"@typescript-eslint/eslint-plugin": "^6.7.2",
"@typescript-eslint/parser": "^6.7.2",
"bootstrap": "^4.3.1", "bootstrap": "^4.3.1",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"concurrently": "^8.2.1", "concurrently": "^8.2.1",
@ -98,6 +106,7 @@
"stylelint-config-standard-scss": "^11.0.0", "stylelint-config-standard-scss": "^11.0.0",
"stylelint-prettier": "^4.0.2", "stylelint-prettier": "^4.0.2",
"tracked-built-ins": "^3.3.0", "tracked-built-ins": "^3.3.0",
"typescript": "^5.2.2",
"webpack": "^5.88.2" "webpack": "^5.88.2"
}, },
"engines": { "engines": {

View file

@ -2,13 +2,14 @@ import {
setupApplicationTest as upstreamSetupApplicationTest, setupApplicationTest as upstreamSetupApplicationTest,
setupRenderingTest as upstreamSetupRenderingTest, setupRenderingTest as upstreamSetupRenderingTest,
setupTest as upstreamSetupTest, setupTest as upstreamSetupTest,
type SetupTestOptions,
} from 'ember-qunit'; } from 'ember-qunit';
// This file exists to provide wrappers around ember-qunit's // This file exists to provide wrappers around ember-qunit's
// test setup functions. This way, you can easily extend the setup that is // test setup functions. This way, you can easily extend the setup that is
// needed per test type. // needed per test type.
function setupApplicationTest(hooks, options) { function setupApplicationTest(hooks: NestedHooks, options?: SetupTestOptions) {
upstreamSetupApplicationTest(hooks, options); upstreamSetupApplicationTest(hooks, options);
// Additional setup for application tests can be done here. // Additional setup for application tests can be done here.
@ -27,13 +28,13 @@ function setupApplicationTest(hooks, options) {
// setupMirage(hooks); // ember-cli-mirage // setupMirage(hooks); // ember-cli-mirage
} }
function setupRenderingTest(hooks, options) { function setupRenderingTest(hooks: NestedHooks, options?: SetupTestOptions) {
upstreamSetupRenderingTest(hooks, options); upstreamSetupRenderingTest(hooks, options);
// Additional setup for rendering tests can be done here. // Additional setup for rendering tests can be done here.
} }
function setupTest(hooks, options) { function setupTest(hooks: NestedHooks, options?: SetupTestOptions) {
upstreamSetupTest(hooks, options); upstreamSetupTest(hooks, options);
// Additional setup for unit tests can be done here. // Additional setup for unit tests can be done here.

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"extends": "@tsconfig/ember/tsconfig.json",
"compilerOptions": {
// The combination of `baseUrl` with `paths` allows Ember's classic package
// layout, which is not resolvable with the Node resolution algorithm, to
// work with TypeScript.
"baseUrl": ".",
"paths": {
"croodle/tests/*": ["tests/*"],
"croodle/*": ["app/*"],
"fetch": [
"node_modules/ember-fetch"
],
"*": ["types/*"]
}
}
}

19
types/ember-cli-flash/flash/object.d.ts vendored Normal file
View file

@ -0,0 +1,19 @@
// Source: https://github.com/adopted-ember-addons/ember-cli-flash/blob/5e9ca769ce30b168eef1a4a8e4cdf5ad0d538a8d/ember-cli-flash/declarations/flash/object.d.ts
declare module 'ember-cli-flash/flash/object' {
import EmberObject from '@ember/object';
import Evented from '@ember/object/evented';
class FlashObject extends EmberObject.extend(Evented) {
exiting: boolean;
exitTimer: number;
isExitable: boolean;
initializedTime: number;
destroyMessage(): void;
exitMessage(): void;
preventExit(): void;
allowExit(): void;
timerTask(): void;
exitTimerTask(): void;
}
export default FlashObject;
}

View file

@ -0,0 +1,46 @@
// Source: https://github.com/adopted-ember-addons/ember-cli-flash/blob/5e9ca769ce30b168eef1a4a8e4cdf5ad0d538a8d/ember-cli-flash/declarations/services/flash-messages.d.ts
declare module 'ember-cli-flash/services/flash-messages' {
import Service from '@ember/service';
import FlashObject from 'ember-cli-flash/flash/object';
export interface MessageOptions {
type: string;
priority: number;
timeout: number;
sticky: boolean;
showProgress: boolean;
extendedTimeout: number;
destroyOnClick: boolean;
onDestroy: () => void;
[key: string]: unknown;
}
export interface CustomMessageInfo extends Partial<MessageOptions> {
message: string;
}
export interface FlashFunction {
(message: string, options?: Partial<MessageOptions>): FlashMessageService;
}
class FlashMessageService extends Service {
queue: FlashObject[];
readonly arrangedQueue: FlashObject[];
readonly isEmpty: boolean;
success: FlashFunction;
warning: FlashFunction;
info: FlashFunction;
danger: FlashFunction;
alert: FlashFunction;
secondary: FlashFunction;
add(messageInfo: CustomMessageInfo): this;
clearMessages(): this;
registerTypes(types: string[]): this;
getFlashObject(): FlashObject;
peekFirst(): FlashObject | undefined;
peekLast(): FlashObject | undefined;
readonly flashMessageDefaults: unknown;
}
export default FlashMessageService;
}

View file

@ -0,0 +1,7 @@
import type Service from '@ember/service';
declare module 'ember-power-calendar/services/power-calendar' {
export default class PowerCalendarService extends Service {
locale: string;
}
}

1
types/global.d.ts vendored Normal file
View file

@ -0,0 +1 @@
import '@glint/environment-ember-loose';