🐈

【React on GAS】Google workspace内環境で動作する本格的なwebアプリの開発方法

に公開

Vite+Reactで作成したwebアプリをGASの中で動作させる方法です。
Viteで作成していれば、Vueでも可能です。

データベースはスプレッドシートを用います。

この時問題になるのが、スプレッドシートのデータをどうやってReactの中で使うかです。

pythonでスプレッドシートのデータを読み取ったりできるライブラリはあるのですが、google cloudの設定が必要だったり、かなり面倒です。

ここに記載しているAppSheet APIを使えば、わりと簡単にAPIを作成することができます。
(このAppSheet APIを使ってスプレッドシートにデータを書き込みをする方法は、ラズパイやArduinoの電子工作でも多用しています)

DB用のスプレッドシート作成

AppSheetでAPI作成

スプレッドシートからAppSheetアプリを作成する

データ画面

initial valueやformulaについて、適宜定めておくといいでしょう

id:initial valueをUNIQUEID()
更新者メールアドレス:formulaをUSEREMAIL()
更新日:initial valueとformulaをTODAY()

APIキーを有効化

Settings > Integrations
AppIDとAccessKeyを使用します

Reactアプリ立ち上げ

こちらの記事を参考にしてください。
https://zenn.dev/yuta_enginner/articles/8de4edd4de3e5c

開発環境or本番環境の識別方法

Viteを使っていれば、import.meta.env.DEVで開発環境か本番環境を識別できます。
Viteはnpm run devで立ち上がったら開発環境、buildされたモノなら本番環境と認識しているみたいです。

const isDev = import.meta.env.DEV
// 開発環境ならTrue

私のやり方では、開発環境と本番環境ではログイン中ユーザーの認識方法を変えたり、APIで参照するデータを変えたりしています。

GASでcurrentUserEmailを取得する

GASではdoGet関数で、HTMLテンプレートを返します。
この時、スクリプトレット<?= =>を使えば、この中に値を埋め込むことができます。

さらにGASではSession.getActiveUser().getEmail()を使えば、現在ログイン中のユーザーのメールアドレスを取得することができます。

これを利用して、HTMLテンプレートを生成する時にテンプレート内にログイン中ユーザーメールアドレスを埋め込んでやります。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React on GASアプリ</title>
  </head>
  <body>
    <div id="current-user-email" class=" hidden"><?= currentUserEmail ?></div>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
function doGet(){
  const template = HtmlService.createTemplateFromFile("dist/index")
  template.currentUserEmail = Session.getActiveUser().getEmail()
  return template.evaluate()
}

これで、フロント側ではdocument.getElementById('current-user-email')?.innerTextを使ってログイン中ユーザーのメールアドレスを取得することができます。

一方で開発環境ではスクリプトレットの部分に値が入ることはありませんから、適当に値を設定する必要があります。

以下のようにすれば、開発環境と本番環境でcurrentUserEmailを切り替えることができます。

const currentUserEmail = import.meta.env.DEV? 'someone@example.com' : document.getElementById('current-user-email')?.innerText

開発環境でのデータ取得 AppSheet APIを利用

api/devApi.ts
const appId = "********************"
const applicationAccessKey = "V2-*********************"


const formatData = (data: any) => {
  return data.map((task: any) => ({
    ...task,
    完了フラグ: task.完了フラグ === "Y"? true:false,
    期限日: new Date(task.期限日).toLocaleDateString('sv-SE'),
    更新日: new Date(task.更新日).toLocaleDateString('sv-SE'),
  }))
}

export const getTasks = async () => {

    const query = {}

    const tableName = "タスク一覧"

    const apiUrl = `https://api.appsheet.com/api/v2/apps/${appId}/tables/${tableName}/Action?applicationAccessKey=${applicationAccessKey}`

    const response = await fetch(apiUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        Action: "Find",
        Properties: {
          Locale: "ja-JP",
        }
      }),
    })

    const data = await response.json()
    return formatData(data)
  } 

export const addTask = async (task: Task): Promise<Task[]> => {

  const tableName = "タスク一覧"

  const apiUrl = `https://api.appsheet.com/api/v2/apps/${appId}/tables/${tableName}/Action?applicationAccessKey=${applicationAccessKey}`

  const response = await fetch(apiUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      Action: "Add",
      Properties: {
        Locale: "ja-JP",
      },
      Rows:[task]
    }),
  })

  const data = await response.json()
  return formatData(data.Rows)
}

