From 707ed43e0765d33d02f644d07a5c2fa3515c3368 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 1 Dec 2022 14:48:46 +0100 Subject: [PATCH] feat: Allow custom languages for code editor (#537) --- .../__snapshots__/documenter.test.ts.snap | 10 ++++- .../__tests__/code-editor.test.tsx | 40 +++++++++++++++++++ src/code-editor/index.tsx | 33 ++++++++------- src/code-editor/interfaces.ts | 16 +++++++- src/code-editor/util.ts | 2 +- 5 files changed, 84 insertions(+), 17 deletions(-) diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 9c5c394433..4ea7ac0961 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -3821,7 +3821,9 @@ The object should contain, among others: "type": "string", }, Object { - "description": "Specifies the programming language. You can use any of the programming languages supported by the \`ace\` object that you provide.", + "description": "Specifies the programming language. You can use any of the programming languages supported by the \`ace\` object that you provide. +Alternatively, this can be used to set a language that is not supported by the default \`language\` list. Make sure you've added the highlighting support for this language to the Ace instance. +For more info on custom languages, see the [Code editor API](/components/code-editor?tabId=api) page.", "inlineType": Object { "name": "CodeEditorProps.Language", "properties": Array [], @@ -3831,6 +3833,12 @@ The object should contain, among others: "optional": false, "type": "CodeEditorProps.Language", }, + Object { + "description": "Specifies a custom label language. If set, it overrides the default language label.", + "name": "languageLabel", + "optional": true, + "type": "string", + }, Object { "description": "Renders the code editor in a loading state.", "name": "loading", diff --git a/src/code-editor/__tests__/code-editor.test.tsx b/src/code-editor/__tests__/code-editor.test.tsx index 7e8743ae9d..22aa402aac 100644 --- a/src/code-editor/__tests__/code-editor.test.tsx +++ b/src/code-editor/__tests__/code-editor.test.tsx @@ -101,6 +101,46 @@ describe('Code editor component', () => { expect(wrapper.findStatusBar()!.getElement()).toHaveTextContent('JavaScript'); }); + it('uses custom language label over the default label', () => { + const { wrapper } = renderCodeEditor({ languageLabel: 'PartiQL' }); + expect(wrapper.findStatusBar()!.getElement()).toHaveTextContent('PartiQL'); + }); + + it('allows providing a custom language', () => { + renderCodeEditor({ language: 'partiql' }); + expect(editorMock.session.setMode).toHaveBeenCalledWith('ace/mode/partiql'); + }); + + it('uses custom language with custom label', () => { + const { wrapper } = renderCodeEditor({ languageLabel: 'PartiQL', language: 'partiql' }); + expect(editorMock.session.setMode).toHaveBeenCalledWith('ace/mode/partiql'); + expect(wrapper.findStatusBar()!.getElement()).toHaveTextContent('PartiQL'); + }); + + it('falls back to language name if a custom language is used without languageLabel', () => { + const { wrapper } = renderCodeEditor({ language: 'partiql' }); + expect(editorMock.session.setMode).not.toHaveBeenCalledWith('ace/mode/javascript'); + expect(editorMock.session.setMode).toHaveBeenCalledWith('ace/mode/partiql'); + expect(wrapper.findStatusBar()!.getElement()).toHaveTextContent('partiql'); + }); + + it("uses custom label even if a language isn't provided", () => { + const { wrapper } = renderCodeEditor({ language: undefined, languageLabel: 'PartiQL' }); + expect(editorMock.session.setMode).toHaveBeenCalledWith('ace/mode/undefined'); + expect(wrapper.findStatusBar()!.getElement()).toHaveTextContent('PartiQL'); + }); + + /** + * Undefined language should run the component anyway even when bypassing language requirements, + * When that happens, Ace handles the missing language error + * renderCodeEditor uses Partial, no casting needed here + */ + it('allows unidentified language without breaking', () => { + const { wrapper } = renderCodeEditor({ language: undefined }); + expect(editorMock.session.setMode).toHaveBeenCalledWith('ace/mode/undefined'); + expect(wrapper.findStatusBar()!.getElement()).not.toHaveTextContent('undefined'); + }); + it('changes value', () => { const { rerender } = renderCodeEditor({ value: 'value-initial' }); diff --git a/src/code-editor/index.tsx b/src/code-editor/index.tsx index e601c89382..196e243533 100644 --- a/src/code-editor/index.tsx +++ b/src/code-editor/index.tsx @@ -28,7 +28,6 @@ import PreferencesModal from './preferences-modal'; import LoadingScreen from './loading-screen'; import ErrorScreen from './error-screen'; -import styles from './styles.css.js'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { useContainerQuery } from '../internal/hooks/container-queries/use-container-query'; import useBaseComponent from '../internal/hooks/use-base-component'; @@ -38,12 +37,24 @@ import { useFormFieldContext } from '../internal/context/form-field-context'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { useControllable } from '../internal/hooks/use-controllable'; import LiveRegion from '../internal/components/live-region'; + +import styles from './styles.css.js'; + export { CodeEditorProps }; export default function CodeEditor(props: CodeEditorProps) { const { __internalRootRef } = useBaseComponent('CodeEditor'); const { controlId, ariaLabelledby, ariaDescribedby } = useFormFieldContext(props); - const { ace, value, language, i18nStrings, editorContentHeight, onEditorContentResize, ...rest } = props; + const { + ace, + value, + language, + i18nStrings, + editorContentHeight, + onEditorContentResize, + languageLabel: customLanguageLabel, + ...rest + } = props; const [editorHeight = 480, setEditorHeight] = useControllable(editorContentHeight, onEditorContentResize, 480, { componentName: 'code-editor', changeHandler: 'onEditorContentResize', @@ -70,7 +81,7 @@ export default function CodeEditor(props: CodeEditorProps) { ); }, [ace] - ); // loads as soon as ace lib is available + ); useEffect(() => { if (!editor) { @@ -90,7 +101,6 @@ export default function CodeEditor(props: CodeEditorProps) { const [paneStatus, setPaneStatus] = useState('hidden'); const [annotations, setAnnotations] = useState([]); const [highlightedAnnotation, setHighlightedAnnotation] = useState(); - const [languageLabel, setLanguageLabel] = useState(''); const [cursorPosition, setCursorPosition] = useState({ row: 0, column: 0 }); const [isTabFocused, setTabFocused] = useState(false); @@ -117,7 +127,7 @@ export default function CodeEditor(props: CodeEditorProps) { return () => { editor?.destroy(); - }; // TODO profile/monitor this + }; }, [ace, editor, __internalRootRef]); useEffect(() => { @@ -134,12 +144,7 @@ export default function CodeEditor(props: CodeEditorProps) { }, [editor, value]); useEffect(() => { - if (!editor) { - return; - } - editor.session.setMode(`ace/mode/${language}`); - - setLanguageLabel(getLanguageLabel(language)); + editor?.session.setMode(`ace/mode/${language}`); }, [editor, language]); useEffect(() => { @@ -148,15 +153,13 @@ export default function CodeEditor(props: CodeEditorProps) { } const theme: CodeEditorProps.Theme = props.preferences?.theme ?? defaultTheme; - editor.setTheme(getAceTheme(theme)); editor.session.setUseWrapMode(props.preferences?.wrapLines ?? true); }, [editor, defaultTheme, props.preferences]); - // listeners + // Change listeners useChangeEffect(editor, props.onChange, props.onDelayedChange); - // TODO implement other listeners // Hide error panel when there are no errors to show. useEffect(() => { @@ -169,6 +172,8 @@ export default function CodeEditor(props: CodeEditorProps) { } }, [annotations, props.onValidate]); + const languageLabel = customLanguageLabel ?? getLanguageLabel(language); + const errorCount = annotations.filter(a => a.type === 'error').length; const warningCount = annotations.filter(a => a.type === 'warning').length; const currentAnnotations = useMemo(() => annotations.filter(a => a.type === paneStatus), [annotations, paneStatus]); diff --git a/src/code-editor/interfaces.ts b/src/code-editor/interfaces.ts index ee2fe4d003..f84452de92 100644 --- a/src/code-editor/interfaces.ts +++ b/src/code-editor/interfaces.ts @@ -20,9 +20,16 @@ export interface CodeEditorProps extends BaseComponentProps, FormFieldControlPro /** * Specifies the programming language. You can use any of the programming languages supported by the `ace` object that you provide. + * Alternatively, this can be used to set a language that is not supported by the default `language` list. Make sure you've added the highlighting support for this language to the Ace instance. + * For more info on custom languages, see the [Code editor API](/components/code-editor?tabId=api) page. */ language: CodeEditorProps.Language; + /** + * Specifies a custom label language. If set, it overrides the default language label. + */ + languageLabel?: string; + /** * An event handler called when the value changes. * The event `detail` contains the current value of the code editor content. @@ -105,8 +112,15 @@ export interface CodeEditorProps extends BaseComponentProps, FormFieldControlPro onEditorContentResize?: NonCancelableEventHandler; } +// Prevents typescript from collapsing a string union type into a string type while still allowing any string. +// This leads to more helpful editor suggestions for known values. +// See: https://github.com/microsoft/TypeScript/issues/29729 +type LiteralUnion = LiteralType | (BaseType & { _?: never }); + +type BuiltInLanguage = typeof AceModes[number]['value']; + export namespace CodeEditorProps { - export type Language = typeof AceModes[number]['value']; + export type Language = LiteralUnion; export type Theme = typeof LightThemes[number]['value'] | typeof DarkThemes[number]['value']; export interface AvailableThemes { diff --git a/src/code-editor/util.ts b/src/code-editor/util.ts index 106c0b8047..58c2d39b01 100644 --- a/src/code-editor/util.ts +++ b/src/code-editor/util.ts @@ -31,5 +31,5 @@ export function getAceTheme(theme: CodeEditorProps.Theme) { } export function getLanguageLabel(language: CodeEditorProps.Language): string { - return AceModes.filter((mode: { value: string }) => mode.value === language)[0]?.label || ''; + return AceModes.filter((mode: { value: string }) => mode.value === language)[0]?.label || language; }