🐈
【React on GAS】Google workspace内環境で動作する本格的なwebアプリの開発方法
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アプリ立ち上げ
こちらの記事を参考にしてください。
開発環境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プラグインの導入
ここに書いている手順に従って導入してください
$ 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