From 10e8b77acf294595ed4afc33c73b9e5cf9162558 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 17 Jan 2020 12:44:22 +0300 Subject: [PATCH 01/12] Reimplement scripts with TS --- @types/cwordin-api.d.ts | 102 +++++++++ @types/multi-progress.d.ts | 13 ++ @types/prompt.d.ts | 66 ++++++ package.json | 6 +- packages/app/package.json | 3 + packages/app/services/captcha.ts | 2 +- .../{localStorage.js => localStorage.ts} | 9 +- packages/scripts/i18n-collect.ts | 69 ++++--- packages/scripts/i18n-crowdin.ts | 195 ++++++------------ packages/scripts/package.json | 7 +- yarn.lock | 59 ++++-- 11 files changed, 343 insertions(+), 188 deletions(-) create mode 100644 @types/cwordin-api.d.ts create mode 100644 @types/multi-progress.d.ts create mode 100644 @types/prompt.d.ts rename packages/app/services/{localStorage.js => localStorage.ts} (79%) diff --git a/@types/cwordin-api.d.ts b/@types/cwordin-api.d.ts new file mode 100644 index 0000000..0ebb860 --- /dev/null +++ b/@types/cwordin-api.d.ts @@ -0,0 +1,102 @@ +declare module 'crowdin-api' { + export interface ProjectInfoFile { + node_type: 'file'; + id: number; + name: string; + created: string; + last_updated: string; + last_accessed: string; + last_revision: string; + } + + export interface ProjectInfoDirectory { + node_type: 'directory'; + id: number; + name: string; + files: Array; + } + + export interface ProjectInfoResponse { + details: { + source_language: { + name: string; + code: string; + }; + name: string; + identifier: string; + created: string; + description: string; + join_policy: string; + last_build: string | null; + last_activity: string; + participants_count: string; // it's number, but string in the response + logo_url: string | null; + total_strings_count: string; // it's number, but string in the response + total_words_count: string; // it's number, but string in the response + duplicate_strings_count: number; + duplicate_words_count: number; + invite_url: { + translator: string; + proofreader: string; + }; + }; + languages: Array<{ + name: string; // English language name + code: string; + can_translate: 0 | 1; + can_approve: 0 | 1; + }>; + files: Array; + } + + export interface LanguageStatusNode { + node_type: 'directory' | 'file'; + id: number; + name: string; + phrases: number; + translated: number; + approved: number; + words: number; + words_translated: number; + words_approved: number; + files: Array; + } + + export interface LanguageStatusResponse { + files: Array; + } + + type FilesList = Record; + + export default class CrowdinApi { + constructor(params: { + apiKey: string; + projectName: string; + baseUrl?: string; + }); + projectInfo(): Promise; + languageStatus(language: string): Promise; + exportFile( + file: string, + language: string, + params?: { + branch?: string; + format?: 'xliff'; + export_translated_only?: boolean; + export_approved_only?: boolean; + }, + ): Promise; // TODO: not sure about Promise return type + updateFile( + files: FilesList, + params: { + titles?: Record; + export_patterns?: Record; + new_names?: Record; + first_line_contains_header?: string; + scheme?: string; + update_option?: 'update_as_unapproved' | 'update_without_changes'; + branch?: string; + }, + ): Promise; + } +} diff --git a/@types/multi-progress.d.ts b/@types/multi-progress.d.ts new file mode 100644 index 0000000..b3ab5e5 --- /dev/null +++ b/@types/multi-progress.d.ts @@ -0,0 +1,13 @@ +declare module 'multi-progress' { + export default class MultiProgress { + constructor(stream?: string); + newBar( + schema: string, + options: ProgressBar.ProgressBarOptions, + ): ProgressBar; + terminate(): void; + move(index: number): void; + tick(index: number, value?: number, options?: any): void; + update(index: number, value?: number, options?: any): void; + } +} diff --git a/@types/prompt.d.ts b/@types/prompt.d.ts new file mode 100644 index 0000000..83bd868 --- /dev/null +++ b/@types/prompt.d.ts @@ -0,0 +1,66 @@ +// Type definitions for Prompt.js 1.0.0 +// Project: https://github.com/flatiron/prompt + +declare module 'prompt' { + type PropertiesType = + | Array + | prompt.PromptSchema + | Array; + + namespace prompt { + interface PromptSchema { + properties: PromptProperties; + } + + interface PromptProperties { + [propName: string]: PromptPropertyOptions; + } + + interface PromptPropertyOptions { + name?: string; + pattern?: RegExp; + message?: string; + required?: boolean; + hidden?: boolean; + description?: string; + type?: string; + default?: string; + before?: (value: any) => any; + conform?: (result: any) => boolean; + } + + export function start(): void; + + export function get( + properties: T, + callback: ( + err: Error, + result: T extends Array + ? Record + : T extends PromptSchema + ? Record + : T extends Array + ? Record< + T[number]['name'] extends string ? T[number]['name'] : number, + string + > + : never, + ) => void, + ): void; + + export function addProperties( + obj: any, + properties: PropertiesType, + callback: (err: Error) => void, + ): void; + + export function history(propertyName: string): any; + + export let override: any; + export let colors: boolean; + export let message: string; + export let delimiter: string; + } + + export = prompt; +} diff --git a/package.json b/package.json index 10e15d9..e6960cc 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "ts:check": "tsc", "ci:check": "yarn lint:check && yarn ts:check && yarn test", "analyze": "yarn run clean && yarn run build:webpack --analyze", - "i18n:collect": "babel-node ./packages/scripts/i18n-collect.js", - "i18n:push": "babel-node ./packages/scripts/i18n-crowdin.js push", - "i18n:pull": "babel-node ./packages/scripts/i18n-crowdin.js pull", + "i18n:collect": "babel-node --extensions \".ts\" ./packages/scripts/i18n-collect.ts", + "i18n:push": "babel-node --extensions \".ts\" ./packages/scripts/i18n-crowdin.ts push", + "i18n:pull": "babel-node --extensions \".ts\" ./packages/scripts/i18n-crowdin.ts pull", "build": "yarn run clean && yarn run build:webpack", "build:install": "yarn install", "build:webpack": "NODE_ENV=production webpack --colors -p --bail", diff --git a/packages/app/package.json b/packages/app/package.json index b1d408f..6a2e9c4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -35,6 +35,9 @@ "whatwg-fetch": "^3.0.0" }, "devDependencies": { + "@types/debounce": "^1.2.0", + "@types/intl": "^1.2.0", + "@types/raf": "^3.4.0", "@types/react-helmet": "^5.0.15", "@types/webpack-env": "^1.15.0", "enzyme": "^3.8.0", diff --git a/packages/app/services/captcha.ts b/packages/app/services/captcha.ts index f160be3..b9cc450 100644 --- a/packages/app/services/captcha.ts +++ b/packages/app/services/captcha.ts @@ -3,7 +3,7 @@ import options from 'app/services/api/options'; let readyPromise: Promise; let lang = 'en'; -let sitekey; +let sitekey: string; export type CaptchaID = string; diff --git a/packages/app/services/localStorage.js b/packages/app/services/localStorage.ts similarity index 79% rename from packages/app/services/localStorage.js rename to packages/app/services/localStorage.ts index dc60659..faa8a89 100644 --- a/packages/app/services/localStorage.js +++ b/packages/app/services/localStorage.ts @@ -17,16 +17,17 @@ export function hasStorage() { return _hasStorage; } -class DummyStorage { - getItem(key) { +// TODO: work on +class DummyStorage implements Storage { + getItem(key: string): string | null { return this[key] || null; } - setItem(key, value) { + setItem(key: string, value: string): void { this[key] = value; } - removeItem(key) { + removeItem(key: string): void { Reflect.deleteProperty(this, key); } } diff --git a/packages/scripts/i18n-collect.ts b/packages/scripts/i18n-collect.ts index 50579b8..7cf6815 100644 --- a/packages/scripts/i18n-collect.ts +++ b/packages/scripts/i18n-collect.ts @@ -12,19 +12,27 @@ const LANG_DIR = `${__dirname}/../app/i18n`; const DEFAULT_LOCALE = 'en'; const SUPPORTED_LANGS = [DEFAULT_LOCALE, ...Object.keys(localesMap)]; +interface MessageDescriptor { + id: string | number; + defaultMessage: string; +} + /** * Aggregates the default messages that were extracted from the app's * React components via the React Intl Babel plugin. An error will be thrown if * there are messages in different components that use the same `id`. The result * is a flat collection of `id: message` pairs for the app's default locale. */ -let idToFileMap: { [key: string]: string[] } = {}; -let duplicateIds: string[] = []; +let idToFileMap: Record> = {}; +let duplicateIds: Array = []; const collectedMessages = globSync(MESSAGES_PATTERN) - .map(filename => [filename, JSON.parse(fs.readFileSync(filename, 'utf8'))]) - .reduce((collection, [file, descriptors]) => { + .map<[string, Array]>(filename => [ + filename, + JSON.parse(fs.readFileSync(filename, 'utf8')), + ]) + .reduce>((collection, [file, descriptors]) => { descriptors.forEach(({ id, defaultMessage }) => { - if (collection.hasOwnProperty(id)) { + if (collection[id]) { duplicateIds.push(id); } @@ -52,13 +60,9 @@ idToFileMap = {}; * Making a diff with the previous DEFAULT_LOCALE version */ const defaultMessagesPath = `${LANG_DIR}/${DEFAULT_LOCALE}.json`; -let keysToUpdate: string[] = []; -let keysToAdd: string[] = []; -let keysToRemove: string[] = []; -const keysToRename: Array<[string, string]> = []; const isNotMarked = (value: string) => value.slice(0, 2) !== '--'; -const prevMessages: { [key: string]: string } = readJSON(defaultMessagesPath); +const prevMessages = readJSON>(defaultMessagesPath); const prevMessagesMap = Object.entries(prevMessages).reduce( (acc, [key, value]) => { if (acc[value]) { @@ -69,21 +73,24 @@ const prevMessagesMap = Object.entries(prevMessages).reduce( return acc; }, - {} as { [key: string]: string[] }, + {} as Record>, ); -keysToAdd = Object.keys(collectedMessages).filter(key => !prevMessages[key]); -keysToRemove = Object.keys(prevMessages) +const keysToAdd = Object.keys(collectedMessages).filter( + key => !prevMessages[key], +); +const keysToRemove: Array = Object.keys(prevMessages) .filter(key => !collectedMessages[key]) .filter(isNotMarked); -keysToUpdate = Object.entries(prevMessages).reduce( +const keysToUpdate: Array = Object.entries(prevMessages).reduce( (acc, [key, message]) => acc.concat( collectedMessages[key] && collectedMessages[key] !== message ? key : [], ), - [] as string[], + [] as Array, ); +const keysToRename: Array<[string, string]> = []; // detect keys to rename, mutating keysToAdd and keysToRemove [...keysToAdd].forEach(toKey => { const keys = prevMessagesMap[collectedMessages[toKey]] || []; @@ -91,7 +98,6 @@ keysToUpdate = Object.entries(prevMessages).reduce( if (fromKey) { keysToRename.push([fromKey, toKey]); - keysToRemove.splice(keysToRemove.indexOf(fromKey), 1); keysToAdd.splice(keysToAdd.indexOf(toKey), 1); } @@ -178,7 +184,7 @@ function buildLocales() { SUPPORTED_LANGS.map(lang => { const destPath = `${LANG_DIR}/${lang}.json`; - const newMessages = readJSON(destPath); + const newMessages = readJSON>(destPath); keysToRename.forEach(([fromKey, toKey]) => { newMessages[toKey] = newMessages[fromKey]; @@ -195,18 +201,23 @@ function buildLocales() { newMessages[key] = collectedMessages[key]; }); - const sortedKeys = Object.keys(newMessages).sort((key1, key2) => { - key1 = key1.replace(/^-+/, ''); - key2 = key2.replace(/^-+/, ''); + const sortedKeys: Array = Object.keys(newMessages).sort( + (key1, key2) => { + key1 = key1.replace(/^-+/, ''); + key2 = key2.replace(/^-+/, ''); - return key1 < key2 || !isNotMarked(key1) ? -1 : 1; - }); + return key1 < key2 || !isNotMarked(key1) ? -1 : 1; + }, + ); - const sortedNewMessages = sortedKeys.reduce((acc, key) => { - acc[key] = newMessages[key]; + const sortedNewMessages = sortedKeys.reduce( + (acc, key) => { + acc[key] = newMessages[key]; - return acc; - }, {}); + return acc; + }, + {}, + ); fs.writeFileSync( destPath, @@ -215,15 +226,15 @@ function buildLocales() { }); } -function readJSON(destPath) { +function readJSON(destPath: string): T { try { return JSON.parse(fs.readFileSync(destPath, 'utf8')); } catch (err) { console.log( - chalk.yellow(`Can not read ${destPath}. The new file will be created.`), + chalk.yellow(`Can't read ${destPath}. The new file will be created.`), `(${err.message})`, ); } - return {}; + return {} as T; } diff --git a/packages/scripts/i18n-crowdin.ts b/packages/scripts/i18n-crowdin.ts index 928f842..a8c8f9b 100644 --- a/packages/scripts/i18n-crowdin.ts +++ b/packages/scripts/i18n-crowdin.ts @@ -1,14 +1,16 @@ /* eslint-env node */ -/* eslint-disable no-console */ +/* eslint-disable */ import fs from 'fs'; import path from 'path'; -import CrowdinApi from 'crowdin-api'; +import CrowdinApi, { LanguageStatusNode, LanguageStatusResponse, ProjectInfoResponse } from 'crowdin-api'; import MultiProgress from 'multi-progress'; import ch from 'chalk'; import iso639 from 'iso-639-1'; import prompt from 'prompt'; +import { ValuesType } from 'utility-types'; + import config from '../../config'; if (!config.crowdinApiKey) { @@ -24,18 +26,21 @@ const LANG_DIR = path.resolve(`${__dirname}/../app/i18n`); const INDEX_FILE_NAME = 'index.js'; const MIN_RELEASE_PROGRESS = 80; // Minimal ready percent before translation can be published -const crowdin = new CrowdinApi({ apiKey: PROJECT_KEY }); +const crowdin = new CrowdinApi({ + apiKey: PROJECT_KEY, + projectName: PROJECT_ID, +}); const progressBar = new MultiProgress(); /** * Locales that has been verified by core team members */ -const RELEASED_LOCALES = ['be', 'fr', 'id', 'pt', 'ru', 'uk', 'vi', 'zh']; +const RELEASED_LOCALES: Array = ['be', 'fr', 'id', 'pt', 'ru', 'uk', 'vi', 'zh']; /** * Array of Crowdin locales to our internal locales representation */ -const LOCALES_MAP = { +const LOCALES_MAP: Record = { 'pt-BR': 'pt', 'zh-CN': 'zh', }; @@ -43,7 +48,7 @@ const LOCALES_MAP = { /** * This array allows us to customise native languages names, because ISO-639-1 sometimes is strange */ -const NATIVE_NAMES_MAP = { +const NATIVE_NAMES_MAP: Record = { be: 'Беларуская', id: 'Bahasa Indonesia', lt: 'Lietuvių', @@ -57,7 +62,7 @@ const NATIVE_NAMES_MAP = { /** * This arrays allows us to override Crowdin English languages names */ -const ENGLISH_NAMES_MAP = { +const ENGLISH_NAMES_MAP: Record = { pt: 'Portuguese, Brazilian', sr: 'Serbian', zh: 'Simplified Chinese', @@ -65,9 +70,6 @@ const ENGLISH_NAMES_MAP = { /** * Converts Crowdin's language code to our internal value - * - * @param {string} code - * @returns {string} */ function toInternalLocale(code: string): string { return LOCALES_MAP[code] || code; @@ -76,108 +78,33 @@ function toInternalLocale(code: string): string { /** * Форматирует входящий объект с переводами в итоговую строку в том формате, в каком они * хранятся в самом приложении - * - * @param {object} translates - * @returns {string} */ -function serializeToFormattedJson( - translates: { [key: string]: any }, - { asModule = false }: { asModule?: boolean } = {}, -): string { +function serializeToModule(translates: Record): string { const src = JSON.stringify(sortByKeys(translates), null, 2); - return asModule ? `module.exports = ${src};\n` : `${src}\n`; + return `module.exports = ${src};\n`; } -/** - * http://stackoverflow.com/a/29622653/5184751 - * - * @param {object} object - * @returns {object} - */ -function sortByKeys(object: { [key: string]: any }): { [key: string]: any } { +// http://stackoverflow.com/a/29622653/5184751 +function sortByKeys>(object: T): T { return Object.keys(object) .sort() .reduce((result, key) => { + // @ts-ignore result[key] = object[key]; return result; - }, {}); + }, {} as T); } -interface ProjectInfoFile { - node_type: 'file'; - id: number; - name: string; - created: string; - last_updated: string; - last_accessed: string; - last_revision: string; -} - -interface ProjectInfoDirectory { - node_type: 'directory'; - id: number; - name: string; - files: Array; -} - -interface ProjectInfoResponse { - details: { - source_language: { - name: string; - code: string; - }; - name: string; - identifier: string; - created: string; - description: string; - join_policy: string; - last_build: string | null; - last_activity: string; - participants_count: string; // it's number, but string in the response - logo_url: string | null; - total_strings_count: string; // it's number, but string in the response - total_words_count: string; // it's number, but string in the response - duplicate_strings_count: number; - duplicate_words_count: number; - invite_url: { - translator: string; - proofreader: string; - }; - }; - languages: Array<{ - name: string; // English language name - code: string; - can_translate: 0 | 1; - can_approve: 0 | 1; - }>; - files: Array; -} - -async function pullLocales() { - const { languages }: ProjectInfoResponse = await crowdin.projectInfo( - PROJECT_ID, - ); +async function pullLocales(): Promise { + const { languages } = await crowdin.projectInfo(); return languages; } -interface LanguageStatusNode { - node_type: 'directory' | 'file'; - id: number; - name: string; - phrases: number; - translated: number; - approved: number; - words: number; - words_translated: number; - words_approved: number; - files: Array; -} - function findFile( - root: Array, + root: LanguageStatusResponse['files'], path: string, ): LanguageStatusNode | null { const [nodeToSearch, ...rest] = path.split('/'); @@ -229,14 +156,18 @@ async function pull() { ); let downloadingTotal = 0; let downloadingReady = 0; + + interface Result { + locale: ValuesType, + progress: number, + translatesFilePath: string, + } + const results = await Promise.all( - locales.map(async locale => { - const { - files, - }: { files: Array } = await crowdin.languageStatus( - PROJECT_ID, - locale.code, - ); + // TODO: there is should be some way to reimplement this + // with reduce to avoid null values + locales.map(async (locale): Promise => { + const { files } = await crowdin.languageStatus(locale.code); checkingProgressBar.tick(); const fileInfo = findFile(files, CROWDIN_FILE_PATH); @@ -261,7 +192,6 @@ async function pull() { }); const translatesFilePath = await crowdin.exportFile( - PROJECT_ID, CROWDIN_FILE_PATH, locale.code, ); @@ -291,47 +221,40 @@ async function pull() { }, }; await Promise.all( - results.map( - result => - new Promise((resolve, reject) => { - if (result === null) { - resolve(); + results + .filter((result): result is Result => result !== null) + .map(result => new Promise((resolve, reject) => { + const { + locale: { code, name }, + progress, + translatesFilePath, + } = result; + const ourCode = toInternalLocale(code); - return; - } + indexFileEntries[ourCode] = { + code: ourCode, + name: NATIVE_NAMES_MAP[ourCode] || iso639.getNativeName(ourCode), + englishName: ENGLISH_NAMES_MAP[ourCode] || name, + progress: parseFloat(progress.toFixed(1)), + isReleased: RELEASED_LOCALES.includes(ourCode), + }; - const { - locale: { code, name }, - progress, - translatesFilePath, - } = result; - const ourCode = toInternalLocale(code); - - indexFileEntries[ourCode] = { - code: ourCode, - name: NATIVE_NAMES_MAP[ourCode] || iso639.getNativeName(ourCode), - englishName: ENGLISH_NAMES_MAP[ourCode] || name, - progress: parseFloat(progress.toFixed(1)), - isReleased: RELEASED_LOCALES.includes(ourCode), - }; - - fs.copyFile( - translatesFilePath, - path.join(LANG_DIR, `${ourCode}.json`), - 0, - err => { - err ? reject(err) : resolve(); - }, - ); - }), - ), + fs.copyFile( + translatesFilePath, + path.join(LANG_DIR, `${ourCode}.json`), + 0, + err => { + err ? reject(err) : resolve(); + }, + ); + })), ); console.log('Writing an index file.'); fs.writeFileSync( path.join(LANG_DIR, INDEX_FILE_NAME), - serializeToFormattedJson(indexFileEntries, { asModule: true }), + serializeToModule(indexFileEntries), ); console.log(ch.green('The index file was successfully written')); @@ -362,12 +285,10 @@ function push() { console.log(`Publishing ${ch.bold(SOURCE_LANG)} translates file...`); await crowdin.updateFile( - PROJECT_ID, { [CROWDIN_FILE_PATH]: path.join(LANG_DIR, `${SOURCE_LANG}.json`), }, { - // eslint-disable-next-line @typescript-eslint/camelcase update_option: disapprove ? 'update_as_unapproved' : 'update_without_changes', diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 6d49004..3cd128a 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -11,12 +11,15 @@ "license": "ISC", "dependencies": { "@babel/node": "^7.8.3", + "@types/mkdirp": "^0.5.2", + "@types/progress": "^2.0.3", "chalk": "^3.0.0", - "crowdin-api": "erickskrauch/crowdin-api#add_missed_methods_and_fix_files_uploading", + "crowdin-api": "^4.0.0", "glob": "^7.1.6", "iso-639-1": "^2.1.0", "mkdirp": "^0.5.1", "multi-progress": "^2.0.0", - "prompt": "^1.0.0" + "prompt": "^1.0.0", + "utility-types": "^3.10.0" } } diff --git a/yarn.lock b/yarn.lock index 41509ec..df18eb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2787,6 +2787,11 @@ resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== +"@types/debounce@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.0.tgz#9ee99259f41018c640b3929e1bb32c3dcecdb192" + integrity sha512-bWG5wapaWgbss9E238T0R6bfo5Fh3OkeoSt245CM7JJwVwpw6MEBCbIxLq5z8KzsE3uJhzcIuQkyiZmzV3M/Dw== + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -2819,6 +2824,11 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" +"@types/intl@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@types/intl/-/intl-1.2.0.tgz#1245511f13064402087979f498764611a3c758fc" + integrity sha512-BP+KwmOvD9AR5aoxnbyyPr3fAtpjEI/bVImHsotmpuC43+z0NAmjJ9cQbX7vPCq8XcvCeAVc8E3KSQPYNaPsUQ== + "@types/invariant@^2.2.31": version "2.2.31" resolved "https://registry.yarnpkg.com/@types/invariant/-/invariant-2.2.31.tgz#4444c03004f215289dbca3856538434317dd28b2" @@ -2866,6 +2876,13 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== +"@types/mkdirp@^0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@types/mkdirp/-/mkdirp-0.5.2.tgz#503aacfe5cc2703d5484326b1b27efa67a339c1f" + integrity sha512-U5icWpv7YnZYGsN4/cmh3WD2onMY0aJIiTE6+51TwJCttdHvtCYmkBNOobHlXwrJRL0nkH9jH4kD+1FAdMN4Tg== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@^12.0.0": version "12.12.24" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.24.tgz#d4606afd8cf6c609036b854360367d1b2c78931f" @@ -2876,6 +2893,13 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/progress@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/progress/-/progress-2.0.3.tgz#7ccbd9c6d4d601319126c469e73b5bb90dfc8ccc" + integrity sha512-bPOsfCZ4tsTlKiBjBhKnM8jpY5nmIll166IPD58D92hR7G7kZDfx5iB9wGF4NfZrdKolebjeAr3GouYkSGoJ/A== + dependencies: + "@types/node" "*" + "@types/prop-types@*": version "15.7.3" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" @@ -2886,6 +2910,11 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== +"@types/raf@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.0.tgz#2b72cbd55405e071f1c4d29992638e022b20acc2" + integrity sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw== + "@types/reach__router@^1.2.3": version "1.2.6" resolved "https://registry.yarnpkg.com/@types/reach__router/-/reach__router-1.2.6.tgz#b14cf1adbd1a365d204bbf6605cd9dd7b8816c87" @@ -4156,7 +4185,7 @@ bluebird@3.7.1: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.1.tgz#df70e302b471d7473489acf26a93d63b53f874de" integrity sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg== -bluebird@^3.3.5, bluebird@^3.5.0, bluebird@^3.5.1, bluebird@^3.5.5: +bluebird@^3.3.5, bluebird@^3.5.0, bluebird@^3.5.3, bluebird@^3.5.5: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== @@ -5252,14 +5281,15 @@ cross-spawn@^7.0.0: shebang-command "^2.0.0" which "^2.0.1" -crowdin-api@erickskrauch/crowdin-api#add_missed_methods_and_fix_files_uploading: - version "2.0.3" - resolved "https://codeload.github.com/erickskrauch/crowdin-api/tar.gz/acf088542aff16e903290ebf43866494fa07e58d" +crowdin-api@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/crowdin-api/-/crowdin-api-4.0.0.tgz#faa1d0af62e97fcbdf94d7a0830db5f5ddbdb0e6" + integrity sha512-NEUMrtEvxhNjiBp68EEm0t4PGQaBxxUlKSQHy3GlgjepQffd2bxnBTx2+8LkB9wzClu2+euVV/MStQG7++tkVw== dependencies: - bluebird "^3.5.1" + bluebird "^3.5.3" request "^2.88.0" - request-promise "^4.2.2" - temp "^0.8.3" + request-promise "^4.2.4" + temp "^0.9.0" crypto-browserify@^3.11.0: version "3.12.0" @@ -12661,7 +12691,7 @@ request-promise-native@^1.0.5: stealthy-require "^1.1.1" tough-cookie "^2.3.3" -request-promise@^4.2.2: +request-promise@^4.2.4: version "4.2.5" resolved "https://registry.yarnpkg.com/request-promise/-/request-promise-4.2.5.tgz#186222c59ae512f3497dfe4d75a9c8461bd0053c" integrity sha512-ZgnepCykFdmpq86fKGwqntyTiUrHycALuGggpyCZwMvGaZWgxW6yagT0FHkgo5LzYvOaCNvxYwWYIjevSH1EDg== @@ -14083,10 +14113,10 @@ telejson@^3.2.0: lodash "^4.17.15" memoizerific "^1.11.3" -temp@^0.8.3: - version "0.8.4" - resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.4.tgz#8c97a33a4770072e0a05f919396c7665a7dd59f2" - integrity sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg== +temp@^0.9.0: + version "0.9.1" + resolved "https://registry.yarnpkg.com/temp/-/temp-0.9.1.tgz#2d666114fafa26966cd4065996d7ceedd4dd4697" + integrity sha512-WMuOgiua1xb5R56lE0eH6ivpVmg/lq2OHm4+LtT/xtEtPQ+sz6N3bBM6WZ5FvO1lO4IKIOb43qnhoc4qxP5OeA== dependencies: rimraf "~2.6.2" @@ -14721,6 +14751,11 @@ utile@0.3.x: ncp "1.0.x" rimraf "2.x.x" +utility-types@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b" + integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg== + utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" From 96049ad4adaf37c2b31647c1bb9b49efbe934a60 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 17 Jan 2020 23:37:52 +0300 Subject: [PATCH 02/12] Implemented strict mode for the project (broken tests, hundreds of @ts-ignore and new errors are included) [skip ci] --- @types/formatjs.d.ts | 2 + @types/redux-localstorage.d.ts | 25 ++ @types/unexpected.d.ts | 86 +++++ @types/webpack-loaders.d.ts | 4 +- package.json | 4 +- .../components/accounts/AccountSwitcher.tsx | 2 +- .../app/components/accounts/actions.test.ts | 13 +- packages/app/components/accounts/actions.ts | 2 +- .../accounts/actions/pure-actions.ts | 87 +++-- .../app/components/accounts/reducer.test.ts | 25 +- packages/app/components/accounts/reducer.ts | 20 +- packages/app/components/auth/BaseAuthBody.tsx | 15 +- .../app/components/auth/PanelTransition.tsx | 222 +++++++------ .../app/components/auth/RejectionLink.tsx | 20 +- ...AcceptRulesBody.js => AcceptRulesBody.tsx} | 0 packages/app/components/auth/actions.test.ts | 38 ++- packages/app/components/auth/actions.ts | 312 +++++++++++------- .../{ActivationBody.js => ActivationBody.tsx} | 9 - .../components/auth/authError/AuthError.js | 45 --- .../components/auth/authError/AuthError.tsx | 45 +++ ...seAccountBody.js => ChooseAccountBody.tsx} | 3 +- packages/app/components/auth/factory.tsx | 46 +-- .../app/components/auth/finish/Finish.tsx | 6 +- ...PasswordBody.js => ForgotPasswordBody.tsx} | 0 .../login/{LoginBody.js => LoginBody.tsx} | 4 +- .../{PasswordBody.js => PasswordBody.tsx} | 0 ...PermissionsBody.js => PermissionsBody.tsx} | 0 ...asswordBody.js => RecoverPasswordBody.tsx} | 9 - packages/app/components/auth/reducer.test.ts | 11 +- packages/app/components/auth/reducer.ts | 271 +++++++-------- .../{RegisterBody.js => RegisterBody.tsx} | 0 ...vationBody.js => ResendActivationBody.tsx} | 0 .../components/contact/ContactForm.test.tsx | 40 ++- .../app/components/contact/ContactForm.tsx | 4 +- packages/app/components/dev/apps/actions.ts | 33 +- .../apps/applicationForm/ApplicationForm.tsx | 28 +- .../ApplicationTypeSwitcher.tsx | 36 +- .../applicationForm/MinecraftServerType.tsx | 93 +++--- .../dev/apps/applicationForm/WebsiteType.tsx | 125 ++++--- packages/app/components/dev/apps/reducer.ts | 4 +- packages/app/components/i18n/IntlProvider.tsx | 20 +- packages/app/components/i18n/actions.ts | 22 +- packages/app/components/i18n/localeFlags.ts | 2 +- packages/app/components/i18n/reducer.ts | 12 +- .../languageSwitcher/LanguageList.tsx | 106 +++--- .../languageSwitcher/LanguageSwitcher.tsx | 8 +- .../languageSwitcher/LocaleItem.tsx | 16 +- .../changeLanguageLink/ChangeLanguageLink.tsx | 34 +- .../profile/changeEmail/ChangeEmail.tsx | 64 ++-- ...ssword.test.js => ChangePassword.test.tsx} | 1 + .../profile/multiFactorAuth/MfaEnable.tsx | 4 +- .../confirmation/Confirmation.tsx | 5 +- .../PasswordRequestForm.tsx | 80 +++-- packages/app/components/ui/bsod/Box.ts | 6 +- .../app/components/ui/bsod/BsodMiddleware.ts | 3 +- packages/app/components/ui/bsod/actions.ts | 12 +- packages/app/components/ui/bsod/reducer.ts | 6 +- packages/app/components/ui/form/Dropdown.tsx | 13 +- packages/app/components/ui/form/Form.tsx | 52 ++- .../app/components/ui/form/FormComponent.tsx | 4 +- packages/app/components/ui/form/FormError.tsx | 55 +-- packages/app/components/ui/form/FormModel.ts | 46 +-- .../app/components/ui/form/Input.test.tsx | 2 +- packages/app/components/ui/form/TextArea.tsx | 25 +- .../components/ui/loader/ComponentLoader.tsx | 35 +- .../app/components/ui/loader/ImageLoader.tsx | 123 ++++--- .../app/components/ui/motion/SlideMotion.tsx | 25 +- ...PopupStack.test.js => PopupStack.test.tsx} | 6 +- .../app/components/ui/popup/PopupStack.tsx | 9 +- packages/app/components/ui/popup/actions.ts | 29 +- .../{reducer.test.js => reducer.test.tsx} | 20 +- packages/app/components/ui/popup/reducer.ts | 16 +- .../components/ui/scroll/ScrollIntoView.tsx | 14 +- .../bearerHeaderMiddleware.test.ts | 33 +- .../refreshTokenMiddleware.test.ts | 100 ++++-- .../app/components/userbar/LoggedInPanel.tsx | 10 +- packages/app/i18n/index.d.ts | 13 + packages/app/index.tsx | 5 +- packages/app/package.json | 6 + packages/app/pages/404/PageNotFound.tsx | 84 ++--- packages/app/pages/auth/AuthPage.tsx | 157 ++++----- packages/app/pages/auth/SuccessOauthPage.tsx | 2 +- packages/app/pages/dev/DevPage.tsx | 58 ++-- .../app/pages/dev/UpdateApplicationPage.tsx | 2 +- .../app/pages/profile/ChangeEmailPage.tsx | 4 +- .../app/pages/profile/ChangeUsernamePage.tsx | 2 +- .../app/pages/profile/MultiFactorAuthPage.tsx | 2 +- packages/app/pages/profile/ProfilePage.tsx | 8 +- packages/app/pages/root/RootPage.tsx | 2 +- packages/app/pages/rules/RulesPage.test.tsx | 14 +- packages/app/{polyfills.js => polyfills.ts} | 6 +- packages/app/services/api/activate.test.ts | 14 +- .../app/services/api/authentication.test.ts | 60 ++-- packages/app/services/api/oauth.ts | 40 ++- packages/app/services/api/options.test.ts | 7 +- packages/app/services/api/signup.ts | 60 ++-- .../app/services/authFlow/AbstractState.ts | 13 +- .../authFlow/AcceptRulesState.test.ts | 15 +- .../app/services/authFlow/AcceptRulesState.ts | 7 +- .../services/authFlow/ActivationState.test.ts | 20 +- .../app/services/authFlow/ActivationState.ts | 9 +- .../authFlow/AuthFlow.functional.test.ts | 20 +- .../app/services/authFlow/AuthFlow.test.ts | 131 ++++++-- packages/app/services/authFlow/AuthFlow.ts | 47 +-- .../authFlow/ChooseAccountState.test.ts | 15 +- .../services/authFlow/ChooseAccountState.ts | 10 +- .../services/authFlow/CompleteState.test.ts | 33 +- .../app/services/authFlow/CompleteState.ts | 4 +- .../app/services/authFlow/FinishState.test.ts | 9 +- packages/app/services/authFlow/FinishState.ts | 3 +- .../authFlow/ForgotPasswordState.test.ts | 16 +- .../services/authFlow/ForgotPasswordState.ts | 11 +- .../app/services/authFlow/LoginState.test.ts | 16 +- packages/app/services/authFlow/LoginState.ts | 8 +- .../app/services/authFlow/MfaState.test.ts | 16 +- packages/app/services/authFlow/MfaState.ts | 7 +- .../app/services/authFlow/OAuthState.test.ts | 15 +- packages/app/services/authFlow/OAuthState.ts | 3 +- .../services/authFlow/PasswordState.test.ts | 16 +- .../app/services/authFlow/PasswordState.ts | 8 +- .../authFlow/PermissionsState.test.ts | 10 +- .../app/services/authFlow/PermissionsState.ts | 9 +- .../authFlow/RecoverPasswordState.test.ts | 18 +- .../services/authFlow/RecoverPasswordState.ts | 12 +- .../services/authFlow/RegisterState.test.ts | 18 +- .../app/services/authFlow/RegisterState.ts | 10 +- .../authFlow/ResendActivationState.test.ts | 20 +- .../authFlow/ResendActivationState.ts | 11 +- packages/app/services/authFlow/helpers.ts | 36 +- packages/app/services/authFlow/index.ts | 4 +- .../{errorsDict.js => errorsDict.tsx} | 115 +++---- packages/app/services/errorsDict/index.js | 3 - packages/app/services/errorsDict/index.ts | 1 + .../app/services/{history.js => history.ts} | 6 +- packages/app/services/loader.js | 29 -- packages/app/services/loader.ts | 27 ++ packages/app/services/localStorage.ts | 15 +- .../logger/{abbreviate.js => abbreviate.ts} | 50 ++- packages/app/services/logger/logger.ts | 4 +- .../services/request/InternalServerError.ts | 12 +- .../request/PromiseMiddlewareLayer.test.ts | 7 +- .../request/PromiseMiddlewareLayer.ts | 10 +- .../services/request/RequestAbortedError.ts | 22 +- packages/app/services/request/request.test.ts | 46 ++- packages/app/services/request/request.ts | 8 +- .../app/test/{unexpected.js => unexpected.ts} | 0 .../scripts/{build-dll.js => build-dll.ts} | 23 +- packages/scripts/package.json | 3 + tsconfig.json | 4 +- webpack.dll.config.js | 1 + yarn.lock | 100 +++++- 151 files changed, 2470 insertions(+), 1869 deletions(-) create mode 100644 @types/formatjs.d.ts create mode 100644 @types/redux-localstorage.d.ts create mode 100644 @types/unexpected.d.ts rename packages/app/components/auth/acceptRules/{AcceptRulesBody.js => AcceptRulesBody.tsx} (100%) rename packages/app/components/auth/activation/{ActivationBody.js => ActivationBody.tsx} (89%) delete mode 100644 packages/app/components/auth/authError/AuthError.js create mode 100644 packages/app/components/auth/authError/AuthError.tsx rename packages/app/components/auth/chooseAccount/{ChooseAccountBody.js => ChooseAccountBody.tsx} (92%) rename packages/app/components/auth/forgotPassword/{ForgotPasswordBody.js => ForgotPasswordBody.tsx} (100%) rename packages/app/components/auth/login/{LoginBody.js => LoginBody.tsx} (85%) rename packages/app/components/auth/password/{PasswordBody.js => PasswordBody.tsx} (100%) rename packages/app/components/auth/permissions/{PermissionsBody.js => PermissionsBody.tsx} (100%) rename packages/app/components/auth/recoverPassword/{RecoverPasswordBody.js => RecoverPasswordBody.tsx} (92%) rename packages/app/components/auth/register/{RegisterBody.js => RegisterBody.tsx} (100%) rename packages/app/components/auth/resendActivation/{ResendActivationBody.js => ResendActivationBody.tsx} (100%) rename packages/app/components/profile/changePassword/{ChangePassword.test.js => ChangePassword.test.tsx} (97%) rename packages/app/components/ui/popup/{PopupStack.test.js => PopupStack.test.tsx} (96%) rename packages/app/components/ui/popup/{reducer.test.js => reducer.test.tsx} (86%) create mode 100644 packages/app/i18n/index.d.ts rename packages/app/{polyfills.js => polyfills.ts} (76%) rename packages/app/services/errorsDict/{errorsDict.js => errorsDict.tsx} (68%) delete mode 100644 packages/app/services/errorsDict/index.js create mode 100644 packages/app/services/errorsDict/index.ts rename packages/app/services/{history.js => history.ts} (83%) delete mode 100644 packages/app/services/loader.js create mode 100644 packages/app/services/loader.ts rename packages/app/services/logger/{abbreviate.js => abbreviate.ts} (78%) rename packages/app/test/{unexpected.js => unexpected.ts} (100%) rename packages/scripts/{build-dll.js => build-dll.ts} (69%) diff --git a/@types/formatjs.d.ts b/@types/formatjs.d.ts new file mode 100644 index 0000000..273fbd7 --- /dev/null +++ b/@types/formatjs.d.ts @@ -0,0 +1,2 @@ +declare module '@formatjs/intl-pluralrules/polyfill' {} +declare module '@formatjs/intl-relativetimeformat/polyfill' {} diff --git a/@types/redux-localstorage.d.ts b/@types/redux-localstorage.d.ts new file mode 100644 index 0000000..07130b3 --- /dev/null +++ b/@types/redux-localstorage.d.ts @@ -0,0 +1,25 @@ +// https://github.com/elgerlambert/redux-localstorage/issues/78#issuecomment-323609784 + +// import * as Redux from 'redux'; + +declare module 'redux-localstorage' { + export interface ConfigRS { + key: string; + merge?: any; + slicer?: any; + serialize?: ( + value: any, + replacer?: (key: string, value: any) => any, + space?: string | number, + ) => string; + deserialize?: ( + text: string, + reviver?: (key: any, value: any) => any, + ) => any; + } + + export default function persistState( + paths: string | string[], + config: ConfigRS, + ): () => any; +} diff --git a/@types/unexpected.d.ts b/@types/unexpected.d.ts new file mode 100644 index 0000000..491d67c --- /dev/null +++ b/@types/unexpected.d.ts @@ -0,0 +1,86 @@ +declare module 'unexpected' { + namespace unexpected { + interface EnchantedPromise extends Promise { + and = []>( + assertionName: string, + subject: unknown, + ...args: A + ): EnchantedPromise; + } + + interface Expect { + /** + * @see http://unexpected.js.org/api/expect/ + */ + = []>( + subject: unknown, + assertionName: string, + ...args: A + ): EnchantedPromise; + + it = []>( + assertionName: string, + subject?: unknown, + ...args: A + ): EnchantedPromise; + + /** + * @see http://unexpected.js.org/api/clone/ + */ + clone(): this; + + /** + * @see http://unexpected.js.org/api/addAssertion/ + */ + addAssertion = []>( + pattern: string, + handler: (expect: Expect, subject: T, ...args: A) => void, + ): this; + + /** + * @see http://unexpected.js.org/api/addType/ + */ + addType(typeDefinition: unexpected.TypeDefinition): this; + + /** + * @see http://unexpected.js.org/api/fail/ + */ + fail = []>(format: string, ...args: A): void; + fail(error: E): void; + + /** + * @see http://unexpected.js.org/api/freeze/ + */ + freeze(): this; + + /** + * @see http://unexpected.js.org/api/use/ + */ + use(plugin: unexpected.PluginDefinition): this; + } + + interface PluginDefinition { + name?: string; + version?: string; + dependencies?: Array; + installInto(expect: Expect): void; + } + + interface TypeDefinition { + name: string; + identify(value: unknown): value is T; + base?: string; + equal?(a: T, b: T, equal: (a: unknown, b: unknown) => boolean): boolean; + inspect?( + value: T, + depth: number, + output: any, + inspect: (value: unknown, depth: number) => any, + ): void; + } + } + + const unexpected: unexpected.Expect; + + export = unexpected; +} diff --git a/@types/webpack-loaders.d.ts b/@types/webpack-loaders.d.ts index 2948cb9..b198f9a 100644 --- a/@types/webpack-loaders.d.ts +++ b/@types/webpack-loaders.d.ts @@ -26,9 +26,7 @@ declare module '*.jpg' { declare module '*.intl.json' { import { MessageDescriptor } from 'react-intl'; - const descriptor: { - [key: string]: MessageDescriptor; - }; + const descriptor: Record; export = descriptor; } diff --git a/package.json b/package.json index e6960cc..62c2c66 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "license": "Apache-2.0", "repository": "https://github.com/elyby/accounts-frontend", "engines": { - "node": ">=10.0.0" + "node": ">=10.0.0", + "yarn": "1.19.1" }, "workspaces": [ "packages/*", @@ -124,6 +125,7 @@ "@storybook/addons": "^5.3.4", "@storybook/react": "^5.3.4", "@types/jest": "^24.9.0", + "@types/sinon": "^7.5.1", "@typescript-eslint/eslint-plugin": "^2.16.0", "@typescript-eslint/parser": "^2.16.0", "babel-loader": "^8.0.0", diff --git a/packages/app/components/accounts/AccountSwitcher.tsx b/packages/app/components/accounts/AccountSwitcher.tsx index a30e056..a5405ce 100644 --- a/packages/app/components/accounts/AccountSwitcher.tsx +++ b/packages/app/components/accounts/AccountSwitcher.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import clsx from 'clsx'; import { Link } from 'react-router-dom'; import { FormattedMessage as Message } from 'react-intl'; -import loader from 'app/services/loader'; +import * as loader from 'app/services/loader'; import { SKIN_DARK, COLOR_WHITE, Skin } from 'app/components/ui'; import { Button } from 'app/components/ui/form'; import { authenticate, revoke } from 'app/components/accounts/actions'; diff --git a/packages/app/components/accounts/actions.test.ts b/packages/app/components/accounts/actions.test.ts index 11a16e7..185f62a 100644 --- a/packages/app/components/accounts/actions.test.ts +++ b/packages/app/components/accounts/actions.test.ts @@ -12,13 +12,10 @@ import { } from 'app/components/accounts/actions'; import { add, - ADD, activate, - ACTIVATE, remove, reset, } from 'app/components/accounts/actions/pure-actions'; -import { SET_LOCALE } from 'app/components/i18n/actions'; import { updateUser, setUser } from 'app/components/user/actions'; import { setLogin, setAccountSwitcher } from 'app/components/auth/actions'; import { Dispatch, RootState } from 'app/reducers'; @@ -124,20 +121,20 @@ describe('components/accounts/actions', () => { ]), )); - it(`dispatches ${ADD} action`, () => + it(`dispatches accounts:add action`, () => authenticate(account)(dispatch, getState, undefined).then(() => expect(dispatch, 'to have a call satisfying', [add(account)]), )); - it(`dispatches ${ACTIVATE} action`, () => + it(`dispatches accounts:activate action`, () => authenticate(account)(dispatch, getState, undefined).then(() => expect(dispatch, 'to have a call satisfying', [activate(account)]), )); - it(`dispatches ${SET_LOCALE} action`, () => + it(`dispatches i18n:setLocale action`, () => authenticate(account)(dispatch, getState, undefined).then(() => expect(dispatch, 'to have a call satisfying', [ - { type: SET_LOCALE, payload: { locale: 'be' } }, + { type: 'i18n:setLocale', payload: { locale: 'be' } }, ]), )); @@ -479,7 +476,7 @@ describe('components/accounts/actions', () => { const key = `stranger${foreignAccount.id}`; beforeEach(() => { - sessionStorage.setItem(key, 1); + sessionStorage.setItem(key, '1'); logoutStrangers()(dispatch, getState, undefined); }); diff --git a/packages/app/components/accounts/actions.ts b/packages/app/components/accounts/actions.ts index 1c2c5d1..5962d58 100644 --- a/packages/app/components/accounts/actions.ts +++ b/packages/app/components/accounts/actions.ts @@ -90,7 +90,7 @@ export function authenticate( if (!newRefreshToken) { // mark user as stranger (user does not want us to remember his account) - sessionStorage.setItem(`stranger${newAccount.id}`, 1); + sessionStorage.setItem(`stranger${newAccount.id}`, '1'); } if (auth && auth.oauth && auth.oauth.clientId) { diff --git a/packages/app/components/accounts/actions/pure-actions.ts b/packages/app/components/accounts/actions/pure-actions.ts index 3859f5c..c65d464 100644 --- a/packages/app/components/accounts/actions/pure-actions.ts +++ b/packages/app/components/accounts/actions/pure-actions.ts @@ -1,78 +1,67 @@ -import { - Account, - AddAction, - RemoveAction, - ActivateAction, - UpdateTokenAction, - ResetAction, -} from '../reducer'; +import { Action as ReduxAction } from 'redux'; +import { Account } from 'app/components/accounts/reducer'; + +interface AddAction extends ReduxAction { + type: 'accounts:add'; + payload: Account; +} -export const ADD = 'accounts:add'; -/** - * @private - * - * @param {Account} account - * - * @returns {object} - action definition - */ export function add(account: Account): AddAction { return { - type: ADD, + type: 'accounts:add', payload: account, }; } -export const REMOVE = 'accounts:remove'; -/** - * @private - * - * @param {Account} account - * - * @returns {object} - action definition - */ +interface RemoveAction extends ReduxAction { + type: 'accounts:remove'; + payload: Account; +} + export function remove(account: Account): RemoveAction { return { - type: REMOVE, + type: 'accounts:remove', payload: account, }; } -export const ACTIVATE = 'accounts:activate'; -/** - * @private - * - * @param {Account} account - * - * @returns {object} - action definition - */ +interface ActivateAction extends ReduxAction { + type: 'accounts:activate'; + payload: Account; +} + export function activate(account: Account): ActivateAction { return { - type: ACTIVATE, + type: 'accounts:activate', payload: account, }; } -export const RESET = 'accounts:reset'; -/** - * @private - * - * @returns {object} - action definition - */ +interface ResetAction extends ReduxAction { + type: 'accounts:reset'; +} + export function reset(): ResetAction { return { - type: RESET, + type: 'accounts:reset', }; } -export const UPDATE_TOKEN = 'accounts:updateToken'; -/** - * @param {string} token - * - * @returns {object} - action definition - */ +interface UpdateTokenAction extends ReduxAction { + type: 'accounts:updateToken'; + payload: string; +} + export function updateToken(token: string): UpdateTokenAction { return { - type: UPDATE_TOKEN, + type: 'accounts:updateToken', payload: token, }; } + +export type Action = + | AddAction + | RemoveAction + | ActivateAction + | ResetAction + | UpdateTokenAction; diff --git a/packages/app/components/accounts/reducer.test.ts b/packages/app/components/accounts/reducer.test.ts index c4f5fa9..4ec1c47 100644 --- a/packages/app/components/accounts/reducer.test.ts +++ b/packages/app/components/accounts/reducer.test.ts @@ -1,17 +1,8 @@ import expect from 'app/test/unexpected'; import { updateToken } from './actions'; -import { - add, - remove, - activate, - reset, - ADD, - REMOVE, - ACTIVATE, - UPDATE_TOKEN, - RESET, -} from './actions/pure-actions'; +import { add, remove, activate, reset } from './actions/pure-actions'; +import { AccountsState } from './index'; import accounts, { Account } from './reducer'; const account: Account = { @@ -22,7 +13,7 @@ const account: Account = { } as Account; describe('Accounts reducer', () => { - let initial; + let initial: AccountsState; beforeEach(() => { initial = accounts(undefined, {} as any); @@ -39,7 +30,7 @@ describe('Accounts reducer', () => { state: 'foo', })); - describe(ACTIVATE, () => { + describe('accounts:activate', () => { it('sets active account', () => { expect(accounts(initial, activate(account)), 'to satisfy', { active: account.id, @@ -47,7 +38,7 @@ describe('Accounts reducer', () => { }); }); - describe(ADD, () => { + describe('accounts:add', () => { it('adds an account', () => expect(accounts(initial, add(account)), 'to satisfy', { available: [account], @@ -106,7 +97,7 @@ describe('Accounts reducer', () => { }); }); - describe(REMOVE, () => { + describe('accounts:remove', () => { it('should remove an account', () => expect( accounts({ ...initial, available: [account] }, remove(account)), @@ -128,7 +119,7 @@ describe('Accounts reducer', () => { }); }); - describe(RESET, () => { + describe('actions:reset', () => { it('should reset accounts state', () => expect( accounts({ ...initial, available: [account] }, reset()), @@ -137,7 +128,7 @@ describe('Accounts reducer', () => { )); }); - describe(UPDATE_TOKEN, () => { + describe('accounts:updateToken', () => { it('should update token', () => { const newToken = 'newToken'; diff --git a/packages/app/components/accounts/reducer.ts b/packages/app/components/accounts/reducer.ts index c18bd16..389f79e 100644 --- a/packages/app/components/accounts/reducer.ts +++ b/packages/app/components/accounts/reducer.ts @@ -1,3 +1,5 @@ +import { Action } from './actions/pure-actions'; + export type Account = { id: number; username: string; @@ -8,25 +10,9 @@ export type Account = { export type State = { active: number | null; - available: Account[]; + available: Array; }; -export type AddAction = { type: 'accounts:add'; payload: Account }; -export type RemoveAction = { type: 'accounts:remove'; payload: Account }; -export type ActivateAction = { type: 'accounts:activate'; payload: Account }; -export type UpdateTokenAction = { - type: 'accounts:updateToken'; - payload: string; -}; -export type ResetAction = { type: 'accounts:reset' }; - -type Action = - | AddAction - | RemoveAction - | ActivateAction - | UpdateTokenAction - | ResetAction; - export function getActiveAccount(state: { accounts: State }): Account | null { const accountId = state.accounts.active; diff --git a/packages/app/components/auth/BaseAuthBody.tsx b/packages/app/components/auth/BaseAuthBody.tsx index 07228ee..7692663 100644 --- a/packages/app/components/auth/BaseAuthBody.tsx +++ b/packages/app/components/auth/BaseAuthBody.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; + import AuthError from 'app/components/auth/authError/AuthError'; import { FormModel } from 'app/components/ui/form'; -import { RouteComponentProps } from 'react-router-dom'; import Context, { AuthContext } from './Context'; @@ -11,7 +12,7 @@ import Context, { AuthContext } from './Context'; class BaseAuthBody extends React.Component< // TODO: this may be converted to generic type RouteComponentProps - RouteComponentProps<{ [key: string]: any }> + RouteComponentProps> > { static contextType = Context; /* TODO: use declare */ context: React.ContextType; @@ -32,10 +33,14 @@ class BaseAuthBody extends React.Component< this.prevErrors = this.context.auth.error; } - renderErrors() { + renderErrors(): ReactNode { const error = this.form.getFirstError(); - return error && ; + if (error === null) { + return null; + } + + return ; } onFormSubmit() { diff --git a/packages/app/components/auth/PanelTransition.tsx b/packages/app/components/auth/PanelTransition.tsx index 5e1a88e..bd2fddb 100644 --- a/packages/app/components/auth/PanelTransition.tsx +++ b/packages/app/components/auth/PanelTransition.tsx @@ -1,8 +1,15 @@ -import React from 'react'; +import React, { CSSProperties, MouseEventHandler, ReactElement, ReactNode } from 'react'; import { AccountsState } from 'app/components/accounts'; import { User } from 'app/components/user'; import { connect } from 'react-redux'; -import { TransitionMotion, spring } from 'react-motion'; +import { + TransitionMotion, + spring, + PlainStyle, + Style, + TransitionStyle, + TransitionPlainStyle, +} from 'react-motion'; import { Panel, PanelBody, @@ -44,7 +51,7 @@ type PanelId = string; * - Panel index defines the direction of X transition of both panels * (e.g. the panel with lower index will slide from left side, and with greater from right side) */ -const contexts: Array = [ +const contexts: Array> = [ ['login', 'password', 'forgotPassword', 'mfa', 'recoverPassword'], ['register', 'activation', 'resendActivation'], ['acceptRules'], @@ -70,40 +77,41 @@ if (process.env.NODE_ENV !== 'production') { }); return acc; - }, {}); + }, {} as Record>); } type ValidationError = | string | { type: string; - payload: { [key: string]: any }; + payload: Record; }; -type AnimationProps = { +interface AnimationStyle extends PlainStyle { opacitySpring: number; transformSpring: number; -}; +} -type AnimationContext = { +interface AnimationData { + Title: ReactElement; + Body: ReactElement; + Footer: ReactElement; + Links: ReactNode; + hasBackButton: boolean | ((props: Props) => boolean); +} + +interface AnimationContext extends TransitionPlainStyle { key: PanelId; - style: AnimationProps; - data: { - Title: React.ReactElement; - Body: React.ReactElement; - Footer: React.ReactElement; - Links: React.ReactElement; - hasBackButton: boolean | ((props: Props) => boolean); - }; -}; + style: AnimationStyle; + data: AnimationData; +} -type OwnProps = { - Title: React.ReactElement; - Body: React.ReactElement; - Footer: React.ReactElement; - Links: React.ReactElement; - children?: React.ReactElement; -}; +interface OwnProps { + Title: ReactElement; + Body: ReactElement; + Footer: ReactElement; + Links: ReactNode; +} interface Props extends OwnProps { // context props @@ -114,17 +122,18 @@ interface Props extends OwnProps { resolve: () => void; reject: () => void; - setErrors: (errors: { [key: string]: ValidationError }) => void; + setErrors: (errors: Record) => void; } -type State = { +interface State { contextHeight: number; panelId: PanelId | void; prevPanelId: PanelId | void; isHeightDirty: boolean; forceHeight: 1 | 0; direction: 'X' | 'Y'; -}; + formsHeights: Record; +} class PanelTransition extends React.PureComponent { state: State = { @@ -134,16 +143,17 @@ class PanelTransition extends React.PureComponent { forceHeight: 0 as const, direction: 'X' as const, prevPanelId: undefined, + formsHeights: {}, }; isHeightMeasured: boolean = false; wasAutoFocused: boolean = false; - body: null | { + body: { autoFocus: () => void; onFormSubmit: () => void; - } = null; + } | null = null; - timerIds: NodeJS.Timeout[] = []; // this is a list of a probably running timeouts to clean on unmount + timerIds: Array = []; // this is a list of a probably running timeouts to clean on unmount componentDidUpdate(prevProps: Props) { const nextPanel: PanelId = @@ -166,7 +176,8 @@ class PanelTransition extends React.PureComponent { if (forceHeight) { this.timerIds.push( - setTimeout(() => { + // https://stackoverflow.com/a/51040768/5184751 + window.setTimeout(() => { this.setState({ forceHeight: 0 }); }, 100), ); @@ -208,7 +219,7 @@ class PanelTransition extends React.PureComponent { hasGoBack: boolean; } = Body.type as any; - const formHeight = this.state[`formHeight${panelId}`] || 0; + const formHeight = this.state.formsHeights[panelId] || 0; // a hack to disable height animation on first render const { isHeightMeasured } = this; @@ -310,7 +321,7 @@ class PanelTransition extends React.PureComponent { ); } - onFormSubmit = () => { + onFormSubmit = (): void => { this.props.clearErrors(); if (this.body) { @@ -318,29 +329,28 @@ class PanelTransition extends React.PureComponent { } }; - onFormInvalid = (errors: { [key: string]: ValidationError }) => + onFormInvalid = (errors: Record): void => this.props.setErrors(errors); - willEnter = (config: AnimationContext) => this.getTransitionStyles(config); - willLeave = (config: AnimationContext) => - this.getTransitionStyles(config, { isLeave: true }); + willEnter = (config: TransitionStyle): PlainStyle => { + const transform = this.getTransformForPanel(config.key); - /** - * @param {object} config - * @param {string} config.key - * @param {object} [options] - * @param {object} [options.isLeave=false] - true, if this is a leave transition - * - * @returns {object} - */ - getTransitionStyles( - { key }: AnimationContext, - options: { isLeave?: boolean } = {}, - ): { - transformSpring: number; - opacitySpring: number; - } { - const { isLeave = false } = options; + return { + transformSpring: transform, + opacitySpring: 1, + }; + }; + + willLeave = (config: TransitionStyle): Style => { + const transform = this.getTransformForPanel(config.key); + + return { + transformSpring: spring(transform, transformSpringConfig), + opacitySpring: spring(0, opacitySpringConfig), + }; + }; + + getTransformForPanel(key: PanelId): number { const { panelId, prevPanelId } = this.state; const fromLeft = -1; @@ -363,14 +373,7 @@ class PanelTransition extends React.PureComponent { sign *= -1; } - const transform = sign * 100; - - return { - transformSpring: isLeave - ? spring(transform, transformSpringConfig) - : transform, - opacitySpring: isLeave ? spring(0, opacitySpringConfig) : 1, - }; + return sign * 100; } getDirection(next: PanelId, prev: PanelId): 'X' | 'Y' { @@ -383,24 +386,23 @@ class PanelTransition extends React.PureComponent { return context.includes(next) ? 'X' : 'Y'; } - onUpdateHeight = (height: number, key: PanelId) => { - const heightKey = `formHeight${key}`; - - // @ts-ignore + onUpdateHeight = (height: number, key: PanelId): void => { this.setState({ - [heightKey]: height, + formsHeights: { + ...this.state.formsHeights, + [key]: height, + }, }); }; - onUpdateContextHeight = (height: number) => { + onUpdateContextHeight = (height: number): void => { this.setState({ contextHeight: height, }); }; - onGoBack = (event: React.MouseEvent) => { + onGoBack: MouseEventHandler = (event): void => { event.preventDefault(); - authFlow.goBack(); }; @@ -409,7 +411,7 @@ class PanelTransition extends React.PureComponent { * * @param {number} length number of panels transitioned */ - tryToAutoFocus(length: number) { + tryToAutoFocus(length: number): void { if (!this.body) { return; } @@ -425,20 +427,17 @@ class PanelTransition extends React.PureComponent { } } - shouldMeasureHeight() { + shouldMeasureHeight(): string { const { user, accounts, auth } = this.props; const { isHeightDirty } = this.state; - const errorString = Object.values(auth.error || {}).reduce( - (acc: string, item: ValidationError): string => { - if (typeof item === 'string') { - return acc + item; - } + const errorString = Object.values(auth.error || {}).reduce((acc, item) => { + if (typeof item === 'string') { + return acc + item; + } - return acc + item.type; - }, - '', - ) as string; + return acc + item.type; + }, '') as string; return [ errorString, @@ -448,9 +447,9 @@ class PanelTransition extends React.PureComponent { ].join(''); } - getHeader({ key, style, data }: AnimationContext) { - const { Title } = data; - const { transformSpring } = style; + getHeader({ key, style, data }: TransitionPlainStyle): ReactElement { + const { Title } = data as AnimationData; + const { transformSpring } = (style as unknown) as AnimationStyle; let { hasBackButton } = data; @@ -459,7 +458,10 @@ class PanelTransition extends React.PureComponent { } const transitionStyle = { - ...this.getDefaultTransitionStyles(key, style), + ...this.getDefaultTransitionStyles( + key, + (style as unknown) as AnimationStyle, + ), opacity: 1, // reset default }; @@ -491,15 +493,12 @@ class PanelTransition extends React.PureComponent { ); } - getBody({ key, style, data }: AnimationContext) { - const { Body } = data; - const { transformSpring } = style; + getBody({ key, style, data }: TransitionPlainStyle): ReactElement { + const { Body } = data as AnimationData; + const { transformSpring } = (style as unknown) as AnimationStyle; const { direction } = this.state; - let transform: { [key: string]: string } = this.translate( - transformSpring, - direction, - ); + let transform = this.translate(transformSpring, direction); let verticalOrigin = 'top'; if (direction === 'Y') { @@ -507,8 +506,11 @@ class PanelTransition extends React.PureComponent { transform = {}; } - const transitionStyle = { - ...this.getDefaultTransitionStyles(key, style), + const transitionStyle: CSSProperties = { + ...this.getDefaultTransitionStyles( + key, + (style as unknown) as AnimationStyle, + ), top: 'auto', // reset default [verticalOrigin]: 0, ...transform, @@ -522,6 +524,7 @@ class PanelTransition extends React.PureComponent { onMeasure={height => this.onUpdateHeight(height, key)} > {React.cloneElement(Body, { + // @ts-ignore ref: body => { this.body = body; }, @@ -530,10 +533,13 @@ class PanelTransition extends React.PureComponent { ); } - getFooter({ key, style, data }: AnimationContext) { - const { Footer } = data; + getFooter({ key, style, data }: TransitionPlainStyle): ReactElement { + const { Footer } = data as AnimationData; - const transitionStyle = this.getDefaultTransitionStyles(key, style); + const transitionStyle = this.getDefaultTransitionStyles( + key, + (style as unknown) as AnimationStyle, + ); return (
@@ -542,10 +548,13 @@ class PanelTransition extends React.PureComponent { ); } - getLinks({ key, style, data }: AnimationContext) { - const { Links } = data; + getLinks({ key, style, data }: TransitionPlainStyle): ReactElement { + const { Links } = data as AnimationData; - const transitionStyle = this.getDefaultTransitionStyles(key, style); + const transitionStyle = this.getDefaultTransitionStyles( + key, + (style as unknown) as AnimationStyle, + ); return (
@@ -554,16 +563,9 @@ class PanelTransition extends React.PureComponent { ); } - /** - * @param {string} key - * @param {object} style - * @param {number} style.opacitySpring - * - * @returns {object} - */ getDefaultTransitionStyles( key: string, - { opacitySpring }: Readonly, + { opacitySpring }: Readonly, ): { position: 'absolute'; top: number; @@ -582,7 +584,11 @@ class PanelTransition extends React.PureComponent { }; } - translate(value: number, direction: 'X' | 'Y' = 'X', unit: '%' | 'px' = '%') { + translate( + value: number, + direction: 'X' | 'Y' = 'X', + unit: '%' | 'px' = '%', + ): CSSProperties { return { WebkitTransform: `translate${direction}(${value}${unit})`, transform: `translate${direction}(${value}${unit})`, diff --git a/packages/app/components/auth/RejectionLink.tsx b/packages/app/components/auth/RejectionLink.tsx index 78f1a76..f63382d 100644 --- a/packages/app/components/auth/RejectionLink.tsx +++ b/packages/app/components/auth/RejectionLink.tsx @@ -1,20 +1,22 @@ -import React, { useContext } from 'react'; +import React, { ComponentType, useContext } from 'react'; import { FormattedMessage as Message, MessageDescriptor } from 'react-intl'; import Context, { AuthContext } from './Context'; interface Props { isAvailable?: (context: AuthContext) => boolean; - payload?: { [key: string]: any }; + payload?: Record; label: MessageDescriptor; } -export type RejectionLinkProps = Props; - -function RejectionLink(props: Props) { +const RejectionLink: ComponentType = ({ + isAvailable, + payload, + label, +}) => { const context = useContext(Context); - if (props.isAvailable && !props.isAvailable(context)) { + if (isAvailable && !isAvailable(context)) { // TODO: if want to properly support multiple links, we should control // the dividers ' | ' rendered from factory too return null; @@ -26,12 +28,12 @@ function RejectionLink(props: Props) { onClick={event => { event.preventDefault(); - context.reject(props.payload); + context.reject(payload); }} > - + ); -} +}; export default RejectionLink; diff --git a/packages/app/components/auth/acceptRules/AcceptRulesBody.js b/packages/app/components/auth/acceptRules/AcceptRulesBody.tsx similarity index 100% rename from packages/app/components/auth/acceptRules/AcceptRulesBody.js rename to packages/app/components/auth/acceptRules/AcceptRulesBody.tsx diff --git a/packages/app/components/auth/actions.test.ts b/packages/app/components/auth/actions.test.ts index 8fa5bc0..2374da5 100644 --- a/packages/app/components/auth/actions.test.ts +++ b/packages/app/components/auth/actions.test.ts @@ -1,3 +1,4 @@ +import { Action as ReduxAction } from 'redux'; import sinon from 'sinon'; import expect from 'app/test/unexpected'; @@ -15,33 +16,36 @@ import { login, setLogin, } from 'app/components/auth/actions'; +import { OauthData, OAuthValidateResponse } from '../../services/api/oauth'; -const oauthData = { +const oauthData: OauthData = { clientId: '', redirectUrl: '', responseType: '', scope: '', state: '', + prompt: 'none', }; describe('components/auth/actions', () => { const dispatch = sinon.stub().named('store.dispatch'); const getState = sinon.stub().named('store.getState'); - function callThunk(fn, ...args) { + function callThunk, F extends (...args: A) => any>( + fn: F, + ...args: A + ): Promise { const thunk = fn(...args); return thunk(dispatch, getState); } - function expectDispatchCalls(calls) { - expect( - dispatch, - 'to have calls satisfying', - [[setLoadingState(true)]] - .concat(calls) - .concat([[setLoadingState(false)]]), - ); + function expectDispatchCalls(calls: Array>) { + expect(dispatch, 'to have calls satisfying', [ + [setLoadingState(true)], + ...calls, + [setLoadingState(false)], + ]); } beforeEach(() => { @@ -58,14 +62,20 @@ describe('components/auth/actions', () => { }); describe('#oAuthValidate()', () => { - let resp; + let resp: OAuthValidateResponse; beforeEach(() => { resp = { - client: { id: 123 }, - oAuth: { state: 123 }, + client: { + id: '123', + name: '', + description: '', + }, + oAuth: { + state: 123, + }, session: { - scopes: ['scopes'], + scopes: ['account_info'], }, }; diff --git a/packages/app/components/auth/actions.ts b/packages/app/components/auth/actions.ts index 38048ba..23fde96 100644 --- a/packages/app/components/auth/actions.ts +++ b/packages/app/components/auth/actions.ts @@ -1,7 +1,8 @@ +import { Action as ReduxAction } from 'redux'; import { browserHistory } from 'app/services/history'; import logger from 'app/services/logger'; import localStorage from 'app/services/localStorage'; -import loader from 'app/services/loader'; +import * as loader from 'app/services/loader'; import history from 'app/services/history'; import { updateUser, @@ -15,22 +16,27 @@ import { recoverPassword as recoverPasswordEndpoint, OAuthResponse, } from 'app/services/api/authentication'; -import oauth, { OauthData, Client, Scope } from 'app/services/api/oauth'; -import signup from 'app/services/api/signup'; +import oauth, { OauthData, Scope } from 'app/services/api/oauth'; +import { + register as registerEndpoint, + activate as activateEndpoint, + resendActivation as resendActivationEndpoint, +} from 'app/services/api/signup'; import dispatchBsod from 'app/components/ui/bsod/dispatchBsod'; import { create as createPopup } from 'app/components/ui/popup/actions'; import ContactForm from 'app/components/contact/ContactForm'; import { Account } from 'app/components/accounts/reducer'; import { ThunkAction, Dispatch } from 'app/reducers'; +import { Resp } from 'app/services/request'; -import { getCredentials } from './reducer'; +import { Credentials, Client, OAuthState, getCredentials } from './reducer'; -type ValidationError = - | string - | { - type: string; - payload: { [key: string]: any }; - }; +interface ValidationErrorLiteral { + type: string; + payload: Record; +} + +type ValidationError = string | ValidationErrorLiteral; /** * Routes user to the previous page if it is possible @@ -169,16 +175,15 @@ export function register({ rulesAgreement: boolean; }) { return wrapInLoader((dispatch, getState) => - signup - .register({ - email, - username, - password, - rePassword, - rulesAgreement, - lang: getState().user.lang, - captcha, - }) + registerEndpoint({ + email, + username, + password, + rePassword, + rulesAgreement, + lang: getState().user.lang, + captcha, + }) .then(() => { dispatch( updateUser({ @@ -201,8 +206,7 @@ export function activate({ key: string; }): ThunkAction> { return wrapInLoader(dispatch => - signup - .activate({ key }) + activateEndpoint(key) .then(authHandler(dispatch)) .catch(validationErrorsHandler(dispatch, '/resend-activation')), ); @@ -216,8 +220,7 @@ export function resendActivation({ captcha: string; }) { return wrapInLoader(dispatch => - signup - .resendActivation({ email, captcha }) + resendActivationEndpoint(email, captcha) .then(resp => { dispatch( updateUser({ @@ -235,25 +238,26 @@ export function contactUs() { return createPopup({ Popup: ContactForm }); } -export const SET_CREDENTIALS = 'auth:setCredentials'; +interface SetCredentialsAction extends ReduxAction { + type: 'auth:setCredentials'; + payload: Credentials | null; +} + +function setCredentials(payload: Credentials | null): SetCredentialsAction { + return { + type: 'auth:setCredentials', + payload, + }; +} + /** * Sets login in credentials state - * * Resets the state, when `null` is passed * - * @param {string|null} login - * - * @returns {object} + * @param login */ -export function setLogin(login: string | null) { - return { - type: SET_CREDENTIALS, - payload: login - ? { - login, - } - : null, - }; +export function setLogin(login: string | null): SetCredentialsAction { + return setCredentials(login ? { login } : null); } export function relogin(login: string | null): ThunkAction { @@ -262,19 +266,20 @@ export function relogin(login: string | null): ThunkAction { const returnUrl = credentials.returnUrl || location.pathname + location.search; - dispatch({ - type: SET_CREDENTIALS, - payload: { + dispatch( + setCredentials({ login, returnUrl, isRelogin: true, - }, - }); + }), + ); browserHistory.push('/login'); }; } +export type CredentialsAction = SetCredentialsAction; + function requestTotp({ login, password, @@ -288,41 +293,55 @@ function requestTotp({ // merging with current credentials to propogate returnUrl const credentials = getCredentials(getState()); - dispatch({ - type: SET_CREDENTIALS, - payload: { + dispatch( + setCredentials({ ...credentials, login, password, rememberMe, isTotpRequired: true, - }, - }); + }), + ); }; } -export const SET_SWITCHER = 'auth:setAccountSwitcher'; -export function setAccountSwitcher(isOn: boolean) { +interface SetSwitcherAction extends ReduxAction { + type: 'auth:setAccountSwitcher'; + payload: boolean; +} + +export function setAccountSwitcher(isOn: boolean): SetSwitcherAction { return { - type: SET_SWITCHER, + type: 'auth:setAccountSwitcher', payload: isOn, }; } -export const ERROR = 'auth:error'; -export function setErrors(errors: { [key: string]: ValidationError } | null) { +export type AccountSwitcherAction = SetSwitcherAction; + +interface SetErrorAction extends ReduxAction { + type: 'auth:error'; + payload: Record | null; + error: boolean; +} + +export function setErrors( + errors: Record | null, +): SetErrorAction { return { - type: ERROR, + type: 'auth:error', payload: errors, error: true, }; } -export function clearErrors() { +export function clearErrors(): SetErrorAction { return setErrors(null); } -const KNOWN_SCOPES = [ +export type ErrorAction = SetErrorAction; + +const KNOWN_SCOPES: ReadonlyArray = [ 'minecraft_server_session', 'offline_access', 'account_info', @@ -470,11 +489,76 @@ function handleOauthParamsValidation( return Promise.reject(resp); } -export const SET_CLIENT = 'set_client'; -export function setClient({ id, name, description }: Client) { +interface SetClientAction extends ReduxAction { + type: 'set_client'; + payload: Client; +} + +export function setClient(payload: Client): SetClientAction { return { - type: SET_CLIENT, - payload: { id, name, description }, + type: 'set_client', + payload, + }; +} + +export type ClientAction = SetClientAction; + +interface SetOauthAction extends ReduxAction { + type: 'set_oauth'; + payload: Pick< + OAuthState, + | 'clientId' + | 'redirectUrl' + | 'responseType' + | 'scope' + | 'prompt' + | 'loginHint' + | 'state' + >; +} + +// Input data is coming right from the query string, so the names +// are the same, as used for initializing OAuth2 request +export function setOAuthRequest(data: { + client_id?: string; + redirect_uri?: string; + response_type?: string; + scope?: string; + prompt?: string; + loginHint?: string; + state?: string; +}): SetOauthAction { + return { + type: 'set_oauth', + payload: { + // TODO: there is too much default empty string. Maybe we can somehow validate it + // on the level, where this action is called? + clientId: data.client_id || '', + redirectUrl: data.redirect_uri || '', + responseType: data.response_type || '', + scope: data.scope || '', + prompt: data.prompt || '', + loginHint: data.loginHint || '', + state: data.state || '', + }, + }; +} + +interface SetOAuthResultAction extends ReduxAction { + type: 'set_oauth_result'; + payload: Pick; +} + +export const SET_OAUTH_RESULT = 'set_oauth_result'; // TODO: remove + +export function setOAuthCode(payload: { + success: boolean; + code: string; + displayCode: boolean; +}): SetOAuthResultAction { + return { + type: 'set_oauth_result', + payload, }; } @@ -507,69 +591,43 @@ export function resetAuth(): ThunkAction { }; } -export const SET_OAUTH = 'set_oauth'; -export function setOAuthRequest(data: { - client_id?: string; - redirect_uri?: string; - response_type?: string; - scope?: string; - prompt?: string; - loginHint?: string; - state?: string; -}) { +interface RequestPermissionsAcceptAction extends ReduxAction { + type: 'require_permissions_accept'; +} + +export function requirePermissionsAccept(): RequestPermissionsAcceptAction { return { - type: SET_OAUTH, - payload: { - clientId: data.client_id, - redirectUrl: data.redirect_uri, - responseType: data.response_type, - scope: data.scope, - prompt: data.prompt, - loginHint: data.loginHint, - state: data.state, - }, + type: 'require_permissions_accept', }; } -export const SET_OAUTH_RESULT = 'set_oauth_result'; -export function setOAuthCode(data: { - success: boolean; - code: string; - displayCode: boolean; -}) { +export type OAuthAction = + | SetOauthAction + | SetOAuthResultAction + | RequestPermissionsAcceptAction; + +interface SetScopesAction extends ReduxAction { + type: 'set_scopes'; + payload: Array; +} + +export function setScopes(payload: Array): SetScopesAction { return { - type: SET_OAUTH_RESULT, - payload: { - success: data.success, - code: data.code, - displayCode: data.displayCode, - }, + type: 'set_scopes', + payload, }; } -export const REQUIRE_PERMISSIONS_ACCEPT = 'require_permissions_accept'; -export function requirePermissionsAccept() { - return { - type: REQUIRE_PERMISSIONS_ACCEPT, - }; +export type ScopesAction = SetScopesAction; + +interface SetLoadingAction extends ReduxAction { + type: 'set_loading_state'; + payload: boolean; } -export const SET_SCOPES = 'set_scopes'; -export function setScopes(scopes: Scope[]) { - if (!Array.isArray(scopes)) { - throw new Error('Scopes must be array'); - } - +export function setLoadingState(isLoading: boolean): SetLoadingAction { return { - type: SET_SCOPES, - payload: scopes, - }; -} - -export const SET_LOADING_STATE = 'set_loading_state'; -export function setLoadingState(isLoading: boolean) { - return { - type: SET_LOADING_STATE, + type: 'set_loading_state', payload: isLoading, }; } @@ -594,6 +652,8 @@ function wrapInLoader(fn: ThunkAction>): ThunkAction> { }; } +export type LoadingAction = SetLoadingAction; + function needActivation() { return updateUser({ isActive: false, @@ -615,12 +675,20 @@ function authHandler(dispatch: Dispatch) { }); } -function validationErrorsHandler(dispatch: Dispatch, repeatUrl?: string) { +function validationErrorsHandler( + dispatch: Dispatch, + repeatUrl?: string, +): ( + resp: Resp<{ + errors?: Record; + data?: Record; + }>, +) => Promise { return resp => { if (resp.errors) { const [firstError] = Object.keys(resp.errors); - const error = { - type: resp.errors[firstError], + const firstErrorObj: ValidationError = { + type: resp.errors[firstError] as string, payload: { isGuest: true, repeatUrl: '', @@ -629,20 +697,24 @@ function validationErrorsHandler(dispatch: Dispatch, repeatUrl?: string) { if (resp.data) { // TODO: this should be formatted on backend - Object.assign(error.payload, resp.data); + Object.assign(firstErrorObj.payload, resp.data); } if ( - ['error.key_not_exists', 'error.key_expire'].includes(error.type) && + ['error.key_not_exists', 'error.key_expire'].includes( + firstErrorObj.type, + ) && repeatUrl ) { // TODO: this should be formatted on backend - error.payload.repeatUrl = repeatUrl; + firstErrorObj.payload.repeatUrl = repeatUrl; } - resp.errors[firstError] = error; + // TODO: can I clone the object or its necessary to catch modified errors list on corresponding catches? + const errors: Record = resp.errors; + errors[firstError] = firstErrorObj; - dispatch(setErrors(resp.errors)); + dispatch(setErrors(errors)); } return Promise.reject(resp); diff --git a/packages/app/components/auth/activation/ActivationBody.js b/packages/app/components/auth/activation/ActivationBody.tsx similarity index 89% rename from packages/app/components/auth/activation/ActivationBody.js rename to packages/app/components/auth/activation/ActivationBody.tsx index a949715..45dc4cd 100644 --- a/packages/app/components/auth/activation/ActivationBody.js +++ b/packages/app/components/auth/activation/ActivationBody.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import { FormattedMessage as Message } from 'react-intl'; @@ -13,14 +12,6 @@ export default class ActivationBody extends BaseAuthBody { static displayName = 'ActivationBody'; static panelId = 'activation'; - static propTypes = { - match: PropTypes.shape({ - params: PropTypes.shape({ - key: PropTypes.string, - }), - }), - }; - autoFocusField = this.props.match.params && this.props.match.params.key ? null : 'key'; diff --git a/packages/app/components/auth/authError/AuthError.js b/packages/app/components/auth/authError/AuthError.js deleted file mode 100644 index 93833ad..0000000 --- a/packages/app/components/auth/authError/AuthError.js +++ /dev/null @@ -1,45 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; - -import errorsDict from 'app/services/errorsDict'; -import { PanelBodyHeader } from 'app/components/ui/Panel'; - -let autoHideTimer; -function resetTimer() { - if (autoHideTimer) { - clearTimeout(autoHideTimer); - autoHideTimer = null; - } -} -export default function AuthError({ error, onClose = function() {} }) { - resetTimer(); - - if (error.payload && error.payload.canRepeatIn) { - error.payload.msLeft = error.payload.canRepeatIn * 1000; - setTimeout(onClose, error.payload.msLeft - Date.now() + 1500); // 1500 to let the user see, that time is elapsed - } - - return ( - { - resetTimer(); - onClose(); - }} - > - {errorsDict.resolve(error)} - - ); -} - -AuthError.displayName = 'AuthError'; -AuthError.propTypes = { - error: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.shape({ - type: PropTypes.string, - payload: PropTypes.object, - }), - ]).isRequired, - onClose: PropTypes.func, -}; diff --git a/packages/app/components/auth/authError/AuthError.tsx b/packages/app/components/auth/authError/AuthError.tsx new file mode 100644 index 0000000..08b29b0 --- /dev/null +++ b/packages/app/components/auth/authError/AuthError.tsx @@ -0,0 +1,45 @@ +import React, { ComponentType, useEffect } from 'react'; + +import { resolve as resolveError } from 'app/services/errorsDict'; +import { PanelBodyHeader } from 'app/components/ui/Panel'; +import { ValidationError } from 'app/components/ui/form/FormModel'; + +interface Props { + error: ValidationError; + onClose?: () => void; +} + +let autoHideTimer: number | null = null; +function resetTimeout(): void { + if (autoHideTimer) { + clearTimeout(autoHideTimer); + autoHideTimer = null; + } +} + +const AuthError: ComponentType = ({ error, onClose }) => { + useEffect(() => { + resetTimeout(); + + if ( + onClose && + typeof error !== 'string' && + error.payload && + error.payload.canRepeatIn + ) { + const msLeft = error.payload.canRepeatIn * 1000; + // 1500 to let the user see, that time is elapsed + setTimeout(onClose, msLeft - Date.now() + 1500); + } + + return resetTimeout; + }, [error, onClose]); + + return ( + + {resolveError(error)} + + ); +}; + +export default AuthError; diff --git a/packages/app/components/auth/chooseAccount/ChooseAccountBody.js b/packages/app/components/auth/chooseAccount/ChooseAccountBody.tsx similarity index 92% rename from packages/app/components/auth/chooseAccount/ChooseAccountBody.js rename to packages/app/components/auth/chooseAccount/ChooseAccountBody.tsx index 3b3d75e..7ea8286 100644 --- a/packages/app/components/auth/chooseAccount/ChooseAccountBody.js +++ b/packages/app/components/auth/chooseAccount/ChooseAccountBody.tsx @@ -4,6 +4,7 @@ import { FormattedMessage as Message } from 'react-intl'; import BaseAuthBody from 'app/components/auth/BaseAuthBody'; import { AccountSwitcher } from 'app/components/accounts'; +import { Account } from 'app/components/accounts/reducer'; import styles from './chooseAccount.scss'; import messages from './ChooseAccount.intl.json'; @@ -46,7 +47,7 @@ export default class ChooseAccountBody extends BaseAuthBody { ); } - onSwitch = account => { + onSwitch = (account: Account): void => { this.context.resolve(account); }; } diff --git a/packages/app/components/auth/factory.tsx b/packages/app/components/auth/factory.tsx index 7d10072..0cf6e13 100644 --- a/packages/app/components/auth/factory.tsx +++ b/packages/app/components/auth/factory.tsx @@ -1,36 +1,36 @@ -import React from 'react'; +import React, { ComponentProps, ComponentType } from 'react'; import { Button } from 'app/components/ui/form'; -import RejectionLink, { - RejectionLinkProps, -} from 'app/components/auth/RejectionLink'; +import RejectionLink from 'app/components/auth/RejectionLink'; import AuthTitle from 'app/components/auth/AuthTitle'; import { MessageDescriptor } from 'react-intl'; import { Color } from 'app/components/ui'; +import BaseAuthBody from './BaseAuthBody'; -/** - * @param {object} options - * @param {string|object} options.title - panel title - * @param {React.ReactElement} options.body - * @param {object} options.footer - config for footer Button - * @param {Array|object|null} options.links - link config or an array of link configs - * - * @returns {object} - structure, required for auth panel to work - */ -export default function({ - title, - body, - footer, - links, -}: { +export type Factory = () => { + Title: ComponentType; + Body: typeof BaseAuthBody; + Footer: ComponentType; + Links: ComponentType; +}; + +type RejectionLinkProps = ComponentProps; +interface FactoryParams { title: MessageDescriptor; - body: React.ElementType; + body: typeof BaseAuthBody; footer: { color?: Color; label: string | MessageDescriptor; autoFocus?: boolean; }; - links?: RejectionLinkProps | RejectionLinkProps[]; -}) { + links?: RejectionLinkProps | Array; +} + +export default function({ + title, + body, + footer, + links, +}: FactoryParams): Factory { return () => ({ Title: () => , Body: body, @@ -38,7 +38,7 @@ export default function({ Links: () => links ? ( - {([] as RejectionLinkProps[]) + {([] as Array) .concat(links) .map((link, index) => [ index ? ' | ' : '', diff --git a/packages/app/components/auth/finish/Finish.tsx b/packages/app/components/auth/finish/Finish.tsx index b52fba7..c5ddeff 100644 --- a/packages/app/components/auth/finish/Finish.tsx +++ b/packages/app/components/auth/finish/Finish.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { MouseEventHandler } from 'react'; import { connect } from 'react-redux'; import { FormattedMessage as Message } from 'react-intl'; import { Helmet } from 'react-helmet-async'; @@ -13,7 +13,7 @@ interface Props { appName: string; code?: string; state: string; - displayCode?: string; + displayCode?: boolean; success?: boolean; } @@ -84,7 +84,7 @@ class Finish extends React.Component { ); } - onCopyClick = event => { + onCopyClick: MouseEventHandler = event => { event.preventDefault(); const { code } = this.props; diff --git a/packages/app/components/auth/forgotPassword/ForgotPasswordBody.js b/packages/app/components/auth/forgotPassword/ForgotPasswordBody.tsx similarity index 100% rename from packages/app/components/auth/forgotPassword/ForgotPasswordBody.js rename to packages/app/components/auth/forgotPassword/ForgotPasswordBody.tsx diff --git a/packages/app/components/auth/login/LoginBody.js b/packages/app/components/auth/login/LoginBody.tsx similarity index 85% rename from packages/app/components/auth/login/LoginBody.js rename to packages/app/components/auth/login/LoginBody.tsx index f903977..a139801 100644 --- a/packages/app/components/auth/login/LoginBody.js +++ b/packages/app/components/auth/login/LoginBody.tsx @@ -1,13 +1,15 @@ import React from 'react'; + import { Input } from 'app/components/ui/form'; import BaseAuthBody from 'app/components/auth/BaseAuthBody'; +import { User } from 'app/components/user/reducer'; import messages from './Login.intl.json'; export default class LoginBody extends BaseAuthBody { static displayName = 'LoginBody'; static panelId = 'login'; - static hasGoBack = state => { + static hasGoBack = (state: { user: User }) => { return !state.user.isGuest; }; diff --git a/packages/app/components/auth/password/PasswordBody.js b/packages/app/components/auth/password/PasswordBody.tsx similarity index 100% rename from packages/app/components/auth/password/PasswordBody.js rename to packages/app/components/auth/password/PasswordBody.tsx diff --git a/packages/app/components/auth/permissions/PermissionsBody.js b/packages/app/components/auth/permissions/PermissionsBody.tsx similarity index 100% rename from packages/app/components/auth/permissions/PermissionsBody.js rename to packages/app/components/auth/permissions/PermissionsBody.tsx diff --git a/packages/app/components/auth/recoverPassword/RecoverPasswordBody.js b/packages/app/components/auth/recoverPassword/RecoverPasswordBody.tsx similarity index 92% rename from packages/app/components/auth/recoverPassword/RecoverPasswordBody.js rename to packages/app/components/auth/recoverPassword/RecoverPasswordBody.tsx index 98a970f..2a252a8 100644 --- a/packages/app/components/auth/recoverPassword/RecoverPasswordBody.js +++ b/packages/app/components/auth/recoverPassword/RecoverPasswordBody.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import { FormattedMessage as Message } from 'react-intl'; @@ -16,14 +15,6 @@ export default class RecoverPasswordBody extends BaseAuthBody { static panelId = 'recoverPassword'; static hasGoBack = true; - static propTypes = { - match: PropTypes.shape({ - params: PropTypes.shape({ - key: PropTypes.string, - }), - }), - }; - autoFocusField = this.props.match.params && this.props.match.params.key ? 'newPassword' diff --git a/packages/app/components/auth/reducer.test.ts b/packages/app/components/auth/reducer.test.ts index 666d668..1b5ba5d 100644 --- a/packages/app/components/auth/reducer.test.ts +++ b/packages/app/components/auth/reducer.test.ts @@ -1,14 +1,9 @@ import expect from 'app/test/unexpected'; import auth from './reducer'; -import { - setLogin, - SET_CREDENTIALS, - setAccountSwitcher, - SET_SWITCHER, -} from './actions'; +import { setLogin, setAccountSwitcher } from './actions'; describe('components/auth/reducer', () => { - describe(SET_CREDENTIALS, () => { + describe('auth:setCredentials', () => { it('should set login', () => { const expectedLogin = 'foo'; @@ -22,7 +17,7 @@ describe('components/auth/reducer', () => { }); }); - describe(SET_SWITCHER, () => { + describe('auth:setAccountSwitcher', () => { it('should be enabled by default', () => expect(auth(undefined, {} as any), 'to satisfy', { isSwitcherEnabled: true, diff --git a/packages/app/components/auth/reducer.ts b/packages/app/components/auth/reducer.ts index 4b0ae6e..8be2f20 100644 --- a/packages/app/components/auth/reducer.ts +++ b/packages/app/components/auth/reducer.ts @@ -1,26 +1,34 @@ -import { combineReducers } from 'redux'; +import { combineReducers, Reducer } from 'redux'; import { RootState } from 'app/reducers'; +import { Scope } from '../../services/api/oauth'; import { - ERROR, - SET_CLIENT, - SET_OAUTH, - SET_OAUTH_RESULT, - SET_SCOPES, - SET_LOADING_STATE, - REQUIRE_PERMISSIONS_ACCEPT, - SET_CREDENTIALS, - SET_SWITCHER, + ErrorAction, + CredentialsAction, + AccountSwitcherAction, + LoadingAction, + ClientAction, + OAuthAction, + ScopesAction, } from './actions'; -type Credentials = { - login?: string; +export interface Credentials { + login?: string | null; // By some reasons there is can be null value. Need to investigate. password?: string; rememberMe?: boolean; returnUrl?: string; isRelogin?: boolean; isTotpRequired?: boolean; -}; +} + +type Error = Record< + string, + | string + | { + type: string; + payload: Record; + } +> | null; export interface Client { id: string; @@ -28,7 +36,7 @@ export interface Client { description: string; } -interface OAuthState { +export interface OAuthState { clientId: string; redirectUrl: string; responseType: string; @@ -39,27 +47,113 @@ interface OAuthState { state: string; success?: boolean; code?: string; - displayCode?: string; + displayCode?: boolean; acceptRequired?: boolean; } +type Scopes = Array; + export interface State { credentials: Credentials; - error: null | { - [key: string]: - | string - | { - type: string; - payload: { [key: string]: any }; - }; - }; + error: Error; isLoading: boolean; isSwitcherEnabled: boolean; client: Client | null; oauth: OAuthState | null; - scopes: string[]; + scopes: Scopes; } +const error: Reducer = ( + state = null, + { type, payload }, +) => { + if (type === 'auth:error') { + return payload; + } + + return state; +}; + +const credentials: Reducer = ( + state = {}, + { type, payload }, +) => { + if (type === 'auth:setCredentials') { + if (payload) { + return { + ...payload, + }; + } + + return {}; + } + + return state; +}; + +const isSwitcherEnabled: Reducer< + State['isSwitcherEnabled'], + AccountSwitcherAction +> = (state = true, { type, payload }) => { + if (type === 'auth:setAccountSwitcher') { + return payload; + } + + return state; +}; + +const isLoading: Reducer = ( + state = false, + { type, payload }, +) => { + if (type === 'set_loading_state') { + return payload; + } + + return state; +}; + +const client: Reducer = ( + state = null, + { type, payload }, +) => { + if (type === 'set_client') { + return payload; + } + + return state; +}; + +const oauth: Reducer = (state = null, action) => { + switch (action.type) { + case 'set_oauth': + return action.payload; + case 'set_oauth_result': + return { + ...(state as OAuthState), + ...action.payload, + }; + case 'require_permissions_accept': + return { + ...(state as OAuthState), + acceptRequired: true, + }; + default: + return state; + } +}; + +const scopes: Reducer = ( + state = [], + { type, payload }, +) => { + if (type === 'set_scopes') { + return payload; + } + + return state; +}; + export default combineReducers({ credentials, error, @@ -70,135 +164,6 @@ export default combineReducers({ scopes, }); -function error( - state = null, - { type, payload = null, error = false }, -): State['error'] { - switch (type) { - case ERROR: - if (!error) { - throw new Error('Expected payload with error'); - } - - return payload; - - default: - return state; - } -} - -function credentials( - state = {}, - { - type, - payload, - }: { - type: string; - payload: Credentials | null; - }, -): State['credentials'] { - if (type === SET_CREDENTIALS) { - if (payload && typeof payload === 'object') { - return { - ...payload, - }; - } - - return {}; - } - - return state; -} - -function isSwitcherEnabled( - state = true, - { type, payload = false }, -): State['isSwitcherEnabled'] { - switch (type) { - case SET_SWITCHER: - if (typeof payload !== 'boolean') { - throw new Error('Expected payload of boolean type'); - } - - return payload; - - default: - return state; - } -} - -function isLoading( - state = false, - { type, payload = null }, -): State['isLoading'] { - switch (type) { - case SET_LOADING_STATE: - return !!payload; - - default: - return state; - } -} - -function client(state = null, { type, payload }): State['client'] { - switch (type) { - case SET_CLIENT: - return { - id: payload.id, - name: payload.name, - description: payload.description, - }; - - default: - return state; - } -} - -function oauth( - state: State['oauth'] = null, - { type, payload }, -): State['oauth'] { - switch (type) { - case SET_OAUTH: - return { - clientId: payload.clientId, - redirectUrl: payload.redirectUrl, - responseType: payload.responseType, - scope: payload.scope, - prompt: payload.prompt, - loginHint: payload.loginHint, - state: payload.state, - }; - - case SET_OAUTH_RESULT: - return { - ...(state as OAuthState), - success: payload.success, - code: payload.code, - displayCode: payload.displayCode, - }; - - case REQUIRE_PERMISSIONS_ACCEPT: - return { - ...(state as OAuthState), - acceptRequired: true, - }; - - default: - return state; - } -} - -function scopes(state = [], { type, payload = [] }): State['scopes'] { - switch (type) { - case SET_SCOPES: - return payload; - - default: - return state; - } -} - export function getLogin( state: RootState | Pick, ): string | null { diff --git a/packages/app/components/auth/register/RegisterBody.js b/packages/app/components/auth/register/RegisterBody.tsx similarity index 100% rename from packages/app/components/auth/register/RegisterBody.js rename to packages/app/components/auth/register/RegisterBody.tsx diff --git a/packages/app/components/auth/resendActivation/ResendActivationBody.js b/packages/app/components/auth/resendActivation/ResendActivationBody.tsx similarity index 100% rename from packages/app/components/auth/resendActivation/ResendActivationBody.js rename to packages/app/components/auth/resendActivation/ResendActivationBody.tsx diff --git a/packages/app/components/contact/ContactForm.test.tsx b/packages/app/components/contact/ContactForm.test.tsx index 4fc7a62..350b525 100644 --- a/packages/app/components/contact/ContactForm.test.tsx +++ b/packages/app/components/contact/ContactForm.test.tsx @@ -1,17 +1,23 @@ -import React from 'react'; +import React, { ComponentProps } from 'react'; import expect from 'app/test/unexpected'; import sinon from 'sinon'; -import { shallow, mount } from 'enzyme'; +import { shallow, mount, ShallowWrapper, ReactWrapper } from 'enzyme'; import { IntlProvider } from 'react-intl'; import feedback from 'app/services/api/feedback'; import { User } from 'app/components/user'; import { ContactForm } from './ContactForm'; +type ContactFormShallowType = ShallowWrapper< + ComponentProps, + any, + ContactForm +>; + describe('ContactForm', () => { describe('when rendered', () => { const user = {} as User; - let component; + let component: ContactFormShallowType; beforeEach(() => { component = shallow(); @@ -57,7 +63,7 @@ describe('ContactForm', () => { const user = { email: 'foo@bar.com', } as User; - let component; + let component: ContactFormShallowType; beforeEach(() => { component = shallow(); @@ -76,7 +82,7 @@ describe('ContactForm', () => { const user = { email: 'foo@bar.com', } as User; - let component; + let component: ContactFormShallowType; beforeEach(() => { component = shallow(); @@ -93,15 +99,15 @@ describe('ContactForm', () => { const user = { email: 'foo@bar.com', } as User; - let component; - let wrapper; + let component: ContactForm; + let wrapper: ReactWrapper; beforeEach(() => { // TODO: add polyfill for from validation for jsdom wrapper = mount( - (component = el)} /> + (component = el!)} /> , ); }); @@ -118,8 +124,8 @@ describe('ContactForm', () => { const user = { email: 'foo@bar.com', } as User; - let component; - let wrapper; + let component: ContactForm; + let wrapper: ReactWrapper; const requestData = { email: user.email, subject: 'Test subject', @@ -137,16 +143,18 @@ describe('ContactForm', () => { // TODO: try to rewrite with unexpected-react wrapper = mount( - (component = el)} /> + (component = el!)} /> , ); - wrapper.find('input[name="email"]').getDOMNode().value = + wrapper.find('input[name="email"]').getDOMNode().value = requestData.email; - wrapper.find('input[name="subject"]').getDOMNode().value = - requestData.subject; - wrapper.find('textarea[name="message"]').getDOMNode().value = - requestData.message; + wrapper + .find('input[name="subject"]') + .getDOMNode().value = requestData.subject; + wrapper + .find('textarea[name="message"]') + .getDOMNode().value = requestData.message; }); afterEach(() => { diff --git a/packages/app/components/contact/ContactForm.tsx b/packages/app/components/contact/ContactForm.tsx index f6d8e15..ca6f76d 100644 --- a/packages/app/components/contact/ContactForm.tsx +++ b/packages/app/components/contact/ContactForm.tsx @@ -172,9 +172,9 @@ export class ContactForm extends React.Component< ); } - onSubmit = () => { + onSubmit = (): Promise => { if (this.state.isLoading) { - return; + return Promise.resolve(); } this.setState({ isLoading: true }); diff --git a/packages/app/components/dev/apps/actions.ts b/packages/app/components/dev/apps/actions.ts index fcf7ea4..bd3e991 100644 --- a/packages/app/components/dev/apps/actions.ts +++ b/packages/app/components/dev/apps/actions.ts @@ -1,17 +1,15 @@ -import { Dispatch } from 'redux'; +import { Dispatch, Action as ReduxAction } from 'redux'; import { OauthAppResponse } from 'app/services/api/oauth'; import oauth from 'app/services/api/oauth'; import { User } from 'app/components/user'; +import { ThunkAction } from 'app/reducers'; import { Apps } from './reducer'; -type SetAvailableAction = { +interface SetAvailableAction extends ReduxAction { type: 'apps:setAvailable'; payload: Array; -}; -type DeleteAppAction = { type: 'apps:deleteApp'; payload: string }; -type AddAppAction = { type: 'apps:addApp'; payload: OauthAppResponse }; -export type Action = SetAvailableAction | DeleteAppAction | AddAppAction; +} export function setAppsList(apps: Array): SetAvailableAction { return { @@ -27,14 +25,19 @@ export function getApp( return state.apps.available.find(app => app.clientId === clientId) || null; } -export function fetchApp(clientId: string) { - return async (dispatch: Dispatch): Promise => { +export function fetchApp(clientId: string): ThunkAction> { + return async dispatch => { const app = await oauth.getApp(clientId); dispatch(addApp(app)); }; } +interface AddAppAction extends ReduxAction { + type: 'apps:addApp'; + payload: OauthAppResponse; +} + function addApp(app: OauthAppResponse): AddAppAction { return { type: 'apps:addApp', @@ -69,6 +72,11 @@ export function deleteApp(clientId: string) { }; } +interface DeleteAppAction extends ReduxAction { + type: 'apps:deleteApp'; + payload: string; +} + function createDeleteAppAction(clientId: string): DeleteAppAction { return { type: 'apps:deleteApp', @@ -76,8 +84,11 @@ function createDeleteAppAction(clientId: string): DeleteAppAction { }; } -export function resetApp(clientId: string, resetSecret: boolean) { - return async (dispatch: Dispatch): Promise => { +export function resetApp( + clientId: string, + resetSecret: boolean, +): ThunkAction> { + return async dispatch => { const { data: app } = await oauth.reset(clientId, resetSecret); if (resetSecret) { @@ -85,3 +96,5 @@ export function resetApp(clientId: string, resetSecret: boolean) { } }; } + +export type Action = SetAvailableAction | DeleteAppAction | AddAppAction; diff --git a/packages/app/components/dev/apps/applicationForm/ApplicationForm.tsx b/packages/app/components/dev/apps/applicationForm/ApplicationForm.tsx index 863e13d..4b0938c 100644 --- a/packages/app/components/dev/apps/applicationForm/ApplicationForm.tsx +++ b/packages/app/components/dev/apps/applicationForm/ApplicationForm.tsx @@ -19,12 +19,15 @@ import ApplicationTypeSwitcher from './ApplicationTypeSwitcher'; import WebsiteType from './WebsiteType'; import MinecraftServerType from './MinecraftServerType'; -const typeToForm: { - [K in ApplicationType]: { +type TypeToForm = Record< + ApplicationType, + { label: MessageDescriptor; component: React.ComponentType; - }; -} = { + } +>; + +const typeToForm: TypeToForm = { [TYPE_APPLICATION]: { label: messages.website, component: WebsiteType, @@ -35,16 +38,15 @@ const typeToForm: { }, }; -const typeToLabel = Object.keys(typeToForm).reduce( - (result, key: ApplicationType) => { - result[key] = typeToForm[key].label; +type TypeToLabel = Record; - return result; - }, - {} as { - [K in ApplicationType]: MessageDescriptor; - }, -); +const typeToLabel: TypeToLabel = ((Object.keys(typeToForm) as unknown) as Array< + ApplicationType +>).reduce((result, key) => { + result[key] = typeToForm[key].label; + + return result; +}, {} as TypeToLabel); export default class ApplicationForm extends React.Component<{ app: OauthAppResponse; diff --git a/packages/app/components/dev/apps/applicationForm/ApplicationTypeSwitcher.tsx b/packages/app/components/dev/apps/applicationForm/ApplicationTypeSwitcher.tsx index 9fcbb58..5cc4776 100644 --- a/packages/app/components/dev/apps/applicationForm/ApplicationTypeSwitcher.tsx +++ b/packages/app/components/dev/apps/applicationForm/ApplicationTypeSwitcher.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ComponentType } from 'react'; import { ApplicationType } from 'app/components/dev/apps'; import { MessageDescriptor } from 'react-intl'; import { SKIN_LIGHT } from 'app/components/ui'; @@ -6,20 +6,20 @@ import { Radio } from 'app/components/ui/form'; import styles from './applicationTypeSwitcher.scss'; -export default function ApplicationTypeSwitcher({ - setType, - appTypes, - selectedType, -}: { - appTypes: { - [K in ApplicationType]: MessageDescriptor; - }; +interface Props { + appTypes: Record; selectedType: ApplicationType | null; setType: (type: ApplicationType) => void; -}) { - return ( -
- {Object.keys(appTypes).map((type: ApplicationType) => ( +} + +const ApplicationTypeSwitcher: ComponentType = ({ + appTypes, + selectedType, + setType, +}) => ( +
+ {((Object.keys(appTypes) as unknown) as Array).map( + type => (
setType(type)} @@ -29,7 +29,9 @@ export default function ApplicationTypeSwitcher({ checked={selectedType === type} />
- ))} -
- ); -} + ), + )} +
+); + +export default ApplicationTypeSwitcher; diff --git a/packages/app/components/dev/apps/applicationForm/MinecraftServerType.tsx b/packages/app/components/dev/apps/applicationForm/MinecraftServerType.tsx index 53a1c31..5993b88 100644 --- a/packages/app/components/dev/apps/applicationForm/MinecraftServerType.tsx +++ b/packages/app/components/dev/apps/applicationForm/MinecraftServerType.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ComponentType } from 'react'; import { FormattedMessage as Message } from 'react-intl'; import { OauthAppResponse } from 'app/services/api/oauth'; import { Input, FormModel } from 'app/components/ui/form'; @@ -7,52 +7,51 @@ import styles from 'app/components/profile/profileForm.scss'; import messages from './ApplicationForm.intl.json'; -export default function MinecraftServerType({ - form, - app, -}: { +interface Props { form: FormModel; app: OauthAppResponse; -}) { - return ( -
-
- -
- -
-

- -

-
-
- -
- -
-

- -

-
-
- -
-
- ); } + +const MinecraftServerType: ComponentType = ({ form, app }) => ( +
+
+ +
+ +
+

+ +

+
+
+ +
+ +
+

+ +

+
+
+ +
+
+); + +export default MinecraftServerType; diff --git a/packages/app/components/dev/apps/applicationForm/WebsiteType.tsx b/packages/app/components/dev/apps/applicationForm/WebsiteType.tsx index 1aa54c4..b7ad785 100644 --- a/packages/app/components/dev/apps/applicationForm/WebsiteType.tsx +++ b/packages/app/components/dev/apps/applicationForm/WebsiteType.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ComponentType } from 'react'; import { FormattedMessage as Message } from 'react-intl'; import { Input, TextArea, FormModel } from 'app/components/ui/form'; import { OauthAppResponse } from 'app/services/api/oauth'; @@ -7,68 +7,67 @@ import styles from 'app/components/profile/profileForm.scss'; import messages from './ApplicationForm.intl.json'; -export default function WebsiteType({ - form, - app, -}: { +interface Props { form: FormModel; app: OauthAppResponse; -}) { - return ( -
-
- -
- -
-

- -

-
-
-