From ffa943e82e262399ccf1430301b56a11e42f86fa Mon Sep 17 00:00:00 2001 From: Armano Date: Fri, 31 Mar 2023 23:35:29 +0200 Subject: [PATCH 1/7] chore(website): [playground] correct issues with re-renders of visual editor --- .eslintrc.js | 2 +- .../website/src/components/Playground.tsx | 14 +- .../src/components/config/ConfigEditor.tsx | 179 ++++++++---------- .../src/components/config/ConfigEslint.tsx | 42 ++-- .../components/config/ConfigTypeScript.tsx | 75 ++++---- .../website/src/components/hooks/useFocus.ts | 6 +- .../website/src/components/layout/Modal.tsx | 14 +- 7 files changed, 152 insertions(+), 180 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 66097b17f060..395191ed87de 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -393,7 +393,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.tsx b/packages/website/src/components/Playground.tsx index d193a6625c8a..03aa2246883d 100644 --- a/packages/website/src/components/Playground.tsx +++ b/packages/website/src/components/Playground.tsx @@ -94,7 +94,7 @@ function Playground(): JSX.Element { return (
- {ruleNames.length > 0 && ( + {!isLoading && ( )} - + {!isLoading && ( + + )}
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; - } - } -} - function filterConfig( options: ConfigOptionsType[], filter: string, @@ -88,18 +47,79 @@ function isDefault(value: unknown, defaults?: unknown[]): boolean { return defaults ? defaults.includes(value) : value === true; } +interface ConfigEditorFieldProps { + readonly item: ConfigOptionsField; + readonly value: unknown; + readonly onChange: (name: string, value: unknown) => void; +} + +function ConfigEditorField({ + item, + value, + onChange, +}: ConfigEditorFieldProps): JSX.Element { + return ( + + ); +} + function ConfigEditor(props: ConfigEditorProps): JSX.Element { - const { onClose: onCloseProps, isOpen, values } = props; + const { onClose: onCloseProps, isOpen, values, options, header } = props; const [filter, setFilter] = useState(''); - const [config, setConfig] = useReducer(reducerObject, {}); + const [config, setConfig] = useState(() => ({})); const [filterInput, setFilterFocus] = useFocus(); + const filteredOptions = useMemo( + () => filterConfig(options, filter), + [options, filter], + ); + const onClose = useCallback(() => { onCloseProps(config); }, [onCloseProps, config]); + const onChange = useCallback( + (name: string, value: unknown): void => { + setConfig(oldConfig => { + const newConfig = { ...oldConfig }; + if (value === '' || value == null) { + delete newConfig[name]; + } else { + newConfig[name] = value; + } + return newConfig; + }); + }, + [setConfig], + ); + useEffect(() => { - setConfig({ type: 'init', config: values }); + setConfig(values); }, [values]); useEffect(() => { @@ -109,7 +129,7 @@ function ConfigEditor(props: ConfigEditorProps): JSX.Element { }, [isOpen, setFilterFocus]); return ( - +
- {filterConfig(props.options, filter).map(group => ( -
-

{group.heading}

-
- {group.fields.map(item => ( - - ))} + {isOpen && + 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..8b8f0999f094 100644 --- a/packages/website/src/components/config/ConfigEslint.tsx +++ b/packages/website/src/components/config/ConfigEslint.tsx @@ -1,8 +1,8 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { shallowEqual } from '../lib/shallowEqual'; import type { ConfigModel, EslintRC, RuleDetails, RuleEntry } from '../types'; -import type { ConfigOptionsType } from './ConfigEditor'; +import type { ConfigOptionsField, ConfigOptionsType } from './ConfigEditor'; import ConfigEditor from './ConfigEditor'; import { parseESLintRC, toJson } from './utils'; @@ -29,8 +29,9 @@ function checkOptions(rule: [string, unknown]): rule is [string, RuleEntry] { function ConfigEslint(props: ConfigEslintProps): JSX.Element { const { isOpen, config, onClose: onCloseProps, ruleOptions } = props; - const [options, updateOptions] = useState([]); - const [configObject, updateConfigObject] = useState(); + const [configObject, updateConfigObject] = useState(() => ({ + rules: {}, + })); useEffect(() => { if (isOpen) { @@ -38,31 +39,24 @@ function ConfigEslint(props: ConfigEslintProps): JSX.Element { } }, [isOpen, config]); - useEffect(() => { - updateOptions([ + 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( @@ -91,7 +85,7 @@ function ConfigEslint(props: ConfigEslintProps): JSX.Element { diff --git a/packages/website/src/components/config/ConfigTypeScript.tsx b/packages/website/src/components/config/ConfigTypeScript.tsx index 40cd634ffb17..d269e9a0efef 100644 --- a/packages/website/src/components/config/ConfigTypeScript.tsx +++ b/packages/website/src/components/config/ConfigTypeScript.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { shallowEqual } from '../lib/shallowEqual'; import type { ConfigModel, TSConfig } from '../types'; @@ -13,9 +13,10 @@ interface ConfigTypeScriptProps { } function ConfigTypeScript(props: ConfigTypeScriptProps): JSX.Element { - const { onClose: onCloseProps, isOpen, config } = props; - const [tsConfigOptions, updateOptions] = useState([]); - const [configObject, updateConfigObject] = useState(); + const { isOpen, config, onClose: onCloseProps } = props; + const [configObject, updateConfigObject] = useState(() => ({ + compilerOptions: {}, + })); useEffect(() => { if (isOpen) { @@ -23,39 +24,35 @@ function ConfigTypeScript(props: ConfigTypeScriptProps): JSX.Element { } }, [isOpen, config]); - 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]); + 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 onClose = useCallback( (newConfig: Record) => { @@ -74,8 +71,8 @@ function ConfigTypeScript(props: ConfigTypeScriptProps): JSX.Element { return ( 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/layout/Modal.tsx b/packages/website/src/components/layout/Modal.tsx index ffc8ca55fef2..53fb365d62c8 100644 --- a/packages/website/src/components/layout/Modal.tsx +++ b/packages/website/src/components/layout/Modal.tsx @@ -13,9 +13,7 @@ interface ModalProps { readonly onClose: () => void; } -function Modal(props: ModalProps): JSX.Element { - const { onClose } = props; - +function Modal({ isOpen, onClose, children, header }: ModalProps): JSX.Element { useEffect(() => { const closeOnEscapeKeyDown = (e: KeyboardEvent): void => { if ( @@ -44,7 +42,7 @@ function Modal(props: ModalProps): JSX.Element { return (
-

{props.header}

+

{header}

-
- {React.Children.map(props.children, child => child)} -
+
{children}
); From 5caae4beab348e31e0aa3fb506e46aead29cec12 Mon Sep 17 00:00:00 2001 From: Armano Date: Sat, 1 Apr 2023 20:16:32 +0200 Subject: [PATCH 2/7] fix: remove modal and update visual editor --- .../src/components/Playground.module.css | 7 +- .../website/src/components/Playground.tsx | 82 +++++----- .../components/config/ConfigEditor.module.css | 3 + .../src/components/config/ConfigEditor.tsx | 96 +++++------- .../src/components/config/ConfigEslint.tsx | 72 +++------ .../components/config/ConfigTypeScript.tsx | 50 +++--- .../src/components/hooks/useHashState.ts | 148 +++++++----------- packages/website/src/components/types.ts | 2 +- 8 files changed, 199 insertions(+), 261 deletions(-) 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 03aa2246883d..8af7cb24eac0 100644 --- a/packages/website/src/components/Playground.tsx +++ b/packages/website/src/components/Playground.tsx @@ -2,7 +2,7 @@ import type { TSESTree } from '@typescript-eslint/utils'; import clsx from 'clsx'; import type * as ESQuery from 'esquery'; import type Monaco from 'monaco-editor'; -import React, { useCallback, useReducer, useState } from 'react'; +import React, { useCallback, useMemo, useReducer, useState } from 'react'; import type { SourceFile } from 'typescript'; import { useMediaQuery } from '../hooks/useMediaQuery'; @@ -24,13 +24,7 @@ import { shallowEqual } from './lib/shallowEqual'; 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 rangeReducer( prevState: T, @@ -68,20 +62,11 @@ function Playground(): JSX.Element { const [selectedRange, setSelectedRange] = useReducer(rangeReducer, null); const [position, setPosition] = useState(null); const [activeTab, setTab] = useState('code'); - const [showModal, setShowModal] = useState(false); const [esQueryFilter, setEsQueryFilter] = useState(); const [esQueryError, setEsQueryError] = useState(); const enableSplitPanes = useMediaQuery('(min-width: 996px)'); - - 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 => { @@ -92,23 +77,27 @@ function Playground(): JSX.Element { [], ); + const activeVisualEditor = useMemo(() => { + if (!isLoading) { + return visualEslintRc && activeTab === 'eslintrc' + ? 'eslintrc' + : visualTSConfig && activeTab === 'tsconfig' + ? 'tsconfig' + : undefined; + } + return undefined; + }, [activeTab, isLoading, visualEslintRc, visualTSConfig]); + + const onVisualEditor = useCallback((tab: TabType): void => { + if (tab === 'tsconfig') { + setVisualTSConfig(val => !val); + } else if (tab === 'eslintrc') { + setVisualEslintRc(val => !val); + } + }, []); + return (
- {!isLoading && ( - - )} - {!isLoading && ( - - )}
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; + readonly onChange: (config: ConfigEditorValues) => void; + readonly className?: string; } function filterConfig( @@ -89,75 +86,58 @@ function ConfigEditorField({ } function ConfigEditor(props: ConfigEditorProps): JSX.Element { - const { onClose: onCloseProps, isOpen, values, options, header } = props; + const { onChange: onChangeProp, values, options, className } = props; const [filter, setFilter] = useState(''); - const [config, setConfig] = useState(() => ({})); - const [filterInput, setFilterFocus] = useFocus(); - const filteredOptions = useMemo( - () => filterConfig(options, filter), - [options, filter], - ); - - const onClose = useCallback(() => { - onCloseProps(config); - }, [onCloseProps, config]); + const filteredOptions = useMemo(() => { + return filterConfig(options, filter); + }, [options, filter]); const onChange = useCallback( (name: string, value: unknown): void => { - setConfig(oldConfig => { - const newConfig = { ...oldConfig }; - if (value === '' || value == null) { - delete newConfig[name]; - } else { - newConfig[name] = value; - } - return newConfig; - }); + const newConfig = { ...values }; + if (value === '' || value == null) { + delete newConfig[name]; + } else { + newConfig[name] = value; + } + onChangeProp(newConfig); }, - [setConfig], + [values, onChangeProp], ); - useEffect(() => { - setConfig(values); - }, [values]); - - useEffect(() => { - if (isOpen) { - setFilterFocus(); - } - }, [isOpen, setFilterFocus]); - return ( - +
-
- {isOpen && - filteredOptions.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 8b8f0999f094..ab14f7024cb5 100644 --- a/packages/website/src/components/config/ConfigEslint.tsx +++ b/packages/website/src/components/config/ConfigEslint.tsx @@ -1,43 +1,34 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { shallowEqual } from '../lib/shallowEqual'; -import type { ConfigModel, EslintRC, RuleDetails, RuleEntry } from '../types'; +import type { ConfigModel, RuleDetails, RulesRecord } 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 [configObject, updateConfigObject] = useState(() => ({ - rules: {}, - })); + const { config, onChange: onChangeProp, ruleOptions, className } = props; + + const [configObject, updateConfigObject] = useState>( + () => ({}), + ); useEffect(() => { - if (isOpen) { - updateConfigObject(parseESLintRC(config)); - } - }, [isOpen, config]); + updateConfigObject(oldConfig => { + const newConfig = parseESLintRC(config).rules; + if (shallowEqual(oldConfig, newConfig)) { + return oldConfig; + } + return newConfig; + }); + }, [config]); const options = useMemo((): ConfigOptionsType[] => { const mappedRules: ConfigOptionsField[] = ruleOptions.map(item => ({ @@ -59,35 +50,22 @@ function ConfigEslint(props: ConfigEslintProps): JSX.Element { ]; }, [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 = parseESLintRC(config); + parsed.rules = newConfig as RulesRecord; + 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 d269e9a0efef..02ff5c5411e7 100644 --- a/packages/website/src/components/config/ConfigTypeScript.tsx +++ b/packages/website/src/components/config/ConfigTypeScript.tsx @@ -1,28 +1,33 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { shallowEqual } from '../lib/shallowEqual'; -import type { ConfigModel, TSConfig } from '../types'; +import type { CompilerFlags, ConfigModel } from '../types'; import type { ConfigOptionsType } from './ConfigEditor'; import ConfigEditor from './ConfigEditor'; import { getTypescriptOptions, parseTSConfig, toJson } 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 { isOpen, config, onClose: onCloseProps } = props; - const [configObject, updateConfigObject] = useState(() => ({ - compilerOptions: {}, - })); + const { config, onChange: onChangeProp, className } = props; + + const [configObject, updateConfigObject] = useState>( + () => ({}), + ); useEffect(() => { - if (isOpen) { - updateConfigObject(parseTSConfig(config)); - } - }, [isOpen, config]); + updateConfigObject(oldConfig => { + const newConfig = parseTSConfig(config).compilerOptions; + if (shallowEqual(oldConfig, newConfig)) { + return oldConfig; + } + return newConfig; + }); + }, [config]); const options = useMemo((): ConfigOptionsType[] => { return Object.values( @@ -54,27 +59,22 @@ function ConfigTypeScript(props: ConfigTypeScriptProps): JSX.Element { ); }, []); - const onClose = useCallback( + const onChange = useCallback( (newConfig: Record) => { - const cfg = { ...newConfig }; - if (!shallowEqual(cfg, configObject?.compilerOptions)) { - onCloseProps({ - tsconfig: toJson({ ...(configObject ?? {}), compilerOptions: cfg }), - }); - } else { - onCloseProps(); - } + const parsed = parseTSConfig(config); + parsed.compilerOptions = newConfig as CompilerFlags; + updateConfigObject(newConfig); + onChangeProp({ eslintrc: toJson(parsed) }); }, - [onCloseProps, configObject], + [config, onChangeProp], ); return ( ); } diff --git a/packages/website/src/components/hooks/useHashState.ts b/packages/website/src/components/hooks/useHashState.ts index d8a7a44b5ce9..53937e8ff7fe 100644 --- a/packages/website/src/components/hooks/useHashState.ts +++ b/packages/website/src/components/hooks/useHashState.ts @@ -1,31 +1,28 @@ -import { toJsonConfig } from '@site/src/components/config/utils'; +import { useHistory } from '@docusaurus/router'; import * as lz from 'lz-string'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; +import { toJsonConfig } from '../config/utils'; import { hasOwnProperty } from '../lib/has-own-property'; -import { shallowEqual } from '../lib/shallowEqual'; import type { ConfigModel } from '../types'; +import { shallowEqual } from '@site/src/components/lib/shallowEqual'; -function writeQueryParam(value: string): string { - return lz.compressToEncodedURIComponent(value); +function writeQueryParam(value: string | null): string { + return (value && lz.compressToEncodedURIComponent(value)) ?? ''; } function readQueryParam(value: string | null, fallback: string): string { - return value - ? lz.decompressFromEncodedURIComponent(value) ?? fallback - : fallback; + return (value && lz.decompressFromEncodedURIComponent(value)) ?? fallback; } -function readShowAST(value: string | null): 'ts' | 'scope' | 'es' | boolean { +function readShowAST(value: string | null): 'ts' | 'scope' | 'es' | false { switch (value) { case 'es': - return 'es'; case 'ts': - return 'ts'; case 'scope': - return 'scope'; + return value; } - return Boolean(value); + return value ? 'es' : false; } function readLegacyParam( @@ -40,7 +37,7 @@ function readLegacyParam( return undefined; } -const parseStateFromUrl = (hash: string): ConfigModel | undefined => { +const parseStateFromUrl = (hash: string): Partial | undefined => { if (!hash) { return; } @@ -66,16 +63,11 @@ const parseStateFromUrl = (hash: string): ConfigModel | undefined => { } return { - // @ts-expect-error: process.env.TS_VERSION - ts: (searchParams.get('ts') ?? process.env.TS_VERSION).trim(), + ts: searchParams.get('ts') ?? undefined, jsx: searchParams.has('jsx'), - showAST: - searchParams.has('showAST') && readShowAST(searchParams.get('showAST')), + showAST: readShowAST(searchParams.get('showAST')), sourceType: - searchParams.has('sourceType') && - searchParams.get('sourceType') === 'script' - ? 'script' - : 'module', + searchParams.get('sourceType') === 'script' ? 'script' : 'module', code: searchParams.has('code') ? readQueryParam(searchParams.get('code'), '') : '', @@ -88,28 +80,25 @@ const parseStateFromUrl = (hash: string): ConfigModel | undefined => { return undefined; }; -const writeStateToUrl = (newState: ConfigModel): string => { +const writeStateToUrl = (newState: ConfigModel): string | undefined => { try { - return Object.entries({ - ts: newState.ts.trim(), - jsx: newState.jsx, - sourceType: newState.sourceType, - showAST: newState.showAST, - code: newState.code ? writeQueryParam(newState.code) : undefined, - eslintrc: newState.eslintrc - ? writeQueryParam(newState.eslintrc) - : undefined, - tsconfig: newState.tsconfig - ? writeQueryParam(newState.tsconfig) - : undefined, - }) - .filter(item => item[1]) - .map(item => `${encodeURIComponent(item[0])}=${item[1]}`) - .join('&'); + const searchParams = new URLSearchParams(); + searchParams.set('ts', newState.ts.trim()); + searchParams.set('jsx', String(newState.jsx)); + if (newState.sourceType === 'script') { + searchParams.set('sourceType', newState.sourceType); + } + if (newState.showAST) { + searchParams.set('showAST', newState.showAST); + } + searchParams.set('code', writeQueryParam(newState.code)); + searchParams.set('eslintrc', writeQueryParam(newState.eslintrc)); + searchParams.set('tsconfig', writeQueryParam(newState.tsconfig)); + return searchParams.toString(); } catch (e) { console.warn(e); } - return ''; + return undefined; }; const retrieveStateFromLocalStorage = (): Partial | undefined => { @@ -139,9 +128,7 @@ const retrieveStateFromLocalStorage = (): Partial | undefined => { } if (hasOwnProperty('showAST', config)) { const showAST = config.showAST; - if (typeof showAST === 'boolean') { - state.showAST = showAST; - } else if (typeof showAST === 'string') { + if (typeof showAST === 'string') { state.showAST = readShowAST(showAST); } } @@ -165,66 +152,41 @@ const writeStateToLocalStorage = (newState: ConfigModel): void => { function useHashState( initialState: ConfigModel, ): [ConfigModel, (cfg: Partial) => void] { - const [hash, setHash] = useState(window.location.hash.slice(1)); + const history = useHistory(); const [state, setState] = useState(() => ({ ...initialState, ...retrieveStateFromLocalStorage(), ...parseStateFromUrl(window.location.hash.slice(1)), })); - const [tmpState, setTmpState] = useState>(() => ({ - ...initialState, - ...retrieveStateFromLocalStorage(), - ...parseStateFromUrl(window.location.hash.slice(1)), - })); - useEffect(() => { - const newHash = window.location.hash.slice(1); - if (newHash !== hash) { - const newState = parseStateFromUrl(newHash); - if (newState) { - setState(newState); - setTmpState(newState); - } - } - }, [hash]); - - useEffect(() => { - const newState = { ...state, ...tmpState }; - if (!shallowEqual(newState, state)) { - writeStateToLocalStorage(newState); - const newHash = writeStateToUrl(newState); - setState(newState); - setHash(newHash); - - if (window.location.hash.slice(1) !== newHash) { - window.history.pushState( - undefined, - document.title, - `${window.location.pathname}#${newHash}`, - ); - } - } - }, [tmpState, state]); + const updateState = useCallback( + (cfg: Partial) => { + console.info('[State] updating config diff', cfg); - const onHashChange = (): void => { - const newHash = window.location.hash; - console.info('[State] hash change detected', newHash); - setHash(newHash); - }; + setState(oldState => { + const newState = { ...oldState, ...cfg }; - useEffect(() => { - window.addEventListener('popstate', onHashChange); - return (): void => { - window.removeEventListener('popstate', onHashChange); - }; - }, []); + if (shallowEqual(oldState, newState)) { + return oldState; + } + + writeStateToLocalStorage(newState); + + history.replace({ + ...history.location, + hash: writeStateToUrl(newState), + }); - const _setState = useCallback((cfg: Partial) => { - console.info('[State] updating config diff', cfg); - setTmpState(cfg); - }, []); + if (cfg.ts) { + window.location.reload(); + } + return newState; + }); + }, + [setState, history], + ); - return [state, _setState]; + return [state, updateState]; } export default useHashState; diff --git a/packages/website/src/components/types.ts b/packages/website/src/components/types.ts index b63d9bca307b..6f10b83983c7 100644 --- a/packages/website/src/components/types.ts +++ b/packages/website/src/components/types.ts @@ -21,7 +21,7 @@ export interface ConfigModel { tsconfig: string; code: string; ts: string; - showAST?: boolean | 'ts' | 'es' | 'scope'; + showAST?: false | 'ts' | 'es' | 'scope'; } export interface SelectedPosition { From e95c9fad1b0f863d411572652d1312938067fe69 Mon Sep 17 00:00:00 2001 From: Armano Date: Sat, 1 Apr 2023 20:26:35 +0200 Subject: [PATCH 3/7] fix: remove no longer needed modal --- .../src/components/hooks/useHashState.ts | 2 +- .../src/components/layout/Modal.module.css | 65 ----------------- .../website/src/components/layout/Modal.tsx | 70 ------------------- 3 files changed, 1 insertion(+), 136 deletions(-) delete mode 100644 packages/website/src/components/layout/Modal.module.css delete mode 100644 packages/website/src/components/layout/Modal.tsx diff --git a/packages/website/src/components/hooks/useHashState.ts b/packages/website/src/components/hooks/useHashState.ts index 53937e8ff7fe..446a71a8d11e 100644 --- a/packages/website/src/components/hooks/useHashState.ts +++ b/packages/website/src/components/hooks/useHashState.ts @@ -4,8 +4,8 @@ import { useCallback, useState } from 'react'; import { toJsonConfig } from '../config/utils'; import { hasOwnProperty } from '../lib/has-own-property'; +import { shallowEqual } from '../lib/shallowEqual'; import type { ConfigModel } from '../types'; -import { shallowEqual } from '@site/src/components/lib/shallowEqual'; function writeQueryParam(value: string | null): string { return (value && lz.compressToEncodedURIComponent(value)) ?? ''; 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 53fb365d62c8..000000000000 --- a/packages/website/src/components/layout/Modal.tsx +++ /dev/null @@ -1,70 +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({ isOpen, onClose, children, header }: ModalProps): JSX.Element { - 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 ( -
-
-
-

{header}

- -
-
{children}
-
-
- ); -} - -export default Modal; From 0e4819f487aeda04c7ab4e9961b852c86703b6e2 Mon Sep 17 00:00:00 2001 From: Armano Date: Sat, 1 Apr 2023 23:06:37 +0200 Subject: [PATCH 4/7] fix: correct some issues --- .../src/components/config/ConfigEditor.tsx | 10 +++-- .../src/components/config/ConfigEslint.tsx | 10 ++--- .../components/config/ConfigTypeScript.tsx | 13 ++++--- .../website/src/components/config/utils.ts | 39 +++++-------------- .../src/components/hooks/useHashState.ts | 2 +- packages/website/src/components/lib/json.ts | 26 +++++++++++++ packages/website/typings/typescript.d.ts | 16 ++++++-- 7 files changed, 69 insertions(+), 47 deletions(-) create mode 100644 packages/website/src/components/lib/json.ts diff --git a/packages/website/src/components/config/ConfigEditor.tsx b/packages/website/src/components/config/ConfigEditor.tsx index 02a3f0d1278f..1b2a78805a35 100644 --- a/packages/website/src/components/config/ConfigEditor.tsx +++ b/packages/website/src/components/config/ConfigEditor.tsx @@ -1,8 +1,8 @@ -import Dropdown from '@site/src/components/inputs/Dropdown'; import clsx from 'clsx'; import React, { useCallback, useMemo, useState } from 'react'; import Checkbox from '../inputs/Checkbox'; +import Dropdown from '../inputs/Dropdown'; import Text from '../inputs/Text'; import styles from './ConfigEditor.module.css'; @@ -85,8 +85,12 @@ function ConfigEditorField({ ); } -function ConfigEditor(props: ConfigEditorProps): JSX.Element { - const { onChange: onChangeProp, values, options, className } = props; +function ConfigEditor({ + onChange: onChangeProp, + values, + options, + className, +}: ConfigEditorProps): JSX.Element { const [filter, setFilter] = useState(''); const filteredOptions = useMemo(() => { diff --git a/packages/website/src/components/config/ConfigEslint.tsx b/packages/website/src/components/config/ConfigEslint.tsx index ab14f7024cb5..7756dbae10b0 100644 --- a/packages/website/src/components/config/ConfigEslint.tsx +++ b/packages/website/src/components/config/ConfigEslint.tsx @@ -1,10 +1,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ensureObject, parseJSONObject, toJson } from '../lib/json'; import { shallowEqual } from '../lib/shallowEqual'; -import type { ConfigModel, RuleDetails, RulesRecord } from '../types'; +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 onChange: (value: Partial) => void; @@ -22,7 +22,7 @@ function ConfigEslint(props: ConfigEslintProps): JSX.Element { useEffect(() => { updateConfigObject(oldConfig => { - const newConfig = parseESLintRC(config).rules; + const newConfig = ensureObject(parseJSONObject(config).rules); if (shallowEqual(oldConfig, newConfig)) { return oldConfig; } @@ -52,8 +52,8 @@ function ConfigEslint(props: ConfigEslintProps): JSX.Element { const onChange = useCallback( (newConfig: Record) => { - const parsed = parseESLintRC(config); - parsed.rules = newConfig as RulesRecord; + const parsed = parseJSONObject(config); + parsed.rules = newConfig; updateConfigObject(newConfig); onChangeProp({ eslintrc: toJson(parsed) }); }, diff --git a/packages/website/src/components/config/ConfigTypeScript.tsx b/packages/website/src/components/config/ConfigTypeScript.tsx index 02ff5c5411e7..eb5c974d2cb9 100644 --- a/packages/website/src/components/config/ConfigTypeScript.tsx +++ b/packages/website/src/components/config/ConfigTypeScript.tsx @@ -1,10 +1,11 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ensureObject, parseJSONObject, toJson } from '../lib/json'; import { shallowEqual } from '../lib/shallowEqual'; -import type { CompilerFlags, ConfigModel } 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 onChange: (config: Partial) => void; @@ -21,7 +22,7 @@ function ConfigTypeScript(props: ConfigTypeScriptProps): JSX.Element { useEffect(() => { updateConfigObject(oldConfig => { - const newConfig = parseTSConfig(config).compilerOptions; + const newConfig = ensureObject(parseJSONObject(config).compilerOptions); if (shallowEqual(oldConfig, newConfig)) { return oldConfig; } @@ -61,10 +62,10 @@ function ConfigTypeScript(props: ConfigTypeScriptProps): JSX.Element { const onChange = useCallback( (newConfig: Record) => { - const parsed = parseTSConfig(config); - parsed.compilerOptions = newConfig as CompilerFlags; + const parsed = parseJSONObject(config); + parsed.compilerOptions = newConfig; updateConfigObject(newConfig); - onChangeProp({ eslintrc: toJson(parsed) }); + onChangeProp({ tsconfig: toJson(parsed) }); }, [config, onChangeProp], ); diff --git a/packages/website/src/components/config/utils.ts b/packages/website/src/components/config/utils.ts index a10333c1bab8..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,15 +65,7 @@ export function tryParseEslintModule(value: string): string { return value; } -export function toJson(cfg: unknown): string { - return JSON.stringify(cfg, null, 2); -} - -export function toJsonConfig(cfg: unknown, prop: string): string { - return toJson({ [prop]: cfg }); -} - -export function getTypescriptOptions(): OptionDeclarations[] { +export function getTypescriptOptions(): ts.OptionDeclarations[] { const allowedCategories = [ 'Command-line Options', 'Projects', @@ -102,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/useHashState.ts b/packages/website/src/components/hooks/useHashState.ts index 446a71a8d11e..9c148f92876e 100644 --- a/packages/website/src/components/hooks/useHashState.ts +++ b/packages/website/src/components/hooks/useHashState.ts @@ -2,8 +2,8 @@ import { useHistory } from '@docusaurus/router'; import * as lz from 'lz-string'; import { useCallback, useState } from 'react'; -import { toJsonConfig } from '../config/utils'; import { hasOwnProperty } from '../lib/has-own-property'; +import { toJsonConfig } from '../lib/json'; import { shallowEqual } from '../lib/shallowEqual'; import type { ConfigModel } from '../types'; 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[]; } From 1d6db444301622333ec13b1264929cb8fe00857a Mon Sep 17 00:00:00 2001 From: Armano Date: Sun, 2 Apr 2023 22:28:33 +0200 Subject: [PATCH 5/7] fix: correct linting after merge --- packages/website/src/components/hooks/useHashState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/src/components/hooks/useHashState.ts b/packages/website/src/components/hooks/useHashState.ts index 386d7d507560..de2f475154b3 100644 --- a/packages/website/src/components/hooks/useHashState.ts +++ b/packages/website/src/components/hooks/useHashState.ts @@ -2,8 +2,8 @@ import { useHistory } from '@docusaurus/router'; import * as lz from 'lz-string'; import { useCallback, useState } from 'react'; -import { hasOwnProperty } from '../lib/has-own-property'; import { fileTypes } from '../config'; +import { hasOwnProperty } from '../lib/has-own-property'; import { toJson } from '../lib/json'; import { shallowEqual } from '../lib/shallowEqual'; import type { ConfigFileType, ConfigModel, ConfigShowAst } from '../types'; From e736df0ccc6a0d20df0430873b993ff291c2ae68 Mon Sep 17 00:00:00 2001 From: Armano Date: Mon, 3 Apr 2023 10:46:41 +0200 Subject: [PATCH 6/7] fix: remove unnecessary useMemo --- .../website/src/components/Playground.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/website/src/components/Playground.tsx b/packages/website/src/components/Playground.tsx index caa70620aca8..591bc5c9bf00 100644 --- a/packages/website/src/components/Playground.tsx +++ b/packages/website/src/components/Playground.tsx @@ -1,7 +1,7 @@ import type { TSESTree } from '@typescript-eslint/utils'; import clsx from 'clsx'; import type * as ESQuery from 'esquery'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import type { SourceFile } from 'typescript'; import ASTViewer from './ast/ASTViewer'; @@ -55,16 +55,13 @@ function Playground(): JSX.Element { [], ); - const activeVisualEditor = useMemo(() => { - if (!isLoading) { - return visualEslintRc && activeTab === 'eslintrc' - ? 'eslintrc' - : visualTSConfig && activeTab === 'tsconfig' - ? 'tsconfig' - : undefined; - } - return undefined; - }, [activeTab, isLoading, visualEslintRc, visualTSConfig]); + const activeVisualEditor = !isLoading + ? visualEslintRc && activeTab === 'eslintrc' + ? 'eslintrc' + : visualTSConfig && activeTab === 'tsconfig' + ? 'tsconfig' + : undefined + : undefined; const onVisualEditor = useCallback((tab: TabType): void => { if (tab === 'tsconfig') { From f38919356f7a62e810f63c5a8ccd795e575b9ca7 Mon Sep 17 00:00:00 2001 From: Armano Date: Mon, 3 Apr 2023 23:13:15 +0200 Subject: [PATCH 7/7] fix: correct minor issue with highlight --- packages/website/src/components/config/ConfigEditor.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/website/src/components/config/ConfigEditor.module.css b/packages/website/src/components/config/ConfigEditor.module.css index 90a6afb816fe..940c7a825ee9 100644 --- a/packages/website/src/components/config/ConfigEditor.module.css +++ b/packages/website/src/components/config/ConfigEditor.module.css @@ -35,6 +35,7 @@ background: var(--ifm-color-emphasis-100); } +.searchResult:nth-child(even):hover, .searchResult:hover { background: var(--ifm-color-emphasis-200); }