Update frontend

* change root dit and change way to import modules
* update deps to latest versions
* use latest tools for building bundles
* rewrite webpack config
* use nomodule technique for loading modern bundle
* inject polyfills by babel usebuiltins
* update eslint rules
* rename all style files to CSS
* use postcss preset env for building styles
* proper typescript typing
* put all html files to templates folder
* etc
This commit is contained in:
Pavel Mineev
2021-01-12 13:39:26 -06:00
committed by Umputun
parent 919fb7af62
commit 5825a55b69
325 changed files with 10488 additions and 18795 deletions
+1 -9
View File
@@ -462,16 +462,8 @@ Add this snippet to the bottom of web page:
// if you set this param in `false` you will get notifications email notifications as admin // if you set this param in `false` you will get notifications email notifications as admin
// but your users won't have interface for subscription // but your users won't have interface for subscription
}; };
(function(c) {
for(var i = 0; i < c.length; i++){
var d = document, s = d.createElement('script');
s.src = remark_config.host + '/web/' +c[i] +'.js';
s.defer = true;
(d.head || d.body).appendChild(s);
}
})(remark_config.components || ['embed']);
</script> </script>
<script>!function(e,n){for(var o=0;o<e.length;o++){var r=n.createElement("script"),c=".js",d=n.head||n.body;"noModule"in r?(r.type="module",c=".mjs"):r.async=!0,r.defer=!0,r.src=remark_config.host+"/web/"+e[o]+c,d.appendChild(r)}}(remark_config.components||["embed"],document);</script>
``` ```
And then add this node in the place where you want to see Remark42 widget: And then add this node in the place where you want to see Remark42 widget:
+2 -2
View File
@@ -1,8 +1,8 @@
#!/bin/sh #!/bin/sh
echo "prepare environment" echo "prepare environment"
# replace BASE_URL constant by REMARK_URL # replace BASE_URL constant by REMARK_URL
sed -i "s|https://demo.remark42.com|${REMARK_URL}|g" /srv/web/*.html sed -i "s|{% REMARK_URL %}|${REMARK_URL}|g" /srv/web/*.html
sed -i "s|https://demo.remark42.com|${REMARK_URL}|g" /srv/web/*.js sed -i "s|{% REMARK_URL %}|${REMARK_URL}|g" /srv/web/*.js
if [ -n "${SITE_ID}" ]; then if [ -n "${SITE_ID}" ]; then
#replace "site_id: 'remark'" by SITE_ID #replace "site_id: 'remark'" by SITE_ID
+30 -14
View File
@@ -1,18 +1,34 @@
const getPresetEnv = options => ['@babel/preset-env', options];
const preactPreset = [
'@babel/preset-react',
{
pragma: 'h',
pragmaFrag: 'Fragment',
},
];
const plugins = ['module:fast-async'];
module.exports = { module.exports = {
presets: [ presets: [
[ getPresetEnv({
'@babel/preset-env', targets: 'defaults, not IE 11, not samsung 12',
{ useBuiltIns: 'usage',
bugfixes: true, corejs: 3,
loose: true, bugfixes: true,
}, loose: true,
], }),
[ preactPreset,
'@babel/preset-react',
{
pragma: 'h',
pragmaFrag: 'Fragment',
},
],
], ],
plugins,
env: {
modern: {
presets: [getPresetEnv({ targets: { esmodules: true }, loose: true, bugfixes: true }), preactPreset],
plugins,
},
test: {
presets: [getPresetEnv({ targets: { node: 'current' } }), preactPreset],
plugins,
},
},
}; };
-1
View File
@@ -1 +0,0 @@
defaults
+19 -106
View File
@@ -1,127 +1,40 @@
/* eslint-disable @typescript-eslint/camelcase */
module.exports = { module.exports = {
parser: 'babel-eslint',
extends: [ extends: [
'eslint:recommended', 'react-app',
'preact',
'plugin:jsx-a11y/recommended', 'plugin:jsx-a11y/recommended',
'plugin:@typescript-eslint/recommended', 'prettier',
'plugin:prettier/recommended', 'prettier/@typescript-eslint',
'prettier/babel',
'prettier/prettier',
'prettier/react',
], ],
plugins: ['react', 'jsx-a11y', 'prettier'], plugins: ['jsx-a11y', 'prettier'],
rules: {
'prettier/prettier': 'error',
},
overrides: [ overrides: [
{ {
files: ['*.ts', '*.tsx'], files: ['*.ts?(x)'],
plugins: ['@typescript-eslint'],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
rules: { rules: {
// needs for using optional chaining 'no-undef': 'off',
'no-undef': 0, 'no-redeclare': 'off',
'jsx-a11y/no-autofocus': 0, 'no-unused-vars': 'off',
// disabling because typescipt uses it's own lint (see next rule)
'no-unused-vars': 0,
// allow Rust-like var starting with _underscore
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
// disabling because it's bad practice to mark accessibility in react classes
'@typescript-eslint/explicit-member-accessibility': 0,
// doesn't work in real world
'@typescript-eslint/no-non-null-assertion': 0,
// disabling because store actions use WATCH_ME_IM_SPECIAL case
'@typescript-eslint/class-name-casing': 0,
// disabling because server response contains snake case
'@typescript-eslint/camelcase': 0,
// disabling because it's standard behaviour that function is hoisted to top
'@typescript-eslint/no-use-before-define': 0,
// well
'@typescript-eslint/ban-ts-ignore': 0,
// better to be explicit here maybe?
'@typescript-eslint/no-inferrable-types': 0,
}, },
}, },
{ {
files: ['*.d.ts'], files: ['*.d.ts'],
rules: { rules: {
'no-var': 0, '@typescript-eslint/no-unused-vars': 'off',
}, },
}, },
{ {
files: ['*.test.ts', '*.test.tsx'], files: ['*.(spec|test).ts?(x)'],
extends: ['react-app/jest'],
rules: { rules: {
'@typescript-eslint/no-explicit-any': 0, 'import/first': 'off',
'@typescript-eslint/no-object-literal-type-assertion': 0,
},
globals: {
fail: true,
},
},
{
files: ['*.test.ts', '*.test.tsx', '*.test.js', '*.test.jsx'],
rules: {
'max-nested-callbacks': ['warn', { max: 10 }],
}, },
}, },
], ],
env: {
browser: true,
node: true,
es6: true,
jest: true,
},
parserOptions: {
ecmaVersion: 6,
sourceType: 'module',
ecmaFeatures: {
modules: true,
jsx: true,
},
},
globals: {
remark_config: true,
__webpack_public_path__: true,
},
rules: {
'@typescript-eslint/indent': 0,
'react/jsx-uses-react': 2,
'react/jsx-uses-vars': 2,
'no-cond-assign': 1,
'no-empty': ['error', { allowEmptyCatch: true }],
'no-console': 1,
camelcase: 0,
'comma-style': 2,
'max-nested-callbacks': [2, 3],
'no-eval': 2,
'no-implied-eval': 2,
'no-new-func': 2,
'guard-for-in': 2,
eqeqeq: 2,
'no-else-return': 2,
'no-redeclare': 2,
'no-dupe-keys': 2,
radix: 2,
strict: [2, 'never'],
'no-shadow': 0,
'callback-return': [1, ['callback', 'cb', 'next', 'done']],
'no-delete-var': 2,
'no-undef-init': 2,
'no-shadow-restricted-names': 2,
'handle-callback-err': 2,
'no-lonely-if': 2,
'constructor-super': 2,
'no-this-before-super': 2,
'no-dupe-class-members': 2,
'no-const-assign': 2,
'prefer-spread': 2,
'prefer-const': 2,
'no-useless-concat': 2,
'no-var': 2,
'object-shorthand': 2,
'prefer-arrow-callback': 2,
'prettier/prettier': 2,
'@typescript-eslint/no-var-requires': 0,
'@typescript-eslint/explicit-function-return-type': 0,
},
}; };
+5 -1
View File
@@ -1,2 +1,6 @@
.env node_modules
extracted-messages extracted-messages
/public
/*.log
.env
tsconfig.tsbuildinfo
+2 -2
View File
@@ -1,5 +1,5 @@
module.exports = { module.exports = {
'./**/*.{ts,tsx,js,jsx}': ['eslint --fix --max-warnings=0', 'prettier --write'], './**/*.{ts,tsx,js,jsx}': ['eslint --fix --max-warnings=0', 'prettier --write'],
'./**/*.{scss,pcss,css}': ['prettier --write', 'stylelint'], './**/*.css': ['prettier --write', 'stylelint'],
'./iframe.html': ['prettier --write', 'stylelint'], './templates/**.html': ['prettier --write', 'stylelint'],
}; };
+2
View File
@@ -1,2 +1,4 @@
node_modules node_modules
public public
package.json
package-lock.json
+2 -1
View File
@@ -1,6 +1,7 @@
module.exports = { module.exports = {
printWidth: 120, printWidth: 120,
useTabs: false, useTabs: false,
tabWidth: 2,
semi: true, semi: true,
singleQuote: true, singleQuote: true,
trailingComma: 'es5', trailingComma: 'es5',
@@ -8,7 +9,7 @@ module.exports = {
arrowParens: 'avoid', arrowParens: 'avoid',
overrides: [ overrides: [
{ {
files: ['*.ejs', '*.html'], files: ['*.html'],
options: { options: {
trailingComma: 'none', trailingComma: 'none',
}, },
+26 -6
View File
@@ -1,22 +1,42 @@
module.exports = [ module.exports = [
{ {
path: 'public/embed.js', path: 'public/embed.mjs',
limit: '2.7 KB', limit: '2.5 KB',
}, },
{ {
limit: '86 KB', path: 'public/embed.js',
limit: '2.5 KB',
},
{
limit: '70 KB',
path: 'public/remark.mjs',
},
{
limit: '73.5 KB',
path: 'public/remark.js', path: 'public/remark.js',
}, },
{ {
limit: '44 KB', limit: '31 KB',
path: 'public/last-comments.mjs',
},
{
limit: '38 KB',
path: 'public/last-comments.js', path: 'public/last-comments.js',
}, },
{
path: 'public/deleteme.mjs',
limit: '11 KB',
},
{ {
path: 'public/deleteme.js', path: 'public/deleteme.js',
limit: '35 KB', limit: '18 KB',
},
{
path: 'public/counter.mjs',
limit: '0.7 KB',
}, },
{ {
path: 'public/counter.js', path: 'public/counter.js',
limit: '1.05 KB', limit: '0.7 KB',
}, },
]; ];
+1
View File
@@ -1,2 +1,3 @@
node_modules node_modules
public public
extracted-messages
+3 -2
View File
@@ -1,4 +1,5 @@
const path = require('path'); const { CUSTOM_PROPERTIES_PATH } = require('./webpack.config');
module.exports = { module.exports = {
extends: ['stylelint-config-standard', 'stylelint-config-prettier'], extends: ['stylelint-config-standard', 'stylelint-config-prettier'],
plugins: ['stylelint-value-no-unknown-custom-properties', '@mavrin/stylelint-declaration-use-css-custom-properties'], plugins: ['stylelint-value-no-unknown-custom-properties', '@mavrin/stylelint-declaration-use-css-custom-properties'],
@@ -19,7 +20,7 @@ module.exports = {
'csstools/value-no-unknown-custom-properties': [ 'csstools/value-no-unknown-custom-properties': [
true, true,
{ {
importFrom: path.resolve(__dirname, './app/custom-properties.css'), importFrom: CUSTOM_PROPERTIES_PATH,
}, },
], ],
}, },
+2 -1
View File
@@ -8,6 +8,7 @@
- if you want IDE integration, you need `eslint` and `stylelint` plugin to be installed. - if you want IDE integration, you need `eslint` and `stylelint` plugin to be installed.
### CSS Styles ### CSS Styles
- although styles have `scss` extension, it is actually pack of post-css plugins, so syntax differs, for example in `calc` function. - although styles have `scss` extension, it is actually pack of post-css plugins, so syntax differs, for example in `calc` function.
- now we are migrating to css-modules and this is recomended way to stylization. A file with styles should be named like `component.module.css` - now we are migrating to css-modules and this is recomended way to stylization. A file with styles should be named like `component.module.css`
- old component styles use BEM notation (at least it should): `block__element_modifier`. Also there are `mix` classes: `block_modifier`. - old component styles use BEM notation (at least it should): `block__element_modifier`. Also there are `mix` classes: `block_modifier`.
@@ -19,7 +20,7 @@
- imports for typescript, javascript files should be without extension: `./index`, not `./index.ts` - imports for typescript, javascript files should be without extension: `./index`, not `./index.ts`
- if file resides in the same directory or in subdirectory import should be relative: `./types/something` - if file resides in the same directory or in subdirectory import should be relative: `./types/something`
- otherwise it should start from `@app` namespace: `@app/common/store` which mapped to `/app/common/store.ts` in webpack, tsconfig and jest - otherwise it should start from ``namespace:`common/store`which mapped to`/appcommon/store.ts` in webpack, tsconfig and jest
### Testing ### Testing
-7
View File
@@ -1,7 +0,0 @@
/**
* Veriable responsive for dynamic setting public path for
* assets. Dynamic imports with relative url will be resolved over this path.
*
* https://webpack.js.org/guides/public-path/#on-the-fly
*/
declare var __webpack_public_path__: string;
-14
View File
@@ -1,14 +0,0 @@
declare module 'bem-react-helper' {
export interface Mods {
[key: string]: string | number | boolean | undefined | null;
}
export type Mix = Array<string | undefined> | string;
export default function b(
classname: string,
props?: {
mods?: Mods;
mix?: Mix;
},
override_props?: Mods
): string;
}
-2
View File
@@ -1,2 +0,0 @@
declare module 'intersection-observer';
declare module 'whatwg-fetch';
-28
View File
@@ -1,28 +0,0 @@
import { Theme } from '@app/common/types';
import { CommentsConfig } from '@app/common/config-types';
declare global {
interface Window {
remark_config: CommentsConfig;
REMARK42: {
changeTheme?: (theme: Theme) => void;
destroy?: () => void;
createInstance: (
remark_config: CommentsConfig
) =>
| {
changeTheme(theme: Theme): void;
destroy(): void;
}
| undefined;
};
}
namespace NodeJS {
interface Global {
Headers: typeof Headers;
localStorage: typeof Storage;
fetch: typeof fetch;
}
}
}
@@ -1,4 +1,4 @@
global.Headers = class HeadersMock implements Headers { global.Headers = class HeadersMock extends Headers implements Headers {
private headers = new Map(); private headers = new Map();
append(key: string, value: string) { append(key: string, value: string) {
@@ -16,7 +16,6 @@ global.Headers = class HeadersMock implements Headers {
delete(key: string) { delete(key: string) {
this.headers.delete(key); this.headers.delete(key);
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
forEach(callbackfn: (value: string, key: string, parent: Headers) => void, thisArg?: any) { forEach(callbackfn: (value: string, key: string, parent: Headers) => void, thisArg?: any) {
this.headers.forEach((value, key) => { this.headers.forEach((value, key) => {
callbackfn.call(thisArg || this, value, key, this); callbackfn.call(thisArg || this, value, key, this);
@@ -1,15 +1,4 @@
import 'jest-extended'; import { StaticStore } from 'common/static-store';
import 'jest-enzyme';
import { configure } from 'enzyme';
import PreactAdapter from 'enzyme-adapter-preact-pure';
import { StaticStore } from '@app/common/static_store';
import './mockHeaders';
configure({ adapter: new PreactAdapter() });
require('document-register-element/pony')(window);
beforeEach(() => { beforeEach(() => {
StaticStore.config = { StaticStore.config = {
@@ -2,4 +2,6 @@ import createMockStore from 'redux-mock-store';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const mockStore = createMockStore<any, any>([thunk]); const mockStore = createMockStore<any, any>([thunk]);
export default mockStore;
@@ -1,4 +1,4 @@
import { User } from '@app/common/types'; import type { User } from 'common/types';
const user: Readonly<User> = { const user: Readonly<User> = {
id: 'email_1', id: 'email_1',
+2 -2
View File
@@ -1,6 +1,6 @@
// @ts-ignore // @ts-ignore
const mock: typeof import('@app/common/constants') = { const mock: typeof import('common/constants') = {
...jest.requireActual('@app/common/constants'), ...jest.requireActual('common/constants'),
BASE_URL: 'https://demo.remark42.com/', BASE_URL: 'https://demo.remark42.com/',
}; };
+2 -2
View File
@@ -1,6 +1,6 @@
// @ts-ignore // @ts-ignore
const mock: typeof import('@app/common/settings') = { const mock: typeof import('common/settings') = {
...jest.requireActual('@app/common/settings'), ...jest.requireActual('common/settings'),
siteId: 'remark', siteId: 'remark',
pageTitle: 'remark test', pageTitle: 'remark test',
url: 'https://remark42.com/test', url: 'https://remark42.com/test',
+6 -6
View File
@@ -2,12 +2,12 @@ import jestFetchMock from 'jest-fetch-mock';
import { emailVerificationForSubscribe } from './api'; import { emailVerificationForSubscribe } from './api';
jest.mock('@app/common/constants', () => ({ jest.mock('common/constants', () => ({
BASE_URL: 'https://example.com', BASE_URL: 'https://example.com',
API_BASE: '/api', API_BASE: '/api',
})); }));
jest.mock('@app/common/settings', () => ({ jest.mock('common/settings', () => ({
siteId: 'remark42', siteId: 'remark42',
})); }));
@@ -29,11 +29,11 @@ describe('api', () => {
expect(jestFetchMock.mock.calls.length).toEqual(1); expect(jestFetchMock.mock.calls.length).toEqual(1);
const url = jestFetchMock.mock.calls[0][0] as string; const url = jestFetchMock.mock.calls[0][0] as string;
const match = url.match(/address=(\S+)$/); const match = url.match(/address=(\S+)$/) as string[];
expect(match).toBeArray(); expect(Array.isArray(match)).toBe(true);
expect((match as string[]).length).toBeGreaterThan(1); expect(match.length).toBeGreaterThan(1);
expect((match as string[])[1]).toBe( expect(match[1]).toBe(
"address.!%23%24%25%26'*%2B-%2F%3D%3F%5E_%60%7B%7C%7D~()%2C%3A%3B%3C%3E%5B%5C%5D%40example.com" "address.!%23%24%25%26'*%2B-%2F%3D%3F%5E_%60%7B%7C%7D~()%2C%3A%3B%3C%3E%5B%5C%5D%40example.com"
); );
}); });
+3 -3
View File
@@ -6,7 +6,7 @@ import fetcher from './fetcher';
/* common */ /* common */
const __loginAnonymously = (username: string): Promise<User | null> => { const __loginAnonymously = (username: string): Promise<User | null> => {
const url = `/auth/anonymous/login?user=${encodeURIComponent(username)}&aud=${siteId}&from=${encodeURIComponent( const url = `/auth/anonymous/login?user=${encodeURIComponent(username)}&aud=${siteId}&from=${encodeURIComponent(
location.origin + location.pathname + '?selfClose' `${window.location.origin}${window.location.pathname}?selfClose`
)}`; )}`;
return fetcher.get<User>({ url, withCredentials: true, overriddenApiBase: '' }); return fetcher.get<User>({ url, withCredentials: true, overriddenApiBase: '' });
}; };
@@ -35,7 +35,7 @@ export const logIn = (provider: AuthProvider): Promise<User | null> => {
return new Promise<User | null>((resolve, reject) => { return new Promise<User | null>((resolve, reject) => {
const url = `${BASE_URL}/auth/${provider.name}/login?from=${encodeURIComponent( const url = `${BASE_URL}/auth/${provider.name}/login?from=${encodeURIComponent(
location.origin + location.pathname + '?selfClose' `${window.location.origin}${window.location.pathname}?selfClose`
)}&site=${siteId}`; )}&site=${siteId}`;
const newWindow = window.open(url); const newWindow = window.open(url);
@@ -267,7 +267,7 @@ export const uploadImage = (image: File): Promise<Image> => {
name: image.name, name: image.name,
size: image.size, size: image.size,
type: image.type, type: image.type,
url: BASE_URL + API_BASE + '/picture/' + resp.id, url: `${BASE_URL + API_BASE}/picture/${resp.id}`,
})); }));
}; };
-17
View File
@@ -1,17 +0,0 @@
if (!Element.prototype.matches) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Element.prototype.matches = (Element.prototype as any).msMatchesSelector || Element.prototype.webkitMatchesSelector;
}
if (!Element.prototype.closest) {
Element.prototype.closest = function (s: string) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-this-alias
let el: any = this;
do {
if (el.matches(s)) return el;
el = el.parentElement || el.parentNode;
} while (el !== null && el.nodeType === 1);
return null;
};
}
-30
View File
@@ -1,30 +0,0 @@
import { UserInfo, Theme } from './types';
export interface CounterConfig {
host: string;
site_id: string;
url?: string;
}
export type UserInfoConfig = UserInfo;
export interface CommentsConfig {
host: string;
site_id: string;
url?: string;
max_shown_comments?: number;
theme?: Theme;
page_title?: string;
node?: string | HTMLElement;
locale?: string;
show_email_subscription?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
__colors__?: any;
}
export interface LastCommentsConfig {
host: string;
site_id: string;
max_last_comments: number;
locale?: string;
}
+8 -9
View File
@@ -1,16 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// based on https://github.com/sindresorhus/copy-text-to-clipboard, but improved to copy text styles too // based on https://github.com/sindresorhus/copy-text-to-clipboard, but improved to copy text styles too
export default (input: string): boolean => { export default function copy(input: string): boolean {
const el = document.createElement('div'); const el = document.createElement('div');
el.innerHTML = input; el.innerHTML = input;
// eslint-disable-next-line @typescript-eslint/no-explicit-any Object.assign(el.style, {
(el.style as any).contain = 'strict'; contain: 'strict',
el.style.position = 'absolute'; position: 'absolute',
el.style.left = '-9999px'; left: '-9999px',
el.style.fontSize = '12pt'; // Prevent zooming on iOS fontSize: '12pt', // Prevent zooming on iOS
});
document.body.appendChild(el); document.body.appendChild(el);
@@ -60,4 +59,4 @@ export default (input: string): boolean => {
} }
return success; return success;
}; }
+9 -7
View File
@@ -8,9 +8,9 @@ describe('fetcher', () => {
error: 'you just cant', error: 'you just cant',
details: 'you just cant at all', details: 'you just cant at all',
}; };
(window.fetch as any) = jest.fn().mockImplementation(async () => ({ window.fetch = jest.fn().mockImplementation(async () => ({
status: 400, status: 400,
headers: new (window as any).Headers(), headers: new Headers(),
json: async () => response, json: async () => response,
text: async () => JSON.stringify(response), text: async () => JSON.stringify(response),
})); }));
@@ -18,7 +18,7 @@ describe('fetcher', () => {
return fetcher return fetcher
.get('/api/some') .get('/api/some')
.then(data => { .then(data => {
fail(data); throw new Error('Request should be failed');
}) })
.catch(e => { .catch(e => {
expect(e.code).toBe(2); expect(e.code).toBe(2);
@@ -40,7 +40,7 @@ describe('fetcher', () => {
return fetcher return fetcher
.get('/api/some') .get('/api/some')
.then(data => { .then(data => {
fail(data); throw new Error('Request should be failed');
}) })
.catch(e => { .catch(e => {
expect(e.code).toBe(401); expect(e.code).toBe(401);
@@ -51,16 +51,18 @@ describe('fetcher', () => {
(jest.spyOn(window, 'fetch') as any).mockImplementation(async () => ({ (jest.spyOn(window, 'fetch') as any).mockImplementation(async () => ({
status: 400, status: 400,
headers: new (window as any).Headers(), headers: new (window as any).Headers(),
json: async () => { async json() {
throw new Error('json parse error'); throw new Error('json parse error');
}, },
text: async () => 'you given me something wrong', async text() {
return 'you given me something wrong';
},
})); }));
return fetcher return fetcher
.get({ url: '/api/some', logError: false }) .get({ url: '/api/some', logError: false })
.then(data => { .then(data => {
fail(data); throw new Error('Request should be failed');
}) })
.catch(e => { .catch(e => {
expect(e.code).toBe(0); expect(e.code).toBe(0);
+11 -17
View File
@@ -1,8 +1,9 @@
import { httpErrorMap, isFailedFetch, httpMessages, RequestError } from 'utils/errorUtils';
import { BASE_URL, API_BASE, HEADER_X_JWT } from './constants'; import { BASE_URL, API_BASE, HEADER_X_JWT } from './constants';
import { siteId } from './settings'; import { siteId } from './settings';
import { StaticStore } from './static_store'; import { StaticStore } from './static-store';
import { getCookie } from './cookies'; import { getCookie } from './cookies';
import { httpErrorMap, isFailedFetch, httpMessages } from '@app/utils/errorUtils';
export type FetcherMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head'; export type FetcherMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head';
const methods: FetcherMethod[] = ['get', 'post', 'put', 'patch', 'delete', 'head']; const methods: FetcherMethod[] = ['get', 'post', 'put', 'patch', 'delete', 'head'];
@@ -33,7 +34,7 @@ type FetcherObject = { [K in FetcherMethod]: <T = unknown>(data: FetcherInit) =>
let activeJwtToken: string | undefined; let activeJwtToken: string | undefined;
const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => { const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => {
acc[method] = <T = unknown>(data: FetcherInit): Promise<T> => { acc[method] = async <T = unknown>(data: FetcherInit): Promise<T> => {
const { const {
url, url,
body = undefined, body = undefined,
@@ -77,7 +78,7 @@ const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => {
} }
if (siteId && method !== 'post' && !rurl.includes('?site=') && !rurl.includes('&site=')) { if (siteId && method !== 'post' && !rurl.includes('?site=') && !rurl.includes('&site=')) {
rurl += (rurl.includes('?') ? '&' : '?') + `site=${siteId}`; rurl += `${rurl.includes('?') ? '&' : '?'}site=${siteId}`;
} }
return fetch(rurl, parameters) return fetch(rurl, parameters)
@@ -99,10 +100,8 @@ const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => {
if (res.status >= 400) { if (res.status >= 400) {
if (httpErrorMap.has(res.status)) { if (httpErrorMap.has(res.status)) {
const descriptor = httpErrorMap.get(res.status) || httpMessages.unexpectedError; const descriptor = httpErrorMap.get(res.status) || httpMessages.unexpectedError;
throw {
code: res.status, throw new RequestError(descriptor.defaultMessage, res.status);
error: descriptor.defaultMessage,
};
} }
return res.text().then(text => { return res.text().then(text => {
let err; let err;
@@ -110,13 +109,10 @@ const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => {
err = JSON.parse(text); err = JSON.parse(text);
} catch (e) { } catch (e) {
if (logError) { if (logError) {
// eslint-disable-next-line no-console
console.error(err); console.error(err);
} }
throw {
code: 0, throw new RequestError(httpMessages.unexpectedError.defaultMessage, 0);
error: httpMessages.unexpectedError.defaultMessage,
};
} }
throw err; throw err;
}); });
@@ -130,11 +126,9 @@ const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => {
}) })
.catch(e => { .catch(e => {
if (isFailedFetch(e)) { if (isFailedFetch(e)) {
throw { throw new RequestError(e.message, -2);
code: -2,
error: e.message,
};
} }
throw e; throw e;
}); });
}; };
+1 -1
View File
@@ -67,7 +67,7 @@ describe('updateJsonItem', () => {
}); });
it('should set data to empty localStorage', () => { it('should set data to empty localStorage', () => {
updateJsonItem<Record<string, string>>(LS_KEY, {}); updateJsonItem(LS_KEY, {});
expect(localStorage.getItem(LS_KEY)).toBe(JSON.stringify({})); expect(localStorage.getItem(LS_KEY)).toBe(JSON.stringify({}));
}); });
+3 -4
View File
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { IS_STORAGE_AVAILABLE } from './constants'; import { IS_STORAGE_AVAILABLE } from './constants';
const failMessage = 'remark42: localStorage access denied, check browser preferences'; const failMessage = 'remark42: localStorage access denied, check browser preferences';
@@ -47,9 +46,9 @@ export function setJsonItem<T = any>(key: string, data: T) {
} }
} }
export function updateJsonItem<T = Record<string, any> | any[]>(key: string, value: (data: T) => T): void; export function updateJsonItem<T extends {}>(key: string, value: (data: T) => T): void;
export function updateJsonItem<T = any[]>(key: string, value: T): void; export function updateJsonItem<T extends {}>(key: string, value: T): void;
export function updateJsonItem<T = Record<string, any>>(key: string, value: T) { export function updateJsonItem<T = Record<string, unknown>>(key: string, value: T) {
const savedData = getJsonItem<any>(key); const savedData = getJsonItem<any>(key);
if (Array.isArray(value)) { if (Array.isArray(value)) {
-42
View File
@@ -1,42 +0,0 @@
import 'regenerator-runtime/runtime';
import 'es6-promise/auto';
import 'focus-visible';
import '@webcomponents/custom-elements';
import './closest-polyfill';
export default async function loadPolyfills() {
function fillCoreJs() {
if (
'startsWith' in String.prototype &&
'endsWith' in String.prototype &&
'includes' in Array.prototype &&
'assign' in Object &&
'keys' in Object
) {
return;
}
return import(/* webpackChunkName: "core-js" */ 'core-js');
}
function fillFetch() {
if ('fetch' in window) return;
return import(/* webpackChunkName: "whatwg-fetch" */ 'whatwg-fetch');
}
function fillIntersectionObserver() {
if (
'IntersectionObserver' in window &&
'IntersectionObserverEntry' in window &&
'intersectionRatio' in window.IntersectionObserverEntry.prototype
) {
return;
}
return import(/* webpackChunkName: "intersection-observer" */ 'intersection-observer');
}
await Promise.all([fillCoreJs(), fillFetch(), fillIntersectionObserver()]);
return;
}
+5 -4
View File
@@ -1,6 +1,7 @@
import { Theme } from './types'; import parseQuery from 'utils/parseQuery';
import type { Theme } from './types';
import { THEMES, MAX_SHOWN_ROOT_COMMENTS } from './constants'; import { THEMES, MAX_SHOWN_ROOT_COMMENTS } from './constants';
import parseQuery from '@app/utils/parseQuery';
export interface QuerySettingsType { export interface QuerySettingsType {
site_id?: string; site_id?: string;
@@ -29,9 +30,9 @@ if (!querySettings.theme || THEMES.indexOf(querySettings.theme) === -1) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
querySettings.show_email_subscription = (querySettings.show_email_subscription as any) !== 'false'; querySettings.show_email_subscription = (querySettings.show_email_subscription as any) !== 'false';
export const siteId = querySettings.site_id; export const siteId = querySettings.site_id!;
export const pageTitle = querySettings.page_title; export const pageTitle = querySettings.page_title;
export const url = querySettings.url; export const url = querySettings.url;
export const maxShownComments = querySettings.max_shown_comments; export const maxShownComments = querySettings.max_shown_comments;
export const token = querySettings.token; export const token = querySettings.token!;
export const theme = querySettings.theme; export const theme = querySettings.theme;
-7
View File
@@ -118,13 +118,6 @@ export interface Config {
emoji_enabled: boolean; emoji_enabled: boolean;
} }
export interface RemarkConfig {
site_id: string;
url: string;
/** used in last comments widget */
max_last_comments?: number;
}
export type Sorting = '-time' | '+time' | '-active' | '+active' | '-score' | '+score' | '-controversy' | '+controversy'; export type Sorting = '-time' | '+time' | '-active' | '+active' | '-score' | '+score' | '-controversy' | '+controversy';
export type AuthProvider = export type AuthProvider =
+4 -3
View File
@@ -1,7 +1,8 @@
import { UserInfo } from './types'; import parseQuery from 'utils/parseQuery';
import parseQuery from '@app/utils/parseQuery';
export const userInfo: Partial<UserInfo> = parseQuery(); import type { UserInfo } from './types';
export const userInfo: UserInfo = parseQuery();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isDefaultPicture = ((userInfo.isDefaultPicture as any) as string) !== '1'; export const isDefaultPicture = ((userInfo.isDefaultPicture as any) as string) !== '1';
@@ -0,0 +1,4 @@
.auth-panel__column:last-child {
margin-left: 8px;
text-align: right;
}
@@ -1,6 +0,0 @@
.auth-panel__column {
&:last-child {
margin-left: 8px;
text-align: right;
}
}
@@ -1,7 +1,7 @@
.auth-panel_logged-in { .auth-panel_logged-in {
font-size: 12px; font-size: 12px;
.auth-panel__column { & .auth-panel__column {
&:first-child { &:first-child {
font-weight: 400; font-weight: 400;
} }
@@ -1,5 +1,5 @@
.auth-panel_theme_dark { .auth-panel_theme_dark {
.auth-panel__user-id { & .auth-panel__user-id {
color: var(--color5); color: var(--color5);
} }
} }
@@ -1,5 +1,5 @@
.auth-panel_theme_light { .auth-panel_theme_light {
.auth-panel__user-id { & .auth-panel__user-id {
color: var(--color13); color: var(--color13);
} }
} }
@@ -1,16 +1,15 @@
/** @jsx createElement */ import { h } from 'preact';
import { createElement } from 'preact';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import createMockStore from 'redux-mock-store'; import createMockStore from 'redux-mock-store';
import { Middleware } from 'redux'; import { Middleware } from 'redux';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import enMessages from '@app/locales/en.json'; import enMessages from 'locales/en.json';
import AuthPanel, { Props } from './auth-panel'; import AuthPanel, { Props } from './auth-panel';
import { Button } from '../button'; import { Button } from '../button';
import { StaticStore } from '@app/common/static_store'; import { StaticStore } from 'common/static-store';
const DefaultProps = { const DefaultProps = {
providers: ['google', 'github'], providers: ['google', 'github'],
@@ -83,7 +82,7 @@ describe('<AuthPanel />', () => {
const firstCol = element.find('.auth-panel__column').first(); const firstCol = element.find('.auth-panel__column').first();
const providerButtons = firstCol.find(Button); const providerButtons = firstCol.find(Button);
expect(firstCol.text()).toStartWith('Login:'); expect(firstCol.text().startsWith('Login:')).toBe(true);
expect(providerButtons.at(0).text()).toBe('Google'); expect(providerButtons.at(0).text()).toBe('Google');
expect(providerButtons.at(1).text()).toBe('GitHub'); expect(providerButtons.at(1).text()).toBe('GitHub');
}); });
@@ -1,22 +1,21 @@
/** @jsx createElement */ import { h, Component, Fragment } from 'preact';
import { createElement, Component, Fragment } from 'preact';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { FormattedMessage, defineMessages, IntlShape, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, IntlShape, useIntl } from 'react-intl';
import b from 'bem-react-helper'; import b from 'bem-react-helper';
import { User, AuthProvider, Sorting, Theme, PostInfo } from '@app/common/types'; import { User, AuthProvider, Sorting, Theme, PostInfo } from 'common/types';
import { IS_STORAGE_AVAILABLE, IS_THIRD_PARTY } from '@app/common/constants'; import { IS_STORAGE_AVAILABLE, IS_THIRD_PARTY } from 'common/constants';
import { requestDeletion } from '@app/utils/email'; import { requestDeletion } from 'utils/email';
import postMessage from '@app/utils/postMessage'; import postMessage from 'utils/postMessage';
import { getHandleClickProps } from '@app/common/accessibility'; import { getHandleClickProps } from 'common/accessibility';
import { StoreState } from '@app/store'; import { StoreState } from 'store';
import { ProviderState } from '@app/store/provider/reducers'; import { ProviderState } from 'store/provider/reducers';
import { Dropdown, DropdownItem } from '@app/components/dropdown'; import { Dropdown, DropdownItem } from 'components/dropdown';
import { Button } from '@app/components/button'; import { Button } from 'components/button';
import Auth from '@app/components/auth'; import Auth from 'components/auth';
import useTheme from '@app/hooks/useTheme'; import useTheme from 'hooks/useTheme';
import { StaticStore } from '@app/common/static_store'; import { StaticStore } from 'common/static-store';
export interface OwnProps { export interface OwnProps {
user: User | null; user: User | null;
@@ -132,6 +131,7 @@ export class AuthPanel extends Component<Props, State> {
className="auth-panel__pseudo-link" className="auth-panel__pseudo-link"
href={`${window.location.origin}/web/comments.html${window.location.search}`} href={`${window.location.origin}/web/comments.html${window.location.search}`}
target="_blank" target="_blank"
rel="noreferrer"
> >
<FormattedMessage id="authPanel.new-page" defaultMessage="new page" /> <FormattedMessage id="authPanel.new-page" defaultMessage="new page" />
</a> </a>
+17 -17
View File
@@ -1,18 +1,18 @@
import './auth-panel.css';
import './__readonly-label/auth-panel__readonly-label.css';
import './__column/auth-panel__column.css';
import './__select/auth-panel__select.css';
import './__select-label/auth-panel__select-label.css';
import './__select-label-value/auth-panel__select-label-value.css';
import './__sort/auth-panel__sort.css';
import './__user-id/auth-panel__user-id.css';
import './_theme/_dark/auth-panel_theme_dark.css';
import './_theme/_light/auth-panel_theme_light.css';
import './_logged-in/auth-panel_logged-in.css';
export { default } from './auth-panel'; export { default } from './auth-panel';
import './auth-panel.scss';
import './__readonly-label/auth-panel__readonly-label.scss';
import './__column/auth-panel__column.scss';
import './__select/auth-panel__select.scss';
import './__select-label/auth-panel__select-label.scss';
import './__select-label-value/auth-panel__select-label-value.scss';
import './__sort/auth-panel__sort.scss';
import './__user-id/auth-panel__user-id.scss';
import './_theme/_dark/auth-panel_theme_dark.scss';
import './_theme/_light/auth-panel_theme_light.scss';
import './_logged-in/auth-panel_logged-in.scss';
@@ -1,11 +1,10 @@
/** @jsx createElement */ import { h, Component, createRef } from 'preact';
import { createElement, Component, createRef } from 'preact';
import b from 'bem-react-helper'; import b from 'bem-react-helper';
import { IntlShape, defineMessages, FormattedMessage } from 'react-intl'; import { IntlShape, defineMessages, FormattedMessage } from 'react-intl';
import { Theme } from '@app/common/types'; import { Theme } from 'common/types';
import { Input } from '@app/components/input'; import { Input } from 'components/input';
import { Button } from '@app/components/button'; import { Button } from 'components/button';
import { validateUserName } from '../validateUserName'; import { validateUserName } from '../validateUserName';
@@ -89,7 +88,7 @@ export class AnonymousLoginForm extends Component<Props, State> {
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname // TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
let className = b('auth-anonymous-login-form', {}, { theme: props.theme }); let className = b('auth-anonymous-login-form', {}, { theme: props.theme });
if (props.className) { if (props.className) {
className += ' ' + b('auth-anonymous-login-form', {}, { theme: props.theme }); className += ` ${b('auth-anonymous-login-form', {}, { theme: props.theme })}`;
} }
const usernameInvalidReason = this.getUsernameInvalidReason(); const usernameInvalidReason = this.getUsernameInvalidReason();
@@ -1,3 +1,3 @@
export { AnonymousLoginForm } from './auth__anonymous-login-form'; import './auth__anonymous-login-form.css';
import './auth__anonymous-login-form.scss'; export { AnonymousLoginForm } from './auth__anonymous-login-form';
@@ -1,16 +1,16 @@
/** @jsx createElement */ import { h } from 'preact';
import { createElement } from 'preact';
import { mount, ReactWrapper } from 'enzyme'; import { mount, ReactWrapper } from 'enzyme';
import { EmailLoginFormConnected as EmailLoginForm, Props, State } from './auth__email-login-form'; import { EmailLoginFormConnected as EmailLoginForm, Props, State } from './auth__email-login-form';
import { User } from '@app/common/types';
import { sleep } from '@app/utils/sleep';
import { validToken } from '@app/testUtils/mocks/jwt';
import { sendEmailVerificationRequest } from '@app/common/api';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import enMessages from '../../../locales/en.json';
import { LS_EMAIL_KEY } from '@app/common/constants';
jest.mock('@app/utils/jwt', () => ({ import { validToken } from '__stubs__/jwt';
import { LS_EMAIL_KEY } from 'common/constants';
import { User } from 'common/types';
import { sleep } from 'utils/sleep';
import { sendEmailVerificationRequest } from 'common/api';
import enMessages from 'locales/en.json';
jest.mock('utils/jwt', () => ({
isJwtExpired: jest isJwtExpired: jest
.fn() .fn()
.mockImplementationOnce(() => true) .mockImplementationOnce(() => true)
@@ -18,7 +18,7 @@ jest.mock('@app/utils/jwt', () => ({
.mockImplementationOnce(() => true), .mockImplementationOnce(() => true),
})); }));
jest.mock('@app/common/api'); jest.mock('common/api');
function simulateInput(input: ReactWrapper, value: string) { function simulateInput(input: ReactWrapper, value: string) {
input.getDOMNode<HTMLTextAreaElement>().value = value; input.getDOMNode<HTMLTextAreaElement>().value = value;
@@ -1,22 +1,20 @@
/** @jsx createElement */ import { h, Component, createRef } from 'preact';
import { createElement, Component, createRef } from 'preact';
import { forwardRef } from 'preact/compat'; import { forwardRef } from 'preact/compat';
import b from 'bem-react-helper'; import b from 'bem-react-helper';
import { Theme, User } from '@app/common/types'; import { Theme, User } from 'common/types';
import { sendEmailVerificationRequest } from '@app/common/api'; import { LS_EMAIL_KEY } from 'common/constants';
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils'; import { sendEmailVerificationRequest } from 'common/api';
import { getHandleClickProps } from '@app/common/accessibility'; import { extractErrorMessageFromResponse } from 'utils/errorUtils';
import { sleep } from '@app/utils/sleep'; import { getHandleClickProps } from 'common/accessibility';
import TextareaAutosize from '@app/components/comment-form/textarea-autosize'; import { sleep } from 'utils/sleep';
import { Input } from '@app/components/input'; import TextareaAutosize from 'components/comment-form/textarea-autosize';
import { Button } from '@app/components/button'; import { Input } from 'components/input';
import { isJwtExpired } from '@app/utils/jwt'; import { Button } from 'components/button';
import { isJwtExpired } from 'utils/jwt';
import { defineMessages, IntlShape, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, IntlShape, useIntl, FormattedMessage } from 'react-intl';
import { validateUserName } from '../validateUserName'; import { validateUserName } from '../validateUserName';
import { messages as loginForm } from '../__anonymous-login-form/auth__anonymous-login-form'; import { messages as loginForm } from '../__anonymous-login-form/auth__anonymous-login-form';
import { LS_EMAIL_KEY } from '@app/common/constants';
interface OwnProps { interface OwnProps {
onSignIn(token: string): Promise<User | null>; onSignIn(token: string): Promise<User | null>;
@@ -199,7 +197,7 @@ export class EmailLoginForm extends Component<Props, State> {
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname // TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
let className = b('auth-email-login-form', {}, { theme: props.theme }); let className = b('auth-email-login-form', {}, { theme: props.theme });
if (props.className) { if (props.className) {
className += ' ' + b('auth-email-login-form', {}, { theme: props.theme }); className += ` ${b('auth-email-login-form', {}, { theme: props.theme })}`;
} }
const form1InvalidReason = this.getForm1InvalidReason(); const form1InvalidReason = this.getForm1InvalidReason();
@@ -208,7 +206,7 @@ export class EmailLoginForm extends Component<Props, State> {
return ( return (
<form className={className} onSubmit={this.onVerificationSubmit}> <form className={className} onSubmit={this.onVerificationSubmit}>
<Input <Input
autoFocus autofocus
name="username" name="username"
mix="auth-email-login-form__input" mix="auth-email-login-form__input"
ref={this.usernameInputRef} ref={this.usernameInputRef}
@@ -1,3 +1,4 @@
import './auth__email-login-form.scss'; import './auth__email-login-form.css';
export { EmailLoginForm, EmailLoginFormConnected, EmailLoginFormRef } from './auth__email-login-form'; export { EmailLoginForm, EmailLoginFormConnected } from './auth__email-login-form';
export type { EmailLoginFormRef } from './auth__email-login-form';
+6 -7
View File
@@ -1,16 +1,15 @@
/** @jsx createElement */ import { h } from 'preact';
import { createElement } from 'preact';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import enMessages from '@app/locales/en.json'; import enMessages from 'locales/en.json';
import { mockStore } from '@app/testUtils/mockStore'; import stubStore from '__stubs__/store';
import { StaticStore } from '@app/common/static_store'; import { StaticStore } from 'common/static-store';
import Auth from './auth'; import Auth from './auth';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { Button } from '../button'; import { Button } from '../button';
import { StoreState } from '@app/store'; import { StoreState } from 'store';
const initialStore = { const initialStore = {
provider: { name: 'google' }, provider: { name: 'google' },
@@ -24,7 +23,7 @@ describe('<Auth/>', () => {
const createWrapper = (store?: Partial<StoreState>) => const createWrapper = (store?: Partial<StoreState>) =>
mount( mount(
<IntlProvider locale="en" messages={enMessages}> <IntlProvider locale="en" messages={enMessages}>
<Provider store={mockStore(store || initialStore)}> <Provider store={stubStore(store || initialStore)}>
<Auth /> <Auth />
</Provider> </Provider>
</IntlProvider> </IntlProvider>
+15 -16
View File
@@ -1,26 +1,25 @@
/** @jsx createElement */ import { h, Component, createRef } from 'preact';
import { createElement, Component, createRef } from 'preact';
import { useCallback } from 'preact/hooks'; import { useCallback } from 'preact/hooks';
import { IntlShape, FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { IntlShape, FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { AuthProvider, Theme, User } from '@app/common/types'; import { AuthProvider, Theme, User } from 'common/types';
import { PROVIDER_NAMES, IS_STORAGE_AVAILABLE } from '@app/common/constants'; import { PROVIDER_NAMES, IS_STORAGE_AVAILABLE } from 'common/constants';
import { getHandleClickProps } from '@app/common/accessibility'; import { getHandleClickProps } from 'common/accessibility';
import { Button } from '@app/components/button'; import { Button } from 'components/button';
import { Dropdown, DropdownItem } from '@app/components/dropdown'; import { Dropdown, DropdownItem } from 'components/dropdown';
import debounce from '@app/utils/debounce'; import debounce from 'utils/debounce';
import { ProviderState } from '@app/store/provider/reducers'; import { ProviderState } from 'store/provider/reducers';
import { StaticStore } from '@app/common/static_store'; import { StaticStore } from 'common/static-store';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { StoreState } from '@app/store'; import { StoreState } from 'store';
import useTheme from '@app/hooks/useTheme'; import useTheme from 'hooks/useTheme';
import { logIn } from '@app/store/user/actions'; import { logIn } from 'store/user/actions';
import { AnonymousLoginForm } from './__anonymous-login-form'; import { AnonymousLoginForm } from './__anonymous-login-form';
import { EmailLoginFormConnected, EmailLoginFormRef } from './__email-login-form'; import { EmailLoginFormConnected, EmailLoginFormRef } from './__email-login-form';
import styles from './auth.module.pcss'; import styles from './auth.module.css';
interface Props { interface Props {
intl: IntlShape; intl: IntlShape;
@@ -186,13 +185,13 @@ const authPanelMessages = defineMessages({
}, },
}); });
export default function () { export default function AuthWrapper() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const provider = useSelector<StoreState, ProviderState>(store => store.provider); const provider = useSelector<StoreState, ProviderState>(store => store.provider);
const user = useSelector<StoreState, User | null>(store => store.user); const user = useSelector<StoreState, User | null>(store => store.user);
const theme = useTheme(); const theme = useTheme();
const intl = useIntl(); const intl = useIntl();
const handleSignin = useCallback((provider: AuthProvider) => dispatch(logIn(provider)), []); const handleSignin = useCallback((provider: AuthProvider) => dispatch(logIn(provider)), [dispatch]);
return <Auth provider={provider} theme={theme} onSignIn={handleSignin} intl={intl} user={user} />; return <Auth provider={provider} theme={theme} onSignIn={handleSignin} intl={intl} user={user} />;
} }
@@ -1,7 +1,6 @@
/** @jsx createElement */ import { h, JSX } from 'preact';
import { createElement, JSX } from 'preact';
import b from 'bem-react-helper'; import b from 'bem-react-helper';
import { Theme } from '@app/common/types'; import { Theme } from 'common/types';
interface Props { interface Props {
picture?: string; picture?: string;
+3 -3
View File
@@ -1,4 +1,4 @@
export { AvatarIcon } from './avatar-icon'; import './avatar-icon.css';
import './_default/avatar-icon_default.css';
import './avatar-icon.scss'; export { AvatarIcon } from './avatar-icon';
import './_default/avatar-icon_default.scss';
+11 -15
View File
@@ -1,26 +1,22 @@
/** @jsx createElement */ import { h, JSX } from 'preact';
import { createElement, JSX } from 'preact';
import { forwardRef } from 'preact/compat'; import { forwardRef } from 'preact/compat';
import b, { Mods, Mix } from 'bem-react-helper'; import b, { Mods, Mix } from 'bem-react-helper';
import { Theme } from '@app/common/types';
interface Props extends Omit<JSX.HTMLAttributes, 'size' | 'className'> { import type { Theme } from 'common/types';
export type ButtonProps = Omit<JSX.HTMLAttributes, 'size' | 'className'> & {
kind?: 'primary' | 'secondary' | 'link'; kind?: 'primary' | 'secondary' | 'link';
size?: 'middle' | 'large'; size?: 'middle' | 'large';
theme?: Theme; theme?: Theme;
mods?: Mods; mods?: Mods;
mix?: Mix; mix?: Mix;
type?: string; type?: string;
} };
export const Button = forwardRef<HTMLButtonElement, Props>( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ children, theme, mods, mix, kind, type = 'button', size, ...props }, ref) => { ({ children, theme, mods, mix, kind, type = 'button', size, ...props }, ref) => (
const className = b('button', { mods: { kind, size }, mix }, { theme, ...mods }); <button className={b('button', { mods: { kind, size, theme }, mix }, { ...mods })} type={type} {...props} ref={ref}>
{children}
return ( </button>
<button className={className} type={type} {...props} ref={ref}> )
{children}
</button>
);
}
); );
+11 -11
View File
@@ -1,12 +1,12 @@
import './button.css';
import './_kind/_link/button_kind_link.css';
import './_kind/_primary/button_kind_primary.css';
import './_kind/_secondary/button_kind_secondary.css';
import './_size/_large/button_size_large.css';
import './_size/_middle/button_size_middle.css';
import './_theme/_dark/button_theme_dark.css';
export { Button } from './button'; export { Button } from './button';
import './button.scss';
import './_kind/_link/button_kind_link.scss';
import './_kind/_primary/button_kind_primary.scss';
import './_kind/_secondary/button_kind_secondary.scss';
import './_size/_large/button_size_large.scss';
import './_size/_middle/button_size_middle.scss';
import './_theme/_dark/button_theme_dark.scss';
@@ -1,20 +1,17 @@
.comment-form__field { .comment-form__field {
$lineHeight: 1.4; /* font-size * line-height * lines * vertical-padding */
$fontSize: 16px; --height: calc(16px * 1.4 * 4 + 10px * 2);
$paddingVrt: 10px;
$lines: 4;
$height: calc($fontSize * $lineHeight * $lines + $paddingVrt * 2);
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
height: $height; height: var(--height);
min-height: $height; min-height: var(--height);
padding: $paddingVrt 12px; padding: 10px 12px;
margin: 0; margin: 0;
font-family: 'PT Sans', Helvetica, Arial, sans-serif; font-family: 'PT Sans', Helvetica, Arial, sans-serif;
font-size: $fontSize; font-size: 16px;
line-height: $lineHeight; line-height: 1.4;
border: 0; border: 0;
resize: none; resize: none;
backface-visibility: hidden; /* let's try to fix blinking in Safari */ backface-visibility: hidden; /* let's try to fix blinking in Safari */
@@ -1,26 +1,35 @@
/** @jsx createElement */ import { h } from 'preact';
import { createElement } from 'preact';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { act } from 'preact/test-utils'; import { act } from 'preact/test-utils';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { Middleware } from 'redux'; import { Middleware } from 'redux';
import createMockStore from 'redux-mock-store'; import createMockStore from 'redux-mock-store';
import '@app/testUtils/mockApi';
import { user, anonymousUser } from '@app/testUtils/mocks/user';
import { validToken } from '@app/testUtils/mocks/jwt';
import * as api from '@app/common/api';
import { sleep } from '@app/utils/sleep';
import { Input } from '@app/components/input';
import { Button } from '@app/components/button';
import { Dropdown } from '@app/components/dropdown';
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import enMessages from '../../../locales/en.json';
import { SubscribeByEmail, SubscribeByEmailForm } from './'; jest.mock('common/api');
import { LS_EMAIL_KEY } from '@app/common/constants';
import { user, anonymousUser } from '__stubs__/user';
import { validToken } from '__stubs__/jwt';
import { emailVerificationForSubscribe, emailConfirmationForSubscribe, unsubscribeFromEmailUpdates } from 'common/api';
import { sleep } from 'utils/sleep';
import { Input } from 'components/input';
import { Button } from 'components/button';
import { Dropdown } from 'components/dropdown';
import TextareaAutosize from 'components/comment-form/textarea-autosize';
import enMessages from 'locales/en.json';
import { LS_EMAIL_KEY } from 'common/constants';
import { SubscribeByEmail, SubscribeByEmailForm } from '.';
const emailVerificationForSubscribeMock = (emailVerificationForSubscribe as unknown) as jest.Mock<
ReturnType<typeof emailVerificationForSubscribe>
>;
const emailConfirmationForSubscribeMock = (emailConfirmationForSubscribe as unknown) as jest.Mock<
ReturnType<typeof emailConfirmationForSubscribe>
>;
const unsubscribeFromEmailUpdatesMock = (unsubscribeFromEmailUpdates as unknown) as jest.Mock<
ReturnType<typeof unsubscribeFromEmailUpdates>
>;
const initialStore = { const initialStore = {
user, user,
@@ -36,7 +45,7 @@ const makeInputEvent = (value: string) => ({
}, },
}); });
jest.mock('@app/utils/jwt', () => ({ jest.mock('utils/jwt', () => ({
isJwtExpired: jest.fn(() => false), isJwtExpired: jest.fn(() => false),
})); }));
@@ -94,49 +103,40 @@ describe('<SubscribeByEmailForm/>', () => {
const wrapper = createWrapper(store); const wrapper = createWrapper(store);
expect(wrapper.find('.comment-form__subscribe-by-email_subscribed')).toHaveLength(1); expect(wrapper.find('.comment-form__subscribe-by-email_subscribed')).toHaveLength(1);
expect(wrapper.text()).toStartWith('You are subscribed on updates by email'); expect(wrapper.text().startsWith('You are subscribed on updates by email')).toBe(true);
}); });
it('should pass throw subscribe process', async () => { it('should pass throw subscribe process', async () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
const emailVerificationForSubscribe = jest.spyOn(api, 'emailVerificationForSubscribe'); const input = wrapper.find('input');
const emailConfirmationForSubscribe = jest.spyOn(api, 'emailConfirmationForSubscribe');
const onInputEmail = wrapper.find(Input).prop('onInput');
const form = wrapper.find('form'); const form = wrapper.find('form');
expect(onInputEmail).toBeFunction(); input.getDOMNode<HTMLInputElement>().value = 'some@email.com';
input.simulate('input');
act(() => onInputEmail(makeInputEvent('some@email.com')));
expect(form).toHaveLength(1);
form.simulate('submit'); form.simulate('submit');
expect(emailVerificationForSubscribe).toHaveBeenCalledWith('some@email.com'); expect(emailVerificationForSubscribeMock).toHaveBeenCalledWith('some@email.com');
await sleep(0); await sleep();
wrapper.update(); wrapper.update();
const textarea = wrapper.find(TextareaAutosize); const textarea = wrapper.find('textarea');
const onInputToken = textarea.prop('onInput') as (e: any) => void; const button = wrapper.find('button');
const button = wrapper.find(Button);
expect(textarea).toHaveLength(1);
expect(onInputToken).toBeFunction();
expect(button.at(0).text()).toEqual('Back'); expect(button.at(0).text()).toEqual('Back');
expect(button.at(1).text()).toEqual('Subscribe'); expect(button.at(1).text()).toEqual('Subscribe');
act(() => onInputToken(makeInputEvent('tokentokentoken'))); textarea.getDOMNode<HTMLTextAreaElement>().value = 'tokentokentoken';
textarea.simulate('input');
form.simulate('submit');
wrapper.find('form').simulate('submit'); expect(emailConfirmationForSubscribeMock).toHaveBeenCalledWith('tokentokentoken');
expect(emailConfirmationForSubscribe).toHaveBeenCalledWith('tokentokentoken');
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
expect(wrapper.text()).toStartWith('You have been subscribed on updates by email'); expect(wrapper.text().startsWith('You have been subscribed on updates by email')).toBe(true);
expect(wrapper.find(Button).text()).toEqual('Unsubscribe'); expect(wrapper.find(Button).text()).toEqual('Unsubscribe');
}); });
@@ -152,7 +152,7 @@ describe('<SubscribeByEmailForm/>', () => {
const onInputEmail = wrapper.find(Input).prop('onInput'); const onInputEmail = wrapper.find(Input).prop('onInput');
const form = wrapper.find('form'); const form = wrapper.find('form');
expect(onInputEmail).toBeFunction(); expect(typeof onInputEmail === 'function').toBe(true);
act(() => onInputEmail(makeInputEvent('some@email.com'))); act(() => onInputEmail(makeInputEvent('some@email.com')));
@@ -169,7 +169,7 @@ describe('<SubscribeByEmailForm/>', () => {
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
expect(wrapper.text()).toStartWith('You have been subscribed on updates by email'); expect(wrapper.text().startsWith('You have been subscribed on updates by email')).toBe(true);
expect(wrapper.find(Button).text()).toEqual('Unsubscribe'); expect(wrapper.find(Button).text()).toEqual('Unsubscribe');
}); });
@@ -177,18 +177,17 @@ describe('<SubscribeByEmailForm/>', () => {
const store = mockStore({ ...initialStore, user: { email_subscription: true } }); const store = mockStore({ ...initialStore, user: { email_subscription: true } });
const wrapper = createWrapper(store); const wrapper = createWrapper(store);
const onClick = wrapper.find(Button).prop('onClick'); const onClick = wrapper.find(Button).prop('onClick');
const unsubscribeFromEmailUpdates = jest.spyOn(api, 'unsubscribeFromEmailUpdates');
expect(onClick).toBeFunction(); expect(typeof onClick === 'function').toBe(true);
act(() => onClick()); act(() => onClick());
expect(unsubscribeFromEmailUpdates).toHaveBeenCalled(); expect(unsubscribeFromEmailUpdatesMock).toHaveBeenCalled();
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
expect(wrapper.text()).toStartWith('You have been unsubscribed by email to updates'); expect(wrapper.text().startsWith('You have been unsubscribed by email to updates')).toBe(true);
expect(wrapper.find(Button).text()).toEqual('Close'); expect(wrapper.find(Button).text()).toEqual('Close');
}); });
}); });
@@ -1,30 +1,25 @@
/** @jsx createElement */ import { h, FunctionComponent, Fragment } from 'preact';
import { createElement, FunctionComponent, Fragment } from 'preact';
import { useState, useCallback, useEffect, useRef, PropRef } from 'preact/hooks'; import { useState, useCallback, useEffect, useRef, PropRef } from 'preact/hooks';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import b from 'bem-react-helper'; import b from 'bem-react-helper';
import { User } from '@app/common/types';
import { StoreState } from '@app/store';
import { setUserSubscribed } from '@app/store/user/actions';
import { sleep } from '@app/utils/sleep';
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
import useTheme from '@app/hooks/useTheme';
import { getHandleClickProps } from '@app/common/accessibility';
import {
emailVerificationForSubscribe,
emailConfirmationForSubscribe,
unsubscribeFromEmailUpdates,
} from '@app/common/api';
import { Input } from '@app/components/input';
import { Button } from '@app/components/button';
import { Dropdown } from '@app/components/dropdown';
import { Preloader } from '@app/components/preloader';
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
import { isJwtExpired } from '@app/utils/jwt';
import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'react-intl'; import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'react-intl';
import { LS_EMAIL_KEY } from '@app/common/constants';
import { User } from 'common/types';
import { LS_EMAIL_KEY } from 'common/constants';
import { StoreState } from 'store';
import { setUserSubscribed } from 'store/user/actions';
import { sleep } from 'utils/sleep';
import { extractErrorMessageFromResponse } from 'utils/errorUtils';
import useTheme from 'hooks/useTheme';
import { getHandleClickProps } from 'common/accessibility';
import { emailVerificationForSubscribe, emailConfirmationForSubscribe, unsubscribeFromEmailUpdates } from 'common/api';
import { Input } from 'components/input';
import { Button } from 'components/button';
import { Dropdown } from 'components/dropdown';
import Preloader from 'components/preloader';
import TextareaAutosize from 'components/comment-form/textarea-autosize';
import { isUserAnonymous } from 'utils/isUserAnonymous';
import { isJwtExpired } from 'utils/jwt';
const emailRegex = /[^@]+@[^.]+\..+/; const emailRegex = /[^@]+@[^.]+\..+/;
@@ -105,7 +100,7 @@ const renderTokenPart = (
handleChangeToken: (e: Event) => void, handleChangeToken: (e: Event) => void,
setEmailStep: () => void setEmailStep: () => void
) => ( ) => (
<Fragment> <>
<Button kind="link" mix="auth-email-login-form__back-button" {...getHandleClickProps(setEmailStep)}> <Button kind="link" mix="auth-email-login-form__back-button" {...getHandleClickProps(setEmailStep)}>
<FormattedMessage id="subscribeByEmail.back" defaultMessage="Back" /> <FormattedMessage id="subscribeByEmail.back" defaultMessage="Back" />
</Button> </Button>
@@ -117,7 +112,7 @@ const renderTokenPart = (
disabled={loading} disabled={loading}
value={token} value={token}
/> />
</Fragment> </>
); );
export const SubscribeByEmailForm: FunctionComponent = () => { export const SubscribeByEmailForm: FunctionComponent = () => {
@@ -165,7 +160,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
setLoading(false); setLoading(false);
} }
}, },
[setLoading, setError, setStep, step, emailAddress, token] [setLoading, setError, setStep, step, emailAddress, token, dispatch, intl]
); );
const handleChangeEmail = useCallback((e: Event) => { const handleChangeEmail = useCallback((e: Event) => {
@@ -193,7 +188,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
setToken(value); setToken(value);
}, },
[sendForm, setError, setToken] [sendForm, setError, setToken, intl]
); );
const handleSubmit = useCallback( const handleSubmit = useCallback(
@@ -212,6 +207,20 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
setStep(Step.Email); setStep(Step.Email);
}, [setStep]); }, [setStep]);
const handleUnsubscribe = useCallback(async () => {
setLoading(true);
try {
await unsubscribeFromEmailUpdates();
dispatch(setUserSubscribed(false));
previousStep.current = Step.Subscribed;
setStep(Step.Unsubscribed);
} catch (e) {
setError(extractErrorMessageFromResponse(e, intl));
} finally {
setLoading(false);
}
}, [setLoading, setStep, setError, dispatch, intl]);
useEffect(() => { useEffect(() => {
if (emailAddressRef.current) { if (emailAddressRef.current) {
emailAddressRef.current.focus(); emailAddressRef.current.focus();
@@ -227,20 +236,6 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
} }
if (step === Step.Subscribed) { if (step === Step.Subscribed) {
const handleUnsubscribe = useCallback(async () => {
setLoading(true);
try {
await unsubscribeFromEmailUpdates();
dispatch(setUserSubscribed(false));
previousStep.current = Step.Subscribed;
setStep(Step.Unsubscribed);
} catch (e) {
setError(extractErrorMessageFromResponse(e, intl));
} finally {
setLoading(false);
}
}, [setLoading, setStep, setError]);
const text = const text =
previousStep.current === Step.Token previousStep.current === Step.Token
? intl.formatMessage(messages.haveSubscribed) ? intl.formatMessage(messages.haveSubscribed)
@@ -318,9 +313,7 @@ export const SubscribeByEmail: FunctionComponent = () => {
const intl = useIntl(); const intl = useIntl();
const user = useSelector<StoreState, User | null>(({ user }) => user); const user = useSelector<StoreState, User | null>(({ user }) => user);
const isAnonymous = isUserAnonymous(user); const isAnonymous = isUserAnonymous(user);
const buttonTitle = isAnonymous const buttonTitle = intl.formatMessage(isAnonymous ? messages.onlyRegisteredUsers : messages.subscribeByEmail);
? intl.formatMessage(messages.onlyRegisteredUsers)
: intl.formatMessage(messages.subscribeByEmail);
return ( return (
<Dropdown <Dropdown
@@ -1,37 +1,33 @@
/** @jsx createElement */ import { h } from 'preact';
import { createElement } from 'preact';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import enMessages from '../../../locales/en.json';
import { SubscribeByRSS, createSubscribeUrl } from './'; import { SubscribeByRSS, createSubscribeUrl } from '.';
jest.mock('react-redux', () => ({ jest.mock('react-redux', () => ({
useSelector: jest.fn(fn => fn({ theme: 'light' })), useSelector: jest.fn(fn => fn({ theme: 'light' })),
})); }));
jest.mock('react-intl', () => { jest.mock('react-intl', () => {
// Require the original module to not be mocked... const reactIntl = jest.requireActual('react-intl');
const originalModule = jest.requireActual('react-intl'); const messages = require('locales/en.json');
const intlProvider = new reactIntl.IntlProvider({ locale: 'en', messages }, {});
return { return {
...originalModule, ...reactIntl,
useIntl: () => originalModule.createIntl({ locale: `en`, messages: enMessages }), useIntl: () => intlProvider.state.intl,
}; };
}); });
describe('<SubscribeByRSS/>', () => { describe('<SubscribeByRSS/>', () => {
let wrapper: ReturnType<typeof shallow>;
beforeAll(() => {
wrapper = shallow(<SubscribeByRSS userId="user-1" />);
});
it('should be render links in dropdown', () => { it('should be render links in dropdown', () => {
wrapper.update(); const wrapper = shallow(<SubscribeByRSS userId="user-1" />);
expect(wrapper.find('.comment-form__rss-dropdown__link')).toHaveLength(3); expect(wrapper.find('.comment-form__rss-dropdown__link')).toHaveLength(3);
}); });
it('should have userId in replies link', () => { it('should have userId in replies link', () => {
const wrapper = shallow(<SubscribeByRSS userId="user-1" />);
expect(wrapper.find('.comment-form__rss-dropdown__link').at(2).prop('href')).toBe( expect(wrapper.find('.comment-form__rss-dropdown__link').at(2).prop('href')).toBe(
createSubscribeUrl('reply', '&user=user-1') createSubscribeUrl('reply', '&user=user-1')
); );
@@ -1,14 +1,13 @@
/** @jsx createElement */ import { h, FunctionComponent } from 'preact';
import { createElement, FunctionComponent } from 'preact';
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { useIntl, defineMessages } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import useTheme from '@app/hooks/useTheme'; import useTheme from 'hooks/useTheme';
import { siteId, url } from '@app/common/settings'; import { siteId, url } from 'common/settings';
import { BASE_URL, API_BASE } from '@app/common/constants'; import { BASE_URL, API_BASE } from 'common/constants';
import { Dropdown, DropdownItem } from '@app/components/dropdown'; import { Dropdown, DropdownItem } from 'components/dropdown';
export const createSubscribeUrl = (type: 'post' | 'site' | 'reply', urlParams: string = '') => export const createSubscribeUrl = (type: 'post' | 'site' | 'reply', urlParams = '') =>
`${BASE_URL}${API_BASE}/rss/${type}?site=${siteId}${urlParams}`; `${BASE_URL}${API_BASE}/rss/${type}?site=${siteId}${urlParams}`;
const messages = defineMessages({ const messages = defineMessages({
@@ -43,7 +42,7 @@ export const SubscribeByRSS: FunctionComponent<{ userId: string | null }> = ({ u
[createSubscribeUrl('site'), intl.formatMessage(messages.site)], [createSubscribeUrl('site'), intl.formatMessage(messages.site)],
[createSubscribeUrl('reply', `&user=${userId}`), intl.formatMessage(messages.replies)], [createSubscribeUrl('reply', `&user=${userId}`), intl.formatMessage(messages.replies)],
], ],
[userId] [userId, intl]
); );
return ( return (
@@ -56,7 +55,7 @@ export const SubscribeByRSS: FunctionComponent<{ userId: string | null }> = ({ u
> >
{items.map(([href, label]) => ( {items.map(([href, label]) => (
<DropdownItem> <DropdownItem>
<a href={href} className="comment-form__rss-dropdown__link" target="_blank"> <a href={href} className="comment-form__rss-dropdown__link" target="_blank" rel="noreferrer">
{label} {label}
</a> </a>
</DropdownItem> </DropdownItem>
@@ -2,41 +2,41 @@
border-color: var(--color7); border-color: var(--color7);
background: var(--color8); /* try to fix textarea blinking in Safari */ background: var(--color8); /* try to fix textarea blinking in Safari */
.comment-form__actions { & .comment-form__actions {
background: var(--color7); background: var(--color7);
} }
.comment-form__button_type_preview { & .comment-form__button_type_preview {
background: var(--color8); background: var(--color8);
color: var(--color20); color: var(--color20);
} }
.comment-form__button_type_send { & .comment-form__button_type_send {
color: var(--color20); color: var(--color20);
} }
.comment-form__error { & .comment-form__error {
border-top: 8px solid var(--color7); border-top: 8px solid var(--color7);
background: var(--color28); background: var(--color28);
color: var(--color27); color: var(--color27);
} }
.comment-form__field { & .comment-form__field {
background: var(--color8); background: var(--color8);
color: var(--color5); color: var(--color5);
} }
.comment-form__preview { & .comment-form__preview {
border-color: var(--color8); border-color: var(--color8);
background: var(--color8); background: var(--color8);
color: var(--color20); color: var(--color20);
} }
.comment-form__preview-wrapper { & .comment-form__preview-wrapper {
background: var(--color7); background: var(--color7);
} }
.comment-form__toolbar-item { & .comment-form__toolbar-item {
color: var(--color20); color: var(--color20);
&:hover { &:hover {
@@ -44,7 +44,7 @@
} }
} }
.comment-form__control-panel { & .comment-form__control-panel {
background-color: var(--color7); background-color: var(--color7);
} }
} }

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