Skip to content

Commit 1a6c0d4

Browse files
authored
[frontend] react integration (cloudera#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
1 parent 895a4b2 commit 1a6c0d4

21 files changed

+17845
-11257
lines changed

babel.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ module.exports = function (api) {
2121
api.cache(true);
2222
api.assertVersion('^7.4.5');
2323

24-
const presets = ['babel-preset-typescript-vue3', '@babel/typescript', '@babel/preset-env'];
24+
const presets = ['babel-preset-typescript-vue3', '@babel/typescript', '@babel/preset-env', '@babel/preset-react'];
2525
const plugins = [
2626
[
2727
'module-resolver',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Component styling using BEM notation
2+
3+
$my-color: #f8f8f8;
4+
5+
.react-example {
6+
background-color: $my-color;
7+
}
8+
9+
.react-example__title,
10+
.react-example__description {
11+
color: black;
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { render, screen } from '@testing-library/react';
2+
import '@testing-library/jest-dom';
3+
4+
import React from 'react';
5+
import ReactExample from './ReactExample';
6+
7+
describe('ReactExample', () => {
8+
test('shows a title', () => {
9+
render(<ReactExample title="test title" />);
10+
const title = screen.getByText('test title');
11+
expect(title).toBeDefined();
12+
});
13+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use strict';
2+
3+
import React, { FunctionComponent } from 'react';
4+
5+
// If importing using "import { Ace } from 'ext/ace'" VSCode typescript will complain
6+
import { Ace } from '../../../../../ext/ace';
7+
8+
import {CURSOR_POSITION_CHANGED_EVENT} from '../../../components/aceEditor/AceLocationHandler';
9+
import ReactExampleGlobal from '../../../../../reactComponents/ReactExampleGlobal/ReactExampleGlobal';
10+
import { useHuePubSub } from '../../../../../reactComponents/useHuePubSub';
11+
import SqlExecutable from '../../../execution/sqlExecutable';
12+
13+
import './ReactExample.scss';
14+
15+
export interface ReactExampleProps {
16+
title: string;
17+
// This example component recieves the "activeExecutable" used in the result page but
18+
// the props in general can of course be of any type
19+
activeExecutable?: SqlExecutable;
20+
}
21+
22+
// When we have type definitions and Ace imported using webackpack we should
23+
// use those types instead of creating our own, e.g. Ace.Position
24+
interface EditorCursor {
25+
position: Ace.Position
26+
}
27+
28+
const defaultProps = { title: 'Default result title' };
29+
30+
// Using the FunctionComponent generic is optional. Alternatively you can explicitly
31+
// define the children prop like in the ReactExampleGlobal component.
32+
const ReactExample: FunctionComponent<ReactExampleProps> = ({ title, activeExecutable }) => {
33+
// Example of having the react component rerender based on changes from useHuePubSub.
34+
// Use with caution and preferrably only at the top level component in your component tree.
35+
const editorCursor = useHuePubSub<EditorCursor>({ topic: CURSOR_POSITION_CHANGED_EVENT });
36+
37+
const id = activeExecutable?.id;
38+
const position =
39+
editorCursor?.position !== undefined ? JSON.stringify(editorCursor.position) : 'not available';
40+
41+
return (
42+
<div className="react-example">
43+
<h1 className="react-example__title">{title}</h1>
44+
<p className="react-example__description">
45+
I'm an Editor specific react component containing subcomponents. The dynamic id that I'm
46+
getting from a Knockout observable is {id}.
47+
</p>
48+
<p className="react-example__description">
49+
{`I'm also geting a cursor position from hue huePubSub using the hook useHuePubSub which is
50+
updated on each 'editor.cursor.position.changed'. Cursor position is
51+
${position}`}
52+
</p>
53+
<ReactExampleGlobal className="react-example__react-example-global-component">
54+
I'm a button from the application global component set
55+
</ReactExampleGlobal>
56+
</div>
57+
);
58+
};
59+
60+
ReactExample.defaultProps = defaultProps;
61+
62+
export default ReactExample;

desktop/core/src/desktop/js/hue.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import _ from 'lodash';
2121
import $ from 'jquery/jquery.common';
2222
import 'ext/bootstrap.2.3.2.min';
2323
import 'ext/bootstrap-editable.1.5.1.min';
24-
2524
import 'utils/d3Extensions';
2625
import * as d3 from 'd3';
2726
import d3v3 from 'd3v3';
@@ -77,6 +76,7 @@ import HueDocument from 'doc/hueDocument';
7776
import { getLastKnownConfig, refreshConfig } from 'config/hueConfig';
7877
import { simpleGet } from 'api/apiUtils'; // In analytics.mako, metrics.mako, threads.mako
7978
import Mustache from 'mustache'; // In hbase/templates/app.mako, jobsub.templates.js, search.ko.js, search.util.js
79+
import { createReactComponents } from 'reactComponents/createRootElements.js';
8080

8181
// TODO: Migrate away
8282
window._ = _;
@@ -115,6 +115,7 @@ window.SqlAutocompleter = SqlAutocompleter;
115115
window.sqlStatementsParser = sqlStatementsParser;
116116
window.hplsqlStatementsParser = hplsqlStatementsParser;
117117
window.sqlUtils = sqlUtils;
118+
window.createReactComponents = createReactComponents;
118119

119120
$(document).ready(async () => {
120121
await refreshConfig(); // Make sure we have config up front
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import * as ko from 'knockout';
2+
import { createElement } from 'react';
3+
import { createRoot } from 'react-dom/client';
4+
5+
import { loadComponent } from '../../reactComponents/imports';
6+
7+
/**
8+
* REACT KNOCKOUT INTEGRATION
9+
* This is a oneway binding from knockout to react.js. Use the data-binding called reactWrapper
10+
* followed by the component name. Props are passed in as js object literal coded as a string using
11+
* the props param. Any new components used must also be added to the import file
12+
* desktop/core/src/desktop/js/reactComponents/imports.js.
13+
*
14+
* Example usage:
15+
*
16+
* <MyComponent data-bind="reactWrapper: 'MyComponent',
17+
* props: { title: 'Result title', activeExecutable: activeExecutable }">
18+
* </MyComponent>
19+
*
20+
*
21+
* The name of the component element tag (eg <MyComponent>) can be anything, but for consistency
22+
* and to stay close to how normal react components look we use the actual component name.
23+
*/
24+
25+
const getProps = allBindings => {
26+
const props = allBindings.get('props');
27+
28+
// Functions are not valid as a React child
29+
return { ...props, children: ko.toJS(props.children) };
30+
};
31+
32+
ko.bindingHandlers.reactWrapper = (() => {
33+
return {
34+
init: function (el, valueAccessor, allBindings, viewModel, bindingContext) {
35+
const componentName = ko.unwrap(valueAccessor());
36+
const props = getProps(allBindings);
37+
38+
// The component's react root should only be created once per DOM
39+
// load so we pass it along via the bindingContext to be reused in the
40+
const reactRoot = createRoot(el);
41+
el.__KO_React_root = reactRoot;
42+
loadComponent(componentName).then(Component => {
43+
reactRoot.render(createElement(Component, props));
44+
});
45+
// Tell Knockout that it does not need to update the children
46+
// of this component, since that is now handled by React
47+
return { controlsDescendantBindings: true };
48+
},
49+
50+
update: function (el, valueAccessor, allBindings, viewModel, bindingContext) {
51+
const componentName = ko.unwrap(valueAccessor());
52+
const props = getProps(allBindings);
53+
54+
loadComponent(componentName).then(Component => {
55+
el.__KO_React_root.render(createElement(Component, props));
56+
});
57+
58+
// Handle KO observables
59+
Object.entries(props).forEach(([propName, propValue]) => {
60+
if (ko.isObservable(propValue)) {
61+
const koSubscription = propValue.subscribe(() => {
62+
loadComponent(componentName).then(Component => {
63+
el.__KO_React_root.render(
64+
createElement(Component, { ...props, [propName]: propValue() })
65+
);
66+
});
67+
});
68+
koSubscription.disposeWhenNodeIsRemoved(el);
69+
}
70+
});
71+
}
72+
};
73+
})();

desktop/core/src/desktop/js/ko/ko.all.js

+1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import 'ko/bindings/ko.onClickOutside';
101101
import 'ko/bindings/ko.oneClickSelect';
102102
import 'ko/bindings/ko.parseArguments';
103103
import 'ko/bindings/ko.publish';
104+
import 'ko/bindings/ko.reactWrapper';
104105
import 'ko/bindings/ko.readOnlyAce';
105106
import 'ko/bindings/ko.resizable';
106107
import 'ko/bindings/ko.select2';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use strict';
2+
3+
import * as React from 'react';
4+
5+
6+
// This component is rendered if the react loadComponent can't find
7+
// which react component to use
8+
const FallbackComponent = () => {
9+
return (
10+
<div>Placeholder component
11+
</div>
12+
);
13+
};
14+
15+
export default FallbackComponent;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.react-example-global {
2+
background-color: lavender;
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import '@testing-library/jest-dom';
3+
4+
import React from 'react';
5+
import ReactExampleGlobal from './ReactExampleGlobal';
6+
7+
describe('ReactExampleGlobal', () => {
8+
// Make sure no unwanted console info are displayed during testing
9+
const consoleSpy = jest.spyOn(console, 'info').mockImplementation();
10+
11+
afterEach(() => consoleSpy.mockClear());
12+
13+
test('disables after click', () => {
14+
render(<ReactExampleGlobal />);
15+
const btn = screen.getByRole('button', { name: 'ReactExampleGlobal - Like me' });
16+
expect(btn).not.toBeDisabled();
17+
fireEvent.click(btn);
18+
expect(btn).toBeDisabled();
19+
});
20+
21+
test('provides click callback', () => {
22+
const clickCallback = jest.fn();
23+
render(<ReactExampleGlobal onClick={clickCallback} />);
24+
const btn = screen.getByRole('button', { name: 'ReactExampleGlobal - Like me' });
25+
fireEvent.click(btn);
26+
expect(clickCallback).toHaveBeenCalled();
27+
});
28+
29+
test('prints to console.info on click', () => {
30+
render(<ReactExampleGlobal version="1" myObj={{ id: 'a' }} />);
31+
const btn = screen.getByRole('button', { name: 'ReactExampleGlobal - Like me' });
32+
fireEvent.click(btn);
33+
expect(consoleSpy).toHaveBeenCalledWith('ReactExampleGlobal clicked 1 a');
34+
});
35+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use strict';
2+
3+
import React, { useState } from 'react';
4+
5+
import './ReactExampleGlobal.scss';
6+
7+
export interface ReactExampleGlobalProps {
8+
onClick(e: React.MouseEvent): any;
9+
version: string;
10+
myObj?: any;
11+
className?: string;
12+
children?: React.ReactNode | React.ReactNode[];
13+
}
14+
15+
const defaultProps = {
16+
onClick: () => {},
17+
version: 'xxx'
18+
};
19+
20+
const ReactExampleGlobal = ({ onClick, children, version, myObj }: ReactExampleGlobalProps) => {
21+
const [isClicked, setIsClicked] = useState(false);
22+
23+
return (
24+
<button
25+
className="react-example-global"
26+
disabled={isClicked}
27+
onClick={e => {
28+
onClick(e);
29+
setIsClicked(true);
30+
console.info(`ReactExampleGlobal clicked ${version} ${myObj?.id}`);
31+
}}
32+
>
33+
ReactExampleGlobal - {children ?? 'Like me'}
34+
</button>
35+
);
36+
};
37+
38+
ReactExampleGlobal.defaultProps = defaultProps;
39+
40+
export default ReactExampleGlobal;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createElement } from 'react';
2+
import { createRoot } from 'react-dom/client';
3+
4+
import { loadComponent } from './imports';
5+
6+
/**
7+
* REACT INTEGRATION
8+
* This react integration script can be used for components that are placed directly in an
9+
* HTML page on load and do not need to have data passed from Knockout.js. The script is called
10+
* using a globally defined function called createReactComponents. The component element
11+
* tag must be present in the part of the DOM specified by the selector when this script runs.
12+
* The component must also be imported and added to the file js/reactComponents/imports.js
13+
* Exmple when used in the editor .mako file:
14+
*
15+
* <script type="text/javascript">
16+
* (function () {
17+
* window.createReactComponents('#embeddable_editor');
18+
* })();
19+
* </script>
20+
*
21+
* <MyComponent
22+
* data-reactcomponent='MyComponent'
23+
* data-props='{"myObj": 2, "children": "mako template only", "version" : "${sys.version_info[0]}"}'>
24+
* </MyComponent>
25+
*
26+
*/
27+
28+
async function render(name, props, root) {
29+
const Component = await loadComponent(name);
30+
root.render(createElement(Component, props));
31+
}
32+
33+
export async function createReactComponents(selector) {
34+
// Find all DOM containers
35+
document.querySelectorAll(`${selector} [data-reactcomponent]`).forEach(domContainer => {
36+
const componentName = domContainer.dataset['reactcomponent'];
37+
const rawPropDataset = domContainer.dataset.props ? JSON.parse(domContainer.dataset.props) : {};
38+
const root = createRoot(domContainer);
39+
render(componentName, rawPropDataset, root);
40+
});
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// ADD NEW RACT COMPONENTS HERE
2+
// We need a way to match an imported module with a component name
3+
// so we handle the imports dynamically for that reason.
4+
export async function loadComponent(name) {
5+
switch (name) {
6+
// Page specific components here
7+
case 'ReactExample':
8+
return (await import('../apps/editor/components/result/reactExample/ReactExample')).default;
9+
10+
// Application global components here
11+
case 'ReactExampleGlobal':
12+
return (await import('./ReactExampleGlobal/ReactExampleGlobal')).default;
13+
14+
default:
15+
console.error(`A placeholder component is rendered because you probably forgot to include your new component in the
16+
loadComponent function of reactComponents/imports.js`);
17+
return (await import('./FallbackComponent')).default;
18+
}
19+
}

0 commit comments

Comments
 (0)