/* * 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 { Flow as _Flow } from "Frontend/generated/jar-resources/Flow.js"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { matchRoutes, useBlocker, useLocation, useNavigate, type NavigateOptions, useHref, } from "react-router-dom"; import type { AgnosticRouteObject } from '@remix-run/router'; const flow = new _Flow({ imports: () => import("Frontend/generated/flow/generated-flow-imports.js") }); const router = { render() { return Promise.resolve(); } }; // ClickHandler for vaadin-router-go event is copied from vaadin/router click.js // @ts-ignore function getAnchorOrigin(anchor) { // IE11: on HTTP and HTTPS the default port is not included into // window.location.origin, so won't include it here either. const port = anchor.port; const protocol = anchor.protocol; const defaultHttp = protocol === 'http:' && port === '80'; const defaultHttps = protocol === 'https:' && port === '443'; const host = (defaultHttp || defaultHttps) ? anchor.hostname // does not include the port number (e.g. www.example.org) : anchor.host; // does include the port number (e.g. www.example.org:80) return `${protocol}//${host}`; } function normalizeURL(url: URL): void | string { // ignore click if baseURI does not match the document (external) if (!url.href.startsWith(document.baseURI)) { return; } // Normalize path against baseURI return '/' + url.href.slice(document.baseURI.length); } function extractPath(event: MouseEvent): void | string { // ignore the click if the default action is prevented if (event.defaultPrevented) { return; } // ignore the click if not with the primary mouse button if (event.button !== 0) { return; } // ignore the click if a modifier key is pressed if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) { return; } // find the element that the click is at (or within) let maybeAnchor = event.target; const path = event.composedPath ? event.composedPath() // @ts-ignore : (event.path || []); // example to check: `for...of` loop here throws the "Not yet implemented" error for (let i = 0; i < path.length; i++) { const target = path[i]; if (target.nodeName && target.nodeName.toLowerCase() === 'a') { maybeAnchor = target; break; } } // @ts-ignore while (maybeAnchor && maybeAnchor.nodeName.toLowerCase() !== 'a') { // @ts-ignore maybeAnchor = maybeAnchor.parentNode; } // ignore the click if not at an element // @ts-ignore if (!maybeAnchor || maybeAnchor.nodeName.toLowerCase() !== 'a') { return; } const anchor = maybeAnchor as HTMLAnchorElement; // ignore the click if the element has a non-default target if (anchor.target && anchor.target.toLowerCase() !== '_self') { return; } // ignore the click if the element has the 'download' attribute if (anchor.hasAttribute('download')) { return; } // ignore the click if the element has the 'router-ignore' attribute if (anchor.hasAttribute('router-ignore')) { return; } // ignore the click if the target URL is a fragment on the current page if (anchor.pathname === window.location.pathname && anchor.hash !== '') { // @ts-ignore window.location.hash = anchor.hash; return; } // ignore the click if the target is external to the app // In IE11 HTMLAnchorElement does not have the `origin` property // @ts-ignore const origin = anchor.origin || getAnchorOrigin(anchor); if (origin !== window.location.origin) { return; } return normalizeURL(new URL(anchor.href, anchor.baseURI)); } /** * Fire 'vaadin-navigated' event to inform components of navigation. * @param pathname pathname of navigation * @param search search of navigation */ function fireNavigated(pathname:string, search: string) { setTimeout(() => { window.dispatchEvent(new CustomEvent('vaadin-navigated', { detail: { pathname, search } })); // @ts-ignore delete window.Vaadin.Flow.navigation; } ) } function postpone() { } const prevent = () => postpone; type RouterContainer = Awaited>; type NavigateOpts = { to: string, callback: boolean, opts?: NavigateOptions }; type NavigateFn = (to: string, callback: boolean, opts?: NavigateOptions) => void; /** * A hook providing the `navigate(path: string, opts?: NavigateOptions)` function * with React Router API that has more consistent history updates. Uses internal * queue for processing navigate calls. */ function useQueuedNavigate(waitReference: React.MutableRefObject | undefined>, navigated: React.MutableRefObject): NavigateFn { const navigate = useNavigate(); const navigateQueue = useRef([]).current; const [navigateQueueLength, setNavigateQueueLength] = useState(0); const dequeueNavigation = useCallback(() => { const navigateArgs = navigateQueue.shift(); if (navigateArgs === undefined) { // Empty queue, do nothing. return; } const blockingNavigate = async () => { if (waitReference.current) { await waitReference.current; waitReference.current = undefined; } navigated.current = !navigateArgs.callback; navigate(navigateArgs.to, navigateArgs.opts); setNavigateQueueLength(navigateQueue.length); } blockingNavigate(); }, [navigate, setNavigateQueueLength]); const dequeueNavigationAfterCurrentTask = useCallback(() => { queueMicrotask(dequeueNavigation); }, [dequeueNavigation]); const enqueueNavigation = useCallback((to: string, callback: boolean, opts?: NavigateOptions) => { navigateQueue.push({to: to, callback: callback, opts: opts}); setNavigateQueueLength(navigateQueue.length); if (navigateQueue.length === 1) { // The first navigation can be started right after any pending sync // jobs, which could add more navigations to the queue. dequeueNavigationAfterCurrentTask(); } }, [setNavigateQueueLength, dequeueNavigationAfterCurrentTask]); useEffect(() => () => { // The Flow component has rendered, but history might not be // updated yet, as React Router does it asynchronously. // Use microtask callback for history consistency. dequeueNavigationAfterCurrentTask(); }, [navigateQueueLength, dequeueNavigationAfterCurrentTask]); return enqueueNavigation; } function Flow() { const ref = useRef(null); const navigate = useNavigate(); const blocker = useBlocker(({ currentLocation, nextLocation }) => { navigated.current = navigated.current || (nextLocation.pathname === currentLocation.pathname && nextLocation.search === currentLocation.search && nextLocation.hash === currentLocation.hash); return true; }); const location = useLocation(); const navigated = useRef(false); const blockerHandled = useRef(false); const fromAnchor = useRef(false); const containerRef = useRef(undefined); const roundTrip = useRef | undefined>(undefined); const queuedNavigate = useQueuedNavigate(roundTrip, navigated); const basename = useHref('/'); const navigateEventHandler = useCallback((event: MouseEvent) => { const path = extractPath(event); if (!path) { return; } if (event && event.preventDefault) { event.preventDefault(); } navigated.current = false; // When navigation is triggered by click on a link, fromAnchor is set to true // in order to get a server round-trip even when navigating to the same URL again fromAnchor.current = true; navigate(path); // Dispatch close event for overlay drawer on click navigation. window.dispatchEvent(new CustomEvent('close-overlay-drawer')); }, [navigate]); const vaadinRouterGoEventHandler = useCallback((event: CustomEvent) => { const url = event.detail; const path = normalizeURL(url); if (!path) { return; } event.preventDefault(); navigate(path); }, [navigate]); const vaadinNavigateEventHandler = useCallback((event: CustomEvent<{state: unknown, url: string, replace?: boolean, callback: boolean}>) => { // @ts-ignore window.Vaadin.Flow.navigation = true; const path = '/' + event.detail.url; fromAnchor.current = false; queuedNavigate(path, event.detail.callback, { state: event.detail.state, replace: event.detail.replace }); }, [navigate]); const redirect = useCallback((path: string) => { return (() => { navigate(path, {replace: true}); }); }, [navigate]); useEffect(() => { // @ts-ignore window.addEventListener('vaadin-router-go', vaadinRouterGoEventHandler); // @ts-ignore window.addEventListener('vaadin-navigate', vaadinNavigateEventHandler); return () => { // @ts-ignore window.removeEventListener('vaadin-router-go', vaadinRouterGoEventHandler); // @ts-ignore window.removeEventListener('vaadin-navigate', vaadinNavigateEventHandler); }; }, [vaadinRouterGoEventHandler, vaadinNavigateEventHandler]); useEffect(() => { return () => { containerRef.current?.parentNode?.removeChild(containerRef.current); containerRef.current = undefined; }; }, []); useEffect(() => { if (blocker.state === 'blocked') { if(blockerHandled.current) { // Blocker is handled and the new navigation // gets queued to be executed after the current handling ends. const {pathname, state} = blocker.location; queuedNavigate(pathname.substring(basename.length), true, { state: state, replace: true }); return; } blockerHandled.current = true; let blockingPromise: any; roundTrip.current = new Promise((resolve,reject) => blockingPromise = {resolve:resolve,reject:reject}); // Release blocker handling after promise is fulfilled roundTrip.current.then(() => blockerHandled.current = false, () => blockerHandled.current = false); // Proceed to the blocked location, unless the navigation originates from a click on a link. // In that case continue with function execution and perform a server round-trip if (navigated.current && !fromAnchor.current) { blocker.proceed(); blockingPromise.resolve(); return; } fromAnchor.current = false; const {pathname, search} = blocker.location; const routes = ((window as any)?.Vaadin?.routesConfig || []) as AgnosticRouteObject[]; let matched = matchRoutes(Array.from(routes), pathname); // Navigation between server routes // @ts-ignore if (matched && matched.filter(path => path.route?.element?.type?.name === Flow.name).length != 0) { containerRef.current?.onBeforeEnter?.call(containerRef?.current, {pathname, search}, { prevent() { blocker.reset(); blockingPromise.resolve(); navigated.current = false; }, redirect, continue() { blocker.proceed(); blockingPromise.resolve(); } }, router); navigated.current = true; } else { // For covering the 'server -> client' use case Promise.resolve(containerRef.current?.onBeforeLeave?.call(containerRef?.current, { pathname, search }, {prevent}, router)) .then((cmd: unknown) => { if (cmd === postpone && containerRef.current) { // postponed navigation: expose existing blocker to Flow containerRef.current.serverConnected = (cancel) => { if (cancel) { blocker.reset(); blockingPromise.resolve(); } else { blocker.proceed(); window.removeEventListener('click', navigateEventHandler); blockingPromise.resolve(); } } } else { // permitted navigation: proceed with the blocker blocker.proceed(); window.removeEventListener('click', navigateEventHandler); blockingPromise.resolve(); } }); } } }, [blocker.state, blocker.location]); useEffect(() => { if (blocker.state === 'blocked') { return; } if (navigated.current) { navigated.current = false; fireNavigated(location.pathname,location.search); return; } flow.serverSideRoutes[0].action({pathname: location.pathname, search: location.search}) .then((container) => { const outlet = ref.current?.parentNode; if (outlet && outlet !== container.parentNode) { outlet.append(container); window.addEventListener('click', navigateEventHandler); containerRef.current = container } return container.onBeforeEnter?.call(container, {pathname: location.pathname, search: location.search}, {prevent, redirect, continue() { fireNavigated(location.pathname,location.search);}}, router); }) .then((result: unknown) => { if (typeof result === "function") { result(); } }); }, [location]); return ; } Flow.type = 'FlowContainer'; // This is for copilot to recognize this export const serverSideRoutes = [ { path: '/*', element: }, ]; /** * Load the script for an exported WebComponent with the given tag * * @param tag name of the exported web-component to load * * @returns Promise(resolve, reject) that is fulfilled on script load */ export const loadComponentScript = (tag: String): Promise => { return new Promise((resolve, reject) => { useEffect(() => { const script = document.createElement('script'); script.src = `/web-component/${tag}.js`; script.onload = function() { resolve(); }; script.onerror = function(err) { reject(err); }; document.head.appendChild(script); return () => { document.head.removeChild(script); } }, []); }); }; interface Properties { [key: string]: string; } /** * Load WebComponent script and create a React element for the WebComponent. * * @param tag custom web-component tag name. * @param props optional Properties object to create element attributes with * @param onload optional callback to be called for script onload * @param onerror optional callback for error loading the script */ export const reactElement = (tag: string, props?: Properties, onload?: () => void, onerror?: (err:any) => void) => { loadComponentScript(tag).then(() => onload?.(), (err) => { if(onerror) { onerror(err); } else { console.error(`Failed to load script for ${tag}.`, err); } }); if(props) { return React.createElement(tag, props); } return React.createElement(tag); }; export default Flow; // @ts-ignore if (import.meta.hot) { // @ts-ignore import.meta.hot.accept((newModule) => { // A hot module replace for Flow.tsx happens when any JS/TS imported through @JsModule // or similar is updated because this updates generated-flow-imports.js and that in turn // is imported by this file. We have no means of hot replacing those files, e.g. some // custom lit element so we need to reload the page. */ if (newModule) { window.location.reload(); } }); }