airbnb/caravel

View on GitHub
superset-frontend/src/components/AsyncAceEditor/index.tsx

Summary

Maintainability
A
2 hrs
Test Coverage
/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
import { forwardRef, useEffect, ComponentType } from 'react';

import type {
  Editor as OrigEditor,
  IEditSession,
  Position,
  TextMode as OrigTextMode,
} from 'brace';
import type AceEditor from 'react-ace';
import type { IAceEditorProps } from 'react-ace';

import AsyncEsmComponent, {
  PlaceholderProps,
} from 'src/components/AsyncEsmComponent';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { useTheme, css } from '@superset-ui/core';
import { Global } from '@emotion/react';

export { getTooltipHTML } from './Tooltip';

export interface AceCompleterKeywordData {
  name: string;
  value: string;
  score: number;
  meta: string;
  docText?: string;
  docHTML?: string;
}

export type TextMode = OrigTextMode & { $id: string };

export interface AceCompleter {
  insertMatch: (
    data?: Editor | { value: string } | string,
    options?: AceCompleterKeywordData,
  ) => void;
}

export type Editor = OrigEditor & {
  completer: AceCompleter;
  completers: AceCompleter[];
};

export interface AceCompleterKeyword extends AceCompleterKeywordData {
  completer?: AceCompleter;
}

/**
 * Async loaders to import brace modules. Must manually create call `import(...)`
 * promises because webpack can only analyze async imports statically.
 */
const aceModuleLoaders = {
  'mode/sql': () => import('brace/mode/sql'),
  'mode/markdown': () => import('brace/mode/markdown'),
  'mode/css': () => import('brace/mode/css'),
  'mode/json': () => import('brace/mode/json'),
  'mode/yaml': () => import('brace/mode/yaml'),
  'mode/html': () => import('brace/mode/html'),
  'mode/javascript': () => import('brace/mode/javascript'),
  'theme/textmate': () => import('brace/theme/textmate'),
  'theme/github': () => import('brace/theme/github'),
  'ext/language_tools': () => import('brace/ext/language_tools'),
  'ext/searchbox': () => import('brace/ext/searchbox'),
};

export type AceModule = keyof typeof aceModuleLoaders;

export type AsyncAceEditorProps = IAceEditorProps & {
  keywords?: AceCompleterKeyword[];
};

export type AceEditorMode = 'sql';
export type AceEditorTheme = 'textmate' | 'github';
export type AsyncAceEditorOptions = {
  defaultMode?: AceEditorMode;
  defaultTheme?: AceEditorTheme;
  defaultTabSize?: number;
  fontFamily?: string;
  placeholder?: ComponentType<
    PlaceholderProps & Partial<IAceEditorProps>
  > | null;
};

/**
 * Get an async AceEditor with automatical loading of specified ace modules.
 */
