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

View file

@ -4,6 +4,8 @@ import loadInitializers from 'ember-load-initializers';
import config from 'croodle/config/environment';
export default class App extends Application {
LOG_TRANSITIONS = true;
modulePrefix = config.modulePrefix;
podModulePrefix = config.podModulePrefix;
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 { DateTime } from 'luxon';
import IntlMessage from '../utils/intl-message';
import type RouterService from '@ember/routing/router-service';
import type Transition from '@ember/routing/transition';
class FormDataTimeOption {
formData;
// ISO 8601 date string: YYYY-MM-DD
date;
date: string;
// 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
// 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
// duplicated time should show the validation error.
const { formData, date } = this;
const timesForThisDate = Array.from(formData.datetimes.get(date));
const timesForThisDate = Array.from(formData.datetimes.get(date)!);
const isDuplicate = timesForThisDate
.slice(0, timesForThisDate.indexOf(this))
.some((timeOption) => timeOption.time == this.time);
@ -64,11 +66,14 @@ class FormDataTimeOption {
const { datetimes } = formData;
return (
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.date = date;
this.time = time;
@ -76,7 +81,7 @@ class FormDataTimeOption {
}
class FormData {
@tracked datetimes;
@tracked datetimes: Map<string, Set<FormDataTimeOption>>;
get optionsValidation() {
const { datetimes } = this;
@ -87,7 +92,7 @@ class FormData {
),
);
if (!allTimeOptionsAreValid) {
return IntlMessage('create.options-datetime.error.invalidTime');
return new IntlMessage('create.options-datetime.error.invalidTime');
}
return null;
@ -98,9 +103,9 @@ class FormData {
}
@action
addOption(date) {
addOption(date: string) {
this.datetimes
.get(date)
.get(date)!
.add(new FormDataTimeOption(this, { date, time: null }));
}
@ -109,8 +114,8 @@ class FormData {
* otherwise it deletes time for this date
*/
@action
deleteOption(option) {
const timeOptionsForDate = this.datetimes.get(option.date);
deleteOption(option: FormDataTimeOption) {
const timeOptionsForDate = this.datetimes.get(option.date)!;
if (timeOptionsForDate.size > 1) {
timeOptionsForDate.delete(option);
@ -122,8 +127,8 @@ class FormData {
@action
adoptTimesOfFirstDay() {
const timeOptionsForFirstDay = Array.from(
Array.from(this.datetimes.values())[0],
);
Array.from(this.datetimes.values())[0]!,
) as FormDataTimeOption[];
const timesForFirstDayAreValid = timeOptionsForFirstDay.every(
(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();
for (const date of dates) {
const timesForDate = times.has(date)
? Array.from(times.get(date))
? Array.from(times.get(date) as Set<string>)
: [null];
datetimes.set(
date,
@ -164,12 +175,22 @@ class FormData {
}
}
export default class CreateOptionsDatetime extends Component {
@service router;
export interface CreateOptoinsDatetimeSignature {
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 });
@tracked errorMesage = null;
@tracked errorMessage: string | null = null;
@action
adoptTimesOfFirstDay() {
@ -177,7 +198,7 @@ export default class CreateOptionsDatetime extends Component {
const successful = formData.adoptTimesOfFirstDay();
if (!successful) {
this.errorMesage =
this.errorMessage =
'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
@action
validateInput(option, event) {
const element = event.target;
validateInput(option: FormDataTimeOption, event: InputEvent) {
const element = event.target as HTMLInputElement;
// update partially filled time validation error
option.isPartiallyFilled = !element.checkValidity();
@ -203,8 +224,8 @@ export default class CreateOptionsDatetime extends Component {
// remove partially filled validation error if user fixed it
@action
updateInputValidation(option, event) {
const element = event.target;
updateInputValidation(option: FormDataTimeOption, event: InputEvent) {
const element = event.target as HTMLInputElement;
if (element.checkValidity() && option.isPartiallyFilled) {
option.isPartiallyFilled = false;
@ -212,23 +233,25 @@ export default class CreateOptionsDatetime extends Component {
}
@action
handleTransition(transition) {
handleTransition(transition: Transition) {
if (transition.from?.name === 'create.options-datetime') {
this.args.updateOptions(
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
new Map(
Array.from(this.formData.datetimes.entries())
.map(([key, timeOptions]) => [
.map(([key, timeOptions]): [string, Set<string>] => {
return [
key,
new Set(
Array.from(timeOptions)
.map(({ time }) => time)
.map(({ time }: FormDataTimeOption) => time)
// There might be FormDataTime objects without a time, which
// 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
.filter(([, times]) => times.size > 0),
),
@ -237,8 +260,8 @@ export default class CreateOptionsDatetime extends Component {
}
}
constructor() {
super(...arguments);
constructor(owner: unknown, args: CreateOptoinsDatetimeSignature['Args']) {
super(owner, args);
this.router.on('routeWillChange', this.handleTransition);
}

View file

@ -1,11 +1,13 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
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 { 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;
formData;
@ -33,7 +35,7 @@ class FormDataOption {
return this.valueValidation === null;
}
constructor(formData, value) {
constructor(formData: FormData, value: string) {
this.formData = formData;
this.value = value;
}
@ -59,25 +61,28 @@ class FormData {
}
@action
updateOptions(values) {
updateOptions(values: string[]) {
this.options = new TrackedArray(
values.map((value) => new FormDataOption(this, value)),
);
}
@action
addOption(value, afterPosition = this.options.length - 1) {
addOption(value: string, afterPosition = this.options.length - 1) {
const option = new FormDataOption(this, value);
this.options.splice(afterPosition + 1, 0, option);
}
@action
deleteOption(option) {
deleteOption(option: FormDataOption) {
this.options.splice(this.options.indexOf(option), 1);
}
constructor({ options }, { defaultOptionCount }) {
constructor(
{ options }: { options: Set<string> },
{ defaultOptionCount }: { defaultOptionCount: number },
) {
const normalizedOptions =
options.size === 0 && defaultOptionCount > 0
? ['', '']
@ -89,8 +94,18 @@ class FormData {
}
}
export default class CreateOptionsComponent extends Component {
@service router;
export interface CreateOptionsSignature {
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(
{ options: this.args.options },
@ -108,15 +123,15 @@ export default class CreateOptionsComponent extends Component {
}
@action
handleTransition(transition) {
handleTransition(transition: Transition) {
if (transition.from?.name === 'create.options') {
this.args.updateOptions(this.formData.options);
this.router.off('routeWillChange', this.handleTransition);
}
}
constructor() {
super(...arguments);
constructor(owner: unknown, args: CreateOptionsSignature['Args']) {
super(owner, args);
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 localesMeta from 'croodle/locales/meta';
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 {
@service intl;
@service powerCalendar;
@service declare intl: IntlService;
@service declare powerCalendar: PowerCalendarService;
get currentLocale() {
return this.intl.primaryLocale;
}
get locales() {
return localesMeta;
}
locales = localesMeta;
@action
handleChange(event) {
const locale = event.target.value;
handleChange(event: Event) {
const selectElement = event.target as HTMLSelectElement;
const locale = selectElement.value as keyof typeof this.locales;
this.intl.locale = locale.includes('-')
? [locale, locale.split('-')[0]]
? [locale, locale.split('-')[0] as string]
: [locale];
this.powerCalendar.locale = locale;

View file

@ -1,7 +1,14 @@
import Component from '@glimmer/component';
import type Poll from 'croodle/models/poll';
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() {
const { poll } = this.args;

View file

@ -1,8 +1,19 @@
import Component from '@glimmer/component';
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 {
@service intl;
export interface PollEvaluationSummarySignature {
Args: {
poll: Poll;
};
}
export default class PollEvaluationSummary extends Component<PollEvaluationSummarySignature> {
@service declare intl: IntlService;
get bestOptions() {
const { poll } = this.args;
@ -18,34 +29,41 @@ export default class PollEvaluationSummary extends Component {
return undefined;
}
let answers = poll.answers.reduce((answers, answer) => {
const answers = poll.answers.reduce(
(answers: Record<string, number>, answer: Answer) => {
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 {
answers: { ...answers },
option,
score: 0,
};
});
let bestOptions = [];
const bestOptions = [];
users.forEach((user) => {
users.forEach((user: User) => {
user.selections.forEach(({ type }, i) => {
evaluation[i].answers[type]++;
evaluation[i]!.answers[type]++;
switch (type) {
case 'yes':
evaluation[i].score += 2;
evaluation[i]!.score += 2;
break;
case 'maybe':
evaluation[i].score += 1;
evaluation[i]!.score += 1;
break;
case 'no':
evaluation[i].score -= 2;
evaluation[i]!.score -= 2;
break;
}
});
@ -53,9 +71,9 @@ export default class PollEvaluationSummary extends Component {
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++) {
if (bestScore === evaluation[i].score) {
if (bestScore === evaluation[i]!.score) {
bestOptions.push(evaluation[i]);
} else {
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 { inject as service } from '@ember/service';
import type FlashMessagesService from 'ember-cli-flash/services/flash-messages';
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 Controller from '@ember/controller';
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 {
@service router;
@service declare router: RouterService;
declare model: CreateRouteModel;
visitedSteps = new TrackedSet();
get canEnterMetaStep() {
return this.visitedSteps.has('meta') && this.model.pollType;
}
get canEnterOptionsStep() {
let { title } = this.model;
const { title } = this.model;
return (
this.visitedSteps.has('options') &&
typeof title === 'string' &&
@ -40,18 +46,18 @@ export default class CreateController extends Controller {
@action
updateVisitedSteps() {
let { currentRouteName } = this.router;
const { currentRouteName } = this.router;
// currentRouteName might not be defined in some edge cases
if (!currentRouteName) {
return;
}
let step = currentRouteName.split('.').pop();
const step = currentRouteName.split('.').pop();
this.visitedSteps.add(step);
}
@action transitionTo(route) {
@action transitionTo(route: string) {
this.router.transitionTo(route);
}

View file

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

View file

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

View file

@ -1,9 +1,13 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
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 {
@service router;
@service declare router: RouterService;
declare model: CreateOptionsDatetimeRouteModel;
@action
nextPage() {
@ -16,7 +20,7 @@ export default class CreateOptionsDatetimeController extends Controller {
}
@action
updateOptions(datetimes) {
updateOptions(datetimes: Map<string, Set<string>>) {
this.model.timesForDateOptions = new Map(datetimes.entries());
}
}

View file

@ -1,10 +1,14 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
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 {
@service router;
@service declare router: RouterService;
declare model: CreateOptionsRouteModel;
@action
nextPage() {
@ -23,7 +27,7 @@ export default class CreateOptionsController extends Controller {
}
@action
updateOptions(newOptions) {
updateOptions(newOptions: { value: string }[]) {
const { pollType } = this.model;
const options = newOptions.map(({ value }) => value);

View file

@ -5,11 +5,17 @@ import { action } from '@ember/object';
import { DateTime, Duration } from 'luxon';
import Poll from '../../models/poll';
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 {
@service flashMessages;
@service intl;
@service router;
@service declare flashMessages: FlashMessagesService;
@service declare intl: IntlService;
@service declare router: RouterService;
declare model: CreateSettingsRouteModel;
get anonymousUser() {
return this.model.anonymousUser;
@ -39,7 +45,7 @@ export default class CreateSettings extends Controller {
}
set expirationDuration(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
previousPage() {
let { pollType } = this.model;
const { pollType } = this.model;
if (pollType === 'FindADate') {
this.router.transitionTo('create.options-datetime');
@ -104,22 +110,22 @@ export default class CreateSettings extends Controller {
} = model;
// calculate options
let options = [];
const options: string[] = [];
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(':');
for (const time of timesForDateOptions.get(date)!) {
const [hour, minute] = time.split(':') as [string, string];
options.push(
DateTime.fromISO(date)
.set({
hour,
minute,
hour: parseInt(hour),
minute: parseInt(minute),
second: 0,
millisecond: 0,
})
.toISO(),
.toISO() as string,
);
}
} else {
@ -139,7 +145,6 @@ export default class CreateSettings extends Controller {
{
anonymousUser,
answerType,
creationDate: new Date().toISOString(),
description,
expirationDate,
forceAnswer,

View file

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

View file

@ -1,11 +1,20 @@
import Helper from '@ember/component/helper';
import { DateTime } from 'luxon';
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 {
@service intl;
@service declare intl: IntlService;
compute([date]) {
compute([date]: Positional) {
if (date instanceof Date) {
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 { assert } from '@ember/debug';
function elementIsNotVisible(element) {
let elementPosition = element.getBoundingClientRect();
let windowHeight = window.innerHeight;
function elementIsNotVisible(element: Element) {
const elementPosition = element.getBoundingClientRect();
const windowHeight = window.innerHeight;
// an element is not visible if
return (
false ||
// it's above the current view port
// check if the element is within current view port
if (
// above current view port
elementPosition.top <= 0 ||
// it's below the current view port
elementPosition.bottom >= windowHeight ||
// it's in current view port but hidden by fixed navigation
(getComputedStyle(document.querySelector('.cr-steps-bottom-nav'))
.position === 'fixed' &&
elementPosition.bottom >=
windowHeight -
document.querySelector('.cr-steps-bottom-nav').offsetHeight)
// below current view port
elementPosition.bottom >= windowHeight
) {
return true;
}
// check if element is within current view port button hidden behind
// 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
// delaying to next runloop therefore
next(function () {
let invalidInput = document.querySelector(
const invalidInput = document.querySelector(
'.form-control.is-invalid, .custom-control-input.is-invalid',
);
) as HTMLInputElement;
assert(
'Atleast one form control must be marked as invalid if form submission was rejected as invalid',
invalidInput,
@ -46,7 +56,7 @@ export function scrollFirstInvalidElementIntoViewPort() {
// 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
// inputs and the `for` of the input group `<label>` (which should be a `<legend>`).
let scrollTarget =
const scrollTarget =
document.querySelector(
`label[for="${invalidInput.id.substr(
0,

View file

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

View file

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

View file

@ -1,9 +1,20 @@
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;
modify(element, positional, { enabled = true }) {
modify(element: HTMLInputElement, _: [], { enabled = true }: Named) {
// element should be only autofocused on initial render
// not when `enabled` option is invalidated
if (this.isInstalled) {

View file

@ -17,5 +17,4 @@ Router.map(function () {
this.route('options-datetime');
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 { tracked } from '@glimmer/tracking';
import IntlMessage from '../../utils/intl-message';
import type { CreateRouteModel } from '../create';
class FormData {
@tracked title;
@tracked description;
@tracked title: string;
@tracked description: string;
get titleValidation() {
const { title } = this;
@ -22,19 +23,24 @@ class FormData {
return null;
}
constructor({ title, description }) {
constructor({ title, description }: { title: string; description: string }) {
this.title = title;
this.description = description;
}
}
export default class MetaRoute extends Route {
export default class CreateMetaRoute extends Route {
model() {
const { title, description } = this.modelFor('create');
const { title, description } = this.modelFor('create') as CreateRouteModel;
return {
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 { tracked } from '@glimmer/tracking';
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 {
@tracked value = null;
@ -21,13 +23,13 @@ class FormDataSelections {
return this.valueValidation === null;
}
constructor(valueIsRequired) {
constructor(valueIsRequired: boolean) {
this.valueIsRequired = valueIsRequired;
}
}
class FormData {
@tracked name = null;
@tracked name: null | string = null;
nameIsRequired;
namesTaken;
selections;
@ -39,8 +41,7 @@ class FormData {
return new IntlMessage('poll.error.name.valueMissing');
}
// TODO: Validate that name is unique for this poll
if (namesTaken.includes(name)) {
if (name && namesTaken.includes(name)) {
return new IntlMessage('poll.error.name.duplicate');
}
@ -57,7 +58,18 @@ class FormData {
return null;
}
constructor(options, { nameIsRequired, namesTaken, selectionIsRequired }) {
constructor(
options: Option[],
{
nameIsRequired,
namesTaken,
selectionIsRequired,
}: {
nameIsRequired: boolean;
namesTaken: string[];
selectionIsRequired: boolean;
},
) {
this.nameIsRequired = nameIsRequired;
this.namesTaken = namesTaken;
this.selections = new TrackedArray(
@ -68,7 +80,7 @@ class FormData {
export default class ParticipationRoute extends Route {
model() {
const poll = this.modelFor('poll');
const poll = this.modelFor('poll') as Poll;
const { anonymousUser, forceAnswer, options, users } = poll;
const formData = new FormData(options, {
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';
export default function (answerType) {
export type Answer = {
labelTranslation: string;
icon: string;
type: string;
};
export default function (
answerType: 'YesNo' | 'YesNoMaybe' | 'FreeText',
): Answer[] {
switch (answerType) {
case 'YesNo':
return [

View file

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

View file

@ -1,14 +1,14 @@
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));
}
function encrypt(plainValue, passphrase) {
function encrypt(plainValue: unknown, passphrase: string) {
return sjclEncrypt(passphrase, JSON.stringify(plainValue));
}
function generatePassphrase() {
function generatePassphrase(): string {
const length = 40;
const possible =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
@ -18,7 +18,9 @@ function generatePassphrase() {
let passphrase = '';
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;

View file

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

View file

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

View file

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

View file

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

9107
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:fix": "ember-template-lint . --fix",
"lint:js": "eslint . --cache",
"release": "release-it",
"lint:js:fix": "eslint . --fix",
"lint:types": "tsc --noEmit",
"release": "release-it",
"start": "ember serve",
"test": "concurrently \"npm:lint\" \"npm:test:*\" --names \"lint,test:\"",
"test:ember": "ember test",
@ -28,14 +29,21 @@
},
"devDependencies": {
"@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/string": "^3.1.1",
"@ember/test-helpers": "^3.2.0",
"@glimmer/component": "^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",
"@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",
"broccoli-asset-rev": "^3.0.0",
"concurrently": "^8.2.1",
@ -98,6 +106,7 @@
"stylelint-config-standard-scss": "^11.0.0",
"stylelint-prettier": "^4.0.2",
"tracked-built-ins": "^3.3.0",
"typescript": "^5.2.2",
"webpack": "^5.88.2"
},
"engines": {

View file

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