diff --git a/README.md b/README.md index 3d8b3db..10e7c28 100644 --- a/README.md +++ b/README.md @@ -72,51 +72,33 @@ To test against a local hydra instance ### Make your frontend editable -- Take the latest [hydra.js](https://github.com/collective/volto-hydra/tree/hydra.js) and include it in your frontend -- Your frontend will know to initialise the hydra iframe bridge when it is being edited using hydra as it will recieve a ```?hydra_auth=xxxxx``` +- Take the latest [hydra.js](https://github.com/collective/volto-hydra/tree/main/packages/hydra-js) frome hydra-js package and include it in your frontend +- Your frontend will know to initialise the hydra iframe bridge when it is being edited using hydra as it will recieve a ```?_edit=true```, [checkout below](#asynchronously-load-the-bridge) to load `hydra.js` asynchronously. - Initialising hydra iframe bridge creates a two way link between the hydra editor and your frontend. You will be able to optionally register call backs for events allowing you to add more advanced editor functionality depending on your needs. -### How to initialise the bridge. - -- Import `initBridge` from [hydra.js](https://github.com/collective/volto-hydra/tree/hydra.js). -- Call the `initBridge` and pass the origin of your adminUI as the argument to the initBridge method. -- For example, if you are trying out demo editor, it will be: `https://hydra.pretagov.com` - ```js - // In Layout.js or App.js - import { initBridge } from './hydra.js'; - initBridge("https://hydra.pretagov.com"); - ``` -- This will enable the 2 way link between hydra and your frontend. -- Log into https://hydra.pretagov.com/ and paste in your local running frontend to test. - -TODO: more integrations will be added below as the [Hydra GSoC project progresses](https://github.com/orgs/collective/projects/3/views/4) - #### Authenticate frontend to access private content -In hydra.js, it initiates the Bridge, and starts listening to the token response from the Hydra. It also have the method `(_getTokenFromCookies)` to fetch the token from the cookies and pass it to the integrator to use it in the `ploneClient.initialize()`. - -Integrate your frontend: - -- Add 'hydra.js` in your frontend. -- Initialize the Bridge using `initBridge` method provided by './hydra.js', use 'https://hydra.pretagov.com' for option `adminOrigin` to tryout demo. -- Use the `getToken()` method provided by './hydra.js' to access the token. Use this in your ploneClient inctance. -- At [Volto-Hydra demo](https://hydra.pretagov.com/) type in your hosted frontend url to preview public content and login to see the private pages. +- When you input your frontend URL at the Volto Hydra (adminUI) it will set 2 params in your frontend URL. +- You can extract the `access_token` parameter directly from the URL for the `ploneClient` token option. +- Or you can use it in Authorization header if you are using other methods to fetch content from plone Backend. Example Usage: ```js -// nextjs 14 +// nextjs 14 using ploneClient import ploneClient from "@plone/client"; import { useQuery } from "@tanstack/react-query"; -import { initBridge } from "@/utils/hydra"; export default function Blog({ params }) { - const bridge = initBridge("http://localhost:3000"); // Origin of your local Volto-Hydra - const token = bridge._getTokenFromCookie(); + // Extract token directly from the URL + const url = new URL(window.location.href); + const token = url.searchParams.get("access_token"); + const client = ploneClient.initialize({ apiPath: "http://localhost:8080/Plone/", // Plone backend token: token, }); + const { getContentQuery } = client; const { data, isLoading } = useQuery(getContentQuery({ path: '/blogs' })); @@ -133,6 +115,49 @@ Reference Issue: [#6](https://github.com/collective/volto-hydra/issues/6) Now your editors login to hydra and navigate the site within the editor or via the frontend displayed in the middle of the screen. They can add, remove objects and do normal plone toolbar functions as well as edit a page metadata via the sidebar. +### How to initialise the bridge. + +- Import `initBridge` from [hydra.js](https://github.com/collective/volto-hydra/tree/main/packages/hydra-js). +- Call the `initBridge` and pass the origin of your adminUI as the argument to the initBridge method. +- For example, if you are trying out demo editor, it will be: `https://hydra.pretagov.com` + ```js + // In Layout.js or App.js + import { initBridge } from './hydra.js'; + initBridge("https://hydra.pretagov.com"); + ``` +- This will enable the 2 way link between hydra and your frontend. +- Log into https://hydra.pretagov.com/ and paste in your local running frontend to test. + +### Asynchronously Load the Bridge + +Since the script has a considerable size, it’s recommended to load the bridge only when necessary, such as in edit mode. +To load the bridge asynchronously, add a function that checks if the bridge is already present. If it isn't, the function will load it and then call a callback function. This ensures the bridge is loaded only when needed. + +```js +function loadBridge(callback) { + const existingScript = document.getElementById("hydraBridge"); + if (!existingScript) { + const script = document.createElement("script"); + script.src = "./hydra.js"; + script.id = "hydraBridge"; + document.body.appendChild(script); + script.onload = () => { + callback(); + }; + } else { + callback(); + } +} + +// Initialize the bridge only inside the admin UI +if (window.location.search.includes('_edit=true')) { + loadBridge(() => { + const { initBridge } = window; + initBridge('https://hydra.pretagov.com'); + }); +} +``` + #### Show changes after save This is the most basic form of integration. For this no additional integraion is needed. @@ -188,4 +213,4 @@ on https://hydra.pretagov.com for others to test. But be sure to subscribe to the project so you can keep your frontend updated with changes to the hydra api as more capabilities are added. If there are bugs lets us know. - +TODO: more integrations will be added below as the [Hydra GSoC project progresses](https://github.com/orgs/collective/projects/3/views/4) diff --git a/packages/hydra-js/hydra.js b/packages/hydra-js/hydra.js index 282b814..171f0b6 100644 --- a/packages/hydra-js/hydra.js +++ b/packages/hydra-js/hydra.js @@ -3,6 +3,9 @@ class Bridge { constructor(adminOrigin) { this.adminOrigin = adminOrigin; this.token = null; + this.navigationHandler = null; // Handler for navigation events + this.realTimeDataHandler = null; // Handler for message events + this.blockClickHandler = null; // Handler for block click events this.init(); } @@ -12,25 +15,27 @@ class Bridge { } if (window.self !== window.top) { - window.navigation.addEventListener('navigate', (event) => { + this.navigationHandler = (event) => { window.parent.postMessage( { type: 'URL_CHANGE', url: event.destination.url }, this.adminOrigin, ); - }); - } + }; - window.addEventListener('message', (event) => { - if (event.origin === this.adminOrigin) { - if (event.data.type === 'GET_TOKEN_RESPONSE') { - this.token = event.data.token; - this._setTokenCookie(event.data.token); - } - } - }); + // Ensure we don't add multiple listeners + window.navigation.removeEventListener('navigate', this.navigationHandler); + window.navigation.addEventListener('navigate', this.navigationHandler); + + // Get the access token from the URL + const url = new URL(window.location.href); + const access_token = url.searchParams.get('access_token'); + this.token = access_token; + this._setTokenCookie(access_token); + } } + onEditChange(callback) { - window.addEventListener('message', (event) => { + this.realTimeDataHandler = (event) => { if (event.origin === this.adminOrigin) { if (event.data.type === 'FORM') { if (event.data.data) { @@ -40,79 +45,50 @@ class Bridge { } } } - }); - } - async get_token() { - if (this.token !== null) { - return this.token; - } - const cookieToken = this._getTokenFromCookie(); - if (cookieToken) { - this.token = cookieToken; - return cookieToken; - } - - if (window.self !== window.top) { - try { - window.parent.postMessage({ type: 'GET_TOKEN' }, this.adminOrigin); - const token = await this._waitForToken(this.adminOrigin); - return token; - } catch (error) { - console.error('Failed to retrieve auth_token:', error); - return null; - } - } else { - return null; - } - } + }; - _waitForToken(adminOrigin) { - return new Promise((resolve, reject) => { - const tokenListener = (event) => { - if (adminOrigin === this.adminOrigin) { - if (event.data.type === 'GET_TOKEN_RESPONSE') { - window.removeEventListener('message', tokenListener); - this._setTokenCookie(event.data.token); - resolve(event.data.token); - } else { - reject( - new Error( - `Invalid message type: Expected GET_TOKEN_RESPONSE, received ${event.data.type}`, - ), - ); - } - } else { - reject( - new Error( - `Origin mismatch: Expected ${this.adminOrigin}, received ${adminOrigin}`, - ), - ); - } - }; - window.addEventListener('message', tokenListener); - }); + // Ensure we don't add multiple listeners + window.removeEventListener('message', this.realTimeDataHandler); + window.addEventListener('message', this.realTimeDataHandler); } _setTokenCookie(token) { const expiryDate = new Date(); expiryDate.setTime(expiryDate.getTime() + 12 * 60 * 60 * 1000); // 12 hours - document.cookie = `auth_token=${token}; expires=${expiryDate.toUTCString()}; path=/`; + + const url = new URL(window.location.href); + const domain = url.hostname; + document.cookie = `auth_token=${token}; expires=${expiryDate.toUTCString()}; path=/; domain=${domain};`; } - _getTokenFromCookie() { - if (typeof document === 'undefined') { - return null; - } - const name = 'auth_token='; - const decodedCookie = decodeURIComponent(document.cookie); - const cookieArray = decodedCookie.split(';'); - for (let i = 0; i < cookieArray.length; i++) { - let cookie = cookieArray[i].trim(); - if (cookie.indexOf(name) === 0) { - return cookie.substring(name.length, cookie.length); + enableBlockClickListener() { + this.blockClickHandler = (event) => { + const blockElement = event.target.closest('[data-block-uid]'); + if (blockElement) { + const blockUid = blockElement.getAttribute('data-block-uid'); + window.parent.postMessage( + { type: 'OPEN_SETTINGS', uid: blockUid }, + this.adminOrigin, + ); } + }; + + // Ensure we don't add multiple listeners + document.removeEventListener('click', this.blockClickHandler); + document.addEventListener('click', this.blockClickHandler); + } + + // Method to clean up all event listeners + cleanup() { + if (this.navigationHandler) { + window.navigation.removeEventListener('navigate', this.navigationHandler); + } + if (this.realTimeDataHandler) { + window.removeEventListener('message', this.realTimeDataHandler); + } + if (this.blockClickHandler) { + document.removeEventListener('click', this.blockClickHandler); } - return null; } } @@ -136,15 +112,24 @@ export function initBridge(adminOrigin) { * Get the token from the admin * @returns string */ -export async function getToken() { - if (bridgeInstance) { - return await bridgeInstance.get_token(); +export function getTokenFromCookie() { + if (typeof document === 'undefined') { + return null; } - return ''; + const name = 'auth_token='; + const decodedCookie = decodeURIComponent(document.cookie); + const cookieArray = decodedCookie.split(';'); + for (let i = 0; i < cookieArray.length; i++) { + let cookie = cookieArray[i].trim(); + if (cookie.indexOf(name) === 0) { + return cookie.substring(name.length, cookie.length); + } + } + return null; } + /** * Enable the frontend to listen for changes in the admin and call the callback with updated data - * @param {*} initialData * @param {*} callback */ export function onEditChange(callback) { @@ -152,3 +137,17 @@ export function onEditChange(callback) { bridgeInstance.onEditChange(callback); } } + +/** + * Enable the frontend to listen for clicks on blocks to open the settings + */ +export function enableBlockClickListener() { + if (bridgeInstance) { + bridgeInstance.enableBlockClickListener(); + } +} + +// Make initBridge available globally +if (typeof window !== 'undefined') { + window.initBridge = initBridge; +} diff --git a/packages/volto-hydra/src/components/Iframe/View.jsx b/packages/volto-hydra/src/components/Iframe/View.jsx index 92b7907..585b562 100644 --- a/packages/volto-hydra/src/components/Iframe/View.jsx +++ b/packages/volto-hydra/src/components/Iframe/View.jsx @@ -1,9 +1,30 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { useSelector } from 'react-redux'; import Cookies from 'js-cookie'; import './styles.css'; +/** + * Get the default URL from the environment + * @returns {string} URL from the environment + */ +const getDefualtUrl = () => + process.env['RAZZLE_DEFAULT_IFRAME_URL'] || + (typeof window !== 'undefined' && window.env['RAZZLE_DEFAULT_IFRAME_URL']) || + 'http://localhost:3002'; // fallback if env is not set + +/** + * Format the URL for the Iframe with location, token and enabling edit mode + * @param {*} url + * @param {*} token + * @returns {string} URL with the admin params + */ +const getUrlWithAdminParams = (url, token) => { + return typeof window !== 'undefined' + ? `${url}${window.location.pathname.replace('/edit', '')}?access_token=${token}&_edit=true` + : null; +}; + function isValidUrl(string) { try { new URL(string); @@ -15,47 +36,76 @@ function isValidUrl(string) { } const Iframe = () => { + const [url, setUrl] = useState(''); + + const [src, setSrc] = useState(''); const history = useHistory(); const token = useSelector((state) => state.userSession.token); const form = useSelector((state) => state.form.global); - const getDefualtUrlFromEnv = () => - process.env['RAZZLE_DEFAULT_IFRAME_URL'] || - (typeof window !== 'undefined' && window.env['RAZZLE_DEFAULT_IFRAME_URL']); - const defaultUrl = getDefualtUrlFromEnv() || 'http://localhost:3002'; // fallback if env is not set + const defaultUrl = getDefualtUrl(); const savedUrl = Cookies.get('iframe_url'); const initialUrl = savedUrl - ? `${savedUrl}${history.location.pathname.replace('/edit', '')}` - : `${defaultUrl}${history.location.pathname.replace('/edit', '')}`; - const [url, setUrl] = useState(initialUrl); - const [src, setSrc] = useState(initialUrl); + ? getUrlWithAdminParams(savedUrl, token) + : getUrlWithAdminParams(defaultUrl, token); + + const handleNavigateToUrl = useCallback( + (givenUrl = null) => { + if (!isValidUrl(givenUrl) && !isValidUrl(url)) { + return; + } + // Update adminUI URL with the new URL + const formattedUrl = givenUrl ? new URL(givenUrl) : new URL(url); + const newOrigin = formattedUrl.origin; + Cookies.set('iframe_url', newOrigin, { expires: 7 }); + + if (formattedUrl.pathname !== '/') { + history.push( + window.location.pathname.endsWith('/edit') + ? `${formattedUrl.pathname}/edit` + : `${formattedUrl.pathname}`, + ); + } else { + history.push( + window.location.pathname.endsWith('/edit') ? `/edit` : `/`, + ); + } + }, + [history, url], + ); + + useEffect(() => { + setUrl( + `${savedUrl || defaultUrl}${window.location.pathname.replace('/edit', '')}`, + ); + setSrc(initialUrl); + }, [savedUrl, defaultUrl, initialUrl]); useEffect(() => { - // Listen for messages from the iframe const initialUrlOrigin = new URL(initialUrl).origin; - window.addEventListener('message', (event) => { + const messageHandler = (event) => { if (event.origin !== initialUrlOrigin) { return; } const { type } = event.data; switch (type) { case 'URL_CHANGE': // URL change from the iframe - setUrl(event.data.url); handleNavigateToUrl(event.data.url); break; - case 'GET_TOKEN': // Request for the token from the iframe - event.source.postMessage( - { type: 'GET_TOKEN_RESPONSE', token: token }, - event.origin, - ); - break; - default: break; } - }); - }, [token]); + }; + + // Listen for messages from the iframe + window.addEventListener('message', messageHandler); + + // Clean up the event listener on unmount + return () => { + window.removeEventListener('message', messageHandler); + }; + }, [handleNavigateToUrl, initialUrl, token]); useEffect(() => { if (Object.keys(form).length > 0 && isValidUrl(initialUrl)) { @@ -71,28 +121,6 @@ const Iframe = () => { setUrl(event.target.value); }; - const handleNavigateToUrl = (givenUrl = '') => { - // Update adminUI URL with the new URL - if (!isValidUrl(givenUrl) && !isValidUrl(url)) { - return; - } - const formattedUrl = givenUrl ? new URL(givenUrl) : new URL(url); - // const newUrl = formattedUrl.href; - // setSrc(newUrl); - const newOrigin = formattedUrl.origin; - Cookies.set('iframe_url', newOrigin, { expires: 7 }); - - if (formattedUrl.pathname !== '/') { - history.push( - window.location.pathname.endsWith('/edit') - ? `${formattedUrl.pathname}/edit` - : `${formattedUrl.pathname}`, - ); - } else { - history.push(window.location.pathname.endsWith('/edit') ? `/edit` : `/`); - } - }; - return (