export const removeTasks = async (taskIds: string[]): Promise<Task[]> => {
  const tableName = "タスク一覧"

  const apiUrl = `https://api.appsheet.com/api/v2/apps/${appId}/tables/${tableName}/Action?applicationAccessKey=${applicationAccessKey}`

  const response = await fetch(apiUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      Action: "Delete",
      Properties: {
        Locale: "ja-JP",
      },
      Rows: taskIds.map((id: string) => ({
        id: id
      }))
    }),
  })

  const data = await response.json()

  return formatData(data.Rows)
}

export const syncTask = async (task: Task) => {
  const tableName = "タスク一覧"

  const apiUrl = `https://api.appsheet.com/api/v2/apps/${appId}/tables/${tableName}/Action?applicationAccessKey=${applicationAccessKey}`
  
  delete task.selected // 余計なプロパティがあると、更新が失敗してしまうので注意

  const response = await fetch(apiUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      Action: "Edit",
      Properties: {
        Locale: "ja-JP",
      },
      Rows:[task]
    }),
  })

  const data = await response.json()

  return formatData(data.Rows)
}

本番環境でのデータ取得

本番環境では、フロント側からgoogle.script.runでサーバー側の関数を叩いて結果を取得することになります。

TypeScript下ではgoogle.script.runでは型補完の恩恵を受けられず、何よりgoogle.script.runはwithSuccessHandlerの受け方独特で書きづらいです。

gas-clientというライブラリを使うと簡単に書くことができます。

https://www.npmjs.com/package/gas-client

import { GASClient } from 'gas-client';
const { serverFunctions, scriptHostFunctions } = new GASClient();

export const getMaterials = async (query?: Query): Promise<Material[]> => {
    const materials = await serverFunctions.get製品(query);
    return JSON.parse(materials);
}

APIの作成方法とデータ取得

私のやり方では、src/api下にdevApi.tsとprodApi.tsを設け、ここに開発環境及び本番環境で使うAPIを格納しています。

const appId = import.meta.env.VITE_APP_ID
const applicationAccessKey = import.meta.env.VITE_APPLICATION_ACCESS_KEY

export const get製品 = async (): Promise<Product[]> => {
  const tableName = "製品"
  const apiUrl = `https://api.appsheet.com/api/v2/apps/${appId}/tables/${tableName}/Action?applicationAccessKey=${applicationAccessKey}`
  const response = await fetch(apiUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      Action: "Find",
      Properties: {
        Locale: "ja-JP",
      }
    }),
  })
  const data = await response.json()
  return formatData(data)
} 
import { GASClient } from 'gas-client';
const { serverFunctions, scriptHostFunctions } = new GASClient();

export const get製品 = async (query?: Query): Promise<Product[]> => {
    try {
        const products = await serverFunctions.get製品(query);
        return products;
    } catch (error) {
        console.error('Error fetching products:', error);
        throw error;
    }
}

Reactのコンポーネントで使う際は以下のように使えます

import * as devApi from '../../api/devApi'
import * as prodApi from '../../api/prodApi'

const 製品 = import.meta.env.DEV ? await devApi.get製品() : await prodApi.get製品()

参考

App.tsx
import { useState, useEffect, useRef } from 'react'
import { getTasks, addTask, removeTasks, syncTask } from './api/devApi'

