Merge branch 'improve_typings'

This commit is contained in:
ErickSkrauch 2020-05-24 01:58:28 +03:00
commit 73f0c37a6a
223 changed files with 7207 additions and 5663 deletions

View File

@ -69,7 +69,6 @@ module.exports = {
'error', 'error',
{ min: 2, exceptions: ['x', 'y', 'i', 'k', 'l', 'm', 'n', '$', '_'] }, { min: 2, exceptions: ['x', 'y', 'i', 'k', 'l', 'm', 'n', '$', '_'] },
], ],
'require-atomic-updates': 'warn',
'guard-for-in': ['error'], 'guard-for-in': ['error'],
'no-var': ['error'], 'no-var': ['error'],
'prefer-const': ['error'], 'prefer-const': ['error'],
@ -159,6 +158,8 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-use-before-define': 'off', '@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/ban-ts-ignore': 'off', '@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-inferrable-types': 'off', '@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [

View File

@ -7,3 +7,5 @@ cache
*.png *.png
*.gif *.gif
*.svg *.svg
*.hbs
.gitlab-ci.yml

View File

@ -5,7 +5,7 @@ import storyDecorator from './storyDecorator';
const req = require.context('../packages/app', true, /\.story\.[tj]sx?$/); const req = require.context('../packages/app', true, /\.story\.[tj]sx?$/);
function loadStories() { function loadStories() {
req.keys().forEach(filename => req(filename)); req.keys().forEach((filename) => req(filename));
} }
addDecorator(storyDecorator); addDecorator(storyDecorator);

View File

@ -9,7 +9,7 @@ import { IntlDecorator } from './decorators';
const store = storeFactory(); const store = storeFactory();
export default (story => { export default ((story) => {
const channel = addons.getChannel(); const channel = addons.getChannel();
return ( return (

102
@types/cwordin-api.d.ts vendored Normal file
View File

@ -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<ProjectInfoFile | ProjectInfoDirectory>;
}
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<ProjectInfoFile | ProjectInfoDirectory>;
}
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<LanguageStatusNode>;
}
export interface LanguageStatusResponse {
files: Array<LanguageStatusNode>;
}
type FilesList = Record<string, string | ReadableStream>;
export default class CrowdinApi {
constructor(params: {
apiKey: string;
projectName: string;
baseUrl?: string;
});
projectInfo(): Promise<ProjectInfoResponse>;
languageStatus(language: string): Promise<LanguageStatusResponse>;
exportFile(
file: string,
language: string,
params?: {
branch?: string;
format?: 'xliff';
export_translated_only?: boolean;
export_approved_only?: boolean;
},
): Promise<string>; // TODO: not sure about Promise return type
updateFile(
files: FilesList,
params: {
titles?: Record<string, string>;
export_patterns?: Record<string, string>;
new_names?: Record<string, string>;
first_line_contains_header?: string;
scheme?: string;
update_option?: 'update_as_unapproved' | 'update_without_changes';
branch?: string;
},
): Promise<void>;
}
}

2
@types/formatjs.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare module '@formatjs/intl-pluralrules/polyfill' {}
declare module '@formatjs/intl-relativetimeformat/polyfill' {}

13
@types/multi-progress.d.ts vendored Normal file
View File

@ -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;
}
}

66
@types/prompt.d.ts vendored Normal file
View File

@ -0,0 +1,66 @@
// Type definitions for Prompt.js 1.0.0
// Project: https://github.com/flatiron/prompt
declare module 'prompt' {
type PropertiesType =
| Array<string>
| prompt.PromptSchema
| Array<prompt.PromptPropertyOptions>;
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<T extends PropertiesType>(
properties: T,
callback: (
err: Error,
result: T extends Array<string>
? Record<T[number], string>
: T extends PromptSchema
? Record<keyof T['properties'], string>
: T extends Array<PromptPropertyOptions>
? 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;
}

25
@types/redux-localstorage.d.ts vendored Normal file
View File

@ -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;
}

86
@types/unexpected.d.ts vendored Normal file
View File

@ -0,0 +1,86 @@
declare module 'unexpected' {
namespace unexpected {
interface EnchantedPromise<T> extends Promise<T> {
and<A extends Array<unknown> = []>(
assertionName: string,
subject: unknown,
...args: A
): EnchantedPromise<any>;
}
interface Expect {
/**
* @see http://unexpected.js.org/api/expect/
*/
<A extends Array<unknown> = []>(
subject: unknown,
assertionName: string,
...args: A
): EnchantedPromise<any>;
it<A extends Array<unknown> = []>(
assertionName: string,
subject?: unknown,
...args: A
): EnchantedPromise<any>;
/**
* @see http://unexpected.js.org/api/clone/
*/
clone(): this;
/**
* @see http://unexpected.js.org/api/addAssertion/
*/
addAssertion<T, A extends Array<unknown> = []>(
pattern: string,
handler: (expect: Expect, subject: T, ...args: A) => void,
): this;
/**
* @see http://unexpected.js.org/api/addType/
*/
addType<T>(typeDefinition: unexpected.TypeDefinition<T>): this;
/**
* @see http://unexpected.js.org/api/fail/
*/
fail<A extends Array<unknown> = []>(format: string, ...args: A): void;
fail<E extends Error>(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<string>;
installInto(expect: Expect): void;
}
interface TypeDefinition<T> {
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;
}

View File

@ -26,9 +26,7 @@ declare module '*.jpg' {
declare module '*.intl.json' { declare module '*.intl.json' {
import { MessageDescriptor } from 'react-intl'; import { MessageDescriptor } from 'react-intl';
const descriptor: { const descriptor: Record<string, MessageDescriptor>;
[key: string]: MessageDescriptor;
};
export = descriptor; export = descriptor;
} }

View File

@ -1,8 +1,5 @@
import 'app/polyfills'; import 'app/polyfills';
import { configure } from 'enzyme'; import '@testing-library/jest-dom';
import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });
if (!window.localStorage) { if (!window.localStorage) {
window.localStorage = { window.localStorage = {

View File

@ -28,21 +28,21 @@
"e2e": "yarn --cwd ./tests-e2e test", "e2e": "yarn --cwd ./tests-e2e test",
"test": "jest", "test": "jest",
"test:watch": "yarn test --watch", "test:watch": "yarn test --watch",
"lint": "eslint --ext js,ts,tsx --fix --quiet .", "lint": "eslint --ext js,ts,tsx --fix .",
"lint:check": "eslint --ext js,ts,tsx --quiet .", "lint:check": "eslint --ext js,ts,tsx --quiet .",
"prettier": "prettier --write \"{packages/**/*,tests-e2e/**/*,jest/**/*,config/**/*,*}.{js,ts,tsx,json,md,scss,css}\"", "prettier": "prettier --write .",
"prettier:check": "prettier --check \"{packages/**/*,tests-e2e/**/*,jest/**/*,config/**/*,*}.{js,ts,tsx,json,md,scss,css}\"", "prettier:check": "prettier --check .",
"ts:check": "tsc", "ts:check": "tsc",
"ci:check": "yarn lint:check && yarn ts:check && yarn test", "ci:check": "yarn lint:check && yarn ts:check && yarn test",
"analyze": "yarn run clean && yarn run build:webpack --analyze", "analyze": "yarn run clean && yarn run build:webpack --analyze",
"i18n:collect": "babel-node ./packages/scripts/i18n-collect.js", "i18n:collect": "babel-node --extensions \".ts\" ./packages/scripts/i18n-collect.ts",
"i18n:push": "babel-node ./packages/scripts/i18n-crowdin.js push", "i18n:push": "babel-node --extensions \".ts\" ./packages/scripts/i18n-crowdin.ts push",
"i18n:pull": "babel-node ./packages/scripts/i18n-crowdin.js pull", "i18n:pull": "babel-node --extensions \".ts\" ./packages/scripts/i18n-crowdin.ts pull",
"build": "yarn run clean && yarn run build:webpack", "build": "yarn run clean && yarn run build:webpack",
"build:install": "yarn install", "build:install": "yarn install",
"build:webpack": "NODE_ENV=production webpack --colors -p --bail", "build:webpack": "NODE_ENV=production webpack --colors -p --bail",
"build:quiet": "yarn run clean && yarn run build:webpack --quiet", "build:quiet": "yarn run clean && yarn run build:webpack --quiet",
"build:dll": "node ./packages/scripts/build-dll.js", "build:dll": "babel-node --extensions '.ts,.d.ts' ./packages/scripts/build-dll.ts",
"build:serve": "http-server --proxy https://dev.account.ely.by ./build", "build:serve": "http-server --proxy https://dev.account.ely.by ./build",
"sb": "APP_ENV=storybook start-storybook -p 9009 --ci", "sb": "APP_ENV=storybook start-storybook -p 9009 --ci",
"sb:build": "APP_ENV=storybook build-storybook" "sb:build": "APP_ENV=storybook build-storybook"
@ -55,12 +55,10 @@
}, },
"lint-staged": { "lint-staged": {
"*.{json,scss,css,md}": [ "*.{json,scss,css,md}": [
"prettier --write", "prettier --write"
"git add"
], ],
"*.{js,ts,tsx}": [ "*.{js,ts,tsx}": [
"eslint --fix --quiet", "eslint --fix"
"git add"
] ]
}, },
"jest": { "jest": {
@ -71,7 +69,6 @@
"<rootDir>/jest/setupAfterEnv.js" "<rootDir>/jest/setupAfterEnv.js"
], ],
"resetMocks": true, "resetMocks": true,
"resetModules": true,
"restoreMocks": true, "restoreMocks": true,
"watchPlugins": [ "watchPlugins": [
"jest-watch-typeahead/filename", "jest-watch-typeahead/filename",
@ -86,13 +83,11 @@
"^.+\\.[tj]sx?$": "babel-jest" "^.+\\.[tj]sx?$": "babel-jest"
} }
}, },
"resolutions": {
"@types/node": "^12.0.0"
},
"dependencies": { "dependencies": {
"react": "^16.12.0", "react": "^16.13.1",
"react-dom": "^16.12.0", "react-dom": "^16.13.1",
"react-intl": "^3.11.0", "react-hot-loader": "^4.12.18",
"react-intl": "^4.5.7",
"regenerator-runtime": "^0.13.3" "regenerator-runtime": "^0.13.3"
}, },
"devDependencies": { "devDependencies": {
@ -118,60 +113,60 @@
"@babel/preset-react": "^7.8.3", "@babel/preset-react": "^7.8.3",
"@babel/preset-typescript": "^7.8.3", "@babel/preset-typescript": "^7.8.3",
"@babel/runtime-corejs3": "^7.8.3", "@babel/runtime-corejs3": "^7.8.3",
"@storybook/addon-actions": "^5.3.4", "@storybook/addon-actions": "^5.3.18",
"@storybook/addon-links": "^5.3.4", "@storybook/addon-links": "^5.3.18",
"@storybook/addon-viewport": "^5.3.4", "@storybook/addon-viewport": "^5.3.18",
"@storybook/addons": "^5.3.4", "@storybook/addons": "^5.3.18",
"@storybook/react": "^5.3.4", "@storybook/react": "^5.3.18",
"@types/jest": "^24.9.0", "@types/jest": "^25.2.3",
"@typescript-eslint/eslint-plugin": "^2.16.0", "@types/sinon": "^9.0.3",
"@typescript-eslint/parser": "^2.16.0", "@typescript-eslint/eslint-plugin": "^3.0.0",
"@typescript-eslint/parser": "^3.0.0",
"babel-loader": "^8.0.0", "babel-loader": "^8.0.0",
"babel-plugin-react-intl": "^5.1.16", "babel-plugin-react-intl": "^7.5.10",
"core-js": "3.6.4", "core-js": "3.6.5",
"csp-webpack-plugin": "^2.0.2", "csp-webpack-plugin": "^2.0.2",
"css-loader": "^3.4.2", "css-loader": "^3.5.3",
"cssnano": "^4.1.10", "cssnano": "^4.1.10",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"eager-imports-webpack-plugin": "^1.0.0", "eager-imports-webpack-plugin": "^1.0.0",
"eslint": "^6.8.0", "eslint": "^7.0.0",
"eslint-config-prettier": "^6.9.0", "eslint-config-prettier": "^6.11.0",
"eslint-plugin-jsdoc": "^20.3.1", "eslint-plugin-jsdoc": "^25.4.2",
"eslint-plugin-prettier": "^3.1.2", "eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-react": "^7.18.0", "eslint-plugin-react": "^7.20.0",
"exports-loader": "^0.7.0", "exports-loader": "^0.7.0",
"file-loader": "^5.0.2", "file-loader": "^6.0.0",
"html-loader": "^0.5.5", "html-loader": "^1.1.0",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^4.3.0",
"husky": "^4.0.10", "husky": "^4.2.5",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"imports-loader": "^0.8.0", "imports-loader": "^0.8.0",
"jest": "^24.9.0", "jest": "^26.0.1",
"jest-watch-typeahead": "^0.4.2", "jest-watch-typeahead": "^0.6.0",
"json-loader": "^0.5.4", "json-loader": "^0.5.4",
"lint-staged": "^9.5.0", "lint-staged": "^10.2.4",
"loader-utils": "^1.0.0", "loader-utils": "^2.0.0",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.13.0", "node-sass": "^4.14.1",
"postcss-import": "^12.0.1", "postcss-import": "^12.0.1",
"postcss-loader": "^3.0.0", "postcss-loader": "^3.0.0",
"postcss-scss": "^2.0.0", "postcss-scss": "^2.1.1",
"prettier": "^1.19.1", "prettier": "^2.0.5",
"raw-loader": "^4.0.0", "raw-loader": "^4.0.1",
"react-test-renderer": "^16.12.0",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"sinon": "^8.0.4", "sinon": "^9.0.2",
"sitemap-webpack-plugin": "^0.6.0", "sitemap-webpack-plugin": "^0.8.0",
"speed-measure-webpack-plugin": "^1.3.1", "speed-measure-webpack-plugin": "^1.3.3",
"storybook-addon-intl": "^2.4.1", "storybook-addon-intl": "^2.4.1",
"style-loader": "~1.1.2", "style-loader": "~1.2.1",
"typescript": "^3.7.4", "typescript": "^3.9.3",
"unexpected": "^11.12.1", "unexpected": "^11.14.0",
"unexpected-sinon": "^10.5.1", "unexpected-sinon": "^10.5.1",
"url-loader": "^3.0.0", "url-loader": "^4.1.0",
"wait-on": "^3.3.0", "wait-on": "^5.0.0",
"webpack": "^4.41.5", "webpack": "^4.41.5",
"webpack-bundle-analyzer": "^3.6.0", "webpack-bundle-analyzer": "^3.8.0",
"webpack-cli": "^3.3.10", "webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.10.1", "webpack-dev-server": "^3.10.1",
"webpackbar": "^4.0.0" "webpackbar": "^4.0.0"

View File

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import clsx from 'clsx'; import clsx from 'clsx';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl'; 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 { SKIN_DARK, COLOR_WHITE, Skin } from 'app/components/ui';
import { Button } from 'app/components/ui/form'; import { Button } from 'app/components/ui/form';
import { authenticate, revoke } from 'app/components/accounts/actions'; import { authenticate, revoke } from 'app/components/accounts/actions';
@ -57,7 +57,9 @@ export class AccountSwitcher extends React.Component<Props> {
let { available } = accounts; let { available } = accounts;
if (highlightActiveAccount) { if (highlightActiveAccount) {
available = available.filter(account => account.id !== activeAccount.id); available = available.filter(
(account) => account.id !== activeAccount.id,
);
} }
return ( return (
@ -152,7 +154,7 @@ export class AccountSwitcher extends React.Component<Props> {
className={styles.addAccount} className={styles.addAccount}
label={ label={
<Message {...messages.addAccount}> <Message {...messages.addAccount}>
{message => ( {(message) => (
<span> <span>
<div className={styles.addIcon} /> <div className={styles.addIcon} />
{message} {message}
@ -167,7 +169,7 @@ export class AccountSwitcher extends React.Component<Props> {
); );
} }
onSwitch = (account: Account) => (event: React.MouseEvent) => { onSwitch = (account: Account) => (event: React.MouseEvent<any>) => {
event.preventDefault(); event.preventDefault();
loader.show(); loader.show();
@ -178,11 +180,11 @@ export class AccountSwitcher extends React.Component<Props> {
.then(() => this.props.onSwitch(account)) .then(() => this.props.onSwitch(account))
// we won't sent any logs to sentry, because an error should be already // we won't sent any logs to sentry, because an error should be already
// handled by external logic // handled by external logic
.catch(error => console.warn('Error switching account', { error })) .catch((error) => console.warn('Error switching account', { error }))
.finally(() => loader.hide()); .finally(() => loader.hide());
}; };
onRemove = (account: Account) => (event: React.MouseEvent) => { onRemove = (account: Account) => (event: React.MouseEvent<any>) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();

View File

@ -12,13 +12,10 @@ import {
} from 'app/components/accounts/actions'; } from 'app/components/accounts/actions';
import { import {
add, add,
ADD,
activate, activate,
ACTIVATE,
remove, remove,
reset, reset,
} from 'app/components/accounts/actions/pure-actions'; } from 'app/components/accounts/actions/pure-actions';
import { SET_LOCALE } from 'app/components/i18n/actions';
import { updateUser, setUser } from 'app/components/user/actions'; import { updateUser, setUser } from 'app/components/user/actions';
import { setLogin, setAccountSwitcher } from 'app/components/auth/actions'; import { setLogin, setAccountSwitcher } from 'app/components/auth/actions';
import { Dispatch, RootState } from 'app/reducers'; import { Dispatch, RootState } from 'app/reducers';
@ -51,7 +48,7 @@ describe('components/accounts/actions', () => {
beforeEach(() => { beforeEach(() => {
dispatch = sinon dispatch = sinon
.spy(arg => (typeof arg === 'function' ? arg(dispatch, getState) : arg)) .spy((arg) => (typeof arg === 'function' ? arg(dispatch, getState) : arg))
.named('store.dispatch'); .named('store.dispatch');
getState = sinon.stub().named('store.getState'); getState = sinon.stub().named('store.getState');
@ -124,20 +121,20 @@ describe('components/accounts/actions', () => {
]), ]),
)); ));
it(`dispatches ${ADD} action`, () => it(`dispatches accounts:add action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() => authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [add(account)]), expect(dispatch, 'to have a call satisfying', [add(account)]),
)); ));
it(`dispatches ${ACTIVATE} action`, () => it(`dispatches accounts:activate action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() => authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]), 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(() => authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [ expect(dispatch, 'to have a call satisfying', [
{ type: SET_LOCALE, payload: { locale: 'be' } }, { type: 'i18n:setLocale', payload: { locale: 'be' } },
]), ]),
)); ));
@ -149,7 +146,7 @@ describe('components/accounts/actions', () => {
)); ));
it('resolves with account', () => it('resolves with account', () =>
authenticate(account)(dispatch, getState, undefined).then(resp => authenticate(account)(dispatch, getState, undefined).then((resp) =>
expect(resp, 'to equal', account), expect(resp, 'to equal', account),
)); ));
@ -479,7 +476,7 @@ describe('components/accounts/actions', () => {
const key = `stranger${foreignAccount.id}`; const key = `stranger${foreignAccount.id}`;
beforeEach(() => { beforeEach(() => {
sessionStorage.setItem(key, 1); sessionStorage.setItem(key, '1');
logoutStrangers()(dispatch, getState, undefined); logoutStrangers()(dispatch, getState, undefined);
}); });

View File

@ -53,7 +53,7 @@ export function authenticate(
} }
const knownAccount = getState().accounts.available.find( const knownAccount = getState().accounts.available.find(
item => item.id === accountId, (item) => item.id === accountId,
); );
if (knownAccount) { if (knownAccount) {
@ -90,7 +90,7 @@ export function authenticate(
if (!newRefreshToken) { if (!newRefreshToken) {
// mark user as stranger (user does not want us to remember his account) // 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) { if (auth && auth.oauth && auth.oauth.clientId) {
@ -246,10 +246,10 @@ export function requestNewToken(): ThunkAction<Promise<void>> {
} }
return requestToken(refreshToken) return requestToken(refreshToken)
.then(token => { .then((token) => {
dispatch(updateToken(token)); dispatch(updateToken(token));
}) })
.catch(resp => { .catch((resp) => {
// all the logic to get the valid token was failed, // all the logic to get the valid token was failed,
// looks like we have some problems with token // looks like we have some problems with token
// lets redirect to login page // lets redirect to login page
@ -313,7 +313,7 @@ export function logoutAll(): ThunkAction<Promise<void>> {
accounts: { available }, accounts: { available },
} = getState(); } = getState();
available.forEach(account => available.forEach((account) =>
logout(account.token).catch(() => { logout(account.token).catch(() => {
// we don't care // we don't care
}), }),
@ -345,10 +345,12 @@ export function logoutStrangers(): ThunkAction<Promise<void>> {
!refreshToken && !sessionStorage.getItem(`stranger${id}`); !refreshToken && !sessionStorage.getItem(`stranger${id}`);
if (available.some(isStranger)) { if (available.some(isStranger)) {
const accountToReplace = available.find(account => !isStranger(account)); const accountToReplace = available.find(
(account) => !isStranger(account),
);
if (accountToReplace) { if (accountToReplace) {
available.filter(isStranger).forEach(account => { available.filter(isStranger).forEach((account) => {
dispatch(remove(account)); dispatch(remove(account));
logout(account.token); logout(account.token);
}); });

View File

@ -1,78 +1,67 @@
import { import { Action as ReduxAction } from 'redux';
Account, import { Account } from 'app/components/accounts/reducer';
AddAction,
RemoveAction, interface AddAction extends ReduxAction {
ActivateAction, type: 'accounts:add';
UpdateTokenAction, payload: Account;
ResetAction, }
} from '../reducer';
export const ADD = 'accounts:add';
/**
* @private
*
* @param {Account} account
*
* @returns {object} - action definition
*/
export function add(account: Account): AddAction { export function add(account: Account): AddAction {
return { return {
type: ADD, type: 'accounts:add',
payload: account, payload: account,
}; };
} }
export const REMOVE = 'accounts:remove'; interface RemoveAction extends ReduxAction {
/** type: 'accounts:remove';
* @private payload: Account;
* }
* @param {Account} account
*
* @returns {object} - action definition
*/
export function remove(account: Account): RemoveAction { export function remove(account: Account): RemoveAction {
return { return {
type: REMOVE, type: 'accounts:remove',
payload: account, payload: account,
}; };
} }
export const ACTIVATE = 'accounts:activate'; interface ActivateAction extends ReduxAction {
/** type: 'accounts:activate';
* @private payload: Account;
* }
* @param {Account} account
*
* @returns {object} - action definition
*/
export function activate(account: Account): ActivateAction { export function activate(account: Account): ActivateAction {
return { return {
type: ACTIVATE, type: 'accounts:activate',
payload: account, payload: account,
}; };
} }
export const RESET = 'accounts:reset'; interface ResetAction extends ReduxAction {
/** type: 'accounts:reset';
* @private }
*
* @returns {object} - action definition
*/
export function reset(): ResetAction { export function reset(): ResetAction {
return { return {
type: RESET, type: 'accounts:reset',
}; };
} }
export const UPDATE_TOKEN = 'accounts:updateToken'; interface UpdateTokenAction extends ReduxAction {
/** type: 'accounts:updateToken';
* @param {string} token payload: string;
* }
* @returns {object} - action definition
*/
export function updateToken(token: string): UpdateTokenAction { export function updateToken(token: string): UpdateTokenAction {
return { return {
type: UPDATE_TOKEN, type: 'accounts:updateToken',
payload: token, payload: token,
}; };
} }
export type Action =
| AddAction
| RemoveAction
| ActivateAction
| ResetAction
| UpdateTokenAction;

View File

@ -1,17 +1,8 @@
import expect from 'app/test/unexpected'; import expect from 'app/test/unexpected';
import { updateToken } from './actions'; import { updateToken } from './actions';
import { import { add, remove, activate, reset } from './actions/pure-actions';
add, import { AccountsState } from './index';
remove,
activate,
reset,
ADD,
REMOVE,
ACTIVATE,
UPDATE_TOKEN,
RESET,
} from './actions/pure-actions';
import accounts, { Account } from './reducer'; import accounts, { Account } from './reducer';
const account: Account = { const account: Account = {
@ -22,7 +13,7 @@ const account: Account = {
} as Account; } as Account;
describe('Accounts reducer', () => { describe('Accounts reducer', () => {
let initial; let initial: AccountsState;
beforeEach(() => { beforeEach(() => {
initial = accounts(undefined, {} as any); initial = accounts(undefined, {} as any);
@ -39,7 +30,7 @@ describe('Accounts reducer', () => {
state: 'foo', state: 'foo',
})); }));
describe(ACTIVATE, () => { describe('accounts:activate', () => {
it('sets active account', () => { it('sets active account', () => {
expect(accounts(initial, activate(account)), 'to satisfy', { expect(accounts(initial, activate(account)), 'to satisfy', {
active: account.id, active: account.id,
@ -47,7 +38,7 @@ describe('Accounts reducer', () => {
}); });
}); });
describe(ADD, () => { describe('accounts:add', () => {
it('adds an account', () => it('adds an account', () =>
expect(accounts(initial, add(account)), 'to satisfy', { expect(accounts(initial, add(account)), 'to satisfy', {
available: [account], available: [account],
@ -106,7 +97,7 @@ describe('Accounts reducer', () => {
}); });
}); });
describe(REMOVE, () => { describe('accounts:remove', () => {
it('should remove an account', () => it('should remove an account', () =>
expect( expect(
accounts({ ...initial, available: [account] }, remove(account)), accounts({ ...initial, available: [account] }, remove(account)),
@ -128,7 +119,7 @@ describe('Accounts reducer', () => {
}); });
}); });
describe(RESET, () => { describe('actions:reset', () => {
it('should reset accounts state', () => it('should reset accounts state', () =>
expect( expect(
accounts({ ...initial, available: [account] }, reset()), accounts({ ...initial, available: [account] }, reset()),
@ -137,7 +128,7 @@ describe('Accounts reducer', () => {
)); ));
}); });
describe(UPDATE_TOKEN, () => { describe('accounts:updateToken', () => {
it('should update token', () => { it('should update token', () => {
const newToken = 'newToken'; const newToken = 'newToken';

View File

@ -1,3 +1,5 @@
import { Action } from './actions/pure-actions';
export type Account = { export type Account = {
id: number; id: number;
username: string; username: string;
@ -8,30 +10,14 @@ export type Account = {
export type State = { export type State = {
active: number | null; active: number | null;
available: Account[]; available: Array<Account>;
}; };
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 { export function getActiveAccount(state: { accounts: State }): Account | null {
const accountId = state.accounts.active; const accountId = state.accounts.active;
return ( return (
state.accounts.available.find(account => account.id === accountId) || null state.accounts.available.find((account) => account.id === accountId) || null
); );
} }
@ -57,7 +43,7 @@ export default function accounts(
const { payload } = action; const { payload } = action;
state.available = state.available state.available = state.available
.filter(account => account.id !== payload.id) .filter((account) => account.id !== payload.id)
.concat(payload); .concat(payload);
state.available.sort((account1, account2) => { state.available.sort((account1, account2) => {
@ -79,7 +65,7 @@ export default function accounts(
const { payload } = action; const { payload } = action;
return { return {
available: state.available.map(account => { available: state.available.map((account) => {
if (account.id === payload.id) { if (account.id === payload.id) {
return { ...payload }; return { ...payload };
} }
@ -105,7 +91,9 @@ export default function accounts(
return { return {
...state, ...state,
available: state.available.filter(account => account.id !== payload.id), available: state.available.filter(
(account) => account.id !== payload.id,
),
}; };
} }
@ -118,7 +106,7 @@ export default function accounts(
return { return {
...state, ...state,
available: state.available.map(account => { available: state.available.map((account) => {
if (account.id === state.active) { if (account.id === state.active) {
return { return {
...account, ...account,

View File

@ -5,7 +5,7 @@ import { FormattedMessage as Message, MessageDescriptor } from 'react-intl';
export default function AuthTitle({ title }: { title: MessageDescriptor }) { export default function AuthTitle({ title }: { title: MessageDescriptor }) {
return ( return (
<Message {...title}> <Message {...title}>
{msg => ( {(msg) => (
<span> <span>
{msg} {msg}
<Helmet title={msg as string} /> <Helmet title={msg as string} />

View File

@ -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 AuthError from 'app/components/auth/authError/AuthError';
import { FormModel } from 'app/components/ui/form'; import { FormModel } from 'app/components/ui/form';
import { RouteComponentProps } from 'react-router-dom';
import Context, { AuthContext } from './Context'; import Context, { AuthContext } from './Context';
@ -11,7 +12,7 @@ import Context, { AuthContext } from './Context';
class BaseAuthBody extends React.Component< class BaseAuthBody extends React.Component<
// TODO: this may be converted to generic type RouteComponentProps<T> // TODO: this may be converted to generic type RouteComponentProps<T>
RouteComponentProps<{ [key: string]: any }> RouteComponentProps<Record<string, any>>
> { > {
static contextType = Context; static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>; /* TODO: use declare */ context: React.ContextType<typeof Context>;
@ -32,10 +33,14 @@ class BaseAuthBody extends React.Component<
this.prevErrors = this.context.auth.error; this.prevErrors = this.context.auth.error;
} }
renderErrors() { renderErrors(): ReactNode {
const error = this.form.getFirstError(); const error = this.form.getFirstError();
return error && <AuthError error={error} onClose={this.onClearErrors} />; if (error === null) {
return null;
}
return <AuthError error={error} onClose={this.onClearErrors} />;
} }
onFormSubmit() { onFormSubmit() {

View File

@ -1,8 +1,20 @@
import React from 'react'; import React, {
CSSProperties,
MouseEventHandler,
ReactElement,
ReactNode,
} from 'react';
import { AccountsState } from 'app/components/accounts'; import { AccountsState } from 'app/components/accounts';
import { User } from 'app/components/user'; import { User } from 'app/components/user';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { TransitionMotion, spring } from 'react-motion'; import {
TransitionMotion,
spring,
PlainStyle,
Style,
TransitionStyle,
TransitionPlainStyle,
} from 'react-motion';
import { import {
Panel, Panel,
PanelBody, PanelBody,
@ -44,7 +56,7 @@ type PanelId = string;
* - Panel index defines the direction of X transition of both panels * - 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) * (e.g. the panel with lower index will slide from left side, and with greater from right side)
*/ */
const contexts: Array<PanelId[]> = [ const contexts: Array<Array<PanelId>> = [
['login', 'password', 'forgotPassword', 'mfa', 'recoverPassword'], ['login', 'password', 'forgotPassword', 'mfa', 'recoverPassword'],
['register', 'activation', 'resendActivation'], ['register', 'activation', 'resendActivation'],
['acceptRules'], ['acceptRules'],
@ -57,7 +69,7 @@ if (process.env.NODE_ENV !== 'production') {
// TODO: it may be moved to tests in future // TODO: it may be moved to tests in future
contexts.reduce((acc, context) => { contexts.reduce((acc, context) => {
context.forEach(panel => { context.forEach((panel) => {
if (acc[panel]) { if (acc[panel]) {
throw new Error( throw new Error(
`Panel ${panel} is already exists in context ${JSON.stringify( `Panel ${panel} is already exists in context ${JSON.stringify(
@ -70,40 +82,41 @@ if (process.env.NODE_ENV !== 'production') {
}); });
return acc; return acc;
}, {}); }, {} as Record<string, Array<PanelId>>);
} }
type ValidationError = type ValidationError =
| string | string
| { | {
type: string; type: string;
payload: { [key: string]: any }; payload: Record<string, any>;
}; };
type AnimationProps = { interface AnimationStyle extends PlainStyle {
opacitySpring: number; opacitySpring: number;
transformSpring: 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; key: PanelId;
style: AnimationProps; style: AnimationStyle;
data: { data: AnimationData;
Title: React.ReactElement<any>; }
Body: React.ReactElement<any>;
Footer: React.ReactElement<any>;
Links: React.ReactElement<any>;
hasBackButton: boolean | ((props: Props) => boolean);
};
};
type OwnProps = { interface OwnProps {
Title: React.ReactElement<any>; Title: ReactElement;
Body: React.ReactElement<any>; Body: ReactElement;
Footer: React.ReactElement<any>; Footer: ReactElement;
Links: React.ReactElement<any>; Links: ReactNode;
children?: React.ReactElement<any>; }
};
interface Props extends OwnProps { interface Props extends OwnProps {
// context props // context props
@ -114,17 +127,18 @@ interface Props extends OwnProps {
resolve: () => void; resolve: () => void;
reject: () => void; reject: () => void;
setErrors: (errors: { [key: string]: ValidationError }) => void; setErrors: (errors: Record<string, ValidationError>) => void;
} }
type State = { interface State {
contextHeight: number; contextHeight: number;
panelId: PanelId | void; panelId: PanelId | void;
prevPanelId: PanelId | void; prevPanelId: PanelId | void;
isHeightDirty: boolean; isHeightDirty: boolean;
forceHeight: 1 | 0; forceHeight: 1 | 0;
direction: 'X' | 'Y'; direction: 'X' | 'Y';
}; formsHeights: Record<PanelId, number>;
}
class PanelTransition extends React.PureComponent<Props, State> { class PanelTransition extends React.PureComponent<Props, State> {
state: State = { state: State = {
@ -134,16 +148,17 @@ class PanelTransition extends React.PureComponent<Props, State> {
forceHeight: 0 as const, forceHeight: 0 as const,
direction: 'X' as const, direction: 'X' as const,
prevPanelId: undefined, prevPanelId: undefined,
formsHeights: {},
}; };
isHeightMeasured: boolean = false; isHeightMeasured: boolean = false;
wasAutoFocused: boolean = false; wasAutoFocused: boolean = false;
body: null | { body: {
autoFocus: () => void; autoFocus: () => void;
onFormSubmit: () => void; onFormSubmit: () => void;
} = null; } | null = null;
timerIds: NodeJS.Timeout[] = []; // this is a list of a probably running timeouts to clean on unmount timerIds: Array<number> = []; // this is a list of a probably running timeouts to clean on unmount
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props) {
const nextPanel: PanelId = const nextPanel: PanelId =
@ -166,7 +181,8 @@ class PanelTransition extends React.PureComponent<Props, State> {
if (forceHeight) { if (forceHeight) {
this.timerIds.push( this.timerIds.push(
setTimeout(() => { // https://stackoverflow.com/a/51040768/5184751
window.setTimeout(() => {
this.setState({ forceHeight: 0 }); this.setState({ forceHeight: 0 });
}, 100), }, 100),
); );
@ -175,7 +191,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
} }
componentWillUnmount() { componentWillUnmount() {
this.timerIds.forEach(id => clearTimeout(id)); this.timerIds.forEach((id) => clearTimeout(id));
this.timerIds = []; this.timerIds = [];
} }
@ -208,7 +224,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
hasGoBack: boolean; hasGoBack: boolean;
} = Body.type as any; } = 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 // a hack to disable height animation on first render
const { isHeightMeasured } = this; const { isHeightMeasured } = this;
@ -251,7 +267,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
willEnter={this.willEnter} willEnter={this.willEnter}
willLeave={this.willLeave} willLeave={this.willLeave}
> >
{items => { {(items) => {
const panels = items.filter(({ key }) => key !== 'common'); const panels = items.filter(({ key }) => key !== 'common');
const [common] = items.filter(({ key }) => key === 'common'); const [common] = items.filter(({ key }) => key === 'common');
@ -278,7 +294,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
> >
<Panel> <Panel>
<PanelHeader> <PanelHeader>
{panels.map(config => this.getHeader(config))} {panels.map((config) => this.getHeader(config))}
</PanelHeader> </PanelHeader>
<div style={contentHeight}> <div style={contentHeight}>
<MeasureHeight <MeasureHeight
@ -287,11 +303,11 @@ class PanelTransition extends React.PureComponent<Props, State> {
> >
<PanelBody> <PanelBody>
<div style={bodyHeight}> <div style={bodyHeight}>
{panels.map(config => this.getBody(config))} {panels.map((config) => this.getBody(config))}
</div> </div>
</PanelBody> </PanelBody>
<PanelFooter> <PanelFooter>
{panels.map(config => this.getFooter(config))} {panels.map((config) => this.getFooter(config))}
</PanelFooter> </PanelFooter>
</MeasureHeight> </MeasureHeight>
</div> </div>
@ -300,7 +316,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
className={helpLinksStyles} className={helpLinksStyles}
data-testid="auth-controls-secondary" data-testid="auth-controls-secondary"
> >
{panels.map(config => this.getLinks(config))} {panels.map((config) => this.getLinks(config))}
</div> </div>
</Form> </Form>
); );
@ -310,7 +326,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
); );
} }
onFormSubmit = () => { onFormSubmit = (): void => {
this.props.clearErrors(); this.props.clearErrors();
if (this.body) { if (this.body) {
@ -318,35 +334,34 @@ class PanelTransition extends React.PureComponent<Props, State> {
} }
}; };
onFormInvalid = (errors: { [key: string]: ValidationError }) => onFormInvalid = (errors: Record<string, ValidationError>): void =>
this.props.setErrors(errors); this.props.setErrors(errors);
willEnter = (config: AnimationContext) => this.getTransitionStyles(config); willEnter = (config: TransitionStyle): PlainStyle => {
willLeave = (config: AnimationContext) => const transform = this.getTransformForPanel(config.key);
this.getTransitionStyles(config, { isLeave: true });
/** return {
* @param {object} config transformSpring: transform,
* @param {string} config.key opacitySpring: 1,
* @param {object} [options] };
* @param {object} [options.isLeave=false] - true, if this is a leave transition };
*
* @returns {object} willLeave = (config: TransitionStyle): Style => {
*/ const transform = this.getTransformForPanel(config.key);
getTransitionStyles(
{ key }: AnimationContext, return {
options: { isLeave?: boolean } = {}, transformSpring: spring(transform, transformSpringConfig),
): { opacitySpring: spring(0, opacitySpringConfig),
transformSpring: number; };
opacitySpring: number; };
} {
const { isLeave = false } = options; getTransformForPanel(key: PanelId): number {
const { panelId, prevPanelId } = this.state; const { panelId, prevPanelId } = this.state;
const fromLeft = -1; const fromLeft = -1;
const fromRight = 1; const fromRight = 1;
const currentContext = contexts.find(context => context.includes(key)); const currentContext = contexts.find((context) => context.includes(key));
if (!currentContext) { if (!currentContext) {
throw new Error(`Can not find settings for ${key} panel`); throw new Error(`Can not find settings for ${key} panel`);
@ -363,18 +378,11 @@ class PanelTransition extends React.PureComponent<Props, State> {
sign *= -1; sign *= -1;
} }
const transform = sign * 100; return sign * 100;
return {
transformSpring: isLeave
? spring(transform, transformSpringConfig)
: transform,
opacitySpring: isLeave ? spring(0, opacitySpringConfig) : 1,
};
} }
getDirection(next: PanelId, prev: PanelId): 'X' | 'Y' { getDirection(next: PanelId, prev: PanelId): 'X' | 'Y' {
const context = contexts.find(item => item.includes(prev)); const context = contexts.find((item) => item.includes(prev));
if (!context) { if (!context) {
throw new Error(`Can not find context for transition ${prev} -> ${next}`); throw new Error(`Can not find context for transition ${prev} -> ${next}`);
@ -383,24 +391,23 @@ class PanelTransition extends React.PureComponent<Props, State> {
return context.includes(next) ? 'X' : 'Y'; return context.includes(next) ? 'X' : 'Y';
} }
onUpdateHeight = (height: number, key: PanelId) => { onUpdateHeight = (height: number, key: PanelId): void => {
const heightKey = `formHeight${key}`;
// @ts-ignore
this.setState({ this.setState({
[heightKey]: height, formsHeights: {
...this.state.formsHeights,
[key]: height,
},
}); });
}; };
onUpdateContextHeight = (height: number) => { onUpdateContextHeight = (height: number): void => {
this.setState({ this.setState({
contextHeight: height, contextHeight: height,
}); });
}; };
onGoBack = (event: React.MouseEvent<HTMLElement>) => { onGoBack: MouseEventHandler<HTMLButtonElement> = (event): void => {
event.preventDefault(); event.preventDefault();
authFlow.goBack(); authFlow.goBack();
}; };
@ -409,7 +416,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
* *
* @param {number} length number of panels transitioned * @param {number} length number of panels transitioned
*/ */
tryToAutoFocus(length: number) { tryToAutoFocus(length: number): void {
if (!this.body) { if (!this.body) {
return; return;
} }
@ -425,20 +432,17 @@ class PanelTransition extends React.PureComponent<Props, State> {
} }
} }
shouldMeasureHeight() { shouldMeasureHeight(): string {
const { user, accounts, auth } = this.props; const { user, accounts, auth } = this.props;
const { isHeightDirty } = this.state; const { isHeightDirty } = this.state;
const errorString = Object.values(auth.error || {}).reduce( const errorString = Object.values(auth.error || {}).reduce((acc, item) => {
(acc: string, item: ValidationError): string => { if (typeof item === 'string') {
if (typeof item === 'string') { return acc + item;
return acc + item; }
}
return acc + item.type; return acc + item.type;
}, }, '') as string;
'',
) as string;
return [ return [
errorString, errorString,
@ -448,9 +452,9 @@ class PanelTransition extends React.PureComponent<Props, State> {
].join(''); ].join('');
} }
getHeader({ key, style, data }: AnimationContext) { getHeader({ key, style, data }: TransitionPlainStyle): ReactElement {
const { Title } = data; const { Title } = data as AnimationData;
const { transformSpring } = style; const { transformSpring } = (style as unknown) as AnimationStyle;
let { hasBackButton } = data; let { hasBackButton } = data;
@ -459,7 +463,10 @@ class PanelTransition extends React.PureComponent<Props, State> {
} }
const transitionStyle = { const transitionStyle = {
...this.getDefaultTransitionStyles(key, style), ...this.getDefaultTransitionStyles(
key,
(style as unknown) as AnimationStyle,
),
opacity: 1, // reset default opacity: 1, // reset default
}; };
@ -491,15 +498,12 @@ class PanelTransition extends React.PureComponent<Props, State> {
); );
} }
getBody({ key, style, data }: AnimationContext) { getBody({ key, style, data }: TransitionPlainStyle): ReactElement {
const { Body } = data; const { Body } = data as AnimationData;
const { transformSpring } = style; const { transformSpring } = (style as unknown) as AnimationStyle;
const { direction } = this.state; const { direction } = this.state;
let transform: { [key: string]: string } = this.translate( let transform = this.translate(transformSpring, direction);
transformSpring,
direction,
);
let verticalOrigin = 'top'; let verticalOrigin = 'top';
if (direction === 'Y') { if (direction === 'Y') {
@ -507,8 +511,11 @@ class PanelTransition extends React.PureComponent<Props, State> {
transform = {}; transform = {};
} }
const transitionStyle = { const transitionStyle: CSSProperties = {
...this.getDefaultTransitionStyles(key, style), ...this.getDefaultTransitionStyles(
key,
(style as unknown) as AnimationStyle,
),
top: 'auto', // reset default top: 'auto', // reset default
[verticalOrigin]: 0, [verticalOrigin]: 0,
...transform, ...transform,
@ -519,10 +526,11 @@ class PanelTransition extends React.PureComponent<Props, State> {
key={`body/${key}`} key={`body/${key}`}
style={transitionStyle} style={transitionStyle}
state={this.shouldMeasureHeight()} state={this.shouldMeasureHeight()}
onMeasure={height => this.onUpdateHeight(height, key)} onMeasure={(height) => this.onUpdateHeight(height, key)}
> >
{React.cloneElement(Body, { {React.cloneElement(Body, {
ref: body => { // @ts-ignore
ref: (body) => {
this.body = body; this.body = body;
}, },
})} })}
@ -530,10 +538,13 @@ class PanelTransition extends React.PureComponent<Props, State> {
); );
} }
getFooter({ key, style, data }: AnimationContext) { getFooter({ key, style, data }: TransitionPlainStyle): ReactElement {
const { Footer } = data; const { Footer } = data as AnimationData;
const transitionStyle = this.getDefaultTransitionStyles(key, style); const transitionStyle = this.getDefaultTransitionStyles(
key,
(style as unknown) as AnimationStyle,
);
return ( return (
<div key={`footer/${key}`} style={transitionStyle}> <div key={`footer/${key}`} style={transitionStyle}>
@ -542,10 +553,13 @@ class PanelTransition extends React.PureComponent<Props, State> {
); );
} }
getLinks({ key, style, data }: AnimationContext) { getLinks({ key, style, data }: TransitionPlainStyle): ReactElement {
const { Links } = data; const { Links } = data as AnimationData;
const transitionStyle = this.getDefaultTransitionStyles(key, style); const transitionStyle = this.getDefaultTransitionStyles(
key,
(style as unknown) as AnimationStyle,
);
return ( return (
<div key={`links/${key}`} style={transitionStyle}> <div key={`links/${key}`} style={transitionStyle}>
@ -554,16 +568,9 @@ class PanelTransition extends React.PureComponent<Props, State> {
); );
} }
/**
* @param {string} key
* @param {object} style
* @param {number} style.opacitySpring
*
* @returns {object}
*/
getDefaultTransitionStyles( getDefaultTransitionStyles(
key: string, key: string,
{ opacitySpring }: Readonly<AnimationProps>, { opacitySpring }: Readonly<AnimationStyle>,
): { ): {
position: 'absolute'; position: 'absolute';
top: number; top: number;
@ -582,7 +589,11 @@ class PanelTransition extends React.PureComponent<Props, State> {
}; };
} }
translate(value: number, direction: 'X' | 'Y' = 'X', unit: '%' | 'px' = '%') { translate(
value: number,
direction: 'X' | 'Y' = 'X',
unit: '%' | 'px' = '%',
): CSSProperties {
return { return {
WebkitTransform: `translate${direction}(${value}${unit})`, WebkitTransform: `translate${direction}(${value}${unit})`,
transform: `translate${direction}(${value}${unit})`, transform: `translate${direction}(${value}${unit})`,
@ -590,7 +601,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
} }
requestRedraw = (): Promise<void> => requestRedraw = (): Promise<void> =>
new Promise(resolve => new Promise((resolve) =>
this.setState({ isHeightDirty: true }, () => { this.setState({ isHeightDirty: true }, () => {
this.setState({ isHeightDirty: false }); this.setState({ isHeightDirty: false });

View File

@ -1,20 +1,22 @@
import React, { useContext } from 'react'; import React, { ComponentType, useContext } from 'react';
import { FormattedMessage as Message, MessageDescriptor } from 'react-intl'; import { FormattedMessage as Message, MessageDescriptor } from 'react-intl';
import Context, { AuthContext } from './Context'; import Context, { AuthContext } from './Context';
interface Props { interface Props {
isAvailable?: (context: AuthContext) => boolean; isAvailable?: (context: AuthContext) => boolean;
payload?: { [key: string]: any }; payload?: Record<string, any>;
label: MessageDescriptor; label: MessageDescriptor;
} }
export type RejectionLinkProps = Props; const RejectionLink: ComponentType<Props> = ({
isAvailable,
function RejectionLink(props: Props) { payload,
label,
}) => {
const context = useContext(Context); 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 // TODO: if want to properly support multiple links, we should control
// the dividers ' | ' rendered from factory too // the dividers ' | ' rendered from factory too
return null; return null;
@ -23,15 +25,15 @@ function RejectionLink(props: Props) {
return ( return (
<a <a
href="#" href="#"
onClick={event => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
context.reject(props.payload); context.reject(payload);
}} }}
> >
<Message {...props.label} /> <Message {...label} />
</a> </a>
); );
} };
export default RejectionLink; export default RejectionLink;

View File

@ -1,3 +1,4 @@
import { Action as ReduxAction } from 'redux';
import sinon from 'sinon'; import sinon from 'sinon';
import expect from 'app/test/unexpected'; import expect from 'app/test/unexpected';
@ -15,33 +16,36 @@ import {
login, login,
setLogin, setLogin,
} from 'app/components/auth/actions'; } from 'app/components/auth/actions';
import { OauthData, OAuthValidateResponse } from '../../services/api/oauth';
const oauthData = { const oauthData: OauthData = {
clientId: '', clientId: '',
redirectUrl: '', redirectUrl: '',
responseType: '', responseType: '',
scope: '', scope: '',
state: '', state: '',
prompt: 'none',
}; };
describe('components/auth/actions', () => { describe('components/auth/actions', () => {
const dispatch = sinon.stub().named('store.dispatch'); const dispatch = sinon.stub().named('store.dispatch');
const getState = sinon.stub().named('store.getState'); const getState = sinon.stub().named('store.getState');
function callThunk(fn, ...args) { function callThunk<A extends Array<any>, F extends (...args: A) => any>(
fn: F,
...args: A
): Promise<void> {
const thunk = fn(...args); const thunk = fn(...args);
return thunk(dispatch, getState); return thunk(dispatch, getState);
} }
function expectDispatchCalls(calls) { function expectDispatchCalls(calls: Array<Array<ReduxAction>>) {
expect( expect(dispatch, 'to have calls satisfying', [
dispatch, [setLoadingState(true)],
'to have calls satisfying', ...calls,
[[setLoadingState(true)]] [setLoadingState(false)],
.concat(calls) ]);
.concat([[setLoadingState(false)]]),
);
} }
beforeEach(() => { beforeEach(() => {
@ -58,14 +62,20 @@ describe('components/auth/actions', () => {
}); });
describe('#oAuthValidate()', () => { describe('#oAuthValidate()', () => {
let resp; let resp: OAuthValidateResponse;
beforeEach(() => { beforeEach(() => {
resp = { resp = {
client: { id: 123 }, client: {
oAuth: { state: 123 }, id: '123',
name: '',
description: '',
},
oAuth: {
state: 123,
},
session: { session: {
scopes: ['scopes'], scopes: ['account_info'],
}, },
}; };
@ -114,7 +124,7 @@ describe('components/auth/actions', () => {
return callThunk(oAuthComplete).then(() => { return callThunk(oAuthComplete).then(() => {
expect(request.post, 'to have a call satisfying', [ expect(request.post, 'to have a call satisfying', [
'/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&prompt=&login_hint=&state=', '/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&prompt=none&login_hint=&state=',
{}, {},
]); ]);
}); });
@ -166,7 +176,7 @@ describe('components/auth/actions', () => {
(request.post as any).returns(Promise.reject(resp)); (request.post as any).returns(Promise.reject(resp));
return callThunk(oAuthComplete).catch(error => { return callThunk(oAuthComplete).catch((error) => {
expect(error.acceptRequired, 'to be true'); expect(error.acceptRequired, 'to be true');
expectDispatchCalls([[requirePermissionsAccept()]]); expectDispatchCalls([[requirePermissionsAccept()]]);
}); });

View File

@ -1,7 +1,8 @@
import { Action as ReduxAction } from 'redux';
import { browserHistory } from 'app/services/history'; import { browserHistory } from 'app/services/history';
import logger from 'app/services/logger'; import logger from 'app/services/logger';
import localStorage from 'app/services/localStorage'; 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 history from 'app/services/history';
import { import {
updateUser, updateUser,
@ -15,22 +16,27 @@ import {
recoverPassword as recoverPasswordEndpoint, recoverPassword as recoverPasswordEndpoint,
OAuthResponse, OAuthResponse,
} from 'app/services/api/authentication'; } from 'app/services/api/authentication';
import oauth, { OauthData, Client, Scope } from 'app/services/api/oauth'; import oauth, { OauthData, Scope } from 'app/services/api/oauth';
import signup from 'app/services/api/signup'; import {
register as registerEndpoint,
activate as activateEndpoint,
resendActivation as resendActivationEndpoint,
} from 'app/services/api/signup';
import dispatchBsod from 'app/components/ui/bsod/dispatchBsod'; import dispatchBsod from 'app/components/ui/bsod/dispatchBsod';
import { create as createPopup } from 'app/components/ui/popup/actions'; import { create as createPopup } from 'app/components/ui/popup/actions';
import ContactForm from 'app/components/contact/ContactForm'; import ContactForm from 'app/components/contact/ContactForm';
import { Account } from 'app/components/accounts/reducer'; import { Account } from 'app/components/accounts/reducer';
import { ThunkAction, Dispatch } from 'app/reducers'; 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 = interface ValidationErrorLiteral {
| string type: string;
| { payload: Record<string, any>;
type: string; }
payload: { [key: string]: any };
}; type ValidationError = string | ValidationErrorLiteral;
/** /**
* Routes user to the previous page if it is possible * Routes user to the previous page if it is possible
@ -81,10 +87,10 @@ export function login({
totp?: string; totp?: string;
rememberMe?: boolean; rememberMe?: boolean;
}) { }) {
return wrapInLoader(dispatch => return wrapInLoader((dispatch) =>
loginEndpoint({ login, password, totp, rememberMe }) loginEndpoint({ login, password, totp, rememberMe })
.then(authHandler(dispatch)) .then(authHandler(dispatch))
.catch(resp => { .catch((resp) => {
if (resp.errors) { if (resp.errors) {
if (resp.errors.password === PASSWORD_REQUIRED) { if (resp.errors.password === PASSWORD_REQUIRED) {
return dispatch(setLogin(login)); return dispatch(setLogin(login));
@ -112,7 +118,7 @@ export function login({
} }
export function acceptRules() { export function acceptRules() {
return wrapInLoader(dispatch => return wrapInLoader((dispatch) =>
dispatch(userAcceptRules()).catch(validationErrorsHandler(dispatch)), dispatch(userAcceptRules()).catch(validationErrorsHandler(dispatch)),
); );
} }
@ -146,7 +152,7 @@ export function recoverPassword({
newPassword: string; newPassword: string;
newRePassword: string; newRePassword: string;
}) { }) {
return wrapInLoader(dispatch => return wrapInLoader((dispatch) =>
recoverPasswordEndpoint(key, newPassword, newRePassword) recoverPasswordEndpoint(key, newPassword, newRePassword)
.then(authHandler(dispatch)) .then(authHandler(dispatch))
.catch(validationErrorsHandler(dispatch, '/forgot-password')), .catch(validationErrorsHandler(dispatch, '/forgot-password')),
@ -169,16 +175,15 @@ export function register({
rulesAgreement: boolean; rulesAgreement: boolean;
}) { }) {
return wrapInLoader((dispatch, getState) => return wrapInLoader((dispatch, getState) =>
signup registerEndpoint({
.register({ email,
email, username,
username, password,
password, rePassword,
rePassword, rulesAgreement,
rulesAgreement, lang: getState().user.lang,
lang: getState().user.lang, captcha,
captcha, })
})
.then(() => { .then(() => {
dispatch( dispatch(
updateUser({ updateUser({
@ -200,9 +205,8 @@ export function activate({
}: { }: {
key: string; key: string;
}): ThunkAction<Promise<Account>> { }): ThunkAction<Promise<Account>> {
return wrapInLoader(dispatch => return wrapInLoader((dispatch) =>
signup activateEndpoint(key)
.activate({ key })
.then(authHandler(dispatch)) .then(authHandler(dispatch))
.catch(validationErrorsHandler(dispatch, '/resend-activation')), .catch(validationErrorsHandler(dispatch, '/resend-activation')),
); );
@ -215,10 +219,9 @@ export function resendActivation({
email: string; email: string;
captcha: string; captcha: string;
}) { }) {
return wrapInLoader(dispatch => return wrapInLoader((dispatch) =>
signup resendActivationEndpoint(email, captcha)
.resendActivation({ email, captcha }) .then((resp) => {
.then(resp => {
dispatch( dispatch(
updateUser({ updateUser({
email, email,
@ -235,25 +238,26 @@ export function contactUs() {
return createPopup({ Popup: ContactForm }); 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 * Sets login in credentials state
*
* Resets the state, when `null` is passed * Resets the state, when `null` is passed
* *
* @param {string|null} login * @param login
*
* @returns {object}
*/ */
export function setLogin(login: string | null) { export function setLogin(login: string | null): SetCredentialsAction {
return { return setCredentials(login ? { login } : null);
type: SET_CREDENTIALS,
payload: login
? {
login,
}
: null,
};
} }
export function relogin(login: string | null): ThunkAction { export function relogin(login: string | null): ThunkAction {
@ -262,19 +266,20 @@ export function relogin(login: string | null): ThunkAction {
const returnUrl = const returnUrl =
credentials.returnUrl || location.pathname + location.search; credentials.returnUrl || location.pathname + location.search;
dispatch({ dispatch(
type: SET_CREDENTIALS, setCredentials({
payload: {
login, login,
returnUrl, returnUrl,
isRelogin: true, isRelogin: true,
}, }),
}); );
browserHistory.push('/login'); browserHistory.push('/login');
}; };
} }
export type CredentialsAction = SetCredentialsAction;
function requestTotp({ function requestTotp({
login, login,
password, password,
@ -288,41 +293,55 @@ function requestTotp({
// merging with current credentials to propogate returnUrl // merging with current credentials to propogate returnUrl
const credentials = getCredentials(getState()); const credentials = getCredentials(getState());
dispatch({ dispatch(
type: SET_CREDENTIALS, setCredentials({
payload: {
...credentials, ...credentials,
login, login,
password, password,
rememberMe, rememberMe,
isTotpRequired: true, isTotpRequired: true,
}, }),
}); );
}; };
} }
export const SET_SWITCHER = 'auth:setAccountSwitcher'; interface SetSwitcherAction extends ReduxAction {
export function setAccountSwitcher(isOn: boolean) { type: 'auth:setAccountSwitcher';
payload: boolean;
}
export function setAccountSwitcher(isOn: boolean): SetSwitcherAction {
return { return {
type: SET_SWITCHER, type: 'auth:setAccountSwitcher',
payload: isOn, payload: isOn,
}; };
} }
export const ERROR = 'auth:error'; export type AccountSwitcherAction = SetSwitcherAction;
export function setErrors(errors: { [key: string]: ValidationError } | null) {
interface SetErrorAction extends ReduxAction {
type: 'auth:error';
payload: Record<string, ValidationError> | null;
error: boolean;
}
export function setErrors(
errors: Record<string, ValidationError> | null,
): SetErrorAction {
return { return {
type: ERROR, type: 'auth:error',
payload: errors, payload: errors,
error: true, error: true,
}; };
} }
export function clearErrors() { export function clearErrors(): SetErrorAction {
return setErrors(null); return setErrors(null);
} }
const KNOWN_SCOPES = [ export type ErrorAction = SetErrorAction;
const KNOWN_SCOPES: ReadonlyArray<string> = [
'minecraft_server_session', 'minecraft_server_session',
'offline_access', 'offline_access',
'account_info', 'account_info',
@ -349,17 +368,17 @@ const KNOWN_SCOPES = [
export function oAuthValidate(oauthData: OauthData) { export function oAuthValidate(oauthData: OauthData) {
// TODO: move to oAuth actions? // TODO: move to oAuth actions?
// test request: /oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session&description=foo // test request: /oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session&description=foo
return wrapInLoader(dispatch => return wrapInLoader((dispatch) =>
oauth oauth
.validate(oauthData) .validate(oauthData)
.then(resp => { .then((resp) => {
const { scopes } = resp.session; const { scopes } = resp.session;
const invalidScopes = scopes.filter( const invalidScopes = scopes.filter(
scope => !KNOWN_SCOPES.includes(scope), (scope) => !KNOWN_SCOPES.includes(scope),
); );
let prompt = (oauthData.prompt || 'none') let prompt = (oauthData.prompt || 'none')
.split(',') .split(',')
.map(item => item.trim()); .map((item) => item.trim());
if (prompt.includes('none')) { if (prompt.includes('none')) {
prompt = ['none']; prompt = ['none'];
@ -396,7 +415,7 @@ export function oAuthValidate(oauthData: OauthData) {
/** /**
* @param {object} params * @param {object} params
* @param {bool} params.accept=false * @param {bool} params.accept=false
* * @param params.accept
* @returns {Promise} * @returns {Promise}
*/ */
export function oAuthComplete(params: { accept?: boolean } = {}) { export function oAuthComplete(params: { accept?: boolean } = {}) {
@ -470,11 +489,76 @@ function handleOauthParamsValidation(
return Promise.reject(resp); return Promise.reject(resp);
} }
export const SET_CLIENT = 'set_client'; interface SetClientAction extends ReduxAction {
export function setClient({ id, name, description }: Client) { type: 'set_client';
payload: Client;
}
export function setClient(payload: Client): SetClientAction {
return { return {
type: SET_CLIENT, type: 'set_client',
payload: { id, name, description }, 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<OAuthState, 'success' | 'code' | 'displayCode'>;
}
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'; interface RequestPermissionsAcceptAction extends ReduxAction {
export function setOAuthRequest(data: { type: 'require_permissions_accept';
client_id?: string; }
redirect_uri?: string;
response_type?: string; export function requirePermissionsAccept(): RequestPermissionsAcceptAction {
scope?: string;
prompt?: string;
loginHint?: string;
state?: string;
}) {
return { return {
type: SET_OAUTH, type: 'require_permissions_accept',
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,
},
}; };
} }
export const SET_OAUTH_RESULT = 'set_oauth_result'; export type OAuthAction =
export function setOAuthCode(data: { | SetOauthAction
success: boolean; | SetOAuthResultAction
code: string; | RequestPermissionsAcceptAction;
displayCode: boolean;
}) { interface SetScopesAction extends ReduxAction {
type: 'set_scopes';
payload: Array<Scope>;
}
export function setScopes(payload: Array<Scope>): SetScopesAction {
return { return {
type: SET_OAUTH_RESULT, type: 'set_scopes',
payload: { payload,
success: data.success,
code: data.code,
displayCode: data.displayCode,
},
}; };
} }
export const REQUIRE_PERMISSIONS_ACCEPT = 'require_permissions_accept'; export type ScopesAction = SetScopesAction;
export function requirePermissionsAccept() {
return { interface SetLoadingAction extends ReduxAction {
type: REQUIRE_PERMISSIONS_ACCEPT, type: 'set_loading_state';
}; payload: boolean;
} }
export const SET_SCOPES = 'set_scopes'; export function setLoadingState(isLoading: boolean): SetLoadingAction {
export function setScopes(scopes: Scope[]) {
if (!Array.isArray(scopes)) {
throw new Error('Scopes must be array');
}
return { return {
type: SET_SCOPES, type: 'set_loading_state',
payload: scopes,
};
}
export const SET_LOADING_STATE = 'set_loading_state';
export function setLoadingState(isLoading: boolean) {
return {
type: SET_LOADING_STATE,
payload: isLoading, payload: isLoading,
}; };
} }
@ -580,12 +638,12 @@ function wrapInLoader<T>(fn: ThunkAction<Promise<T>>): ThunkAction<Promise<T>> {
const endLoading = () => dispatch(setLoadingState(false)); const endLoading = () => dispatch(setLoadingState(false));
return fn(dispatch, getState, undefined).then( return fn(dispatch, getState, undefined).then(
resp => { (resp) => {
endLoading(); endLoading();
return resp; return resp;
}, },
resp => { (resp) => {
endLoading(); endLoading();
return Promise.reject(resp); return Promise.reject(resp);
@ -594,6 +652,8 @@ function wrapInLoader<T>(fn: ThunkAction<Promise<T>>): ThunkAction<Promise<T>> {
}; };
} }
export type LoadingAction = SetLoadingAction;
function needActivation() { function needActivation() {
return updateUser({ return updateUser({
isActive: false, isActive: false,
@ -608,19 +668,27 @@ function authHandler(dispatch: Dispatch) {
token: oAuthResp.access_token, token: oAuthResp.access_token,
refreshToken: oAuthResp.refresh_token || null, refreshToken: oAuthResp.refresh_token || null,
}), }),
).then(resp => { ).then((resp) => {
dispatch(setLogin(null)); dispatch(setLogin(null));
return resp; return resp;
}); });
} }
function validationErrorsHandler(dispatch: Dispatch, repeatUrl?: string) { function validationErrorsHandler(
return resp => { dispatch: Dispatch,
repeatUrl?: string,
): (
resp: Resp<{
errors?: Record<string, string | ValidationError>;
data?: Record<string, any>;
}>,
) => Promise<never> {
return (resp) => {
if (resp.errors) { if (resp.errors) {
const [firstError] = Object.keys(resp.errors); const [firstError] = Object.keys(resp.errors);
const error = { const firstErrorObj: ValidationError = {
type: resp.errors[firstError], type: resp.errors[firstError] as string,
payload: { payload: {
isGuest: true, isGuest: true,
repeatUrl: '', repeatUrl: '',
@ -629,20 +697,24 @@ function validationErrorsHandler(dispatch: Dispatch, repeatUrl?: string) {
if (resp.data) { if (resp.data) {
// TODO: this should be formatted on backend // TODO: this should be formatted on backend
Object.assign(error.payload, resp.data); Object.assign(firstErrorObj.payload, resp.data);
} }
if ( if (
['error.key_not_exists', 'error.key_expire'].includes(error.type) && ['error.key_not_exists', 'error.key_expire'].includes(
firstErrorObj.type,
) &&
repeatUrl repeatUrl
) { ) {
// TODO: this should be formatted on backend // 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 } = resp;
errors[firstError] = firstErrorObj;
dispatch(setErrors(resp.errors)); dispatch(setErrors(errors));
} }
return Promise.reject(resp); return Promise.reject(resp);

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
@ -13,14 +12,6 @@ export default class ActivationBody extends BaseAuthBody {
static displayName = 'ActivationBody'; static displayName = 'ActivationBody';
static panelId = 'activation'; static panelId = 'activation';
static propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
key: PropTypes.string,
}),
}),
};
autoFocusField = autoFocusField =
this.props.match.params && this.props.match.params.key ? null : 'key'; this.props.match.params && this.props.match.params.key ? null : 'key';

View File

@ -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 (
<PanelBodyHeader
type="error"
onClose={() => {
resetTimer();
onClose();
}}
>
{errorsDict.resolve(error)}
</PanelBodyHeader>
);
}
AuthError.displayName = 'AuthError';
AuthError.propTypes = {
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
type: PropTypes.string,
payload: PropTypes.object,
}),
]).isRequired,
onClose: PropTypes.func,
};

View File

@ -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<Props> = ({ 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 (
<PanelBodyHeader type="error" onClose={onClose}>
{resolveError(error)}
</PanelBodyHeader>
);
};
export default AuthError;

View File

@ -4,6 +4,7 @@ import { FormattedMessage as Message } from 'react-intl';
import BaseAuthBody from 'app/components/auth/BaseAuthBody'; import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import { AccountSwitcher } from 'app/components/accounts'; import { AccountSwitcher } from 'app/components/accounts';
import { Account } from 'app/components/accounts/reducer';
import styles from './chooseAccount.scss'; import styles from './chooseAccount.scss';
import messages from './ChooseAccount.intl.json'; 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); this.context.resolve(account);
}; };
} }

View File

@ -1,36 +1,36 @@
import React from 'react'; import React, { ComponentProps, ComponentType } from 'react';
import { Button } from 'app/components/ui/form'; import { Button } from 'app/components/ui/form';
import RejectionLink, { import RejectionLink from 'app/components/auth/RejectionLink';
RejectionLinkProps,
} from 'app/components/auth/RejectionLink';
import AuthTitle from 'app/components/auth/AuthTitle'; import AuthTitle from 'app/components/auth/AuthTitle';
import { MessageDescriptor } from 'react-intl'; import { MessageDescriptor } from 'react-intl';
import { Color } from 'app/components/ui'; import { Color } from 'app/components/ui';
import BaseAuthBody from './BaseAuthBody';
/** export type Factory = () => {
* @param {object} options Title: ComponentType;
* @param {string|object} options.title - panel title Body: typeof BaseAuthBody;
* @param {React.ReactElement} options.body Footer: ComponentType;
* @param {object} options.footer - config for footer Button Links: ComponentType;
* @param {Array|object|null} options.links - link config or an array of link configs };
*
* @returns {object} - structure, required for auth panel to work type RejectionLinkProps = ComponentProps<typeof RejectionLink>;
*/ interface FactoryParams {
export default function({
title,
body,
footer,
links,
}: {
title: MessageDescriptor; title: MessageDescriptor;
body: React.ElementType; body: typeof BaseAuthBody;
footer: { footer: {
color?: Color; color?: Color;
label: string | MessageDescriptor; label: string | MessageDescriptor;
autoFocus?: boolean; autoFocus?: boolean;
}; };
links?: RejectionLinkProps | RejectionLinkProps[]; links?: RejectionLinkProps | Array<RejectionLinkProps>;
}) { }
export default function ({
title,
body,
footer,
links,
}: FactoryParams): Factory {
return () => ({ return () => ({
Title: () => <AuthTitle title={title} />, Title: () => <AuthTitle title={title} />,
Body: body, Body: body,
@ -38,7 +38,7 @@ export default function({
Links: () => Links: () =>
links ? ( links ? (
<span> <span>
{([] as RejectionLinkProps[]) {([] as Array<RejectionLinkProps>)
.concat(links) .concat(links)
.map((link, index) => [ .map((link, index) => [
index ? ' | ' : '', index ? ' | ' : '',

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { MouseEventHandler } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
@ -13,7 +13,7 @@ interface Props {
appName: string; appName: string;
code?: string; code?: string;
state: string; state: string;
displayCode?: string; displayCode?: boolean;
success?: boolean; success?: boolean;
} }
@ -21,7 +21,6 @@ class Finish extends React.Component<Props> {
render() { render() {
const { appName, code, state, displayCode, success } = this.props; const { appName, code, state, displayCode, success } = this.props;
const authData = JSON.stringify({ const authData = JSON.stringify({
// eslint-disable-next-line @typescript-eslint/camelcase
auth_code: code, auth_code: code,
state, state,
}); });
@ -84,7 +83,7 @@ class Finish extends React.Component<Props> {
); );
} }
onCopyClick = event => { onCopyClick: MouseEventHandler = (event) => {
event.preventDefault(); event.preventDefault();
const { code } = this.props; const { code } = this.props;

View File

@ -10,7 +10,7 @@ export default factory({
label: messages.next, label: messages.next,
}, },
links: { links: {
isAvailable: context => !context.user.isGuest, isAvailable: (context) => !context.user.isGuest,
label: messages.createNewAccount, label: messages.createNewAccount,
}, },
}); });

View File

@ -1,13 +1,15 @@
import React from 'react'; import React from 'react';
import { Input } from 'app/components/ui/form'; import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody'; import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import { User } from 'app/components/user/reducer';
import messages from './Login.intl.json'; import messages from './Login.intl.json';
export default class LoginBody extends BaseAuthBody { export default class LoginBody extends BaseAuthBody {
static displayName = 'LoginBody'; static displayName = 'LoginBody';
static panelId = 'login'; static panelId = 'login';
static hasGoBack = state => { static hasGoBack = (state: { user: User }) => {
return !state.user.isGuest; return !state.user.isGuest;
}; };

View File

@ -41,7 +41,7 @@ export default class PermissionsBody extends BaseAuthBody {
<Message {...messages.theAppNeedsAccess2} /> <Message {...messages.theAppNeedsAccess2} />
</div> </div>
<ul className={styles.permissionsList}> <ul className={styles.permissionsList}>
{scopes.map(scope => { {scopes.map((scope) => {
const key = `scope_${scope}`; const key = `scope_${scope}`;
const message = messages[key]; const message = messages[key];
@ -50,7 +50,7 @@ export default class PermissionsBody extends BaseAuthBody {
{message ? ( {message ? (
<Message {...message} /> <Message {...message} />
) : ( ) : (
scope.replace(/^\w|_/g, match => scope.replace(/^\w|_/g, (match) =>
match.replace('_', ' ').toUpperCase(), match.replace('_', ' ').toUpperCase(),
) )
)} )}

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
@ -16,14 +15,6 @@ export default class RecoverPasswordBody extends BaseAuthBody {
static panelId = 'recoverPassword'; static panelId = 'recoverPassword';
static hasGoBack = true; static hasGoBack = true;
static propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
key: PropTypes.string,
}),
}),
};
autoFocusField = autoFocusField =
this.props.match.params && this.props.match.params.key this.props.match.params && this.props.match.params.key
? 'newPassword' ? 'newPassword'

View File

@ -1,14 +1,9 @@
import expect from 'app/test/unexpected'; import expect from 'app/test/unexpected';
import auth from './reducer'; import auth from './reducer';
import { import { setLogin, setAccountSwitcher } from './actions';
setLogin,
SET_CREDENTIALS,
setAccountSwitcher,
SET_SWITCHER,
} from './actions';
describe('components/auth/reducer', () => { describe('components/auth/reducer', () => {
describe(SET_CREDENTIALS, () => { describe('auth:setCredentials', () => {
it('should set login', () => { it('should set login', () => {
const expectedLogin = 'foo'; const expectedLogin = 'foo';
@ -22,7 +17,7 @@ describe('components/auth/reducer', () => {
}); });
}); });
describe(SET_SWITCHER, () => { describe('auth:setAccountSwitcher', () => {
it('should be enabled by default', () => it('should be enabled by default', () =>
expect(auth(undefined, {} as any), 'to satisfy', { expect(auth(undefined, {} as any), 'to satisfy', {
isSwitcherEnabled: true, isSwitcherEnabled: true,

View File

@ -1,26 +1,34 @@
import { combineReducers } from 'redux'; import { combineReducers, Reducer } from 'redux';
import { RootState } from 'app/reducers'; import { RootState } from 'app/reducers';
import { Scope } from '../../services/api/oauth';
import { import {
ERROR, ErrorAction,
SET_CLIENT, CredentialsAction,
SET_OAUTH, AccountSwitcherAction,
SET_OAUTH_RESULT, LoadingAction,
SET_SCOPES, ClientAction,
SET_LOADING_STATE, OAuthAction,
REQUIRE_PERMISSIONS_ACCEPT, ScopesAction,
SET_CREDENTIALS,
SET_SWITCHER,
} from './actions'; } from './actions';
type Credentials = { export interface Credentials {
login?: string; login?: string | null; // By some reasons there is can be null value. Need to investigate.
password?: string; password?: string;
rememberMe?: boolean; rememberMe?: boolean;
returnUrl?: string; returnUrl?: string;
isRelogin?: boolean; isRelogin?: boolean;
isTotpRequired?: boolean; isTotpRequired?: boolean;
}; }
type Error = Record<
string,
| string
| {
type: string;
payload: Record<string, any>;
}
> | null;
export interface Client { export interface Client {
id: string; id: string;
@ -28,7 +36,7 @@ export interface Client {
description: string; description: string;
} }
interface OAuthState { export interface OAuthState {
clientId: string; clientId: string;
redirectUrl: string; redirectUrl: string;
responseType: string; responseType: string;
@ -39,27 +47,113 @@ interface OAuthState {
state: string; state: string;
success?: boolean; success?: boolean;
code?: string; code?: string;
displayCode?: string; displayCode?: boolean;
acceptRequired?: boolean; acceptRequired?: boolean;
} }
type Scopes = Array<Scope>;
export interface State { export interface State {
credentials: Credentials; credentials: Credentials;
error: null | { error: Error;
[key: string]:
| string
| {
type: string;
payload: { [key: string]: any };
};
};
isLoading: boolean; isLoading: boolean;
isSwitcherEnabled: boolean; isSwitcherEnabled: boolean;
client: Client | null; client: Client | null;
oauth: OAuthState | null; oauth: OAuthState | null;
scopes: string[]; scopes: Scopes;
} }
const error: Reducer<State['error'], ErrorAction> = (
state = null,
{ type, payload },
) => {
if (type === 'auth:error') {
return payload;
}
return state;
};
const credentials: Reducer<State['credentials'], CredentialsAction> = (
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['isLoading'], LoadingAction> = (
state = false,
{ type, payload },
) => {
if (type === 'set_loading_state') {
return payload;
}
return state;
};
const client: Reducer<State['client'], ClientAction> = (
state = null,
{ type, payload },
) => {
if (type === 'set_client') {
return payload;
}
return state;
};
const oauth: Reducer<State['oauth'], OAuthAction> = (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['scopes'], ScopesAction> = (
state = [],
{ type, payload },
) => {
if (type === 'set_scopes') {
return payload;
}
return state;
};
export default combineReducers<State>({ export default combineReducers<State>({
credentials, credentials,
error, error,
@ -70,135 +164,6 @@ export default combineReducers<State>({
scopes, 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( export function getLogin(
state: RootState | Pick<RootState, 'auth'>, state: RootState | Pick<RootState, 'auth'>,
): string | null { ): string | null {

View File

@ -1,55 +1,60 @@
import React from 'react'; import React from 'react';
import expect from 'app/test/unexpected'; import expect from 'app/test/unexpected';
import sinon from 'sinon'; import sinon from 'sinon';
import { shallow, mount } from 'enzyme'; import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import feedback from 'app/services/api/feedback'; import feedback from 'app/services/api/feedback';
import { User } from 'app/components/user'; import { User } from 'app/components/user';
import { TestContextProvider } from 'app/shell';
import { ContactForm } from './ContactForm'; import { ContactForm } from './ContactForm';
describe('ContactForm', () => { beforeEach(() => {
describe('when rendered', () => { sinon.stub(feedback, 'send').returns(Promise.resolve() as any);
const user = {} as User; });
let component;
beforeEach(() => { afterEach(() => {
component = shallow(<ContactForm user={user} />); (feedback.send as any).restore();
}); });
describe('ContactForm', () => {
it('should contain Form', () => {
const user = {} as User;
render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
expect(screen.getAllByRole('textbox').length, 'to be greater than', 1);
expect(
screen.getByRole('button', { name: /Send/ }),
'to have property',
'type',
'submit',
);
[ [
{ {
type: 'Input', label: 'subject',
name: 'subject', name: 'subject',
}, },
{ {
type: 'Input', label: 'Email',
name: 'email', name: 'email',
}, },
{ {
type: 'Dropdown', label: 'message',
name: 'category',
},
{
type: 'TextArea',
name: 'message', name: 'message',
}, },
].forEach(el => { ].forEach((el) => {
it(`should have ${el.name} field`, () => { expect(
expect(component.find(`${el.type}[name="${el.name}"]`), 'to satisfy', { screen.getByLabelText(el.label, { exact: false }),
length: 1, 'to have property',
}); 'name',
}); el.name,
}); );
it('should contain Form', () => {
expect(component.find('Form'), 'to satisfy', { length: 1 });
});
it('should contain submit Button', () => {
expect(component.find('Button[type="submit"]'), 'to satisfy', {
length: 1,
});
}); });
}); });
@ -57,146 +62,109 @@ describe('ContactForm', () => {
const user = { const user = {
email: 'foo@bar.com', email: 'foo@bar.com',
} as User; } as User;
let component;
beforeEach(() => {
component = shallow(<ContactForm user={user} />);
});
it('should render email field with user email', () => { it('should render email field with user email', () => {
render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
expect(screen.getByDisplayValue(user.email), 'to be a', HTMLInputElement);
});
});
it('should submit and then hide form and display success message', async () => {
const user = {
email: 'foo@bar.com',
} as User;
render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
fireEvent.change(screen.getByLabelText(/subject/i), {
target: {
value: 'subject',
},
});
fireEvent.change(screen.getByLabelText(/message/i), {
target: {
value: 'the message',
},
});
const button = screen.getByRole('button', { name: 'Send' });
expect(button, 'to have property', 'disabled', false);
fireEvent.click(button);
expect(button, 'to have property', 'disabled', true);
expect(feedback.send, 'to have a call exhaustively satisfying', [
{
subject: 'subject',
email: user.email,
category: '',
message: 'the message',
},
]);
await waitFor(() => {
expect( expect(
component.find('Input[name="email"]').prop('defaultValue'), screen.getByText('Your message was received', { exact: false }),
'to equal', 'to be a',
user.email, HTMLElement,
); );
}); });
expect(screen.getByText(user.email), 'to be a', HTMLElement);
expect(screen.queryByRole('button', { name: /Send/ }), 'to be null');
}); });
describe('when email was successfully sent', () => { it('should show validation messages', async () => {
const user = { const user = {
email: 'foo@bar.com', email: 'foo@bar.com',
} as User; } as User;
let component;
beforeEach(() => { (feedback.send as any).callsFake(() =>
component = shallow(<ContactForm user={user} />); Promise.reject({
success: false,
errors: { email: 'error.email_invalid' },
}),
);
component.setState({ isSuccessfullySent: true }); render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
fireEvent.change(screen.getByLabelText(/subject/i), {
target: {
value: 'subject',
},
}); });
it('should not contain Form', () => { fireEvent.change(screen.getByLabelText(/message/i), {
expect(component.find('Form'), 'to satisfy', { length: 0 }); target: {
value: 'the message',
},
}); });
});
xdescribe('validation', () => { fireEvent.click(screen.getByRole('button', { name: 'Send' }));
const user = {
email: 'foo@bar.com',
} as User;
let component;
let wrapper;
beforeEach(() => { await waitFor(() => {
// TODO: add polyfill for from validation for jsdom expect(
screen.getByRole('alert'),
wrapper = mount( 'to have property',
<IntlProvider locale="en" defaultLocale="en"> 'innerHTML',
<ContactForm user={user} ref={el => (component = el)} /> 'Email is invalid',
</IntlProvider>,
); );
}); });
it('should require email, subject and message', () => {
// wrapper.find('[type="submit"]').simulate('click');
wrapper.find('form').simulate('submit');
expect(component.form.hasErrors(), 'to be true');
});
});
describe('when user submits form', () => {
const user = {
email: 'foo@bar.com',
} as User;
let component;
let wrapper;
const requestData = {
email: user.email,
subject: 'Test subject',
message: 'Test message',
};
beforeEach(() => {
sinon.stub(feedback, 'send');
// TODO: add polyfill for from validation for jsdom
if (!(Element.prototype as any).checkValidity) {
(Element.prototype as any).checkValidity = () => true;
}
// TODO: try to rewrite with unexpected-react
wrapper = mount(
<IntlProvider locale="en" defaultLocale="en">
<ContactForm user={user} ref={el => (component = el)} />
</IntlProvider>,
);
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;
});
afterEach(() => {
(feedback.send as any).restore();
});
xit('should call onSubmit', () => {
sinon.stub(component, 'onSubmit');
wrapper.find('form').simulate('submit');
expect(component.onSubmit, 'was called');
});
it('should call send with required data', () => {
(feedback.send as any).returns(Promise.resolve());
component.onSubmit();
expect(feedback.send, 'to have a call satisfying', [requestData]);
});
it('should set isSuccessfullySent', () => {
(feedback.send as any).returns(Promise.resolve());
return component
.onSubmit()
.then(() =>
expect(component.state, 'to satisfy', { isSuccessfullySent: true }),
);
});
it('should handle isLoading during request', () => {
(feedback.send as any).returns(Promise.resolve());
const promise = component.onSubmit();
expect(component.state, 'to satisfy', { isLoading: true });
return promise.then(() =>
expect(component.state, 'to satisfy', { isLoading: false }),
);
});
it('should render success message with user email', () => {
(feedback.send as any).returns(Promise.resolve());
return component
.onSubmit()
.then(() => expect(wrapper.text(), 'to contain', user.email));
});
}); });
}); });

View File

@ -140,7 +140,12 @@ export class ContactForm extends React.Component<
</div> </div>
<div className={styles.footer}> <div className={styles.footer}>
<Button label={messages.send} block type="submit" /> <Button
label={messages.send}
block
type="submit"
disabled={isLoading}
/>
</div> </div>
</Form> </Form>
); );
@ -172,9 +177,9 @@ export class ContactForm extends React.Component<
); );
} }
onSubmit = () => { onSubmit = (): Promise<void> => {
if (this.state.isLoading) { if (this.state.isLoading) {
return; return Promise.resolve();
} }
this.setState({ isLoading: true }); this.setState({ isLoading: true });
@ -187,7 +192,7 @@ export class ContactForm extends React.Component<
lastEmail: this.form.value('email'), lastEmail: this.form.value('email'),
}), }),
) )
.catch(resp => { .catch((resp) => {
if (resp.errors) { if (resp.errors) {
this.form.setErrors(resp.errors); this.form.setErrors(resp.errors);

View File

@ -12,7 +12,7 @@ function ContactLink({ createContactPopup, ...props }: Props) {
<a <a
href="#" href="#"
data-e2e-button="feedbackPopup" data-e2e-button="feedbackPopup"
onClick={event => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
createContactPopup(); createContactPopup();

View File

@ -1,17 +1,15 @@
import { Dispatch } from 'redux'; import { Dispatch, Action as ReduxAction } from 'redux';
import { OauthAppResponse } from 'app/services/api/oauth'; import { OauthAppResponse } from 'app/services/api/oauth';
import oauth from 'app/services/api/oauth'; import oauth from 'app/services/api/oauth';
import { User } from 'app/components/user'; import { User } from 'app/components/user';
import { ThunkAction } from 'app/reducers';
import { Apps } from './reducer'; import { Apps } from './reducer';
type SetAvailableAction = { interface SetAvailableAction extends ReduxAction {
type: 'apps:setAvailable'; type: 'apps:setAvailable';
payload: Array<OauthAppResponse>; payload: Array<OauthAppResponse>;
}; }
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<OauthAppResponse>): SetAvailableAction { export function setAppsList(apps: Array<OauthAppResponse>): SetAvailableAction {
return { return {
@ -24,17 +22,22 @@ export function getApp(
state: { apps: Apps }, state: { apps: Apps },
clientId: string, clientId: string,
): OauthAppResponse | null { ): OauthAppResponse | null {
return state.apps.available.find(app => app.clientId === clientId) || null; return state.apps.available.find((app) => app.clientId === clientId) || null;
} }
export function fetchApp(clientId: string) { export function fetchApp(clientId: string): ThunkAction<Promise<void>> {
return async (dispatch: Dispatch<any>): Promise<void> => { return async (dispatch) => {
const app = await oauth.getApp(clientId); const app = await oauth.getApp(clientId);
dispatch(addApp(app)); dispatch(addApp(app));
}; };
} }
interface AddAppAction extends ReduxAction {
type: 'apps:addApp';
payload: OauthAppResponse;
}
function addApp(app: OauthAppResponse): AddAppAction { function addApp(app: OauthAppResponse): AddAppAction {
return { return {
type: 'apps:addApp', 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 { function createDeleteAppAction(clientId: string): DeleteAppAction {
return { return {
type: 'apps:deleteApp', type: 'apps:deleteApp',
@ -76,8 +84,11 @@ function createDeleteAppAction(clientId: string): DeleteAppAction {
}; };
} }
export function resetApp(clientId: string, resetSecret: boolean) { export function resetApp(
return async (dispatch: Dispatch<any>): Promise<void> => { clientId: string,
resetSecret: boolean,
): ThunkAction<Promise<void>> {
return async (dispatch) => {
const { data: app } = await oauth.reset(clientId, resetSecret); const { data: app } = await oauth.reset(clientId, resetSecret);
if (resetSecret) { if (resetSecret) {
@ -85,3 +96,5 @@ export function resetApp(clientId: string, resetSecret: boolean) {
} }
}; };
} }
export type Action = SetAvailableAction | DeleteAppAction | AddAppAction;

View File

@ -19,12 +19,15 @@ import ApplicationTypeSwitcher from './ApplicationTypeSwitcher';
import WebsiteType from './WebsiteType'; import WebsiteType from './WebsiteType';
import MinecraftServerType from './MinecraftServerType'; import MinecraftServerType from './MinecraftServerType';
const typeToForm: { type TypeToForm = Record<
[K in ApplicationType]: { ApplicationType,
{
label: MessageDescriptor; label: MessageDescriptor;
component: React.ComponentType<any>; component: React.ComponentType<any>;
}; }
} = { >;
const typeToForm: TypeToForm = {
[TYPE_APPLICATION]: { [TYPE_APPLICATION]: {
label: messages.website, label: messages.website,
component: WebsiteType, component: WebsiteType,
@ -35,16 +38,15 @@ const typeToForm: {
}, },
}; };
const typeToLabel = Object.keys(typeToForm).reduce( type TypeToLabel = Record<ApplicationType, MessageDescriptor>;
(result, key: ApplicationType) => {
result[key] = typeToForm[key].label;
return result; const typeToLabel: TypeToLabel = ((Object.keys(typeToForm) as unknown) as Array<
}, ApplicationType
{} as { >).reduce((result, key) => {
[K in ApplicationType]: MessageDescriptor; result[key] = typeToForm[key].label;
},
); return result;
}, {} as TypeToLabel);
export default class ApplicationForm extends React.Component<{ export default class ApplicationForm extends React.Component<{
app: OauthAppResponse; app: OauthAppResponse;

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { ComponentType } from 'react';
import { ApplicationType } from 'app/components/dev/apps'; import { ApplicationType } from 'app/components/dev/apps';
import { MessageDescriptor } from 'react-intl'; import { MessageDescriptor } from 'react-intl';
import { SKIN_LIGHT } from 'app/components/ui'; import { SKIN_LIGHT } from 'app/components/ui';
@ -6,20 +6,20 @@ import { Radio } from 'app/components/ui/form';
import styles from './applicationTypeSwitcher.scss'; import styles from './applicationTypeSwitcher.scss';
export default function ApplicationTypeSwitcher({ interface Props {
setType, appTypes: Record<ApplicationType, MessageDescriptor>;
appTypes,
selectedType,
}: {
appTypes: {
[K in ApplicationType]: MessageDescriptor;
};
selectedType: ApplicationType | null; selectedType: ApplicationType | null;
setType: (type: ApplicationType) => void; setType: (type: ApplicationType) => void;
}) { }
return (
<div> const ApplicationTypeSwitcher: ComponentType<Props> = ({
{Object.keys(appTypes).map((type: ApplicationType) => ( appTypes,
selectedType,
setType,
}) => (
<div>
{((Object.keys(appTypes) as unknown) as Array<ApplicationType>).map(
(type) => (
<div className={styles.radioContainer} key={type}> <div className={styles.radioContainer} key={type}>
<Radio <Radio
onChange={() => setType(type)} onChange={() => setType(type)}
@ -29,7 +29,9 @@ export default function ApplicationTypeSwitcher({
checked={selectedType === type} checked={selectedType === type}
/> />
</div> </div>
))} ),
</div> )}
); </div>
} );
export default ApplicationTypeSwitcher;

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { ComponentType } from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import { OauthAppResponse } from 'app/services/api/oauth'; import { OauthAppResponse } from 'app/services/api/oauth';
import { Input, FormModel } from 'app/components/ui/form'; 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'; import messages from './ApplicationForm.intl.json';
export default function MinecraftServerType({ interface Props {
form,
app,
}: {
form: FormModel; form: FormModel;
app: OauthAppResponse; app: OauthAppResponse;
}) {
return (
<div>
<div className={styles.formRow}>
<Input
{...form.bindField('name')}
label={messages.serverName}
defaultValue={app.name}
required
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.ipAddressIsOptionButPreferable} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('minecraftServerIp')}
label={messages.serverIp}
defaultValue={app.minecraftServerIp}
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.youCanAlsoSpecifyServerSite} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('websiteUrl')}
label={messages.websiteLink}
defaultValue={app.websiteUrl}
skin={SKIN_LIGHT}
/>
</div>
</div>
);
} }
const MinecraftServerType: ComponentType<Props> = ({ form, app }) => (
<div>
<div className={styles.formRow}>
<Input
{...form.bindField('name')}
label={messages.serverName}
defaultValue={app.name}
required
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.ipAddressIsOptionButPreferable} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('minecraftServerIp')}
label={messages.serverIp}
defaultValue={app.minecraftServerIp}
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.youCanAlsoSpecifyServerSite} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('websiteUrl')}
label={messages.websiteLink}
defaultValue={app.websiteUrl}
skin={SKIN_LIGHT}
/>
</div>
</div>
);
export default MinecraftServerType;

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { ComponentType } from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import { Input, TextArea, FormModel } from 'app/components/ui/form'; import { Input, TextArea, FormModel } from 'app/components/ui/form';
import { OauthAppResponse } from 'app/services/api/oauth'; 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'; import messages from './ApplicationForm.intl.json';
export default function WebsiteType({ interface Props {
form,
app,
}: {
form: FormModel; form: FormModel;
app: OauthAppResponse; app: OauthAppResponse;
}) {
return (
<div>
<div className={styles.formRow}>
<Input
{...form.bindField('name')}
label={messages.applicationName}
defaultValue={app.name}
required
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.appDescriptionWillBeAlsoVisibleOnOauthPage} />
</p>
</div>
<div className={styles.formRow}>
<TextArea
{...form.bindField('description')}
label={messages.description}
defaultValue={app.description}
skin={SKIN_LIGHT}
minRows={3}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.websiteLinkWillBeUsedAsAdditionalId} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('websiteUrl')}
label={messages.websiteLink}
defaultValue={app.websiteUrl}
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.redirectUriLimitsAllowableBaseAddress} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('redirectUri')}
label={messages.redirectUri}
defaultValue={app.redirectUri}
required
skin={SKIN_LIGHT}
/>
</div>
</div>
);
} }
const WebsiteType: ComponentType<Props> = ({ form, app }) => (
<div>
<div className={styles.formRow}>
<Input
{...form.bindField('name')}
label={messages.applicationName}
defaultValue={app.name}
required
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.appDescriptionWillBeAlsoVisibleOnOauthPage} />
</p>
</div>
<div className={styles.formRow}>
<TextArea
{...form.bindField('description')}
label={messages.description}
defaultValue={app.description}
skin={SKIN_LIGHT}
minRows={3}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.websiteLinkWillBeUsedAsAdditionalId} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('websiteUrl')}
label={messages.websiteLink}
defaultValue={app.websiteUrl}
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.redirectUriLimitsAllowableBaseAddress} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('redirectUri')}
label={messages.redirectUri}
defaultValue={app.redirectUri}
required
skin={SKIN_LIGHT}
/>
</div>
</div>
);
export default WebsiteType;

View File

@ -1,3 +1,3 @@
export type ApplicationType = 'application' | 'minecraft-server'; export type ApplicationType = 'application' | 'minecraft-server';
export const TYPE_APPLICATION: 'application' = 'application'; export const TYPE_APPLICATION = 'application' as const;
export const TYPE_MINECRAFT_SERVER: 'minecraft-server' = 'minecraft-server'; export const TYPE_MINECRAFT_SERVER = 'minecraft-server' as const;

View File

@ -151,7 +151,7 @@ export default class ApplicationItem extends React.Component<
<div <div
className={styles.appActionContainer} className={styles.appActionContainer}
ref={el => { ref={(el) => {
this.actionContainer = el; this.actionContainer = el;
}} }}
> >

View File

@ -55,10 +55,10 @@ export default class ApplicationsList extends React.Component<Props, State> {
/> />
</div> </div>
<div className={styles.appsListContainer}> <div className={styles.appsListContainer}>
{applications.map(app => ( {applications.map((app) => (
<div <div
key={app.clientId} key={app.clientId}
ref={elem => { ref={(elem) => {
this.appsRefs[app.clientId] = elem; this.appsRefs[app.clientId] = elem;
}} }}
> >
@ -83,7 +83,7 @@ export default class ApplicationsList extends React.Component<Props, State> {
if ( if (
clientId && clientId &&
expandedApp !== clientId && expandedApp !== clientId &&
applications.some(app => app.clientId === clientId) applications.some((app) => app.clientId === clientId)
) { ) {
requestAnimationFrame(() => requestAnimationFrame(() =>
this.onTileClick(clientId, { noReset: true }), this.onTileClick(clientId, { noReset: true }),

View File

@ -3,7 +3,7 @@ import { OauthAppResponse } from 'app/services/api/oauth';
import { Action } from './actions'; import { Action } from './actions';
export interface Apps { export interface Apps {
available: OauthAppResponse[]; available: Array<OauthAppResponse>;
} }
const defaults: Apps = { const defaults: Apps = {
@ -21,7 +21,9 @@ export default function apps(state: Apps = defaults, action: Action): Apps {
case 'apps:addApp': { case 'apps:addApp': {
const { payload } = action; const { payload } = action;
const available = [...state.available]; const available = [...state.available];
let index = available.findIndex(app => app.clientId === payload.clientId); let index = available.findIndex(
(app) => app.clientId === payload.clientId,
);
if (index === -1) { if (index === -1) {
index = available.length; index = available.length;
@ -39,11 +41,9 @@ export default function apps(state: Apps = defaults, action: Action): Apps {
return { return {
...state, ...state,
available: state.available.filter( available: state.available.filter(
app => app.clientId !== action.payload, (app) => app.clientId !== action.payload,
), ),
}; };
default:
} }
return state; return state;

View File

@ -11,8 +11,10 @@ import messages from './footerMenu.intl.json';
const FooterMenu: ComponentType = () => { const FooterMenu: ComponentType = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const onLanguageSwitcherClick = useCallback<MouseEventHandler>( const onLanguageSwitcherClick = useCallback<
event => { MouseEventHandler<HTMLAnchorElement>
>(
(event) => {
event.preventDefault(); event.preventDefault();
dispatch(createPopup({ Popup: LanguageSwitcher })); dispatch(createPopup({ Popup: LanguageSwitcher }));
}, },

View File

@ -1,26 +1,27 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, ComponentType } from 'react';
import { connect } from 'react-redux'; import { useSelector } from 'react-redux';
import { RawIntlProvider, IntlShape } from 'react-intl'; import { RawIntlProvider, IntlShape } from 'react-intl';
import i18n from 'app/services/i18n'; import i18n from 'app/services/i18n';
import { RootState } from 'app/reducers'; import { RootState } from 'app/reducers';
type Props = { const IntlProvider: ComponentType = ({ children }) => {
children: React.ReactNode;
locale: string;
};
function IntlProvider({ children, locale }: Props) {
const [intl, setIntl] = useState<IntlShape>(i18n.getIntl()); const [intl, setIntl] = useState<IntlShape>(i18n.getIntl());
const locale = useSelector(
({ i18n: i18nState }: RootState) => i18nState.locale,
);
useEffect(() => { useEffect(() => {
if (process.env.NODE_ENV === 'test') {
// disable async modules loading in tests
return;
}
(async () => { (async () => {
setIntl(await i18n.changeLocale(locale)); setIntl(await i18n.changeLocale(locale));
})(); })();
}, [locale]); }, [locale]);
return <RawIntlProvider value={intl}>{children}</RawIntlProvider>; return <RawIntlProvider value={intl}>{children}</RawIntlProvider>;
} };
export default connect(({ i18n: i18nState }: RootState) => i18nState)( export default IntlProvider;
IntlProvider,
);

View File

@ -1,10 +1,9 @@
import { Action as ReduxAction } from 'redux';
import i18n from 'app/services/i18n'; import i18n from 'app/services/i18n';
import { ThunkAction } from 'app/reducers';
export const SET_LOCALE = 'i18n:setLocale'; export function setLocale(desiredLocale: string): ThunkAction<Promise<string>> {
export function setLocale(desiredLocale: string) { return async (dispatch) => {
return async (
dispatch: (action: { [key: string]: any }) => any,
): Promise<string> => {
const locale = i18n.detectLanguage(desiredLocale); const locale = i18n.detectLanguage(desiredLocale);
dispatch(_setLocale(locale)); dispatch(_setLocale(locale));
@ -13,11 +12,20 @@ export function setLocale(desiredLocale: string) {
}; };
} }
function _setLocale(locale: string) { interface SetAction extends ReduxAction {
type: 'i18n:setLocale';
payload: {
locale: string;
};
}
function _setLocale(locale: string): SetAction {
return { return {
type: SET_LOCALE, type: 'i18n:setLocale',
payload: { payload: {
locale, locale,
}, },
}; };
} }
export type Action = SetAction;

View File

@ -1,6 +1,6 @@
import supportedLocales from 'app/i18n'; import supportedLocales from 'app/i18n';
const localeToCountryCode = { const localeToCountryCode: Record<string, string> = {
en: 'gb', en: 'gb',
be: 'by', be: 'by',
pt: 'br', pt: 'br',
@ -15,7 +15,7 @@ const SUPPORTED_LANGUAGES: string[] = Object.keys(supportedLocales);
export default { export default {
getCountryList(): string[] { getCountryList(): string[] {
return SUPPORTED_LANGUAGES.map( return SUPPORTED_LANGUAGES.map(
locale => localeToCountryCode[locale] || locale, (locale) => localeToCountryCode[locale] || locale,
); );
}, },
@ -29,9 +29,9 @@ export default {
*/ */
getIconUrl(locale: string): string { getIconUrl(locale: string): string {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require(`flag-icon-css/flags/4x3/${localeToCountryCode[ const mod = require(`flag-icon-css/flags/4x3/${
locale localeToCountryCode[locale] || locale
] || locale}.svg`); }.svg`);
return mod.default || mod; return mod.default || mod;
}, },

View File

@ -1,18 +1,20 @@
import i18n from 'app/services/i18n'; import i18n from 'app/services/i18n';
import { SET_LOCALE } from './actions'; import { Action } from './actions';
export type State = { locale: string }; export interface State {
locale: string;
}
const defaultState = { const defaultState: State = {
locale: i18n.detectLanguage(), locale: i18n.detectLanguage(),
}; };
export default function( export default function (
state: State = defaultState, state: State = defaultState,
{ type, payload }: { type: string; payload: State }, { type, payload }: Action,
): State { ): State {
if (type === SET_LOCALE) { if (type === 'i18n:setLocale') {
return payload; return payload;
} }

View File

@ -1,5 +1,13 @@
import React from 'react'; import React, { MouseEventHandler } from 'react';
import { TransitionMotion, spring, presets } from 'react-motion'; import {
TransitionMotion,
spring,
presets,
TransitionStyle,
TransitionPlainStyle,
PlainStyle,
Style,
} from 'react-motion';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import clsx from 'clsx'; import clsx from 'clsx';
@ -13,6 +21,34 @@ import mayTheForceBeWithYou from './images/may_the_force_be_with_you.svg';
import biteMyShinyMetalAss from './images/bite_my_shiny_metal_ass.svg'; import biteMyShinyMetalAss from './images/bite_my_shiny_metal_ass.svg';
import iTookAnArrowInMyKnee from './images/i_took_an_arrow_in_my_knee.svg'; import iTookAnArrowInMyKnee from './images/i_took_an_arrow_in_my_knee.svg';
interface EmptyCaption {
src: string;
caption: string;
}
const emptyCaptions: ReadonlyArray<EmptyCaption> = [
{
// Homestuck
src: thatFuckingPumpkin,
caption: 'That fucking pumpkin',
},
{
// Star Wars
src: mayTheForceBeWithYou,
caption: 'May The Force Be With You',
},
{
// Futurama
src: biteMyShinyMetalAss,
caption: 'Bite my shiny metal ass',
},
{
// The Elder Scrolls V: Skyrim
src: iTookAnArrowInMyKnee,
caption: 'I took an arrow in my knee',
},
];
const itemHeight = 51; const itemHeight = 51;
export default class LanguageList extends React.Component<{ export default class LanguageList extends React.Component<{
@ -35,7 +71,7 @@ export default class LanguageList extends React.Component<{
willLeave={this.willLeave} willLeave={this.willLeave}
willEnter={this.willEnter} willEnter={this.willEnter}
> >
{items => ( {(items) => (
<div className={styles.languagesList} data-testid="language-list"> <div className={styles.languagesList} data-testid="language-list">
<div <div
className={clsx(styles.emptyLanguagesListWrapper, { className={clsx(styles.emptyLanguagesListWrapper, {
@ -84,70 +120,60 @@ export default class LanguageList extends React.Component<{
); );
} }
getEmptyCaption() { getEmptyCaption(): EmptyCaption {
const emptyCaptions = [
{
// Homestuck
src: thatFuckingPumpkin,
caption: 'That fucking pumpkin',
},
{
// Star Wars
src: mayTheForceBeWithYou,
caption: 'May The Force Be With You',
},
{
// Futurama
src: biteMyShinyMetalAss,
caption: 'Bite my shiny metal ass',
},
{
// The Elder Scrolls V: Skyrim
src: iTookAnArrowInMyKnee,
caption: 'I took an arrow in my knee',
},
];
return emptyCaptions[Math.floor(Math.random() * emptyCaptions.length)]; return emptyCaptions[Math.floor(Math.random() * emptyCaptions.length)];
} }
onChangeLang(lang: string) { onChangeLang(lang: string): MouseEventHandler<HTMLDivElement> {
return (event: React.MouseEvent<HTMLElement>) => { return (event) => {
event.preventDefault(); event.preventDefault();
this.props.onChangeLang(lang); this.props.onChangeLang(lang);
}; };
} }
getItemsWithDefaultStyles = () => getItemsWithDefaultStyles = (): Array<TransitionPlainStyle> => {
this.getItemsWithStyles({ useSpring: false }); return Object.keys({ ...this.props.langs }).reduce(
getItemsWithStyles = (
{ useSpring }: { useSpring?: boolean } = { useSpring: true },
) =>
Object.keys({ ...this.props.langs }).reduce(
(previous, key) => [ (previous, key) => [
...previous, ...previous,
{ {
key, key,
data: this.props.langs[key], data: this.props.langs[key],
style: { style: {
height: useSpring ? spring(itemHeight, presets.gentle) : itemHeight, height: itemHeight,
opacity: useSpring ? spring(1, presets.gentle) : 1, opacity: 1,
}, },
}, },
], ],
[], [] as Array<TransitionPlainStyle>,
); );
};
willEnter() { getItemsWithStyles = (): Array<TransitionStyle> => {
return Object.keys({ ...this.props.langs }).reduce(
(previous, key) => [
...previous,
{
key,
data: this.props.langs[key],
style: {
height: spring(itemHeight, presets.gentle),
opacity: spring(1, presets.gentle),
},
},
],
[] as Array<TransitionStyle>,
);
};
willEnter(): PlainStyle {
return { return {
height: 0, height: 0,
opacity: 1, opacity: 1,
}; };
} }
willLeave() { willLeave(): Style {
return { return {
height: spring(0), height: spring(0),
opacity: spring(0), opacity: spring(0),

View File

@ -15,15 +15,15 @@ import { RootState } from 'app/reducers';
const translateUrl = 'http://ely.by/translate'; const translateUrl = 'http://ely.by/translate';
export type LocaleData = { export interface LocaleData {
code: string; code: string;
name: string; name: string;
englishName: string; englishName: string;
progress: number; progress: number;
isReleased: boolean; isReleased: boolean;
}; }
export type LocalesMap = { [code: string]: LocaleData }; export type LocalesMap = Record<string, LocaleData>;
type OwnProps = { type OwnProps = {
onClose: () => void; onClose: () => void;
@ -142,7 +142,7 @@ class LanguageSwitcher extends React.Component<
previous[key] = langs[key]; previous[key] = langs[key];
return previous; return previous;
}, {}); }, {} as typeof langs);
this.setState({ this.setState({
filter, filter,

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { ComponentType, ReactNode } from 'react';
import { localeFlags } from 'app/components/i18n'; import { localeFlags } from 'app/components/i18n';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
@ -6,10 +6,14 @@ import messages from './languageSwitcher.intl.json';
import styles from './languageSwitcher.scss'; import styles from './languageSwitcher.scss';
import { LocaleData } from './LanguageSwitcher'; import { LocaleData } from './LanguageSwitcher';
export default function LocaleItem({ locale }: { locale: LocaleData }) { interface Props {
const { code, name, englishName, progress, isReleased } = locale; locale: LocaleData;
}
let progressLabel; const LocaleItem: ComponentType<Props> = ({
locale: { code, name, englishName, progress, isReleased },
}) => {
let progressLabel: ReactNode;
if (progress !== 100) { if (progress !== 100) {
progressLabel = ( progressLabel = (
@ -41,4 +45,6 @@ export default function LocaleItem({ locale }: { locale: LocaleData }) {
<span className={styles.languageCircle} /> <span className={styles.languageCircle} />
</div> </div>
); );
} };
export default LocaleItem;

View File

@ -1,25 +1,23 @@
import React from 'react'; import React, { ComponentType, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import clsx from 'clsx'; import clsx from 'clsx';
import { localeFlags } from 'app/components/i18n'; import { localeFlags } from 'app/components/i18n';
import LANGS from 'app/i18n'; import LANGS from 'app/i18n';
import { connect } from 'react-redux';
import { create as createPopup } from 'app/components/ui/popup/actions'; import { create as createPopup } from 'app/components/ui/popup/actions';
import LanguageSwitcher from 'app/components/languageSwitcher'; import LanguageSwitcher from 'app/components/languageSwitcher';
import { RootState } from 'app/reducers'; import { RootState } from 'app/reducers';
import styles from './link.scss'; import styles from './link.scss';
type Props = { const LanguageLink: ComponentType = () => {
userLang: string; const dispatch = useDispatch();
interfaceLocale: string; const showLanguageSwitcherPopup = useCallback(() => {
showLanguageSwitcherPopup: (event: React.MouseEvent<HTMLSpanElement>) => void; dispatch(createPopup({ Popup: LanguageSwitcher }));
}; }, [dispatch]);
const userLang = useSelector((state: RootState) => state.user.lang);
const interfaceLocale = useSelector((state: RootState) => state.i18n.locale);
function LanguageLink({
userLang,
interfaceLocale,
showLanguageSwitcherPopup,
}: Props) {
const localeDefinition = LANGS[userLang] || LANGS[interfaceLocale]; const localeDefinition = LANGS[userLang] || LANGS[interfaceLocale];
return ( return (
@ -40,14 +38,6 @@ function LanguageLink({
{localeDefinition.name} {localeDefinition.name}
</span> </span>
); );
} };
export default connect( export default LanguageLink;
(state: RootState) => ({
userLang: state.user.lang,
interfaceLocale: state.i18n.locale,
}),
{
showLanguageSwitcherPopup: () => createPopup({ Popup: LanguageSwitcher }),
},
)(LanguageLink);

View File

@ -19,11 +19,11 @@ function ProfileField({
let Action: React.ElementType | null = null; let Action: React.ElementType | null = null;
if (link) { if (link) {
Action = props => <Link to={link} {...props} />; Action = (props) => <Link to={link} {...props} />;
} }
if (onChange) { if (onChange) {
Action = props => <a {...props} onClick={onChange} href="#" />; Action = (props) => <a {...props} onClick={onChange} href="#" />;
} }
return ( return (

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { ReactNode } from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { SlideMotion } from 'app/components/ui/motion'; import { SlideMotion } from 'app/components/ui/motion';
@ -21,27 +21,31 @@ import messages from './ChangeEmail.intl.json';
const STEPS_TOTAL = 3; const STEPS_TOTAL = 3;
export type ChangeEmailStep = 0 | 1 | 2; export type ChangeEmailStep = 0 | 1 | 2;
type HeightProp = 'step0Height' | 'step1Height' | 'step2Height';
type HeightDict = {
[K in HeightProp]?: number;
};
interface Props { interface Props {
onChangeStep: (step: ChangeEmailStep) => void; onChangeStep: (step: ChangeEmailStep) => void;
lang: string; lang: string;
email: string; email: string;
stepForms: FormModel[]; stepForms: Array<FormModel>;
onSubmit: (step: ChangeEmailStep, form: FormModel) => Promise<void>; onSubmit: (step: ChangeEmailStep, form: FormModel) => Promise<void>;
step: ChangeEmailStep; step: ChangeEmailStep;
code?: string; code?: string;
} }
interface State extends HeightDict { interface State {
newEmail: string | null; newEmail: string | null;
activeStep: ChangeEmailStep; activeStep: ChangeEmailStep;
code: string; code: string;
} }
interface FormStepParams {
form: FormModel;
isActiveStep: boolean;
isCodeSpecified: boolean;
email: string;
code?: string;
}
export default class ChangeEmail extends React.Component<Props, State> { export default class ChangeEmail extends React.Component<Props, State> {
static get defaultProps(): Partial<Props> { static get defaultProps(): Partial<Props> {
return { return {
@ -84,7 +88,7 @@ export default class ChangeEmail extends React.Component<Props, State> {
<div className={styles.form}> <div className={styles.form}>
<div className={styles.formBody}> <div className={styles.formBody}>
<Message {...messages.changeEmailTitle}> <Message {...messages.changeEmailTitle}>
{pageTitle => ( {(pageTitle) => (
<h3 className={styles.violetTitle}> <h3 className={styles.violetTitle}>
<Helmet title={pageTitle as string} /> <Helmet title={pageTitle as string} />
{pageTitle} {pageTitle}
@ -145,21 +149,28 @@ export default class ChangeEmail extends React.Component<Props, State> {
return ( return (
<SlideMotion activeStep={activeStep}> <SlideMotion activeStep={activeStep}>
{new Array(STEPS_TOTAL).fill(0).map((_, step) => { {new Array(STEPS_TOTAL).fill(0).map((_, step) => {
const form = this.props.stepForms[step]; const formParams: FormStepParams = {
form: this.props.stepForms[step],
return this[`renderStep${step}`]({ isActiveStep: step === activeStep,
isCodeSpecified,
email, email,
code, code,
isCodeSpecified, };
form,
isActiveStep: step === activeStep, switch (step) {
}); case 0:
return this.renderStep0(formParams);
case 1:
return this.renderStep1(formParams);
case 2:
return this.renderStep2(formParams);
}
})} })}
</SlideMotion> </SlideMotion>
); );
} }
renderStep0({ email, form }) { renderStep0({ email, form }: FormStepParams): ReactNode {
return ( return (
<div key="step0" data-testid="step1" className={styles.formBody}> <div key="step0" data-testid="step1" className={styles.formBody}>
<div className={styles.formRow}> <div className={styles.formRow}>
@ -183,7 +194,13 @@ export default class ChangeEmail extends React.Component<Props, State> {
); );
} }
renderStep1({ email, form, code, isCodeSpecified, isActiveStep }) { renderStep1({
email,
form,
code,
isCodeSpecified,
isActiveStep,
}: FormStepParams): ReactNode {
return ( return (
<div key="step1" data-testid="step2" className={styles.formBody}> <div key="step1" data-testid="step2" className={styles.formBody}>
<div className={styles.formRow}> <div className={styles.formRow}>
@ -230,7 +247,12 @@ export default class ChangeEmail extends React.Component<Props, State> {
); );
} }
renderStep2({ form, code, isCodeSpecified, isActiveStep }) { renderStep2({
form,
code,
isCodeSpecified,
isActiveStep,
}: FormStepParams): ReactNode {
const { newEmail } = this.state; const { newEmail } = this.state;
return ( return (
@ -268,14 +290,6 @@ export default class ChangeEmail extends React.Component<Props, State> {
); );
} }
onStepMeasure(step: ChangeEmailStep) {
return (height: number) =>
// @ts-ignore
this.setState({
[`step${step}Height`]: height,
});
}
nextStep() { nextStep() {
const { activeStep } = this.state; const { activeStep } = this.state;
const nextStep = activeStep + 1; const nextStep = activeStep + 1;
@ -299,7 +313,7 @@ export default class ChangeEmail extends React.Component<Props, State> {
return this.state.activeStep + 1 === STEPS_TOTAL; return this.state.activeStep + 1 === STEPS_TOTAL;
} }
onSwitchStep = (event: React.MouseEvent) => { onSwitchStep = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault(); event.preventDefault();
this.nextStep(); this.nextStep();
@ -333,7 +347,7 @@ export default class ChangeEmail extends React.Component<Props, State> {
code: '', code: '',
}); });
}, },
resp => { (resp) => {
if (resp.errors) { if (resp.errors) {
form.setErrors(resp.errors); form.setErrors(resp.errors);
this.forceUpdate(); this.forceUpdate();

View File

@ -1,24 +0,0 @@
import React from 'react';
import expect from 'app/test/unexpected';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import ChangePassword from 'app/components/profile/changePassword/ChangePassword';
describe('<ChangePassword />', () => {
it('renders two <Input /> components', () => {
const component = shallow(<ChangePassword onSubmit={async () => {}} />);
expect(component.find('Input'), 'to satisfy', { length: 2 });
});
it('should call onSubmit if passwords entered', () => {
const onSubmit = sinon.spy(() => ({ catch: () => {} })).named('onSubmit');
const component = shallow(<ChangePassword onSubmit={onSubmit} />);
component.find('Form').simulate('submit');
expect(onSubmit, 'was called');
});
});

View File

@ -0,0 +1,57 @@
import React from 'react';
import uxpect from 'app/test/unexpected';
import { render, fireEvent, screen } from '@testing-library/react';
import sinon from 'sinon';
import { TestContextProvider } from 'app/shell';
import ChangePassword from './ChangePassword';
describe('<ChangePassword />', () => {
it('renders two <Input /> components', () => {
render(
<TestContextProvider>
<ChangePassword onSubmit={async () => {}} />
</TestContextProvider>,
);
expect(
screen.getByLabelText('New password', { exact: false }),
).toBeInTheDocument();
expect(
screen.getByLabelText('Repeat the password', { exact: false }),
).toBeInTheDocument();
});
it('should call onSubmit if passwords entered', async () => {
const onSubmit = sinon.spy(() => Promise.resolve()).named('onSubmit');
render(
<TestContextProvider>
<ChangePassword onSubmit={onSubmit} />
</TestContextProvider>,
);
fireEvent.change(screen.getByLabelText('New password', { exact: false }), {
target: {
value: '123',
},
});
fireEvent.change(
screen.getByLabelText('Repeat the password', { exact: false }),
{
target: {
value: '123',
},
},
);
fireEvent.click(
screen
.getByRole('button', { name: 'Change password' })
.closest('button') as HTMLButtonElement,
);
uxpect(onSubmit, 'was called');
});
});

View File

@ -36,7 +36,7 @@ export default class ChangePassword extends React.Component<Props> {
<div className={styles.form}> <div className={styles.form}>
<div className={styles.formBody}> <div className={styles.formBody}>
<Message {...messages.changePasswordTitle}> <Message {...messages.changePasswordTitle}>
{pageTitle => ( {(pageTitle) => (
<h3 className={styles.title}> <h3 className={styles.title}>
<Helmet title={pageTitle as string} /> <Helmet title={pageTitle as string} />
{pageTitle} {pageTitle}
@ -105,7 +105,7 @@ export default class ChangePassword extends React.Component<Props> {
onFormSubmit = () => { onFormSubmit = () => {
const { form } = this.props; const { form } = this.props;
this.props.onSubmit(form).catch(resp => { this.props.onSubmit(form).catch((resp) => {
if (resp.errors) { if (resp.errors) {
form.setErrors(resp.errors); form.setErrors(resp.errors);
} else { } else {

View File

@ -32,7 +32,7 @@ export default class ChangeUsername extends React.Component<Props> {
<div className={styles.form}> <div className={styles.form}>
<div className={styles.formBody}> <div className={styles.formBody}>
<Message {...messages.changeUsernameTitle}> <Message {...messages.changeUsernameTitle}>
{pageTitle => ( {(pageTitle) => (
<h3 className={styles.title}> <h3 className={styles.title}>
<Helmet title={pageTitle as string} /> <Helmet title={pageTitle as string} />
{pageTitle} {pageTitle}
@ -82,7 +82,7 @@ export default class ChangeUsername extends React.Component<Props> {
onFormSubmit = () => { onFormSubmit = () => {
const { form } = this.props; const { form } = this.props;
this.props.onSubmit(form).catch(resp => { this.props.onSubmit(form).catch((resp) => {
if (resp.errors) { if (resp.errors) {
form.setErrors(resp.errors); form.setErrors(resp.errors);
} else { } else {

View File

@ -46,7 +46,7 @@ export default class MfaDisable extends React.Component<
return disableMFA(this.context.userId, totp, password); return disableMFA(this.context.userId, totp, password);
}) })
.then(() => this.props.onComplete()) .then(() => this.props.onComplete())
.catch(resp => { .catch((resp) => {
const { errors } = resp || {}; const { errors } = resp || {};
if (errors) { if (errors) {

View File

@ -55,7 +55,7 @@ export default class MfaEnable extends React.PureComponent<Props, State> {
} }
static getDerivedStateFromProps(props: Props, state: State) { static getDerivedStateFromProps(props: Props, state: State) {
if (typeof props.step === 'number' && props.step !== state.activeStep) { if (props.step !== state.activeStep) {
return { return {
activeStep: props.step, activeStep: props.step,
}; };
@ -122,7 +122,7 @@ export default class MfaEnable extends React.PureComponent<Props, State> {
<Confirmation <Confirmation
key="step3" key="step3"
form={this.props.confirmationForm} form={this.props.confirmationForm}
formRef={(el: Form) => (this.confirmationFormEl = el)} formRef={(el) => (this.confirmationFormEl = el)}
onSubmit={this.onTotpSubmit} onSubmit={this.onTotpSubmit}
onInvalid={() => this.forceUpdate()} onInvalid={() => this.forceUpdate()}
/> />
@ -136,7 +136,7 @@ export default class MfaEnable extends React.PureComponent<Props, State> {
if (props.step === 1 && !isLoading && !qrCodeSrc) { if (props.step === 1 && !isLoading && !qrCodeSrc) {
this.setState({ isLoading: true }); this.setState({ isLoading: true });
getSecret(this.context.userId).then(resp => { getSecret(this.context.userId).then((resp) => {
this.setState({ this.setState({
isLoading: false, isLoading: false,
secret: resp.secret, secret: resp.secret,
@ -164,7 +164,7 @@ export default class MfaEnable extends React.PureComponent<Props, State> {
return enableMFA(this.context.userId, data.totp, data.password); return enableMFA(this.context.userId, data.totp, data.password);
}) })
.then(() => this.props.onComplete()) .then(() => this.props.onComplete())
.catch(resp => { .catch((resp) => {
const { errors } = resp || {}; const { errors } = resp || {};
if (errors) { if (errors) {

View File

@ -32,7 +32,7 @@ class MultiFactorAuth extends React.Component<{
<div className={styles.form}> <div className={styles.form}>
<div className={styles.formBody}> <div className={styles.formBody}>
<Message {...messages.mfaTitle}> <Message {...messages.mfaTitle}>
{pageTitle => ( {(pageTitle) => (
<h3 className={styles.title}> <h3 className={styles.title}>
<Helmet title={pageTitle as string} /> <Helmet title={pageTitle as string} />
{pageTitle} {pageTitle}

View File

@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import { Input, Form, FormModel } from 'app/components/ui/form';
import { Input, Form, FormModel } from 'app/components/ui/form';
import profileForm from 'app/components/profile/profileForm.scss'; import profileForm from 'app/components/profile/profileForm.scss';
import messages from '../MultiFactorAuth.intl.json'; import messages from '../MultiFactorAuth.intl.json';
export default function Confirmation({ export default function Confirmation({
@ -13,7 +14,7 @@ export default function Confirmation({
}: { }: {
form: FormModel; form: FormModel;
formRef?: (el: Form | null) => void; formRef?: (el: Form | null) => void;
onSubmit: (form: FormModel) => Promise<void>; onSubmit: (form: FormModel) => Promise<void> | void;
onInvalid: () => void; onInvalid: () => void;
}) { }) {
return ( return (

View File

@ -48,19 +48,19 @@ export default class Instructions extends React.Component<{}, State> {
className={styles.androidTile} className={styles.androidTile}
logo={androidLogo} logo={androidLogo}
label="Google Play" label="Google Play"
onClick={event => this.onChangeOs(event, 'android')} onClick={(event) => this.onChangeOs(event, 'android')}
/> />
<OsTile <OsTile
className={styles.appleTile} className={styles.appleTile}
logo={appleLogo} logo={appleLogo}
label="App Store" label="App Store"
onClick={event => this.onChangeOs(event, 'ios')} onClick={(event) => this.onChangeOs(event, 'ios')}
/> />
<OsTile <OsTile
className={styles.windowsTile} className={styles.windowsTile}
logo={windowsLogo} logo={windowsLogo}
label="Windows Store" label="Windows Store"
onClick={event => this.onChangeOs(event, 'windows')} onClick={(event) => this.onChangeOs(event, 'windows')}
/> />
</div> </div>

View File

@ -87,7 +87,7 @@ export default function OsInstruction({ os }: { os: OS }) {
</h3> </h3>
<ul className={styles.appList}> <ul className={styles.appList}>
{linksByOs[os].featured.map(item => ( {linksByOs[os].featured.map((item) => (
<li key={item.label}> <li key={item.label}>
<a href={item.link} target="_blank"> <a href={item.link} target="_blank">
{item.label} {item.label}

View File

@ -31,7 +31,7 @@ export default function MfaStatus({ onProceed }: { onProceed: () => void }) {
<p className={styles.description}> <p className={styles.description}>
<a <a
href="#" href="#"
onClick={event => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
onProceed(); onProceed();
}} }}

View File

@ -1,54 +1,52 @@
import React from 'react'; import React, { ComponentType } from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import clsx from 'clsx'; import clsx from 'clsx';
import { Form, Button, Input, FormModel } from 'app/components/ui/form'; import { Form, Button, Input, FormModel } from 'app/components/ui/form';
import popupStyles from 'app/components/ui/popup/popup.scss'; import popupStyles from 'app/components/ui/popup/popup.scss';
import styles from './passwordRequestForm.scss'; import styles from './passwordRequestForm.scss';
import messages from './PasswordRequestForm.intl.json'; import messages from './PasswordRequestForm.intl.json';
function PasswordRequestForm({ interface Props {
form,
onSubmit,
}: {
form: FormModel; form: FormModel;
onSubmit: (form: FormModel) => void; onSubmit: (form: FormModel) => void;
}) {
return (
<div
className={styles.requestPasswordForm}
data-testid="password-request-form"
>
<div className={popupStyles.popup}>
<Form onSubmit={() => onSubmit(form)} form={form}>
<div className={popupStyles.header}>
<h2 className={popupStyles.headerTitle}>
<Message {...messages.title} />
</h2>
</div>
<div className={clsx(popupStyles.body, styles.body)}>
<span className={styles.lockIcon} />
<div className={styles.description}>
<Message {...messages.description} />
</div>
<Input
{...form.bindField('password')}
type="password"
required
autoFocus
color="green"
skin="light"
center
/>
</div>
<Button color="green" label={messages.continue} block type="submit" />
</Form>
</div>
</div>
);
} }
const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => (
<div
className={styles.requestPasswordForm}
data-testid="password-request-form"
>
<div className={popupStyles.popup}>
<Form onSubmit={onSubmit} form={form}>
<div className={popupStyles.header}>
<h2 className={popupStyles.headerTitle}>
<Message {...messages.title} />
</h2>
</div>
<div className={clsx(popupStyles.body, styles.body)}>
<span className={styles.lockIcon} />
<div className={styles.description}>
<Message {...messages.description} />
</div>
<Input
{...form.bindField('password')}
type="password"
required
autoFocus
color="green"
skin="light"
center
/>
</div>
<Button color="green" label={messages.continue} block type="submit" />
</Form>
</div>
</div>
);
export default PasswordRequestForm; export default PasswordRequestForm;

View File

@ -103,7 +103,7 @@ export class PanelBodyHeader extends React.Component<
); );
} }
onClose = (event: React.MouseEvent) => { onClose = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault(); event.preventDefault();
const { onClose } = this.props; const { onClose } = this.props;

View File

@ -91,9 +91,7 @@ export default class Box {
endY: number; endY: number;
}> = []; }> = [];
// eslint-disable-next-line guard-for-in Object.values(boxPoints).forEach((point) => {
for (const i in boxPoints) {
const point = boxPoints[i];
const angle = Math.atan2(light.y - point.y, light.x - point.x); const angle = Math.atan2(light.y - point.y, light.x - point.x);
const endX = point.x + shadowLength * Math.sin(-angle - Math.PI / 2); const endX = point.x + shadowLength * Math.sin(-angle - Math.PI / 2);
const endY = point.y + shadowLength * Math.cos(-angle - Math.PI / 2); const endY = point.y + shadowLength * Math.cos(-angle - Math.PI / 2);
@ -103,7 +101,7 @@ export default class Box {
endX, endX,
endY, endY,
}); });
} });
for (let i = points.length - 1; i >= 0; i--) { for (let i = points.length - 1; i >= 0; i--) {
const n = i === 3 ? 0 : i + 1; const n = i === 3 ? 0 : i + 1;

View File

@ -160,7 +160,7 @@ export default class BoxesField {
bindWindowListeners() { bindWindowListeners() {
window.addEventListener('resize', this.resize.bind(this)); window.addEventListener('resize', this.resize.bind(this));
window.addEventListener('mousemove', event => { window.addEventListener('mousemove', (event) => {
this.light.x = event.clientX; this.light.x = event.clientX;
this.light.y = event.clientY; this.light.y = event.clientY;
}); });

View File

@ -4,7 +4,7 @@ import sinon from 'sinon';
import BsodMiddleware from 'app/components/ui/bsod/BsodMiddleware'; import BsodMiddleware from 'app/components/ui/bsod/BsodMiddleware';
describe('BsodMiddleware', () => { describe('BsodMiddleware', () => {
[500, 503, 555].forEach(code => [500, 503, 555].forEach((code) =>
it(`should dispatch for ${code}`, () => { it(`should dispatch for ${code}`, () => {
const resp = { const resp = {
originalResponse: { status: code }, originalResponse: { status: code },
@ -27,7 +27,7 @@ describe('BsodMiddleware', () => {
}), }),
); );
[200, 404].forEach(code => [200, 404].forEach((code) =>
it(`should not dispatch for ${code}`, () => { it(`should not dispatch for ${code}`, () => {
const resp = { const resp = {
originalResponse: { status: code }, originalResponse: { status: code },

View File

@ -18,8 +18,7 @@ class BsodMiddleware implements Middleware {
async catch<T extends Resp<any>>( async catch<T extends Resp<any>>(
resp?: T | InternalServerError | Error, resp?: T | InternalServerError | Error,
): Promise<T> { ): Promise<T> {
const { originalResponse }: { originalResponse?: Resp<any> } = (resp || const { originalResponse } = (resp || {}) as InternalServerError;
{}) as InternalServerError;
if ( if (
resp && resp &&

View File

@ -1,9 +1,13 @@
import { Action } from 'redux'; import { Action as ReduxAction } from 'redux';
export const BSOD = 'BSOD'; interface BSoDAction extends ReduxAction {
type: 'BSOD';
}
export function bsod(): Action { export function bsod(): BSoDAction {
return { return {
type: BSOD, type: 'BSOD',
}; };
} }
export type Action = BSoDAction;

View File

@ -1,9 +1,9 @@
import { BSOD } from './actions'; import { Action } from './actions';
export type State = boolean; export type State = boolean;
export default function(state: State = false, { type }): State { export default function (state: State = false, { type }: Action): State {
if (type === BSOD) { if (type === 'BSOD') {
return true; return true;
} }

View File

@ -1,6 +1,7 @@
@import '~app/components/ui/colors.scss'; @import '~app/components/ui/colors.scss';
$font-family-monospaced: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Roboto Mono', monospace; $font-family-monospaced: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Roboto Mono',
monospace;
.body { .body {
height: 100%; height: 100%;

View File

@ -36,10 +36,10 @@ export default class Captcha extends FormInputComponent<
skin: this.props.skin, skin: this.props.skin,
onSetCode: this.setCode, onSetCode: this.setCode,
}) })
.then(captchaId => { .then((captchaId) => {
this.captchaId = captchaId; this.captchaId = captchaId;
}) })
.catch(error => { .catch((error) => {
logger.error('Failed rendering captcha', { logger.error('Failed rendering captcha', {
error, error,
}); });

View File

@ -1,4 +1,4 @@
import React, { InputHTMLAttributes } from 'react'; import React, { InputHTMLAttributes, MouseEventHandler } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { MessageDescriptor } from 'react-intl'; import { MessageDescriptor } from 'react-intl';
import clsx from 'clsx'; import clsx from 'clsx';
@ -40,10 +40,12 @@ export default class Dropdown extends FormInputComponent<Props, State> {
componentDidMount() { componentDidMount() {
// listen to capturing phase to ensure, that our event handler will be // listen to capturing phase to ensure, that our event handler will be
// called before all other // called before all other
// @ts-ignore
document.addEventListener('click', this.onBodyClick, true); document.addEventListener('click', this.onBodyClick, true);
} }
componentWillUnmount() { componentWillUnmount() {
// @ts-ignore
document.removeEventListener('click', this.onBodyClick); document.removeEventListener('click', this.onBodyClick);
} }
@ -98,8 +100,8 @@ export default class Dropdown extends FormInputComponent<Props, State> {
}); });
} }
onSelectItem(item: OptionItem) { onSelectItem(item: OptionItem): MouseEventHandler<HTMLDivElement> {
return event => { return (event) => {
event.preventDefault(); event.preventDefault();
this.setState({ this.setState({
@ -141,11 +143,12 @@ export default class Dropdown extends FormInputComponent<Props, State> {
this.toggle(); this.toggle();
}; };
onBodyClick = (event: MouseEvent) => { onBodyClick: MouseEventHandler = (event) => {
if (this.state.isActive) { if (this.state.isActive) {
const el = ReactDOM.findDOMNode(this); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const el = ReactDOM.findDOMNode(this)!;
if (!el.contains(event.target) && el !== event.target) { if (!el.contains(event.target as HTMLElement) && el !== event.target) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();

View File

@ -1,18 +1,33 @@
import React from 'react'; import React from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import logger from 'app/services/logger'; import logger from 'app/services/logger';
import FormModel from './FormModel'; import FormModel from './FormModel';
import styles from './form.scss'; import styles from './form.scss';
interface Props { interface BaseProps {
id: string; id: string;
isLoading: boolean; isLoading: boolean;
form?: FormModel; onInvalid: (errors: Record<string, string>) => void;
onSubmit: (form: FormModel | FormData) => void | Promise<void>;
onInvalid: (errors: { [errorKey: string]: string }) => void;
children: React.ReactNode; children: React.ReactNode;
} }
interface PropsWithoutForm extends BaseProps {
onSubmit: (form: FormData) => Promise<void> | void;
}
interface PropsWithForm extends BaseProps {
form: FormModel;
onSubmit: (form: FormModel) => Promise<void> | void;
}
type Props = PropsWithoutForm | PropsWithForm;
function hasForm(props: Props): props is PropsWithForm {
return 'form' in props;
}
interface State { interface State {
id: string; // just to track value for derived updates id: string; // just to track value for derived updates
isTouched: boolean; isTouched: boolean;
@ -39,7 +54,7 @@ export default class Form extends React.Component<Props, State> {
mounted = false; mounted = false;
componentDidMount() { componentDidMount() {
if (this.props.form) { if (hasForm(this.props)) {
this.props.form.addLoadingListener(this.onLoading); this.props.form.addLoadingListener(this.onLoading);
} }
@ -65,8 +80,8 @@ export default class Form extends React.Component<Props, State> {
} }
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props) {
const nextForm = this.props.form; const nextForm = hasForm(this.props) ? this.props.form : undefined;
const prevForm = prevProps.form; const prevForm = hasForm(prevProps) ? prevProps.form : undefined;
if (nextForm !== prevForm) { if (nextForm !== prevForm) {
if (prevForm) { if (prevForm) {
@ -80,7 +95,7 @@ export default class Form extends React.Component<Props, State> {
} }
componentWillUnmount() { componentWillUnmount() {
if (this.props.form) { if (hasForm(this.props)) {
this.props.form.removeLoadingListener(this.onLoading); this.props.form.removeLoadingListener(this.onLoading);
} }
@ -119,15 +134,19 @@ export default class Form extends React.Component<Props, State> {
} }
if (form.checkValidity()) { if (form.checkValidity()) {
const result = this.props.onSubmit( let result: Promise<void> | void;
this.props.form ? this.props.form : new FormData(form),
); if (hasForm(this.props)) {
result = this.props.onSubmit(this.props.form);
} else {
result = this.props.onSubmit(new FormData(form));
}
if (result && result.then) { if (result && result.then) {
this.setState({ isLoading: true }); this.setState({ isLoading: true });
result result
.catch((errors: { [key: string]: string }) => { .catch((errors: Record<string, string>) => {
this.setErrors(errors); this.setErrors(errors);
}) })
.finally(() => this.mounted && this.setState({ isLoading: false })); .finally(() => this.mounted && this.setState({ isLoading: false }));
@ -136,10 +155,10 @@ export default class Form extends React.Component<Props, State> {
const invalidEls: NodeListOf<InputElement> = form.querySelectorAll( const invalidEls: NodeListOf<InputElement> = form.querySelectorAll(
':invalid', ':invalid',
); );
const errors = {}; const errors: Record<string, string> = {};
invalidEls[0].focus(); // focus on first error invalidEls[0].focus(); // focus on first error
Array.from(invalidEls).reduce((acc, el: InputElement) => { Array.from(invalidEls).reduce((acc, el) => {
if (!el.name) { if (!el.name) {
logger.warn('Found an element without name', { el }); logger.warn('Found an element without name', { el });
@ -164,7 +183,10 @@ export default class Form extends React.Component<Props, State> {
} }
setErrors(errors: { [key: string]: string }) { setErrors(errors: { [key: string]: string }) {
this.props.form && this.props.form.setErrors(errors); if (hasForm(this.props)) {
this.props.form.setErrors(errors);
}
this.props.onInvalid(errors); this.props.onInvalid(errors);
} }

View File

@ -2,7 +2,7 @@ import React from 'react';
import { MessageDescriptor } from 'react-intl'; import { MessageDescriptor } from 'react-intl';
import i18n from 'app/services/i18n'; import i18n from 'app/services/i18n';
class FormComponent<P, S = {}> extends React.Component<P, S> { export default class FormComponent<P, S = {}> extends React.Component<P, S> {
/** /**
* Formats message resolving intl translations * Formats message resolving intl translations
* *
@ -37,5 +37,3 @@ class FormComponent<P, S = {}> extends React.Component<P, S> {
*/ */
onFormInvalid() {} onFormInvalid() {}
} }
export default FormComponent;

View File

@ -1,31 +1,40 @@
import React from 'react'; import React, { ComponentType, ReactNode } from 'react';
import PropTypes from 'prop-types'; import { resolve as resolveError } from 'app/services/errorsDict';
import errorsDict from 'app/services/errorsDict'; import { MessageDescriptor } from 'react-intl';
import styles from './form.scss'; import styles from './form.scss';
export default function FormError({ interface Props {
error, error?: Parameters<typeof resolveError>[0] | MessageDescriptor | null;
}: {
error?:
| string
| React.ReactNode
| {
type: string;
payload: { [key: string]: any };
};
}) {
return error ? (
<div className={styles.fieldError}>{errorsDict.resolve(error)}</div>
) : null;
} }
FormError.propTypes = { function isMessageDescriptor(
error: PropTypes.oneOfType([ message: Props['error'],
PropTypes.string, ): message is MessageDescriptor {
PropTypes.shape({ return (
type: PropTypes.string.isRequired, typeof message === 'object' &&
payload: PropTypes.object.isRequired, typeof (message as MessageDescriptor).id !== 'undefined'
}), );
]), }
const FormError: ComponentType<Props> = ({ error }) => {
if (!error) {
return null;
}
let content: ReactNode;
if (isMessageDescriptor(error)) {
content = error;
} else {
content = resolveError(error);
}
return (
<div className={styles.fieldError} role="alert">
{content}
</div>
);
}; };
export default FormError;

View File

@ -6,15 +6,13 @@ export type ValidationError =
| string | string
| { | {
type: string; type: string;
payload?: { [key: string]: any }; payload?: Record<string, any>;
}; };
export default class FormModel { export default class FormModel {
fields = {}; fields: Record<string, any> = {};
errors: { errors: Record<string, ValidationError> = {};
[fieldId: string]: ValidationError; handlers: Array<LoadingListener> = [];
} = {};
handlers: LoadingListener[] = [];
renderErrors: boolean; renderErrors: boolean;
_isLoading: boolean; _isLoading: boolean;
@ -27,7 +25,7 @@ export default class FormModel {
this.renderErrors = options.renderErrors !== false; this.renderErrors = options.renderErrors !== false;
} }
hasField(fieldId: string) { hasField(fieldId: string): boolean {
return !!this.fields[fieldId]; return !!this.fields[fieldId];
} }
@ -83,7 +81,7 @@ export default class FormModel {
* *
* @param {string} fieldId - an id of field to focus * @param {string} fieldId - an id of field to focus
*/ */
focus(fieldId: string) { focus(fieldId: string): void {
if (!this.fields[fieldId]) { if (!this.fields[fieldId]) {
throw new Error( throw new Error(
`Can not focus. The field with an id ${fieldId} does not exists`, `Can not focus. The field with an id ${fieldId} does not exists`,
@ -100,7 +98,7 @@ export default class FormModel {
* *
* @returns {string} * @returns {string}
*/ */
value(fieldId: string) { value(fieldId: string): string {
const field = this.fields[fieldId]; const field = this.fields[fieldId];
if (!field) { if (!field) {
@ -124,7 +122,7 @@ export default class FormModel {
* *
* @param {object} errors - object maping {fieldId: errorType} * @param {object} errors - object maping {fieldId: errorType}
*/ */
setErrors(errors: { [key: string]: ValidationError }) { setErrors(errors: Record<string, ValidationError>): void {
if (typeof errors !== 'object' || errors === null) { if (typeof errors !== 'object' || errors === null) {
throw new Error('Errors must be an object'); throw new Error('Errors must be an object');
} }
@ -132,7 +130,7 @@ export default class FormModel {
const oldErrors = this.errors; const oldErrors = this.errors;
this.errors = errors; this.errors = errors;
Object.keys(this.fields).forEach(fieldId => { Object.keys(this.fields).forEach((fieldId) => {
if (this.renderErrors) { if (this.renderErrors) {
if (oldErrors[fieldId] || errors[fieldId]) { if (oldErrors[fieldId] || errors[fieldId]) {
this.fields[fieldId].setError(errors[fieldId] || null); this.fields[fieldId].setError(errors[fieldId] || null);
@ -151,21 +149,11 @@ export default class FormModel {
return error || null; return error || null;
} }
/** getError(fieldId: string): ValidationError | null {
* Get error by id
*
* @param {string} fieldId - an id of field to get error for
*
* @returns {string|object|null}
*/
getError(fieldId: string) {
return this.errors[fieldId] || null; return this.errors[fieldId] || null;
} }
/** hasErrors(): boolean {
* @returns {bool}
*/
hasErrors() {
return Object.keys(this.errors).length > 0; return Object.keys(this.errors).length > 0;
} }
@ -174,7 +162,7 @@ export default class FormModel {
* *
* @returns {object} * @returns {object}
*/ */
serialize(): { [key: string]: any } { serialize(): Record<string, any> {
return Object.keys(this.fields).reduce((acc, fieldId) => { return Object.keys(this.fields).reduce((acc, fieldId) => {
const field = this.fields[fieldId]; const field = this.fields[fieldId];
@ -185,7 +173,7 @@ export default class FormModel {
} }
return acc; return acc;
}, {}); }, {} as Record<string, any>);
} }
/** /**
@ -193,7 +181,7 @@ export default class FormModel {
* *
* @param {Function} fn * @param {Function} fn
*/ */
addLoadingListener(fn: LoadingListener) { addLoadingListener(fn: LoadingListener): void {
this.removeLoadingListener(fn); this.removeLoadingListener(fn);
this.handlers.push(fn); this.handlers.push(fn);
} }
@ -203,14 +191,14 @@ export default class FormModel {
* *
* @param {Function} fn * @param {Function} fn
*/ */
removeLoadingListener(fn: LoadingListener) { removeLoadingListener(fn: LoadingListener): void {
this.handlers = this.handlers.filter(handler => handler !== fn); this.handlers = this.handlers.filter((handler) => handler !== fn);
} }
/** /**
* Switch form in loading state * Switch form in loading state
*/ */
beginLoading() { beginLoading(): void {
this._isLoading = true; this._isLoading = true;
this.notifyHandlers(); this.notifyHandlers();
} }
@ -218,12 +206,12 @@ export default class FormModel {
/** /**
* Disable loading state * Disable loading state
*/ */
endLoading() { endLoading(): void {
this._isLoading = false; this._isLoading = false;
this.notifyHandlers(); this.notifyHandlers();
} }
private notifyHandlers() { private notifyHandlers(): void {
this.handlers.forEach(fn => fn(this._isLoading)); this.handlers.forEach((fn) => fn(this._isLoading));
} }
} }

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { mount } from 'enzyme'; import { render, screen } from '@testing-library/react';
import expect from 'app/test/unexpected'; import expect from 'app/test/unexpected';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
@ -7,25 +7,21 @@ import Input from './Input';
describe('Input', () => { describe('Input', () => {
it('should return input value', () => { it('should return input value', () => {
let component: any; let component: Input | null = null;
const wrapper = mount( render(
<IntlProvider locale="en" defaultLocale="en"> <IntlProvider locale="en" defaultLocale="en">
<Input <Input
defaultValue="foo" defaultValue="foo"
name="test" name="test"
ref={el => { ref={(el) => {
component = el; component = el;
}} }}
/> />
</IntlProvider>, </IntlProvider>,
); );
expect( expect(screen.getByDisplayValue('foo'), 'to be a', HTMLElement);
wrapper.find('input[name="test"]').getDOMNode().value, expect(component && (component as Input).getValue(), 'to equal', 'foo');
'to equal',
'foo',
);
expect(component.getValue(), 'to equal', 'foo');
}); });
}); });

View File

@ -3,16 +3,16 @@ import { Link } from 'react-router-dom';
import Button from './Button'; import Button from './Button';
export default function LinkButton( type ButtonProps = React.ComponentProps<typeof Button>;
props: React.ComponentProps<typeof Button> & type LinkProps = React.ComponentProps<typeof Link>;
React.ComponentProps<typeof Link>,
) { export default function LinkButton(props: ButtonProps & LinkProps) {
const { to, ...restProps } = props; const { to, ...restProps } = props;
return ( return (
<Button <Button
component={linkProps => <Link {...linkProps} to={to} />} component={(linkProps) => <Link {...linkProps} to={to} />}
{...restProps} {...(restProps as ButtonProps)}
/> />
); );
} }

View File

@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { MessageDescriptor } from 'react-intl'; import { MessageDescriptor } from 'react-intl';
import TextareaAutosize from 'react-textarea-autosize'; import TextareaAutosize, {
TextareaAutosizeProps,
} from 'react-textarea-autosize';
import clsx from 'clsx'; import clsx from 'clsx';
import { uniqueId, omit } from 'app/functions'; import { uniqueId, omit } from 'app/functions';
import { SKIN_DARK, COLOR_GREEN, Skin, Color } from 'app/components/ui'; import { SKIN_DARK, COLOR_GREEN, Skin, Color } from 'app/components/ui';
@ -8,22 +10,15 @@ import { SKIN_DARK, COLOR_GREEN, Skin, Color } from 'app/components/ui';
import styles from './form.scss'; import styles from './form.scss';
import FormInputComponent from './FormInputComponent'; import FormInputComponent from './FormInputComponent';
type TextareaAutosizeProps = { interface OwnProps {
onHeightChange?: (number, TextareaAutosizeProps) => void; placeholder?: string | MessageDescriptor;
useCacheForDOMMeasurements?: boolean; label?: string | MessageDescriptor;
minRows?: number; skin: Skin;
maxRows?: number; color: Color;
inputRef?: (el?: HTMLTextAreaElement) => void; }
};
export default class TextArea extends FormInputComponent< export default class TextArea extends FormInputComponent<
{ OwnProps & Omit<TextareaAutosizeProps, keyof OwnProps>
placeholder?: string | MessageDescriptor;
label?: string | MessageDescriptor;
skin: Skin;
color: Color;
} & TextareaAutosizeProps &
React.TextareaHTMLAttributes<HTMLTextAreaElement>
> { > {
static defaultProps = { static defaultProps = {
color: COLOR_GREEN, color: COLOR_GREEN,

View File

@ -1,26 +1,29 @@
import React from 'react'; import React, { ComponentType } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Skin } from 'app/components/ui'; import { Skin } from 'app/components/ui';
import styles from './componentLoader.scss'; import styles from './componentLoader.scss';
// TODO: add mode to not show loader until first ~150ms // TODO: add mode to not show loader until first ~150ms
function ComponentLoader({ skin = 'dark' }: { skin?: Skin }) { interface Props {
return ( skin?: Skin;
<div
className={clsx(styles.componentLoader, styles[`${skin}ComponentLoader`])}
>
<div className={styles.spins}>
{new Array(5).fill(0).map((_, index) => (
<div
className={clsx(styles.spin, styles[`spin${index}`])}
key={index}
/>
))}
</div>
</div>
);
} }
const ComponentLoader: ComponentType<Props> = ({ skin = 'dark' }) => (
<div
className={clsx(styles.componentLoader, styles[`${skin}ComponentLoader`])}
>
<div className={styles.spins}>
{new Array(5).fill(0).map((_, index) => (
<div
className={clsx(styles.spin, styles[`spin${index}`])}
key={index}
/>
))}
</div>
</div>
);
export default ComponentLoader; export default ComponentLoader;

View File

@ -1,71 +1,64 @@
import React from 'react'; import React, { ComponentType, useEffect, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { ComponentLoader } from 'app/components/ui/loader';
import { SKIN_LIGHT } from 'app/components/ui'; import { SKIN_LIGHT } from 'app/components/ui';
import ComponentLoader from './ComponentLoader';
import styles from './imageLoader.scss'; import styles from './imageLoader.scss';
export default class ImageLoader extends React.Component< interface Props {
{ src: string;
src: string; alt: string;
alt: string; ratio: number; // width:height ratio
ratio: number; // width:height ratio onLoad?: () => void;
onLoad?: Function;
},
{
isLoading: boolean;
}
> {
state = {
isLoading: true,
};
componentDidMount() {
this.preloadImage();
}
preloadImage() {
const img = new Image();
img.onload = () => this.imageLoaded();
img.onerror = () => this.preloadImage();
img.src = this.props.src;
}
imageLoaded() {
this.setState({ isLoading: false });
if (this.props.onLoad) {
this.props.onLoad();
}
}
render() {
const { isLoading } = this.state;
const { src, alt, ratio } = this.props;
return (
<div className={styles.container}>
<div
style={{
height: 0,
paddingBottom: `${ratio * 100}%`,
}}
/>
{isLoading && (
<div className={styles.loader}>
<ComponentLoader skin={SKIN_LIGHT} />
</div>
)}
<div
className={clsx(styles.image, {
[styles.imageLoaded]: !isLoading,
})}
>
<img src={src} alt={alt} />
</div>
</div>
);
}
} }
const ImageLoader: ComponentType<Props> = ({
src,
alt,
ratio,
onLoad = () => {},
}) => {
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
function preloadImage() {
const img = new Image();
img.onload = () => {
setIsLoading(false);
onLoad();
};
img.onerror = preloadImage;
img.src = src;
}
preloadImage();
}, [src]);
return (
<div className={styles.container}>
<div
style={{
height: 0,
paddingBottom: `${ratio * 100}%`,
}}
/>
{isLoading && (
<div className={styles.loader}>
<ComponentLoader skin={SKIN_LIGHT} />
</div>
)}
<div
className={clsx(styles.image, {
[styles.imageLoaded]: !isLoading,
})}
>
<img src={src} alt={alt} />
</div>
</div>
);
};
export default ImageLoader;

View File

@ -1,12 +1,12 @@
<div id="loader" class="loader-overlay is-first-launch"> <div id="loader" class="loader-overlay is-first-launch">
<div class="loader"> <div class="loader">
<div class="loader__cube loader__cube--1"></div> <div class="loader__cube loader__cube--1"></div>
<div class="loader__cube loader__cube--2"></div> <div class="loader__cube loader__cube--2"></div>
<div class="loader__cube loader__cube--3"></div> <div class="loader__cube loader__cube--3"></div>
<div class="loader__cube loader__cube--4"></div> <div class="loader__cube loader__cube--4"></div>
<div class="loader__cube loader__cube--5"></div> <div class="loader__cube loader__cube--5"></div>
<div class="loader__cube loader__cube--6"></div> <div class="loader__cube loader__cube--6"></div>
<div class="loader__cube loader__cube--7"></div> <div class="loader__cube loader__cube--7"></div>
<div class="loader__cube loader__cube--8"></div> <div class="loader__cube loader__cube--8"></div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Motion, spring } from 'react-motion'; import { Motion, spring } from 'react-motion';
import MeasureHeight from 'app/components/MeasureHeight'; import MeasureHeight from 'app/components/MeasureHeight';
import styles from './slide-motion.scss'; import styles from './slide-motion.scss';
@ -10,18 +11,19 @@ interface Props {
} }
interface State { interface State {
// [stepHeight: string]: number;
version: string; version: string;
prevChildren: React.ReactNode | undefined; prevChildren: React.ReactNode | undefined;
stepsHeights: Record<Props['activeStep'], number>;
} }
class SlideMotion extends React.PureComponent<Props, State> { class SlideMotion extends React.PureComponent<Props, State> {
state: State = { state: State = {
prevChildren: undefined, // to track version updates prevChildren: undefined, // to track version updates
version: `${this.props.activeStep}.0`, version: `${this.props.activeStep}.0`,
stepsHeights: [],
}; };
isHeightMeasured: boolean; private isHeightMeasured: boolean;
static getDerivedStateFromProps(props: Props, state: State) { static getDerivedStateFromProps(props: Props, state: State) {
let [, version] = state.version.split('.').map(Number); let [, version] = state.version.split('.').map(Number);
@ -42,7 +44,7 @@ class SlideMotion extends React.PureComponent<Props, State> {
const { version } = this.state; const { version } = this.state;
const activeStepHeight = this.state[`step${activeStep}Height`] || 0; const activeStepHeight = this.state.stepsHeights[activeStep] || 0;
// a hack to disable height animation on first render // a hack to disable height animation on first render
const { isHeightMeasured } = this; const { isHeightMeasured } = this;
@ -65,7 +67,7 @@ class SlideMotion extends React.PureComponent<Props, State> {
return ( return (
<Motion style={motionStyle}> <Motion style={motionStyle}>
{(interpolatingStyle: { height: number; transform: string }) => ( {(interpolatingStyle) => (
<div <div
style={{ style={{
overflow: 'hidden', overflow: 'hidden',
@ -96,13 +98,14 @@ class SlideMotion extends React.PureComponent<Props, State> {
); );
} }
onStepMeasure(step: number) { onStepMeasure = (step: number) => (height: number) => {
return (height: number) => this.setState({
// @ts-ignore stepsHeights: {
this.setState({ ...this.state.stepsHeights,
[`step${step}Height`]: height, [step]: height,
}); },
} });
};
} }
export default SlideMotion; export default SlideMotion;

View File

@ -1,189 +0,0 @@
import sinon from 'sinon';
import expect from 'app/test/unexpected';
import React from 'react';
import { shallow, mount } from 'enzyme';
import { PopupStack } from 'app/components/ui/popup/PopupStack';
import styles from 'app/components/ui/popup/popup.scss';
function DummyPopup(/** @type {{[key: string]: any}} */ _props) {
return null;
}
describe('<PopupStack />', () => {
it('renders all popup components', () => {
const props = {
destroy: () => {},
popups: [
{
Popup: DummyPopup,
},
{
Popup: DummyPopup,
},
],
};
const component = shallow(<PopupStack {...props} />);
expect(component.find(DummyPopup), 'to satisfy', { length: 2 });
});
it('should pass onClose as props', () => {
const expectedProps = {
foo: 'bar',
};
const props = {
destroy: () => {},
popups: [
{
Popup: (props = {}) => {
// eslint-disable-next-line
expect(props.onClose, 'to be a', 'function');
return <DummyPopup {...expectedProps} />;
},
},
],
};
const component = mount(<PopupStack {...props} />);
const popup = component.find(DummyPopup);
expect(popup, 'to satisfy', { length: 1 });
expect(popup.props(), 'to equal', expectedProps);
});
it('should hide popup, when onClose called', () => {
const props = {
popups: [
{
Popup: DummyPopup,
},
{
Popup: DummyPopup,
},
],
destroy: sinon.stub().named('props.destroy'),
};
const component = shallow(<PopupStack {...props} />);
component
.find(DummyPopup)
.last()
.prop('onClose')();
expect(props.destroy, 'was called once');
expect(props.destroy, 'to have a call satisfying', [
expect.it('to be', props.popups[1]),
]);
});
it('should hide popup, when overlay clicked', () => {
const preventDefault = sinon.stub().named('event.preventDefault');
const props = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
},
],
};
const component = shallow(<PopupStack {...props} />);
const overlay = component.find(`.${styles.overlay}`);
overlay.simulate('click', { target: 1, currentTarget: 1, preventDefault });
expect(props.destroy, 'was called once');
expect(preventDefault, 'was called once');
});
it('should hide popup on overlay click if disableOverlayClose', () => {
const props = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
disableOverlayClose: true,
},
],
};
const component = shallow(<PopupStack {...props} />);
const overlay = component.find(`.${styles.overlay}`);
overlay.simulate('click', {
target: 1,
currentTarget: 1,
preventDefault() {},
});
expect(props.destroy, 'was not called');
});
it('should hide popup, when esc pressed', () => {
const props = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
},
],
};
mount(<PopupStack {...props} />);
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
document.dispatchEvent(event);
expect(props.destroy, 'was called once');
});
it('should hide first popup in stack if esc pressed', () => {
const props = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup() {
return null;
},
},
{
Popup: DummyPopup,
},
],
};
mount(<PopupStack {...props} />);
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
document.dispatchEvent(event);
expect(props.destroy, 'was called once');
expect(props.destroy, 'to have a call satisfying', [
expect.it('to be', props.popups[1]),
]);
});
it('should NOT hide popup on esc pressed if disableOverlayClose', () => {
const props = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
disableOverlayClose: true,
},
],
};
mount(<PopupStack {...props} />);
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
document.dispatchEvent(event);
expect(props.destroy, 'was not called');
});
});

View File

@ -0,0 +1,169 @@
import sinon from 'sinon';
import uxpect from 'app/test/unexpected';
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import React from 'react';
import { PopupStack } from './PopupStack';
function DummyPopup({ onClose }: Record<string, any>) {
return (
<div>
<button type="button" onClick={onClose}>
close
</button>
</div>
);
}
describe('<PopupStack />', () => {
it('renders all popup components', () => {
const props: any = {
destroy: () => {},
popups: [
{
Popup: DummyPopup,
},
{
Popup: DummyPopup,
},
],
};
render(<PopupStack {...props} />);
const popups = screen.getAllByRole('dialog');
uxpect(popups, 'to have length', 2);
});
it('should hide popup, when onClose called', async () => {
const destroy = sinon.stub().named('props.destroy');
const props: any = {
popups: [
{
Popup: DummyPopup,
},
],
destroy,
};
const { rerender } = render(<PopupStack {...props} />);
fireEvent.click(screen.getByRole('button', { name: 'close' }));
uxpect(destroy, 'was called once');
uxpect(destroy, 'to have a call satisfying', [
uxpect.it('to be', props.popups[0]),
]);
rerender(<PopupStack popups={[]} destroy={destroy} />);
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('should hide popup, when overlay clicked', () => {
const props: any = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
},
],
};
render(<PopupStack {...props} />);
fireEvent.click(screen.getByRole('dialog'));
uxpect(props.destroy, 'was called once');
});
it('should not hide popup on overlay click if disableOverlayClose', () => {
const props: any = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
disableOverlayClose: true,
},
],
};
render(<PopupStack {...props} />);
fireEvent.click(screen.getByRole('dialog'));
uxpect(props.destroy, 'was not called');
});
it('should hide popup, when esc pressed', () => {
const props: any = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
},
],
};
render(<PopupStack {...props} />);
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
document.dispatchEvent(event);
uxpect(props.destroy, 'was called once');
});
it('should hide first popup in stack if esc pressed', () => {
const props: any = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup() {
return null;
},
},
{
Popup: DummyPopup,
},
],
};
render(<PopupStack {...props} />);
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
document.dispatchEvent(event);
uxpect(props.destroy, 'was called once');
uxpect(props.destroy, 'to have a call satisfying', [
uxpect.it('to be', props.popups[1]),
]);
});
it('should NOT hide popup on esc pressed if disableOverlayClose', () => {
const props: any = {
destroy: sinon.stub().named('props.destroy'),
popups: [
{
Popup: DummyPopup,
disableOverlayClose: true,
},
],
};
render(<PopupStack {...props} />);
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
document.dispatchEvent(event);
uxpect(props.destroy, 'was not called');
});
});

Some files were not shown because too many files have changed in this diff Show More