Skip to content

Commit

Permalink
feat: Flow components as children for ReactAdapterComponent (#19434)
Browse files Browse the repository at this point in the history
Add support for having Flow Components
as children in a ReactAdapoterComponent

Fixes #19379
  • Loading branch information
caalador authored Jun 4, 2024
1 parent 6260b07 commit 0747540
Show file tree
Hide file tree
Showing 11 changed files with 413 additions and 3 deletions.
3 changes: 3 additions & 0 deletions flow-client/src/main/java/com/vaadin/client/ElementUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,7 @@ public static native Element getElementById(Node context, String id)
}
}-*/;

public static native Element getElementByName(Node context, String name) /*-{
return Array.from(context.querySelectorAll('[name]')).find(function(e) {return e.getAttribute('name') == name});
}-*/;
}
63 changes: 63 additions & 0 deletions flow-client/src/main/java/com/vaadin/client/ReactUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2000-2024 Vaadin Ltd.
*
* Licensed 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.
*/

package com.vaadin.client;

import java.util.function.Supplier;

import elemental.dom.Element;

/**
* Utils class, intended to ease working with React component related code on
* the client side.
*
* @author Vaadin Ltd
* @since 24.5.
*/
public final class ReactUtils {

/**
* Add a callback to the react component that is called when the component
* initialization is ready for binding flow.
*
* @param element
* react component element
* @param name
* name of container to bind to
* @param runnable
* callback function runnable
*/
public static native void addReadyCallback(Element element, String name,
Runnable runnable)
/*-{
if(element.addReadyCallback){
element.addReadyCallback(name,
$entry(runnable.@java.lang.Runnable::run(*).bind(runnable))
);
}
}-*/;

/**
* Check if the react element is initialized and functional.
*
* @param elementLookup
* react element lookup supplier
* @return {@code true} if Flow binding can already be done
*/
public static boolean isInitialized(Supplier<Element> elementLookup) {
return elementLookup.get() != null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import com.vaadin.client.InitialPropertiesHandler;
import com.vaadin.client.LitUtils;
import com.vaadin.client.PolymerUtils;
import com.vaadin.client.ReactUtils;
import com.vaadin.client.WidgetUtil;
import com.vaadin.client.flow.ConstantPool;
import com.vaadin.client.flow.StateNode;
Expand Down Expand Up @@ -878,6 +879,23 @@ private void appendVirtualChild(BindingContext context, StateNode node,
return;
}
handleTemplateInTemplate(context, node, object, reactivePhase);
} else if (NodeProperties.INJECT_BY_NAME.equals(type)) {
String name = object.getString(NodeProperties.PAYLOAD);
String address = "name='" + name + "'";

Supplier<Element> elementLookup = () -> ElementUtil
.getElementByName(context.htmlNode, name);

if (!ReactUtils.isInitialized(elementLookup)) {
ReactUtils.addReadyCallback((Element) context.htmlNode, name,
() -> {
doAppendVirtualChild(context, node, false,
elementLookup, name, address);
});
return;
}
doAppendVirtualChild(context, node, reactivePhase, elementLookup,
name, address);
} else {
assert false : "Unexpected payload type " + type;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,21 @@

import com.vaadin.flow.component.Component;
import com.vaadin.flow.dom.DomListenerRegistration;
import com.vaadin.flow.dom.Element;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.function.SerializableFunction;
import com.vaadin.flow.internal.JsonCodec;
import com.vaadin.flow.internal.JsonUtils;

import com.vaadin.flow.internal.StateNode;
import com.vaadin.flow.internal.nodefeature.NodeProperties;

import elemental.json.Json;
import elemental.json.JsonValue;

import java.util.HashMap;
import java.util.Map;

/**
* An abstract implementation of an adapter for integrating with React
* components. To be used together with a React adapter Web Component that
Expand All @@ -49,6 +56,8 @@
* @since 24.4
*/
public abstract class ReactAdapterComponent extends Component {
private Map<String, Element> contentMap;

/**
* Adds the specified listener for the state change event in the React
* adapter.
Expand Down Expand Up @@ -177,6 +186,22 @@ protected static JsonValue writeAsJson(Object object) {
return JsonUtils.writeValue(object);
}

protected Element getContentElement(String name) {
if (contentMap == null) {
contentMap = new HashMap<>();
}
if (!contentMap.containsKey(name)) {
var element = new Element("flow-content-container");
contentMap.put(name, element);
getElement().getStateProvider().appendVirtualChild(
getElement().getNode(), element,
NodeProperties.INJECT_BY_NAME, name);
return element;
}

return contentMap.get(name);
}

private JsonValue getPropertyJson(String propertyName) {
var rawValue = getElement().getPropertyRaw(propertyName);
if (rawValue == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
* the License.
*/
import {createRoot, Root} from "react-dom/client";
import {createElement, type Dispatch, type ReactElement, useReducer} from "react";
import {
createElement,
type Dispatch,
type ReactElement, Ref, useEffect,
useReducer,
useRef
} from "react";

type FlowStateKeyChangedAction<K extends string, V> = Readonly<{
type: 'stateKeyChanged',
Expand Down Expand Up @@ -82,6 +88,10 @@ export type RenderHooks = {
readonly useCustomEvent: ReactAdapterElement["useCustomEvent"]
};

interface ReadyCallbackFunction {
(): void;
}

/**
* A base class for Web Components that render using React. Enables creating
* adapters for integrating React components with Flow. Intended for use with
Expand All @@ -96,6 +106,8 @@ export abstract class ReactAdapterElement extends HTMLElement {
#customEvents = new Map<string, DispatchEvent<unknown>>();
#dispatchFlowState: Dispatch<FlowStateReducerAction> = emptyAction;

#readyCallback = new Map<string, ReadyCallbackFunction>();

readonly #renderHooks: RenderHooks;

readonly #Wrapper: () => ReactElement | null;
Expand All @@ -109,7 +121,6 @@ export abstract class ReactAdapterElement extends HTMLElement {
useCustomEvent: this.useCustomEvent.bind(this)
};
this.#Wrapper = this.#renderWrapper.bind(this);
this.#markAsUsed();
}

public async connectedCallback() {
Expand All @@ -118,6 +129,20 @@ export abstract class ReactAdapterElement extends HTMLElement {
this.#maybeRenderRoot();
}

/**
* Add a callback for specified element identifier to be called when
* react element is ready.
* <p>
* For internal use only. May be renamed or removed in a future release.
*
* @param id element identifier that callback is for
* @param readyCallback callback method to be informed on element ready state
* @internal
*/
public addReadyCallback(id: string, readyCallback: ReadyCallbackFunction) {
this.#readyCallback.set(id, readyCallback);
}

public async disconnectedCallback() {
this.#unmountComplete = Promise.resolve();
await this.#unmountComplete;
Expand Down Expand Up @@ -207,6 +232,13 @@ export abstract class ReactAdapterElement extends HTMLElement {
*/
protected abstract render(hooks: RenderHooks): ReactElement | null;

protected useContent(name: string): ReactElement | null {
useEffect(() => {
this.#readyCallback.get(name)?.();
}, []);
return createElement('flow-content-container', {name, style: {display: 'contents'}});
}

#maybeRenderRoot() {
if (this.#rootRendered || !this.#root) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,8 @@ public void visit(StateNode node, NodeVisitor visitor) {
visitDescendants = visitor
.visit(NodeVisitor.ElementType.VIRTUAL, element);
} else if (NodeProperties.INJECT_BY_ID.equals(type)
|| NodeProperties.TEMPLATE_IN_TEMPLATE.equals(type)) {
|| NodeProperties.TEMPLATE_IN_TEMPLATE.equals(type)
|| NodeProperties.INJECT_BY_NAME.equals(type)) {
visitDescendants = visitor.visit(
NodeVisitor.ElementType.VIRTUAL_ATTACHED, element);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ public final class NodeProperties {
*/
public static final String INJECT_BY_ID = "@id";

/**
* JsonObject {@code @name} type value for {@link VirtualChildrenList}.
*/
public static final String INJECT_BY_NAME = "@name";

/**
* JsonObject template-in-template type value for
* {@link VirtualChildrenList}.
Expand Down
35 changes: 35 additions & 0 deletions flow-tests/test-react-adapter/src/main/frontend/ReactLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright 2000-2024 Vaadin Ltd.
*
* Licensed 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 {ReactAdapterElement, RenderHooks} from "Frontend/generated/flow/ReactAdapter.js";
import React from "react";

class ReactLayoutElement extends ReactAdapterElement {
protected render(hooks: RenderHooks): React.ReactElement | null {
const content = this.useContent('content');
const second = this.useContent('second');
return <>
<span>Before Flow components content</span>
{content}
<div></div>
<span>After Flow components content</span>
<div></div>
{second}
</>;
}
}

customElements.define('react-layout', ReactLayoutElement);
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright 2000-2024 Vaadin Ltd.
*
* Licensed 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.
*/

package com.vaadin.flow;

import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.H4;
import com.vaadin.flow.component.html.NativeButton;
import com.vaadin.flow.router.Route;

@Route("com.vaadin.flow.FlowInReactComponentView")
public class FlowInReactComponentView extends Div {

public static final String ADD_MAIN = "add";
public static final String REMOVE_MAIN = "remove";
public static final String ADD_SECONDARY = "add-secondary";
public static final String REMOVE_SECONDARY = "remove-secondary";

public FlowInReactComponentView() {

ReactLayout gridLayout = new ReactLayout();
add(gridLayout);

NativeButton addDiv = new NativeButton("Add div",
event -> gridLayout.add(new Div("Clicked button")));
addDiv.setId(ADD_MAIN);
NativeButton removeDiv = new NativeButton("Remove div",
event -> gridLayout.getChildren()
.filter(component -> component instanceof Div)
.findFirst().ifPresent(Component::removeFromParent));
removeDiv.setId(REMOVE_MAIN);

gridLayout.add(new H3("Flow Admin View"), addDiv, removeDiv);

NativeButton addSecondary = new NativeButton("Add div",
event -> gridLayout.addSecondary(new Div("Secondary div")));
addSecondary.setId(ADD_SECONDARY);
NativeButton removeSecondary = new NativeButton("Remove div",
event -> gridLayout.getSecondaryChildren()
.filter(component -> component instanceof Div)
.findFirst().ifPresent(Component::removeFromParent));
removeSecondary.setId(REMOVE_SECONDARY);
gridLayout.addSecondary(new H4("Second container"), addSecondary,
removeSecondary);
}
}
Loading

0 comments on commit 0747540

Please sign in to comment.