function App() {

  const [isProd, setIsProd] = useState<boolean>(false)

  const [tasks, setTasks] = useState<Task[]>([])

  const setFieldValue = (index: number, field: keyof Task, value: Task[keyof Task]) => {
    setTasks(prev => {
      const newTasks = [...prev];
      newTasks[index] = {...newTasks[index], [field]: value};

      if(field !== 'selected'){
        if (isProd) {
          syncTask(newTasks[index])
        }else{
          syncTask(newTasks[index])
        }        
      }

      return newTasks;
    });
  }

  useEffect(() => {
    try {
      google.script.run
        .withSuccessHandler((res: any)=>{
          console.log("本番環境")
          setIsProd(true)
          setTasks(res)
        })
        .getTasks()
    } catch (error) {
      console.log("開発環境",error)
      setIsProd(false)      
      readTasks()
    }
  }, [])

  const readTasks = async () => {
    const tasks = isProd ? [] : await getTasks()

    setTasks(tasks)
  }

  const modalRef = useRef<HTMLDialogElement>(null)
  const [newTask, setNewTask] = useState<Task>({
    タイトル名: '',
    完了フラグ: false,
    期限日: new Date().toLocaleDateString('sv-SE'),
    更新者メールアドレス: 'test@test.com',
    更新日: new Date().toLocaleDateString('sv-SE'),
  })

  const createTask = async () => {
    const res = isProd ? []:await addTask(newTask)
    console.log(res)

    setTasks(prev => [...prev, ...res])
  }

  const deleteTasks = async () => {
    const taskIds = tasks.filter(task => task.selected).map(task => task.id ?? '')
    const res = isProd ? []:await removeTasks(taskIds)

    const deletedTasksIds = res.map(task => task.id)

    setTasks(prev => prev.filter(task => !deletedTasksIds.includes(task.id ?? '')))
  }


  return (
    <>
      <h1 className='text-2xl font-bold'>React on GAS</h1>

      <button className='btn btn-primary' disabled={tasks.filter(task => task.selected).length === 0} onClick={()=>deleteTasks()}>削除</button>

      <button className="btn btn-primary" onClick={()=>modalRef.current?.showModal()}>新規作成</button>
      <dialog ref={modalRef} className="modal">
        <div className="modal-box">
          <h3 className="font-bold text-lg">新規作成</h3>

          <div className='form-control'>
            <label className='label'>
              <span className='label-text'>タイトル名</span>
              <input className='input input-sm' defaultValue={newTask.タイトル名} onBlur={(e) => setNewTask({...newTask, タイトル名: e.target.value})}/>
            </label>
            <label className='label'>
              <span className='label-text'>期限日</span>
              <input className='input input-sm' type='date' defaultValue={newTask.期限日} onBlur={(e) => setNewTask({...newTask, 期限日: new Date(e.target.value)})}/>            
            </label>
          </div>


          <div className="modal-action">
            <form method="dialog">
              <button className="btn btn-primary" onClick={()=>createTask()}>作成</button>
              <button className="btn btn-outline">Close</button>
            </form>
          </div>
        </div>
      </dialog>

      <h2>タスク一覧</h2>

      <table className='table table-bordered'>
        <thead>
          <tr>
            <th>選択</th>
            <th>タイトル名</th>
            <th>完了フラグ</th>
            <th>期限日</th>
          </tr>
        </thead>
        <tbody>
          {tasks.map((task,i) => (
            <tr key={task.タイトル名}>
              <td><input className='checkbox' type="checkbox" checked={task.selected ?? false} onChange={(e)=>setFieldValue(i, 'selected', e.target.checked)}/></td>
              <td><input className='input input-sm' defaultValue={task.タイトル名} onBlur={(e) => setFieldValue(i, 'タイトル名', e.target.value)}/></td>
              <td><input className=' checkbox' type="checkbox" checked={task.完了フラグ} onChange={(e)=>setFieldValue(i, '完了フラグ', e.target.checked)}/></td>
              <td><input className='input input-sm' type='date' defaultValue={task.期限日} onBlur={(e) => setFieldValue(i, '期限日', e.target.value)}/></td>
            </tr>
          ))}
        </tbody>
      </table>

      <div>{JSON.stringify(isProd)}</div>

    </>
  )
}

export default App

ビルド

singlefileプラグインの導入

ここに書いている手順に従って導入してください
https://www.npmjs.com/package/vite-plugin-singlefile

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite'  //追加
import { viteSingleFile } from "vite-plugin-singlefile"

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss(), viteSingleFile()],  //追加
})
$ npx vite build
vite v6.1.1 building for production...
transforming (16) src/api/devApi.ts/*! 🌼 daisyUI 5.0.0-beta.8 */
✓ 30 modules transformed.
rendering chunks (1)...

Inlining: index-B3KHui-B.js
Inlining: style-DxKaRvpn.css
dist/index.html  248.29 kB │ gzip: 72.51 kB

これでdist/index.htmlにビルドされました。

GASアプリの作成

server.gs
function doGet(){
  const template = HtmlService.createTemplateFromFile('dist/index')
  return template.evaluate()
}
dist/index.html
// ここに先ほどコピーしたコードを貼り付ける

GASをwebアプリでデプロイしましょう

Discussion