瀏覽代碼

[frontend] react integration (#2963)

[frontend] react integration setup

- add and configure react-testing-library
- adds react hook for huePubSub and updates tests
- move jest-dom package to devDependencies
- [frontent] fixed usePubSub removal of subscription
- Move react to TypeScript
Bjorn Alm 3 年之前
父節點
當前提交
1a6c0d4a1b

+ 1 - 1
babel.config.js

@@ -21,7 +21,7 @@ module.exports = function (api) {
   api.cache(true);
   api.assertVersion('^7.4.5');
 
-  const presets = ['babel-preset-typescript-vue3', '@babel/typescript', '@babel/preset-env'];
+  const presets = ['babel-preset-typescript-vue3', '@babel/typescript', '@babel/preset-env', '@babel/preset-react'];
   const plugins = [
     [
       'module-resolver',

+ 12 - 0
desktop/core/src/desktop/js/apps/editor/components/result/reactExample/ReactExample.scss

@@ -0,0 +1,12 @@
+// Component styling using BEM notation
+
+$my-color:   #f8f8f8;
+
+.react-example {
+  background-color: $my-color;
+}
+
+.react-example__title,
+.react-example__description {
+  color: black;
+}

+ 13 - 0
desktop/core/src/desktop/js/apps/editor/components/result/reactExample/ReactExample.test.tsx

@@ -0,0 +1,13 @@
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import React from 'react';
+import ReactExample from './ReactExample';
+
+describe('ReactExample', () => {
+  test('shows a title', () => {
+    render(<ReactExample title="test title" />);
+    const title = screen.getByText('test title');
+    expect(title).toBeDefined();
+  });
+});

+ 62 - 0
desktop/core/src/desktop/js/apps/editor/components/result/reactExample/ReactExample.tsx

@@ -0,0 +1,62 @@
+'use strict';
+
+import React, { FunctionComponent } from 'react';
+
+// If importing using "import { Ace } from 'ext/ace'" VSCode typescript will complain
+import { Ace } from '../../../../../ext/ace';
+
+import {CURSOR_POSITION_CHANGED_EVENT} from '../../../components/aceEditor/AceLocationHandler';
+import ReactExampleGlobal from '../../../../../reactComponents/ReactExampleGlobal/ReactExampleGlobal';
+import { useHuePubSub } from '../../../../../reactComponents/useHuePubSub';
+import SqlExecutable from '../../../execution/sqlExecutable';
+
+import './ReactExample.scss';
+
+export interface ReactExampleProps {
+  title: string;
+  // This example component recieves the "activeExecutable" used in the result page but
+  // the props in general can of course be of any type
+  activeExecutable?: SqlExecutable;
+}
+
+// When we have type definitions and Ace imported using webackpack we should
+// use those types instead of creating our own, e.g. Ace.Position
+interface EditorCursor {
+  position: Ace.Position
+}
+
+const defaultProps = { title: 'Default result title' };
+
+// Using the FunctionComponent generic is optional. Alternatively you can explicitly
+// define the children prop like in the ReactExampleGlobal component.
+const ReactExample: FunctionComponent<ReactExampleProps> = ({ title, activeExecutable }) => {
+  // Example of having the react component rerender based on changes from useHuePubSub.
+  // Use with caution and preferrably only at the top level component in your component tree.
+  const editorCursor = useHuePubSub<EditorCursor>({ topic: CURSOR_POSITION_CHANGED_EVENT });
+
+  const id = activeExecutable?.id;
+  const position =
+    editorCursor?.position !== undefined ? JSON.stringify(editorCursor.position) : 'not available';
+
+  return (
+    <div className="react-example">
+      <h1 className="react-example__title">{title}</h1>
+      <p className="react-example__description">
+        I'm an Editor specific react component containing subcomponents. The dynamic id that I'm
+        getting from a Knockout observable is {id}.
+      </p>
+      <p className="react-example__description">
+        {`I'm also geting a cursor position from hue huePubSub using the hook useHuePubSub which is
+        updated on each 'editor.cursor.position.changed'. Cursor position is
+        ${position}`}
+      </p>
+      <ReactExampleGlobal className="react-example__react-example-global-component">
+        I'm a button from the application global component set
+      </ReactExampleGlobal>
+    </div>
+  );
+};
+
+ReactExample.defaultProps = defaultProps;
+
+export default ReactExample;

+ 2 - 1
desktop/core/src/desktop/js/hue.js

@@ -21,7 +21,6 @@ import _ from 'lodash';
 import $ from 'jquery/jquery.common';
 import 'ext/bootstrap.2.3.2.min';
 import 'ext/bootstrap-editable.1.5.1.min';
-
 import 'utils/d3Extensions';
 import * as d3 from 'd3';
 import d3v3 from 'd3v3';
@@ -77,6 +76,7 @@ import HueDocument from 'doc/hueDocument';
 import { getLastKnownConfig, refreshConfig } from 'config/hueConfig';
 import { simpleGet } from 'api/apiUtils'; // In analytics.mako, metrics.mako, threads.mako
 import Mustache from 'mustache'; // In hbase/templates/app.mako, jobsub.templates.js, search.ko.js, search.util.js
+import { createReactComponents } from 'reactComponents/createRootElements.js';
 
 // TODO: Migrate away
 window._ = _;
@@ -115,6 +115,7 @@ window.SqlAutocompleter = SqlAutocompleter;
 window.sqlStatementsParser = sqlStatementsParser;
 window.hplsqlStatementsParser = hplsqlStatementsParser;
 window.sqlUtils = sqlUtils;
+window.createReactComponents = createReactComponents;
 
 $(document).ready(async () => {
   await refreshConfig(); // Make sure we have config up front

+ 73 - 0
desktop/core/src/desktop/js/ko/bindings/ko.reactWrapper.js

@@ -0,0 +1,73 @@
+import * as ko from 'knockout';
+import { createElement } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { loadComponent } from '../../reactComponents/imports';
+
+/**
+ * REACT KNOCKOUT INTEGRATION
+ * This is a oneway binding from knockout to react.js. Use the data-binding called reactWrapper
+ * followed by the component name. Props are passed in as js object literal coded as a string using
+ * the props param. Any new components used must also be added to the import file
+ * desktop/core/src/desktop/js/reactComponents/imports.js.
+ *
+ * Example usage:
+ *
+ * <MyComponent data-bind="reactWrapper: 'MyComponent',
+ *    props: { title: 'Result title', activeExecutable: activeExecutable }">
+ * </MyComponent>
+ *
+ *
+ * The name of the component element tag (eg <MyComponent>) can be anything, but for consistency
+ * and to stay close to how normal react components look we use the actual component name.
+ */
+
+const getProps = allBindings => {
+  const props = allBindings.get('props');
+
+  // Functions are not valid as a React child
+  return { ...props, children: ko.toJS(props.children) };
+};
+
+ko.bindingHandlers.reactWrapper = (() => {
+  return {
+    init: function (el, valueAccessor, allBindings, viewModel, bindingContext) {
+      const componentName = ko.unwrap(valueAccessor());
+      const props = getProps(allBindings);
+
+      // The component's react root should only be created once per DOM
+      // load so we pass it along via the bindingContext to be reused in the
+      const reactRoot = createRoot(el);
+      el.__KO_React_root = reactRoot;
+      loadComponent(componentName).then(Component => {
+        reactRoot.render(createElement(Component, props));
+      });
+      // Tell Knockout that it does not need to update the children
+      // of this component, since that is now handled by React
+      return { controlsDescendantBindings: true };
+    },
+
+    update: function (el, valueAccessor, allBindings, viewModel, bindingContext) {
+      const componentName = ko.unwrap(valueAccessor());
+      const props = getProps(allBindings);
+
+      loadComponent(componentName).then(Component => {
+        el.__KO_React_root.render(createElement(Component, props));
+      });
+
+      // Handle KO observables
+      Object.entries(props).forEach(([propName, propValue]) => {
+        if (ko.isObservable(propValue)) {
+          const koSubscription = propValue.subscribe(() => {
+            loadComponent(componentName).then(Component => {
+              el.__KO_React_root.render(
+                createElement(Component, { ...props, [propName]: propValue() })
+              );
+            });
+          });
+          koSubscription.disposeWhenNodeIsRemoved(el);
+        }
+      });
+    }
+  };
+})();

+ 1 - 0
desktop/core/src/desktop/js/ko/ko.all.js

@@ -101,6 +101,7 @@ import 'ko/bindings/ko.onClickOutside';
 import 'ko/bindings/ko.oneClickSelect';
 import 'ko/bindings/ko.parseArguments';
 import 'ko/bindings/ko.publish';
+import 'ko/bindings/ko.reactWrapper';
 import 'ko/bindings/ko.readOnlyAce';
 import 'ko/bindings/ko.resizable';
 import 'ko/bindings/ko.select2';

+ 15 - 0
desktop/core/src/desktop/js/reactComponents/FallbackComponent.jsx

@@ -0,0 +1,15 @@
+'use strict';
+
+import * as React from 'react';
+
+
+// This component is rendered if the react loadComponent can't find
+// which react component to use 
+const FallbackComponent = () => {
+  return (
+    <div>Placeholder component
+    </div>
+  );
+};
+
+export default FallbackComponent;

+ 3 - 0
desktop/core/src/desktop/js/reactComponents/ReactExampleGlobal/ReactExampleGlobal.scss

@@ -0,0 +1,3 @@
+.react-example-global {
+  background-color: lavender;
+}

+ 35 - 0
desktop/core/src/desktop/js/reactComponents/ReactExampleGlobal/ReactExampleGlobal.test.tsx

@@ -0,0 +1,35 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import React from 'react';
+import ReactExampleGlobal from './ReactExampleGlobal';
+
+describe('ReactExampleGlobal', () => {
+  // Make sure no unwanted console info are displayed during testing
+  const consoleSpy = jest.spyOn(console, 'info').mockImplementation();
+
+  afterEach(() => consoleSpy.mockClear());
+
+  test('disables after click', () => {
+    render(<ReactExampleGlobal />);
+    const btn = screen.getByRole('button', { name: 'ReactExampleGlobal - Like me' });
+    expect(btn).not.toBeDisabled();
+    fireEvent.click(btn);
+    expect(btn).toBeDisabled();
+  });
+
+  test('provides click callback', () => {
+    const clickCallback = jest.fn();
+    render(<ReactExampleGlobal onClick={clickCallback} />);
+    const btn = screen.getByRole('button', { name: 'ReactExampleGlobal - Like me' });
+    fireEvent.click(btn);
+    expect(clickCallback).toHaveBeenCalled();
+  });
+
+  test('prints to console.info on click', () => {
+    render(<ReactExampleGlobal version="1" myObj={{ id: 'a' }} />);
+    const btn = screen.getByRole('button', { name: 'ReactExampleGlobal - Like me' });
+    fireEvent.click(btn);
+    expect(consoleSpy).toHaveBeenCalledWith('ReactExampleGlobal clicked  1 a');
+  });
+});

+ 40 - 0
desktop/core/src/desktop/js/reactComponents/ReactExampleGlobal/ReactExampleGlobal.tsx

@@ -0,0 +1,40 @@
+'use strict';
+
+import React, { useState } from 'react';
+
+import './ReactExampleGlobal.scss';
+
+export interface ReactExampleGlobalProps {
+  onClick(e: React.MouseEvent): any;
+  version: string;
+  myObj?: any;
+  className?: string;
+  children?: React.ReactNode | React.ReactNode[];
+}
+
+const defaultProps = {
+  onClick: () => {},
+  version: 'xxx'
+};
+
+const ReactExampleGlobal = ({ onClick, children, version, myObj }: ReactExampleGlobalProps) => {
+  const [isClicked, setIsClicked] = useState(false);
+
+  return (
+    <button
+      className="react-example-global"
+      disabled={isClicked}
+      onClick={e => {
+        onClick(e);
+        setIsClicked(true);
+        console.info(`ReactExampleGlobal clicked  ${version} ${myObj?.id}`);
+      }}
+    >
+      ReactExampleGlobal - {children ?? 'Like me'}
+    </button>
+  );
+};
+
+ReactExampleGlobal.defaultProps = defaultProps;
+
+export default ReactExampleGlobal;

+ 41 - 0
desktop/core/src/desktop/js/reactComponents/createRootElements.js

@@ -0,0 +1,41 @@
+import { createElement } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { loadComponent } from './imports';
+
+/**
+ * REACT INTEGRATION
+ * This react integration script can be used for components that are placed directly in an
+ * HTML page on load and do not need to have data passed from Knockout.js. The script is called
+ * using a globally defined function called createReactComponents. The component element
+ * tag must be present in the part of the DOM specified by the selector when this script runs.
+ * The component must also be imported and added to the file js/reactComponents/imports.js
+ * Exmple when used in the editor .mako file:
+ *
+ * <script type="text/javascript">
+ *   (function () {
+ *     window.createReactComponents('#embeddable_editor');
+ *   })();
+ * </script>
+ *
+ * <MyComponent
+ *    data-reactcomponent='MyComponent'
+ *    data-props='{"myObj": 2, "children": "mako template only", "version" : "${sys.version_info[0]}"}'>
+ * </MyComponent>
+ *
+ */
+
+async function render(name, props, root) {
+  const Component = await loadComponent(name);
+  root.render(createElement(Component, props));
+}
+
+export async function createReactComponents(selector) {
+  // Find all DOM containers
+  document.querySelectorAll(`${selector} [data-reactcomponent]`).forEach(domContainer => {
+    const componentName = domContainer.dataset['reactcomponent'];
+    const rawPropDataset = domContainer.dataset.props ? JSON.parse(domContainer.dataset.props) : {};
+    const root = createRoot(domContainer);
+    render(componentName, rawPropDataset, root);
+  });
+}

+ 19 - 0
desktop/core/src/desktop/js/reactComponents/imports.js

@@ -0,0 +1,19 @@
+// ADD NEW RACT COMPONENTS HERE
+// We need a way to match an imported module with a component name
+// so we handle the imports dynamically for that reason.
+export async function loadComponent(name) {
+  switch (name) {
+    // Page specific components here
+    case 'ReactExample':
+      return (await import('../apps/editor/components/result/reactExample/ReactExample')).default;
+
+    // Application global components here
+    case 'ReactExampleGlobal':
+      return (await import('./ReactExampleGlobal/ReactExampleGlobal')).default;
+
+    default:
+      console.error(`A placeholder component is rendered because you probably forgot to include your new component in the 
+      loadComponent function of reactComponents/imports.js`);
+      return (await import('./FallbackComponent')).default;
+  }
+}

+ 54 - 0
desktop/core/src/desktop/js/reactComponents/useHuePubSub.test.tsx

@@ -0,0 +1,54 @@
+import { renderHook, act } from '@testing-library/react';
+import { useHuePubSub } from './useHuePubSub';
+import huePubSub from '../utils/huePubSub';
+
+describe('useHuePubSub', () => {
+  const originalSubscribe = huePubSub.subscribe;
+  let publishCallback;
+  let remove = jest.fn();
+
+  const subscribeMock = jest.fn().mockImplementation((topic, callback) => {
+    publishCallback = callback;
+    return { remove }
+  });
+
+  beforeAll(() => {
+    huePubSub.subscribe = subscribeMock;
+  });
+
+  afterEach(() => {
+    subscribeMock.mockClear();
+  });
+
+  afterAll(() => {
+    huePubSub.subscribe = originalSubscribe;
+  });
+
+  test('only subscribes once per hook lifecycle', () => {
+    expect(huePubSub.subscribe).toBeCalledTimes(0);
+
+    const { rerender } = renderHook(() => useHuePubSub({ topic: 'my.test.topic' }));
+    expect(huePubSub.subscribe).toHaveBeenCalledWith('my.test.topic', expect.anything(), undefined);
+    expect(huePubSub.subscribe).toHaveBeenCalledTimes(1);
+    expect(remove).not.toHaveBeenCalled();
+    
+    rerender();
+    expect(remove).toHaveBeenCalled();
+    expect(huePubSub.subscribe).toHaveBeenCalledTimes(2);
+  });
+
+  test('initial state is undefined', () => {
+    const { result } = renderHook(() => useHuePubSub({ topic: 'my.test.topic' }));
+    expect(result.current).toBeUndefined();
+  });
+
+  test('triggers a state update with the info when a message is published', () => {
+    const { result } = renderHook(() => useHuePubSub({ topic: 'my.test.topic' }));
+
+    act(() => {
+      publishCallback('some info');
+    });
+
+    expect(result.current).toEqual('some info');
+  });
+});

+ 37 - 0
desktop/core/src/desktop/js/reactComponents/useHuePubSub.ts

@@ -0,0 +1,37 @@
+import { useState, useEffect } from 'react';
+import huePubSub from '../utils/huePubSub';
+
+// Basic helper hook to let a component subscribe to a huePubSub topic and rerender for each message
+// by placing the message/info in a state that is automatically updated.
+// Use with caution and preferrably only at the top level component in your component tree since
+// we don't want to have states stored all over the app.
+
+export function useHuePubSub<Type>({
+  topic,
+  app
+}: {
+  topic: string;
+  app?: string;
+}): Type | undefined {
+  const [huePubSubState, setHuePubSubState] = useState({ info: undefined });
+
+  useEffect(() => {
+    const pubSub = huePubSub.subscribe(
+      topic,
+      info => {
+        // Always create a new state so that the react component is rerendered even
+        // if the info is the same as previous info. This is to stay aligned with the idea
+        // of having a component being notified for EVERY message for the topics it subscribes to.
+        setHuePubSubState(() => ({ info }));
+      },
+      app
+    );
+
+    return () => {
+      // Remove huePubSub subscription
+      pubSub.remove();
+    };
+  });
+
+  return huePubSubState.info;
+}

+ 31 - 1
desktop/libs/notebook/src/notebook/templates/editor2.mako

@@ -43,6 +43,23 @@
   ${ render_bundle(bundle) | n,unicode }
 % endfor
 
+
+<!-- REACT INTEGRATION EXAMPLE WITHOUT KO-BINDING 
+This script is used to generate react root components when the page is loaded. The component element
+tag must be present in the part of the DOM specified by the selector when this script runs.
+There is no bridge to KO for components using this integration. Example using inside main HTML code:
+
+<script type="text/javascript">
+  (function () {    
+    window.createReactComponents('#embeddable_editor');
+  })();
+</script>
+<p style="position: absolute; z-index: 99999; top: 50px">           
+  <ReactExampleGlobal data-reactcomponent='ReactExampleGlobal' data-props='{"myObj": {"id": 1}, "children": "mako template only", "version" : "${sys.version_info[0]}"}'></ReactExampleGlobal>
+</p>  
+
+!-->
+
 <script type="text/html" id="editor-snippet-icon">
   <!-- ko if: viewSettings().snippetImage -->
   <img class="snippet-icon-image" data-bind="attr: { 'src': viewSettings().snippetImage }" alt="${ _('Snippet icon') }">
@@ -985,7 +1002,20 @@
           </div>
 
           <div class="tab-pane" id="queryResults" data-bind="css: {'active': currentQueryTab() == 'queryResults'}">
-            <div class="execution-results-tab-panel">
+            <div class="execution-results-tab-panel">              
+
+              <!-- REACT EXAMPLES WITH KO-BINDING 
+              These components below show how to integrate react with Knockout.js using the KO reactWrapper binding
+              New components, regardless if they are global or app specific must be added to 
+              desktop/core/src/desktop/js/reactComponents/imports.js              
+              
+              Example component defined and used globally within Hue
+              <ReactExampleGlobal data-bind="reactWrapper: 'ReactExampleGlobal', props: { children: 'KO binding used', myObj: activeExecutable }"></ReactExampleGlobal>
+              
+              Example component defined and used only within the "Editor app"
+              <ReactExample data-bind="reactWrapper: 'ReactExample', props: { title: 'Result title', activeExecutable: activeExecutable }"></ReactExample>
+              !-->
+
               <result-table-ko-bridge class="table-results-bridge" data-bind="vueKoProps: {
                   'executable-observable': activeExecutable
                 }"></result-table-ko-bridge>

+ 3 - 3
jest.config.js

@@ -1,8 +1,8 @@
 module.exports = {
   moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'vue'],
   transform: {
-    '^.+\\.(js|ts)$': 'babel-jest',
-    '^.+\\.vue$': '@vue/vue3-jest'
+    '^.+\\.(js|ts|jsx|tsx)$': 'babel-jest',
+    '^.+\\.vue$': '@vue/vue3-jest',
   },
   moduleNameMapper: {
     '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
@@ -11,7 +11,7 @@ module.exports = {
   moduleDirectories: ['node_modules', 'desktop/core/src/desktop/js'],
   modulePaths: ['desktop/core/src/desktop/js'],
   testMatch: ['<rootDir>/desktop/core/src/desktop/js/**/*.test.(js|jsx|ts|tsx)'],
-  testEnvironment: 'jsdom',
+  testEnvironment: 'jest-environment-jsdom',
   testURL: 'https://www.gethue.com/hue',
   setupFilesAfterEnv: ['<rootDir>/desktop/core/src/desktop/js/jest/jest.init.js'],
   watchPathIgnorePatterns: ['<rootDir>/desktop/core/src/desktop/static'],

文件差異過大導致無法顯示
+ 285 - 256
package-lock.json


+ 12 - 3
package.json

@@ -32,6 +32,8 @@
   "dependencies": {
     "@gethue/sql-formatter": "4.0.3",
     "axios": "0.24.0",
+    "babel-cli": "^6.26.0",
+    "babel-preset-react-app": "^3.1.2",
     "clipboard": "1.7.1",
     "core-js": "3.19.1",
     "d3": "5.7.0",
@@ -58,6 +60,8 @@
     "page": "1.8.6",
     "plotly.js-dist": "1.45.3",
     "qs": "6.9.4",
+    "react": "^18.1.0",
+    "react-dom": "^18.1.0",
     "regenerator-runtime": "^0.13.3",
     "removeNPMAbsolutePaths": "^1.0.4",
     "sanitize-html": "^2.1.2",
@@ -77,8 +81,11 @@
     "@babel/plugin-proposal-decorators": "7.13.0",
     "@babel/plugin-proposal-object-rest-spread": "7.13.0",
     "@babel/plugin-syntax-dynamic-import": "7.8.3",
-    "@babel/preset-env": "7.14.4",
+    "@babel/preset-env": "^7.14.4",
+    "@babel/preset-react": "^7.17.12",
     "@babel/preset-typescript": "7.13.0",
+    "@testing-library/jest-dom": "^5.16.5",
+    "@testing-library/react": "^13.3.0",
     "@types/file-saver": "^2.0.1",
     "@types/jest": "27.0.2",
     "@types/jquery": "3.5.0",
@@ -86,6 +93,8 @@
     "@types/lodash": "^4.14.161",
     "@types/luxon": "1.25.0",
     "@types/qs": "6.9.4",
+    "@types/react": "^18.0.18",
+    "@types/react-dom": "^18.0.6",
     "@types/sanitize-html": "1.27.0",
     "@types/webpack": "5.28.0",
     "@typescript-eslint/eslint-plugin": "4.25.0",
@@ -118,6 +127,8 @@
     "license-checker": "25.0.1",
     "load-grunt-tasks": "5.1.0",
     "markdown": "0.5.0",
+    "postcss-less": "6.0.0",
+    "postcss-scss": "4.0.3",
     "prettier": "2.3.0",
     "sass": "1.34.0",
     "sass-loader": "11.1.1",
@@ -127,8 +138,6 @@
     "stylelint-config-standard": "24.0.0",
     "stylelint-config-standard-scss": "3.0.0",
     "stylelint-scss": "4.1.0",
-    "postcss-less": "6.0.0",
-    "postcss-scss": "4.0.3",
     "ts-jest": "27.0.7",
     "ts-loader": "9.2.2",
     "typescript": "4.3.2",

+ 1 - 0
tsconfig.json

@@ -1,5 +1,6 @@
 {
   "compilerOptions": {
+    "jsx": "react",
     "declaration": true,
     "emitDeclarationOnly": true,
     "target": "esnext",

+ 12 - 2
webpack.config.js

@@ -36,7 +36,7 @@ const config = {
   },
   mode: 'development',
   module: {
-    rules: [
+    rules: [    
       {
         test: /\.vue$/,
         exclude: /node_modules/,
@@ -45,8 +45,18 @@ const config = {
       {
         test: /\.(jsx?|tsx?)$/,
         exclude: /node_modules/,
-        use: ['source-map-loader', 'babel-loader']
+        use: [{
+          loader: 'babel-loader',
+          options: {
+            presets: ['@babel/preset-env', '@babel/preset-react']
+          }
+        }] 
       },
+      {
+        test: /\.(jsx?|tsx?)$/,
+        enforce: "pre",
+        use: ["source-map-loader"],
+      },      
       {
         test: /\.scss$/,
         exclude: /node_modules/,

部分文件因文件數量過多而無法顯示