export default function AsyncAceEditor(
  aceModules: AceModule[],
  {
    defaultMode,
    defaultTheme,
    defaultTabSize = 2,
    fontFamily = 'Menlo, Consolas, Courier New, Ubuntu Mono, source-code-pro, Lucida Console, monospace',
    placeholder,
  }: AsyncAceEditorOptions = {},
) {
  return AsyncEsmComponent(async () => {
    const reactAcePromise = import('react-ace');
    const aceBuildsConfigPromise = import('ace-builds');
    const cssWorkerUrlPromise = import(
      'ace-builds/src-min-noconflict/worker-css'
    );
    const acequirePromise = import('ace-builds/src-min-noconflict/ace');

    const [
      { default: ReactAceEditor },
      { config },
      { default: cssWorkerUrl },
      { acequire },
    ] = await Promise.all([
      reactAcePromise,
      aceBuildsConfigPromise,
      cssWorkerUrlPromise,
      acequirePromise,
    ]);

    config.setModuleUrl('ace/mode/css_worker', cssWorkerUrl);

    await Promise.all(aceModules.map(x => aceModuleLoaders[x]()));

    const inferredMode =
      defaultMode ||
      aceModules.find(x => x.startsWith('mode/'))?.replace('mode/', '');
    const inferredTheme =
      defaultTheme ||
      aceModules.find(x => x.startsWith('theme/'))?.replace('theme/', '');

    return forwardRef<AceEditor, AsyncAceEditorProps>(
      function ExtendedAceEditor(
        {
          keywords,
          mode = inferredMode,
          theme = inferredTheme,
          tabSize = defaultTabSize,
          defaultValue = '',
          ...props
        },
        ref,
      ) {
        const supersetTheme = useTheme();
        const langTools = acequire('ace/ext/language_tools');
        const setCompleters = useEffectEvent(
          (keywords: AceCompleterKeyword[]) => {
            const completer = {
              getCompletions: (
                editor: AceEditor,
                session: IEditSession,
                pos: Position,
                prefix: string,
                callback: (error: null, wordList: object[]) => void,
              ) => {
                // If the prefix starts with a number, don't try to autocomplete
                if (!Number.isNaN(parseInt(prefix, 10))) {
                  return;
                }
                if (
                  (session.getMode() as TextMode).$id === `ace/mode/${mode}`
                ) {
                  callback(null, keywords);
                }
              },
            };
            langTools.setCompleters([completer]);
          },
        );
        useEffect(() => {
          if (keywords) {
            setCompleters(keywords);
          }
        }, [keywords, setCompleters]);

        return (
          <>
            <Global
              styles={css`
                .ace_tooltip {
                  margin-left: ${supersetTheme.gridUnit * 2}px;
                  padding: 0px;
                  border: 1px solid ${supersetTheme.colors.grayscale.light1};
                }

                & .tooltip-detail {
                  background-color: ${supersetTheme.colors.grayscale.light5};
                  white-space: pre-wrap;
                  word-break: break-all;
                  min-width: ${supersetTheme.gridUnit * 50}px;
                  max-width: ${supersetTheme.gridUnit * 100}px;
                  & .tooltip-detail-head {
                    background-color: ${supersetTheme.colors.grayscale.light4};
                    color: ${supersetTheme.colors.grayscale.dark1};
                    display: flex;
                    column-gap: ${supersetTheme.gridUnit}px;
                    align-items: baseline;
                    justify-content: space-between;
                  }
                  & .tooltip-detail-title {
                    display: flex;
                    column-gap: ${supersetTheme.gridUnit}px;
                  }
                  & .tooltip-detail-body {
                    word-break: break-word;
                  }
                  & .tooltip-detail-head,
                  & .tooltip-detail-body {
                    padding: ${supersetTheme.gridUnit}px
                      ${supersetTheme.gridUnit * 2}px;
                  }
                  & .tooltip-detail-footer {
                    border-top: 1px ${supersetTheme.colors.grayscale.light2}
                      solid;
                    padding: 0 ${supersetTheme.gridUnit * 2}px;
                    color: ${supersetTheme.colors.grayscale.dark1};
                    font-size: ${supersetTheme.typography.sizes.xs}px;
                  }
                  & .tooltip-detail-meta {
                    & > .ant-tag {
                      margin-right: 0px;
                    }
                  }
                }
              `}
            />
            <ReactAceEditor
              ref={ref}
              mode={mode}
              theme={theme}
              tabSize={tabSize}
              defaultValue={defaultValue}
              setOptions={{ fontFamily }}
              {...props}
            />
          </>
        );
      },
    );
  }, placeholder);
}

export const SQLEditor = AsyncAceEditor([
  'mode/sql',
  'theme/github',
  'ext/language_tools',
  'ext/searchbox',
]);

export const FullSQLEditor = AsyncAceEditor(
  ['mode/sql', 'theme/github', 'ext/language_tools', 'ext/searchbox'],
  {
    // a custom placeholder in SQL lab for less jumpy re-renders
    placeholder: () => {
      const gutterBackground = '#e8e8e8'; // from ace-github theme
      return (
        <div
          style={{
            height: '100%',
          }}
        >
          <div
            style={{ width: 41, height: '100%', background: gutterBackground }}
          />
          {/* make it possible to resize the placeholder */}
          <div className="ace_content" />
        </div>
      );
    },
  },
);

export const MarkdownEditor = AsyncAceEditor([
  'mode/markdown',
  'theme/textmate',
]);

export const TextAreaEditor = AsyncAceEditor([
  'mode/markdown',
  'mode/sql',
  'mode/json',
  'mode/html',
  'mode/javascript',
  'theme/textmate',
]);

export const CssEditor = AsyncAceEditor(['mode/css', 'theme/github']);

export const JsonEditor = AsyncAceEditor(['mode/json', 'theme/github']);

/**
 * JSON or Yaml config editor.
 */
export const ConfigEditor = AsyncAceEditor([
  'mode/json',
  'mode/yaml',
  'theme/github',
]);