diff --git a/.eslintrc.js b/.eslintrc.js index 237a302d48e5..4e670b5f0358 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -396,7 +396,7 @@ module.exports = { 'import/no-default-export': 'off', 'react/jsx-no-target-blank': 'off', 'react/no-unescaped-entities': 'off', - 'react-hooks/exhaustive-deps': 'off', // TODO: enable it later + 'react-hooks/exhaustive-deps': 'warn', // TODO: enable it later }, settings: { react: { diff --git a/packages/website/src/components/Playground.module.css b/packages/website/src/components/Playground.module.css index 8046463c1dce..0ce6400ded7d 100644 --- a/packages/website/src/components/Playground.module.css +++ b/packages/website/src/components/Playground.module.css @@ -51,7 +51,12 @@ } .tabCode { - height: calc(100% - 32px); + height: calc(100% - 41px); +} + +.hidden { + display: none; + visibility: hidden; } @media only screen and (max-width: 996px) { diff --git a/packages/website/src/components/Playground.tsx b/packages/website/src/components/Playground.tsx index 12e393682b72..591bc5c9bf00 100644 --- a/packages/website/src/components/Playground.tsx +++ b/packages/website/src/components/Playground.tsx @@ -19,13 +19,7 @@ import Loader from './layout/Loader'; import OptionsSelector from './OptionsSelector'; import styles from './Playground.module.css'; import ConditionalSplitPane from './SplitPane/ConditionalSplitPane'; -import type { - ConfigModel, - ErrorGroup, - RuleDetails, - SelectedRange, - TabType, -} from './types'; +import type { ErrorGroup, RuleDetails, SelectedRange, TabType } from './types'; function Playground(): JSX.Element { const [state, setState] = useHashState({ @@ -47,19 +41,10 @@ function Playground(): JSX.Element { const [selectedRange, setSelectedRange] = useState(); const [position, setPosition] = useState(); const [activeTab, setTab] = useState('code'); - const [showModal, setShowModal] = useState(false); const [esQueryFilter, setEsQueryFilter] = useState(); const [esQueryError, setEsQueryError] = useState(); - - const updateModal = useCallback( - (config?: Partial) => { - if (config) { - setState(config); - } - setShowModal(false); - }, - [setState], - ); + const [visualEslintRc, setVisualEslintRc] = useState(false); + const [visualTSConfig, setVisualTSConfig] = useState(false); const onLoaded = useCallback( (ruleNames: RuleDetails[], tsVersions: readonly string[]): void => { @@ -70,6 +55,22 @@ function Playground(): JSX.Element { [], ); + const activeVisualEditor = !isLoading + ? visualEslintRc && activeTab === 'eslintrc' + ? 'eslintrc' + : visualTSConfig && activeTab === 'tsconfig' + ? 'tsconfig' + : undefined + : undefined; + + const onVisualEditor = useCallback((tab: TabType): void => { + if (tab === 'tsconfig') { + setVisualTSConfig(val => !val); + } else if (tab === 'eslintrc') { + setVisualEslintRc(val => !val); + } + }, []); + const astToShow = state.showAST === 'ts' ? tsAst @@ -81,19 +82,6 @@ function Playground(): JSX.Element { return (
- {ruleNames.length > 0 && ( - - )} -
setShowModal(activeTab)} + showModal={onVisualEditor} /> -
+ {(activeVisualEditor === 'eslintrc' && ( + + )) || + (activeVisualEditor === 'tsconfig' && ( + + ))} +
; export interface ConfigEditorProps { readonly options: ConfigOptionsType[]; readonly values: ConfigEditorValues; - readonly isOpen: boolean; - readonly header: string; - readonly onClose: (config: ConfigEditorValues) => void; -} - -function reducerObject( - state: ConfigEditorValues, - action: - | { type: 'init'; config?: ConfigEditorValues } - | { - type: 'set'; - name: string; - value: unknown; - } - | { - type: 'toggle'; - checked: boolean; - default: unknown[] | undefined; - name: string; - }, -): ConfigEditorValues { - switch (action.type) { - case 'init': { - return action.config ?? {}; - } - case 'set': { - const newState = { ...state }; - if (action.value === '') { - delete newState[action.name]; - } else { - newState[action.name] = action.value; - } - return newState; - } - case 'toggle': { - const newState = { ...state }; - if (action.checked) { - newState[action.name] = action.default ? action.default[0] : true; - } else if (action.name in newState) { - delete newState[action.name]; - } - return newState; - } - } + readonly onChange: (config: ConfigEditorValues) => void; + readonly className?: string; } function filterConfig( @@ -88,93 +44,104 @@ function isDefault(value: unknown, defaults?: unknown[]): boolean { return defaults ? defaults.includes(value) : value === true; } -function ConfigEditor(props: ConfigEditorProps): JSX.Element { - const { onClose: onCloseProps, isOpen, values } = props; - const [filter, setFilter] = useState(''); - const [config, setConfig] = useReducer(reducerObject, {}); - const [filterInput, setFilterFocus] = useFocus(); +interface ConfigEditorFieldProps { + readonly item: ConfigOptionsField; + readonly value: unknown; + readonly onChange: (name: string, value: unknown) => void; +} + +function ConfigEditorField({ + item, + value, + onChange, +}: ConfigEditorFieldProps): JSX.Element { + return ( + + ); +} - const onClose = useCallback(() => { - onCloseProps(config); - }, [onCloseProps, config]); +function ConfigEditor({ + onChange: onChangeProp, + values, + options, + className, +}: ConfigEditorProps): JSX.Element { + const [filter, setFilter] = useState(''); - useEffect(() => { - setConfig({ type: 'init', config: values }); - }, [values]); + const filteredOptions = useMemo(() => { + return filterConfig(options, filter); + }, [options, filter]); - useEffect(() => { - if (isOpen) { - setFilterFocus(); - } - }, [isOpen, setFilterFocus]); + const onChange = useCallback( + (name: string, value: unknown): void => { + const newConfig = { ...values }; + if (value === '' || value == null) { + delete newConfig[name]; + } else { + newConfig[name] = value; + } + onChangeProp(newConfig); + }, + [values, onChangeProp], + ); return ( - +
-
- {filterConfig(props.options, filter).map(group => ( -
-

{group.heading}

-
- {group.fields.map(item => ( - - ))} -
+ {filteredOptions.map(group => ( +
+

{group.heading}

+
+ {group.fields.map(item => ( + + ))}
- ))} -
- +
+ ))} +
); } diff --git a/packages/website/src/components/config/ConfigEslint.tsx b/packages/website/src/components/config/ConfigEslint.tsx index fe6222df2e3c..7756dbae10b0 100644 --- a/packages/website/src/components/config/ConfigEslint.tsx +++ b/packages/website/src/components/config/ConfigEslint.tsx @@ -1,99 +1,71 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ensureObject, parseJSONObject, toJson } from '../lib/json'; import { shallowEqual } from '../lib/shallowEqual'; -import type { ConfigModel, EslintRC, RuleDetails, RuleEntry } from '../types'; -import type { ConfigOptionsType } from './ConfigEditor'; +import type { ConfigModel, RuleDetails } from '../types'; +import type { ConfigOptionsField, ConfigOptionsType } from './ConfigEditor'; import ConfigEditor from './ConfigEditor'; -import { parseESLintRC, toJson } from './utils'; export interface ConfigEslintProps { - readonly isOpen: boolean; - readonly onClose: (value?: Partial) => void; + readonly onChange: (value: Partial) => void; readonly ruleOptions: RuleDetails[]; readonly config?: string; -} - -function checkSeverity(value: unknown): boolean { - if (typeof value === 'string' || typeof value === 'number') { - return [0, 1, 2, 'off', 'warn', 'error'].includes(value); - } - return false; -} - -function checkOptions(rule: [string, unknown]): rule is [string, RuleEntry] { - if (Array.isArray(rule[1])) { - return rule[1].length > 0 && checkSeverity(rule[1][0]); - } - return checkSeverity(rule[1]); + readonly className?: string; } function ConfigEslint(props: ConfigEslintProps): JSX.Element { - const { isOpen, config, onClose: onCloseProps, ruleOptions } = props; - const [options, updateOptions] = useState([]); - const [configObject, updateConfigObject] = useState(); + const { config, onChange: onChangeProp, ruleOptions, className } = props; - useEffect(() => { - if (isOpen) { - updateConfigObject(parseESLintRC(config)); - } - }, [isOpen, config]); + const [configObject, updateConfigObject] = useState>( + () => ({}), + ); useEffect(() => { - updateOptions([ + updateConfigObject(oldConfig => { + const newConfig = ensureObject(parseJSONObject(config).rules); + if (shallowEqual(oldConfig, newConfig)) { + return oldConfig; + } + return newConfig; + }); + }, [config]); + + const options = useMemo((): ConfigOptionsType[] => { + const mappedRules: ConfigOptionsField[] = ruleOptions.map(item => ({ + key: item.name, + label: item.description, + type: 'boolean', + defaults: ['error', 2, 'warn', 1, ['error'], ['warn'], [2], [1]], + })); + + return [ { heading: 'Rules', - fields: ruleOptions - .filter(item => item.name.startsWith('@typescript')) - .map(item => ({ - key: item.name, - label: item.description, - type: 'boolean', - defaults: ['error', 2, 'warn', 1, ['error'], ['warn'], [2], [1]], - })), + fields: mappedRules.filter(item => item.key.startsWith('@typescript')), }, { heading: 'Core rules', - fields: ruleOptions - .filter(item => !item.name.startsWith('@typescript')) - .map(item => ({ - key: item.name, - label: item.description, - type: 'boolean', - defaults: ['error', 2, 'warn', 1, ['error'], ['warn'], [2], [1]], - })), + fields: mappedRules.filter(item => !item.key.startsWith('@typescript')), }, - ]); + ]; }, [ruleOptions]); - const onClose = useCallback( + const onChange = useCallback( (newConfig: Record) => { - const cfg = Object.fromEntries( - Object.entries(newConfig) - .map<[string, unknown]>(([name, value]) => - Array.isArray(value) && value.length === 1 - ? [name, value[0]] - : [name, value], - ) - .filter(checkOptions), - ); - if (!shallowEqual(cfg, configObject?.rules)) { - onCloseProps({ - eslintrc: toJson({ ...(configObject ?? {}), rules: cfg }), - }); - } else { - onCloseProps(); - } + const parsed = parseJSONObject(config); + parsed.rules = newConfig; + updateConfigObject(newConfig); + onChangeProp({ eslintrc: toJson(parsed) }); }, - [onCloseProps, configObject], + [config, onChangeProp], ); return ( ); } diff --git a/packages/website/src/components/config/ConfigTypeScript.tsx b/packages/website/src/components/config/ConfigTypeScript.tsx index 40cd634ffb17..eb5c974d2cb9 100644 --- a/packages/website/src/components/config/ConfigTypeScript.tsx +++ b/packages/website/src/components/config/ConfigTypeScript.tsx @@ -1,83 +1,81 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ensureObject, parseJSONObject, toJson } from '../lib/json'; import { shallowEqual } from '../lib/shallowEqual'; -import type { ConfigModel, TSConfig } from '../types'; +import type { ConfigModel } from '../types'; import type { ConfigOptionsType } from './ConfigEditor'; import ConfigEditor from './ConfigEditor'; -import { getTypescriptOptions, parseTSConfig, toJson } from './utils'; +import { getTypescriptOptions } from './utils'; interface ConfigTypeScriptProps { - readonly isOpen: boolean; - readonly onClose: (config?: Partial) => void; + readonly onChange: (config: Partial) => void; readonly config?: string; + readonly className?: string; } function ConfigTypeScript(props: ConfigTypeScriptProps): JSX.Element { - const { onClose: onCloseProps, isOpen, config } = props; - const [tsConfigOptions, updateOptions] = useState([]); - const [configObject, updateConfigObject] = useState(); + const { config, onChange: onChangeProp, className } = props; - useEffect(() => { - if (isOpen) { - updateConfigObject(parseTSConfig(config)); - } - }, [isOpen, config]); + const [configObject, updateConfigObject] = useState>( + () => ({}), + ); useEffect(() => { - if (window.ts) { - updateOptions( - Object.values( - getTypescriptOptions().reduce>( - (group, item) => { - const category = item.category!.message; - group[category] = group[category] ?? { - heading: category, - fields: [], - }; - if (item.type === 'boolean') { - group[category].fields.push({ - key: item.name, - type: 'boolean', - label: item.description!.message, - }); - } else if (item.type instanceof Map) { - group[category].fields.push({ - key: item.name, - type: 'string', - label: item.description!.message, - enum: ['', ...Array.from(item.type.keys())], - }); - } - return group; - }, - {}, - ), - ), - ); - } - }, [isOpen]); + updateConfigObject(oldConfig => { + const newConfig = ensureObject(parseJSONObject(config).compilerOptions); + if (shallowEqual(oldConfig, newConfig)) { + return oldConfig; + } + return newConfig; + }); + }, [config]); - const onClose = useCallback( + const options = useMemo((): ConfigOptionsType[] => { + return Object.values( + getTypescriptOptions().reduce>( + (group, item) => { + const category = item.category!.message; + group[category] = group[category] ?? { + heading: category, + fields: [], + }; + if (item.type === 'boolean') { + group[category].fields.push({ + key: item.name, + type: 'boolean', + label: item.description!.message, + }); + } else if (item.type instanceof Map) { + group[category].fields.push({ + key: item.name, + type: 'string', + label: item.description!.message, + enum: ['', ...Array.from(item.type.keys())], + }); + } + return group; + }, + {}, + ), + ); + }, []); + + const onChange = useCallback( (newConfig: Record) => { - const cfg = { ...newConfig }; - if (!shallowEqual(cfg, configObject?.compilerOptions)) { - onCloseProps({ - tsconfig: toJson({ ...(configObject ?? {}), compilerOptions: cfg }), - }); - } else { - onCloseProps(); - } + const parsed = parseJSONObject(config); + parsed.compilerOptions = newConfig; + updateConfigObject(newConfig); + onChangeProp({ tsconfig: toJson(parsed) }); }, - [onCloseProps, configObject], + [config, onChangeProp], ); return ( ); } diff --git a/packages/website/src/components/config/utils.ts b/packages/website/src/components/config/utils.ts index a3ecff1afe7a..8061c25f1e9e 100644 --- a/packages/website/src/components/config/utils.ts +++ b/packages/website/src/components/config/utils.ts @@ -1,27 +1,17 @@ -import { isRecord } from '@site/src/components/ast/utils'; -import type { EslintRC, TSConfig } from '@site/src/components/types'; -import json5 from 'json5'; +import type * as ts from 'typescript'; -export interface OptionDeclarations { - name: string; - type?: unknown; - category?: { message: string }; - description?: { message: string }; - element?: { - type: unknown; - }; -} +import { isRecord } from '../ast/utils'; +import { parseJSONObject, toJson } from '../lib/json'; +import type { EslintRC, TSConfig } from '../types'; export function parseESLintRC(code?: string): EslintRC { if (code) { try { - const parsed: unknown = json5.parse(code); - if (isRecord(parsed)) { - if ('rules' in parsed && isRecord(parsed.rules)) { - return parsed as EslintRC; - } - return { ...parsed, rules: {} }; + const parsed = parseJSONObject(code); + if ('rules' in parsed && isRecord(parsed.rules)) { + return parsed as EslintRC; } + return { ...parsed, rules: {} }; } catch (e) { console.error(e); } @@ -75,11 +65,7 @@ export function tryParseEslintModule(value: string): string { return value; } -export function toJson(cfg: unknown): string { - return JSON.stringify(cfg, null, 2); -} - -export function getTypescriptOptions(): OptionDeclarations[] { +export function getTypescriptOptions(): ts.OptionDeclarations[] { const allowedCategories = [ 'Command-line Options', 'Projects', @@ -98,8 +84,7 @@ export function getTypescriptOptions(): OptionDeclarations[] { 'jsx', ]; - // @ts-expect-error: definition is not fully correct - return (window.ts.optionDeclarations as OptionDeclarations[]).filter( + return window.ts.optionDeclarations.filter( item => (item.type === 'boolean' || item.type === 'list' || diff --git a/packages/website/src/components/hooks/useFocus.ts b/packages/website/src/components/hooks/useFocus.ts index 9c0cd6747da7..3e94d308e956 100644 --- a/packages/website/src/components/hooks/useFocus.ts +++ b/packages/website/src/components/hooks/useFocus.ts @@ -1,14 +1,14 @@ import type React from 'react'; -import { useRef } from 'react'; +import { useCallback, useRef } from 'react'; function useFocus(): [ React.RefObject, () => void, ] { const htmlElRef = useRef(null); - const setFocus = (): void => { + const setFocus = useCallback((): void => { htmlElRef.current?.focus(); - }; + }, []); return [htmlElRef, setFocus]; } diff --git a/packages/website/src/components/hooks/useHashState.ts b/packages/website/src/components/hooks/useHashState.ts index 2c80d7a3ab23..de2f475154b3 100644 --- a/packages/website/src/components/hooks/useHashState.ts +++ b/packages/website/src/components/hooks/useHashState.ts @@ -3,8 +3,9 @@ import * as lz from 'lz-string'; import { useCallback, useState } from 'react'; import { fileTypes } from '../config'; -import { toJson } from '../config/utils'; import { hasOwnProperty } from '../lib/has-own-property'; +import { toJson } from '../lib/json'; +import { shallowEqual } from '../lib/shallowEqual'; import type { ConfigFileType, ConfigModel, ConfigShowAst } from '../types'; function writeQueryParam(value: string | null): string { @@ -183,9 +184,14 @@ function useHashState( const updateState = useCallback( (cfg: Partial) => { console.info('[State] updating config diff', cfg); + setState(oldState => { const newState = { ...oldState, ...cfg }; + if (shallowEqual(oldState, newState)) { + return oldState; + } + writeStateToLocalStorage(newState); history.replace({ diff --git a/packages/website/src/components/layout/Modal.module.css b/packages/website/src/components/layout/Modal.module.css deleted file mode 100644 index c33aaa17f434..000000000000 --- a/packages/website/src/components/layout/Modal.module.css +++ /dev/null @@ -1,65 +0,0 @@ -.modal { - display: none; - position: fixed; - z-index: 2; - padding-top: 100px; - left: 0; - top: 0; - width: 100%; - height: 100%; - overflow: auto; - background-color: rgb(0 0 0 / 40%); -} - -.modal.open { - display: block; -} - -.modalContent { - position: relative; - background-color: var(--ifm-background-surface-color); - border-radius: var(--ifm-global-radius); - margin: auto; - padding: 0; - width: calc(var(--ifm-container-width) * 0.7); - animation-name: animatetop; - animation-duration: 0.4s; - max-width: 95%; -} - -@keyframes animatetop { - from { - top: -30rem; - opacity: 0; - } - - to { - top: 0; - opacity: 1; - } -} - -.modalClose { - transition: color var(--ifm-transition-fast) - var(--ifm-transition-timing-default); -} - -.modalClose:hover, -.modalClose:focus { - color: var(--ifm-color-primary); -} - -.modalHeader { - display: flex; - padding: 1rem 1.5rem; - justify-content: space-between; - align-items: baseline; -} - -.modalHeader h2 { - margin: 0; -} - -.modalBody { - padding: 1rem 1.5rem; -} diff --git a/packages/website/src/components/layout/Modal.tsx b/packages/website/src/components/layout/Modal.tsx deleted file mode 100644 index ffc8ca55fef2..000000000000 --- a/packages/website/src/components/layout/Modal.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* eslint-disable jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */ -import CloseIcon from '@site/src/icons/close.svg'; -import clsx from 'clsx'; -import type { MouseEvent } from 'react'; -import React, { useCallback, useEffect } from 'react'; - -import styles from './Modal.module.css'; - -interface ModalProps { - readonly header: string; - readonly children: JSX.Element | (JSX.Element | false)[]; - readonly isOpen: boolean; - readonly onClose: () => void; -} - -function Modal(props: ModalProps): JSX.Element { - const { onClose } = props; - - useEffect(() => { - const closeOnEscapeKeyDown = (e: KeyboardEvent): void => { - if ( - e.key === 'Escape' || - // eslint-disable-next-line deprecation/deprecation -- intentional fallback for old browsers - e.keyCode === 27 - ) { - onClose(); - } - }; - - document.body.addEventListener('keydown', closeOnEscapeKeyDown); - return (): void => { - document.body.removeEventListener('keydown', closeOnEscapeKeyDown); - }; - }, [onClose]); - - const onClick = useCallback( - (e: MouseEvent) => { - if (e.currentTarget === e.target) { - onClose(); - } - }, - [onClose], - ); - - return ( -
-
-
-

{props.header}

- -
-
- {React.Children.map(props.children, child => child)} -
-
-
- ); -} - -export default Modal; diff --git a/packages/website/src/components/lib/json.ts b/packages/website/src/components/lib/json.ts new file mode 100644 index 000000000000..232584954477 --- /dev/null +++ b/packages/website/src/components/lib/json.ts @@ -0,0 +1,26 @@ +import json5 from 'json5'; + +import { isRecord } from '../ast/utils'; + +export function ensureObject(obj: unknown): Record { + return isRecord(obj) ? obj : {}; +} + +export function parseJSONObject(code?: string): Record { + if (code) { + try { + return ensureObject(json5.parse(code)); + } catch (e) { + console.error(e); + } + } + return {}; +} + +export function toJson(cfg: unknown): string { + return JSON.stringify(cfg, null, 2); +} + +export function toJsonConfig(cfg: unknown, prop: string): string { + return toJson({ [prop]: cfg }); +} diff --git a/packages/website/typings/typescript.d.ts b/packages/website/typings/typescript.d.ts index 7239e4ddcb5f..30af30c4ae78 100644 --- a/packages/website/typings/typescript.d.ts +++ b/packages/website/typings/typescript.d.ts @@ -1,7 +1,5 @@ import 'typescript'; -type StringMap = Map; - declare module 'typescript' { /** * Map of available libraries @@ -9,5 +7,17 @@ declare module 'typescript' { * The key is the key used in compilerOptions.lib * The value is the file name */ - const libMap: StringMap; + const libMap: Map; + + interface OptionDeclarations { + name: string; + type?: unknown; + category?: { message: string }; + description?: { message: string }; + element?: { + type: unknown; + }; + } + + const optionDeclarations: OptionDeclarations[]; }