enuchi / React-Google-Apps-Script

This is your boilerplate project for developing React apps inside Google Sheets, Docs, Forms and Slides projects. It's perfect for personal projects and for publishing complex add-ons in the Google Workspace Marketplace.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Solution for webapp react-router sync with google.script.history (Client-side API)

samturner3 opened this issue · comments

Hello đź‘‹

This is a great project and I saw here in the issues people wanted to use the WebApp along with react-router, but were running into issues as react-router needs to access the browser APIs however GoogleAppsScripts actually runs in an isolated sandbox.

I was able to

  • Get react-router working for internal nav
  • PLUS get react-router to sync with the google history (routing) APIs.

This allows you to navigate directly to a hash (url) and react-router will route to the correct page.
Also clicking links internally with react-router will update the hash (url) accordingly.

We are also able to get url params from the hash (url) - so we can do things like have a url :?recordToUpdateId=123#update and we will nav to an update page and have access to the url params!

This is what I was wanting to achieve with this project and I solved it, so hopefully it is useful for others!

URL updates sync with react-router

Screen.Recording.2024-05-07.at.4.38.15.pm.mov

URL Params fed into page

Screen.Recording.2024-05-07.at.4.45.31.pm.mov

src/client/utils/router-gas-sync.ts

import { useEffect } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { routes } from '../routes';
import { generateRouteUrl } from '../routes/utils';

// when we add a # at the end of the url, make the react router path go the the corrsponding page.
const SyncRoutes = () => {
  if (typeof google === 'undefined' || google.script === undefined) {
    return;
  }
  const reactRouterLocation = useLocation();
  const reactRouterHistory = useHistory();

  // on first load, take google hash and push to correct react router path
  useEffect(() => {
    google.script.url.getLocation((location) => {
      const indexOfFound = routes.findIndex(
        (route) => route.name == location.hash
      );
      if (indexOfFound >= 0) {
        if (
          reactRouterLocation.pathname !==
          generateRouteUrl(routes[indexOfFound].name)
        ) {
          reactRouterHistory.push(generateRouteUrl(routes[indexOfFound].name));
        }
      }
    });
  }, []);

  useEffect(() => {
    console.log('Location changed');
    google.script.url.getLocation((location) => {
      console.log('google location changed', location);

      const indexOfFound = routes.findIndex(
        (route) => generateRouteUrl(route.name) == reactRouterLocation.pathname
      );
      if (indexOfFound >= 0) {
        if (location.hash !== routes[indexOfFound].name) {
          google.script.history.push(
            '',
            { ['']: '' },
            routes[indexOfFound].name
          );
        }
      }
    });
  }, [reactRouterLocation]);
};

export default SyncRoutes;
src/client/routes/index.tsx
import React from 'react';
import { Route } from '../interfaces';
import { Home, About, UpdateSpool } from '../pages';

export const googleAppScriptPreUrl = '/userCodeAppPanel/';

export const routes: Route[] = [
{
  friendlyName: 'Home',
  name: 'home',
  component: <Home />,
},
{
  friendlyName: 'About',
  name: 'about',
  component: <About />,
},
{
  friendlyName: 'Update spool',
  name: 'update-spool',
  component: <UpdateSpool />,
  excludeFromNav: true,
},
];
src/client/routes/utils.tsx
import React, { ReactNode } from 'react';
import { Route, Link } from 'react-router-dom';
import { Route as RouteType } from '../interfaces';
import { googleAppScriptPreUrl } from './index';

export const generateRouteUrl = (routeName: string): string =>
`${googleAppScriptPreUrl}${routeName}`;

export const generateReactRouterRoutes = (routes: RouteType[]): ReactNode => {
return routes.map((route) => (
  <Route key={route.name} path={generateRouteUrl(route.name)}>
    {route.component}
  </Route>
));
};

export const generateNavLinks = (routes: RouteType[]): ReactNode => {
return routes.map((route) => {
  if (!route.excludeFromNav) {
    return (
      <li key={route.name}>
        <Link to={generateRouteUrl(route.name)}>{route.friendlyName}</Link>
      </li>
    );
  }
});
};
src/client/App.tsx
import React from 'react';
import { Switch } from 'react-router-dom';
import Nav from './components/Nav';
import SyncRoutes from './utils/router-gas-sync';
import { routes } from './routes';
import { generateReactRouterRoutes } from './routes/utils';

const App = () => {
SyncRoutes();
return (
  <>
    <Nav />
    <Switch>{generateReactRouterRoutes(routes)}</Switch>
  </>
);
};

export default App;
src/client/interfaces/index.ts
export interface Route {
friendlyName: string;
name: string;
component: JSX.Element;
excludeFromNav?: boolean;
}
src/client/interfaces/google.script.d.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-types */
// Google Apps Script methods available to scripts
declare namespace google {
/**
 * Methods available to Google Apps Script
 */
namespace script {
  interface IRun {
    [serverSideFunction: string]: Function;

    /**
     * Sets a callback function to run if the server-side function throws an exception. Without a failure handler, failures are logged to the JavaScript console. To override this, call withFailureHandler(null) or supply a failure handler that does nothing.
     * @param callback a client-side callback function to run if the server-side function throws an exception; the Error object is passed to the function as the first argument, and the user object (if any) is passed as a second argument
     */
    withFailureHandler(callback: (error: Error, object?: any) => void): IRun;
    /**
     * Sets a callback function to run if the server-side function returns successfully.
     * @param callback a client-side callback function to run if the server-side function returns successfully; the server's return value is passed to the function as the first argument, and the user object (if any) is passed as a second argument
     */
    withSuccessHandler(callback: (value: any, object?: any) => void): IRun;
    /**
     * Sets an object to pass as a second parameter to the success and failure handlers.
     * @param {Object} object an object to pass as a second parameter to the success and failure handlers; because user objects are not sent to the server, they are not subject to the restrictions on parameters and return values for server calls. User objects cannot, however, be objects constructed with the new operator
     */
    withUserObject(object: Object): IRun;
  }

