mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-11-17 21:53:03 +05:30
Merge branch 'improve_typings'
This commit is contained in:
commit
73f0c37a6a
@ -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': [
|
||||||
|
@ -7,3 +7,5 @@ cache
|
|||||||
*.png
|
*.png
|
||||||
*.gif
|
*.gif
|
||||||
*.svg
|
*.svg
|
||||||
|
*.hbs
|
||||||
|
.gitlab-ci.yml
|
||||||
|
@ -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);
|
||||||
|
@ -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
102
@types/cwordin-api.d.ts
vendored
Normal 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
2
@types/formatjs.d.ts
vendored
Normal 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
13
@types/multi-progress.d.ts
vendored
Normal 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
66
@types/prompt.d.ts
vendored
Normal 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
25
@types/redux-localstorage.d.ts
vendored
Normal 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
86
@types/unexpected.d.ts
vendored
Normal 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;
|
||||||
|
}
|
4
@types/webpack-loaders.d.ts
vendored
4
@types/webpack-loaders.d.ts
vendored
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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 = {
|
||||||
|
107
package.json
107
package.json
@ -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"
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
@ -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';
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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} />
|
||||||
|
@ -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() {
|
||||||
|
@ -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 });
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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()]]);
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
|
@ -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';
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
45
packages/app/components/auth/authError/AuthError.tsx
Normal file
45
packages/app/components/auth/authError/AuthError.tsx
Normal 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;
|
@ -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);
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -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 ? ' | ' : '',
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
)}
|
)}
|
@ -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'
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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: 'E‑mail',
|
||||||
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)} />
|
'E‑mail 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));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -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 }),
|
||||||
|
@ -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;
|
||||||
|
@ -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 }));
|
||||||
},
|
},
|
||||||
|
@ -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,
|
|
||||||
);
|
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
},
|
},
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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),
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
|
||||||
|
@ -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 (
|
||||||
|
@ -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();
|
||||||
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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) {
|
||||||
|
@ -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) {
|
||||||
|
@ -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}
|
||||||
|
@ -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 (
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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}
|
||||||
|
@ -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();
|
||||||
}}
|
}}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
|
@ -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 },
|
||||||
|
@ -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 &&
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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%;
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
|
@ -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;
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
169
packages/app/components/ui/popup/PopupStack.test.tsx
Normal file
169
packages/app/components/ui/popup/PopupStack.test.tsx
Normal 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
Loading…
Reference in New Issue
Block a user