🐈

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

2025/02/21に公開

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

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

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

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

ここに記載しているAppSheet APIを使えば、かなり簡単にAPIを作成することができます。

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本番環境の識別方法

開発環境ではGASの外で動いているため、google.script.runは実行できません(google.script.runが定義されていないエラーとなります)
本番環境ではGASの中で動くため、google.script.runが有効になります。

これを利用して、try{...}catch(err){...}で開発環境か本番環境かを見分けてやります

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

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

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)
}

参考

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

$ 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