  interface IUrlLocation {
    /**
     * The string value of URL fragment after the # character, or an emptry string if no URL fragment is present
     */
    hash: string;
    /**
     * An object of key/value pairs that correspond to the URL request parameters. Only the first value will be returned for parameters that have multiple values. If no parameters are present, this will be an empty object.
     */
    parameter: { [key: string]: any };
    /**
     * An object similar to location.parameter, but with an array of values for each key. If no parameters are present, this will be an empty object.
     */
    parameters: { [key: string]: any[] };
  }

  /**
   * google.script.run is an asynchronous client-side JavaScript API available in HTML-service pages that can call server-side Apps Script functions.
   */
  const run: IRun;

  /**
   * google.script.history is an asynchronous client-side JavaScript API that can interact with the browser history stack. It can only be used in the context of a web app that uses IFRAME.
   */
  namespace history {
    /**
     * Pushes the provided state object, URL parameters and URL fragment onto the browser history stack.
     * @param stateObject An developer-defined object to be associated with a browser history event, and which resurfaces when the state is popped. Typically used to store application state information (such as page data) for future retrieval.
     * @param params An object containing URL parameters to associate with this state. For example, {foo: “bar”, fiz: “baz”} equates to "?foo=bar&fiz=baz". Alternatively, arrays can be used: {foo: [“bar”, “cat”], fiz: “baz”} equates to "?foo=bar&foo=cat&fiz=baz". If null or undefined, the current URL parameters are not changed. If empty, the URL parameters are cleared.
     * @param hash The string URL fragment appearing after the '#' character. If null or undefined, the current URL fragment is not changed. If empty, the URL fragment is cleared.
     */
    function push(
      stateObject?: any,
      params?: { [key: string]: any },
      hash?: string
    ): void;
    /**
     * Replaces the top event on the browser history stack with the provided (developer-defined) state object, URL parameters and URL fragment. This is otherwise identical to push().
     * @param stateObject An developer-defined object to be associated with a browser history event, and which resurfaces when the state is popped. Typically used to store application state information (such as page data) for future retrieval.
     * @param params An object containing URL parameters to associate with this state. For example, {foo: “bar”, fiz: “baz”} equates to "?foo=bar&fiz=baz". Alternatively, arrays can be used: {foo: [“bar”, “cat”], fiz: “baz”} equates to "?foo=bar&foo=cat&fiz=baz". If null or undefined, the current URL parameters are not changed. If empty, the URL parameters are cleared.
     * @param hash The string URL fragment appearing after the '#' character. If null or undefined, the current URL fragment is not changed. If empty, the URL fragment is cleared.
     */
    function replace(
      stateObject?: any,
      params?: { [key: string]: any },
      hash?: string
    ): void;
    /**
     * Sets a callback function to respond to changes in the browser history. The callback function should take only a single event object as an argument.
     * @param callback a client-side callback function to run upon a history change event, using the event object as the only argument.
     */
    function setChangeHandler(
      callback: (event: { state: any; location: IUrlLocation }) => void
    ): void;
  }

  namespace host {
    /**
     * Closes the current dialog or sidebar.
     */
    function close(): void;
    /**
     * Sets the height of the current dialog.
     * @param {number} height the new height, in pixels
     */
    function setHeight(height: number): void;
    /**
     * Sets the width of the current dialog.
     * @param {number} width the new width, in pixels
     */
    function setWidth(width: number): void;
    namespace editor {
      /**
       * Switches browser focus from the dialog or sidebar to the Google Docs, Sheets, or Forms editor.
       */
      function focus(): void;
    }
  }
  /**
   * google.script.url is an asynchronous client-side JavaScript API that can query URLs to obtain the current URL parameters and fragment. This API supports the google.script.history API. It can only be used in the context of a web app that uses IFRAME.
   */
  namespace url {
    /**
     * Gets a URL location object and passes it to the specified callback function (as the only argument).
     * @param callback a client-side callback function to run, using the location object as the only argument.
     */
    function getLocation(callback: (location: IUrlLocation) => void): void;
  }
}
}
src/client/pages/UpdateSpool.tsx
import React from 'react';
import { useState } from 'react';

const UpdateSpool = () => {
const [spoolId, setSpoolId] = useState<string[]>();

google.script.url.getLocation((location) => {
  setSpoolId(location.parameters.spoolId);
});
return (
  <>
    <h1>UpdateSpool</h1>
    <p>Some text here</p>
    <p>{JSON.stringify(spoolId)}</p>

  </>
);
};

export default UpdateSpool;

Very cool stuff. Do you think it would work with regular dialog/sidebar apps for internal routing, even though the url is not exposed?

@samturner3 I am trying to configure react router on my apps script react project, Could you provide public repo, I need to use this as boilerplate.

@samturner3 I am trying to configure react router on my apps script react project, Could you provide public repo, I need to use this as boilerplate.

Here you go. Not sharing repo as private and will develop it for another purpose.

spool-tracker-googleAppsScript-main.zip