Support react-refresh and shared client/server types (#98)
- Adds support for react-refresh through `@pmmwh/react-refresh-webpack-plugin`. Updated webpack config to support this. Means entire app is no longer refreshed on changes, only individual components, and state is maintained. - React refresh removes need for `google-apps-script-webpack-dev-server` package - In client code, support for server function autocomplete through use of gas-client 1.0.0. Requires certain files to be typescript .ts files - Remove need for "global" exports in main server file through updated gas-webpack-plugin
This commit is contained in:
@@ -11,7 +11,7 @@ jobs:
|
||||
os: [macos-10.15, macos-latest, windows-latest]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
node-version: [12, 14, 16]
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 11
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<a href="" rel="noopener">
|
||||
<img width="400" src="https://i.imgur.com/83Y7bWN.png" alt="React & Google Apps Script logos"></a>
|
||||
</p>
|
||||
<p align="center"><i>
|
||||
Update 2022: Now with support for React v17 and React Fast Refresh
|
||||
</i></p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -12,7 +15,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center"> 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 G Suite Marketplace.
|
||||
<p align="center"> 🚀 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 G Suite Marketplace.
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -189,6 +192,8 @@ After running the start command, navigate to your spreadsheet and open one of th
|
||||
|
||||
<img width="100%" src="https://i.imgur.com/EsnOEHP.gif">
|
||||
|
||||
Support for [Fast Refresh](https://github.com/pmmmwh/react-refresh-webpack-plugin) now means that only modified components are refreshed when files are changed, and state is not lost.
|
||||
|
||||
<br/>
|
||||
|
||||
### 🔍 Using React DevTools <a name="dev-tools"></a>
|
||||
|
||||
+2
-7
@@ -1,8 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import server from '../src/client/utils/server';
|
||||
|
||||
const { serverFunctions } = server;
|
||||
import { serverFunctions } from '../src/client/utils/serverFunctions.ts';
|
||||
|
||||
const { FILENAME, PORT } = process.env;
|
||||
|
||||
@@ -54,10 +52,7 @@ const DevServer = () => {
|
||||
position: 'absolute',
|
||||
}}
|
||||
ref={iframe}
|
||||
// The "/gas/" path here must match the path where the custom dev server is being loaded.
|
||||
// See webpack.config.js "devServer" "before" settings.
|
||||
// Filename extension "-impl" must match webpack.config.js clientConfigs filename settings for development.
|
||||
src={`https://localhost:${PORT}/gas/${FILENAME}-impl.html`}
|
||||
src={`https://localhost:${PORT}/${FILENAME}-impl.html`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Generated
+1521
-1340
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "react-google-apps-script",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "Starter project for using React with Google Apps Script",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -34,7 +34,6 @@
|
||||
"npm": ">=6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"gas-client": "^0.2.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^17.0.2",
|
||||
"react-bootstrap": "^1.3.0",
|
||||
@@ -51,7 +50,8 @@
|
||||
"@babel/polyfill": "^7.10.4",
|
||||
"@babel/preset-env": "^7.11.0",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@google/clasp": "^2.3.1",
|
||||
"@google/clasp": "2.3.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
|
||||
"@types/expect-puppeteer": "^4.4.6",
|
||||
"@types/jest-environment-puppeteer": "^4.4.1",
|
||||
"@types/puppeteer": "^5.4.4",
|
||||
@@ -66,11 +66,10 @@
|
||||
"css-loader": "^3.6.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"dynamic-cdn-webpack-plugin": "^5.0.0",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-config-airbnb-base": "^14.2.0",
|
||||
"eslint-config-prettier": "^6.11.0",
|
||||
"eslint-config-standard": "^14.1.1",
|
||||
"eslint-loader": "^3.0.4",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"eslint-plugin-googleappsscript": "^1.0.3",
|
||||
"eslint-plugin-import": "^2.22.0",
|
||||
@@ -81,10 +80,10 @@
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.20.5",
|
||||
"eslint-plugin-standard": "^4.0.1",
|
||||
"gas-client": "^1.0.0",
|
||||
"gas-lib": "^2.0.3",
|
||||
"gas-types-detailed": "^1.0.0",
|
||||
"gas-webpack-plugin": "^1.0.4",
|
||||
"google-apps-script-webpack-dev-server": "^1.0.0",
|
||||
"gas-webpack-plugin": "^1.2.3",
|
||||
"html-webpack-inline-source-plugin": "0.0.10",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"jest": "^27.5.1",
|
||||
@@ -95,6 +94,7 @@
|
||||
"puppeteer": "^13.4.1",
|
||||
"puppeteer-extra": "^3.2.3",
|
||||
"puppeteer-extra-plugin-stealth": "^2.9.0",
|
||||
"react-refresh": "^0.10.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"style-loader": "^1.2.1",
|
||||
"tern": "^0.24.3",
|
||||
|
||||
@@ -29,7 +29,19 @@
|
||||
"camelcase": "warn",
|
||||
"import/prefer-default-export": "warn",
|
||||
"import/no-extraneous-dependencies": "warn",
|
||||
"prefer-object-spread": "warn"
|
||||
"prefer-object-spread": "warn",
|
||||
"rules": {
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
"js": "never",
|
||||
"jsx": "never",
|
||||
"ts": "never",
|
||||
"tsx": "never"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
|
||||
@@ -4,9 +4,7 @@ import { Button, ListGroup } from 'react-bootstrap';
|
||||
import FormInput from './FormInput.tsx';
|
||||
|
||||
// This is a wrapper for google.script.run that lets us use promises.
|
||||
import server from '../../utils/server';
|
||||
|
||||
const { serverFunctions } = server;
|
||||
import { serverFunctions } from '../../utils/serverFunctions';
|
||||
|
||||
const SheetEditor = () => {
|
||||
const [names, setNames] = useState([]);
|
||||
|
||||
@@ -4,9 +4,7 @@ import FormInput from './FormInput';
|
||||
import SheetButton from './SheetButton';
|
||||
|
||||
// This is a wrapper for google.script.run that lets us use promises.
|
||||
import server from '../../utils/server';
|
||||
|
||||
const { serverFunctions } = server;
|
||||
import { serverFunctions } from '../../utils/serverFunctions';
|
||||
|
||||
const SheetEditor = () => {
|
||||
const [names, setNames] = useState([]);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import Server from 'gas-client';
|
||||
|
||||
const { PORT } = process.env;
|
||||
|
||||
const server = new Server({
|
||||
// this is necessary for local development but will be ignored in production
|
||||
allowedDevelopmentDomains: `https://localhost:${PORT}`,
|
||||
});
|
||||
|
||||
export default server;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { GASClient } from 'gas-client';
|
||||
import * as publicServerFunctions from '../../server';
|
||||
|
||||
const { serverFunctions } = new GASClient<typeof publicServerFunctions>({
|
||||
// this is necessary for local development but will be ignored in production
|
||||
allowedDevelopmentDomains: origin =>
|
||||
/https:\/\/.*\.googleusercontent\.com$/.test(origin),
|
||||
});
|
||||
|
||||
export { serverFunctions };
|
||||
@@ -1,12 +0,0 @@
|
||||
import * as publicUiFunctions from './ui';
|
||||
import * as publicSheetFunctions from './sheets';
|
||||
|
||||
// Expose public functions by attaching to `global`
|
||||
global.onOpen = publicUiFunctions.onOpen;
|
||||
global.openDialog = publicUiFunctions.openDialog;
|
||||
global.openDialogBootstrap = publicUiFunctions.openDialogBootstrap;
|
||||
global.openAboutSidebar = publicUiFunctions.openAboutSidebar;
|
||||
global.getSheetsData = publicSheetFunctions.getSheetsData;
|
||||
global.addSheet = publicSheetFunctions.addSheet;
|
||||
global.deleteSheet = publicSheetFunctions.deleteSheet;
|
||||
global.setActiveSheet = publicSheetFunctions.setActiveSheet;
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
onOpen,
|
||||
openDialog,
|
||||
openDialogBootstrap,
|
||||
openAboutSidebar,
|
||||
} from './ui';
|
||||
|
||||
import { getSheetsData, addSheet, deleteSheet, setActiveSheet } from './sheets';
|
||||
|
||||
// Public functions must be exported as named exports
|
||||
export {
|
||||
onOpen,
|
||||
openDialog,
|
||||
openDialogBootstrap,
|
||||
openAboutSidebar,
|
||||
getSheetsData,
|
||||
addSheet,
|
||||
deleteSheet,
|
||||
setActiveSheet,
|
||||
};
|
||||
@@ -18,7 +18,7 @@ const toMatchImageSnapshot = configureToMatchImageSnapshot({
|
||||
allowSizeMismatch: true,
|
||||
});
|
||||
expect.extend({ toMatchImageSnapshot });
|
||||
jest.setTimeout(120000);
|
||||
jest.setTimeout(180000);
|
||||
|
||||
const srcTestFile = path.join(
|
||||
__dirname,
|
||||
@@ -48,10 +48,11 @@ describe(`Local setup ${isExtended ? '*extended*' : ''}`, () => {
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
|
||||
await webpackDevServerReady(process);
|
||||
|
||||
if (isExtended) {
|
||||
await openAddon(page);
|
||||
} else {
|
||||
await webpackDevServerReady(process);
|
||||
await page.goto('https://localhost:3000/dialog-demo-bootstrap.html');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
const openAddon = async page => {
|
||||
await page.goto(process.env.SHEET_URL);
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
await page.click('a:nth-child(2)'); // click on signin button
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
await page.waitForSelector('input[name="identifier"]', { visible: true });
|
||||
await page.type('input[name="identifier"]', process.env.EMAIL); // type email
|
||||
await page.click('#identifierNext'); // click "next" button
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
await page.waitForSelector('input[name="password"]', { visible: true });
|
||||
await page.type('input[name="password"]', process.env.PASSWORD); // type pass
|
||||
await page.waitForTimeout(3000);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.click('#passwordNext'); // click "next" button
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
@@ -35,7 +37,10 @@ const openAddon = async page => {
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(25000); // wait long enough for onopen to be called
|
||||
await page.waitForSelector(
|
||||
'div.menu-button.goog-control.goog-inline-block:nth-child(11)',
|
||||
{ visible: true }
|
||||
);
|
||||
|
||||
// open new addon menubar item
|
||||
await page.evaluate(() => {
|
||||
@@ -48,7 +53,10 @@ const openAddon = async page => {
|
||||
addOnMenuButton.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(6000);
|
||||
await page.waitForSelector(
|
||||
'div.goog-menu.goog-menu-vertical.apps-menu-hide-mnemonics:last-child > div:nth-child(2) > div',
|
||||
{ visible: true }
|
||||
);
|
||||
|
||||
// open "bootstrap" menu item
|
||||
await page.evaluate(() => {
|
||||
@@ -68,7 +76,12 @@ const openAddon = async page => {
|
||||
new MouseEvent('mouseup', { bubbles: true })
|
||||
);
|
||||
});
|
||||
await page.waitForTimeout(10000);
|
||||
await page.waitForSelector('.script-app-dialog', {
|
||||
visible: true,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
};
|
||||
|
||||
module.exports = { openAddon };
|
||||
|
||||
+42
-19
@@ -11,6 +11,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
|
||||
const DynamicCdnWebpackPlugin = require('dynamic-cdn-webpack-plugin');
|
||||
const moduleToCdn = require('module-to-cdn');
|
||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||
|
||||
/*********************************
|
||||
* set up environment variables
|
||||
@@ -32,7 +33,7 @@ const isProd = process.env.NODE_ENV === 'production';
|
||||
const destination = path.resolve(__dirname, 'dist');
|
||||
|
||||
// define server paths
|
||||
const serverEntry = './src/server/index.js';
|
||||
const serverEntry = './src/server/index.ts';
|
||||
|
||||
// define appsscript.json file path
|
||||
const copyAppscriptEntry = './appsscript.json';
|
||||
@@ -98,7 +99,7 @@ const sharedClientAndServerConfig = {
|
||||
};
|
||||
|
||||
// webpack settings used by all client entrypoints
|
||||
const clientConfig = {
|
||||
const clientConfig = ({ isDevClientWrapper }) => ({
|
||||
...sharedClientAndServerConfig,
|
||||
mode: isProd ? 'production' : 'development',
|
||||
output: {
|
||||
@@ -119,6 +120,14 @@ const clientConfig = {
|
||||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
// only enable react-refresh for dev builds, and not when building the dev client "wrapper"
|
||||
options: {
|
||||
plugins: [
|
||||
!isProd &&
|
||||
!isDevClientWrapper &&
|
||||
require.resolve('react-refresh/babel'),
|
||||
].filter(Boolean),
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'ts-loader',
|
||||
@@ -130,6 +139,14 @@ const clientConfig = {
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
// only enable react-refresh for dev builds, and not when building the dev client "wrapper"
|
||||
options: {
|
||||
plugins: [
|
||||
!isProd &&
|
||||
!isDevClientWrapper &&
|
||||
require.resolve('react-refresh/babel'),
|
||||
].filter(Boolean),
|
||||
},
|
||||
},
|
||||
},
|
||||
// we could add support for scss here
|
||||
@@ -139,7 +156,7 @@ const clientConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// DynamicCdnWebpackPlugin settings
|
||||
// these settings help us load 'react', 'react-dom' and the packages defined below from a CDN
|
||||
@@ -150,9 +167,19 @@ const DynamicCdnWebpackPluginConfig = {
|
||||
resolver: (packageName, packageVersion, options) => {
|
||||
const packageSuffix = isProd ? '.min.js' : '.js';
|
||||
const moduleDetails = moduleToCdn(packageName, packageVersion, options);
|
||||
|
||||
// don't externalize react during development due to issue with react-refresh
|
||||
// https://github.com/pmmmwh/react-refresh-webpack-plugin/issues/334
|
||||
if (!isProd && packageName === 'react') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// return defaults if Dynamic CDN plugin finds package
|
||||
if (moduleDetails) {
|
||||
return moduleDetails;
|
||||
}
|
||||
|
||||
// define custom CDN configuration for new packages
|
||||
// "name" should match the package being imported
|
||||
// "var" is important to get right -- this should be the exposed global. Look up "webpack externals" for info.
|
||||
switch (packageName) {
|
||||
@@ -178,11 +205,14 @@ const DynamicCdnWebpackPluginConfig = {
|
||||
|
||||
// webpack settings used by each client entrypoint defined at top
|
||||
const clientConfigs = clientEntrypoints.map(clientEntrypoint => {
|
||||
const isDevClientWrapper = false;
|
||||
return {
|
||||
...clientConfig,
|
||||
...clientConfig({ isDevClientWrapper }),
|
||||
name: clientEntrypoint.name,
|
||||
entry: clientEntrypoint.entry,
|
||||
plugins: [
|
||||
!isProd && new webpack.HotModuleReplacementPlugin(),
|
||||
!isProd && new ReactRefreshWebpackPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': JSON.stringify(envVars),
|
||||
}),
|
||||
@@ -195,26 +225,15 @@ const clientConfigs = clientEntrypoints.map(clientEntrypoint => {
|
||||
new HtmlWebpackInlineSourcePlugin(),
|
||||
// this plugin allows us to add dynamically load packages from a CDN
|
||||
new DynamicCdnWebpackPlugin(DynamicCdnWebpackPluginConfig),
|
||||
],
|
||||
].filter(Boolean),
|
||||
};
|
||||
});
|
||||
|
||||
const gasWebpackDevServerPath = require.resolve(
|
||||
'google-apps-script-webpack-dev-server'
|
||||
);
|
||||
|
||||
// webpack settings for devServer https://webpack.js.org/configuration/dev-server/
|
||||
const devServer = {
|
||||
hot: true,
|
||||
port: PORT,
|
||||
https: true,
|
||||
// run our own route to serve the package google-apps-script-webpack-dev-server
|
||||
before: app => {
|
||||
// this '/gas/' path needs to match the path loaded in the iframe in dev/index.js
|
||||
app.get('/gas/*', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
fs.createReadStream(gasWebpackDevServerPath).pipe(res);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
||||
@@ -238,8 +257,9 @@ if (fs.existsSync(pfxPath)) {
|
||||
// webpack settings for the development client wrapper
|
||||
const devClientConfigs = clientEntrypoints.map(clientEntrypoint => {
|
||||
envVars.FILENAME = clientEntrypoint.filename;
|
||||
const isDevClientWrapper = true;
|
||||
return {
|
||||
...clientConfig,
|
||||
...clientConfig({ isDevClientWrapper }),
|
||||
name: `DEVELOPMENT: ${clientEntrypoint.name}`,
|
||||
entry: devDialogEntry,
|
||||
plugins: [
|
||||
@@ -332,7 +352,10 @@ const serverConfig = {
|
||||
isProd ? 'production' : 'development'
|
||||
),
|
||||
}),
|
||||
new GasPlugin(),
|
||||
new GasPlugin({
|
||||
// removes need for assigning public server functions to "global"
|
||||
autoGlobalExportsFiles: [serverEntry],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user