Added react-query, implemented useQuery wrapper for it, refactored Todo

example app to use new queries, removed old Wasp code from it.
This commit is contained in:
Martin Sosic 2020-08-31 14:41:49 +02:00 committed by Martin Šošić
parent f5fe865193
commit faf34a7d26
14 changed files with 144 additions and 129 deletions

View File

@ -10,6 +10,7 @@
"lodash": "^4.17.15",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-query": "^2.14.1",
"react-redux": "^7.1.3",
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.0",

View File

@ -1,6 +1,6 @@
const config = {
apiUrl: 'https://localhost:3001'
apiUrl: 'http://localhost:3001'
}
export default config

View File

@ -3,9 +3,9 @@ import axios from 'axios'
import config from '../config.js'
const {= queryFnName =} = async ({ args, context }) => {
const {= queryFnName =} = async (args) => {
try {
const response = await axios.post(config.apiUrl + '/{= queryRoute =}', { args })
const response = await axios.post(config.apiUrl + '/{= queryRoute =}', args)
return response.data
} catch (error) {
// TODO: This is a really crude error handling for now, and we should look into improving it,
@ -18,4 +18,6 @@ const {= queryFnName =} = async ({ args, context }) => {
}
}
{= queryFnName =}.useQueryKey = '{= queryRoute =}'
export default {= queryFnName =}

View File

@ -0,0 +1,18 @@
import { useQuery as rqUseQuery } from 'react-query'
export const useQuery = (queryFn, queryFnArgs, config) => {
if (typeof queryFn !== 'function') {
throw new Error('useQuery requires queryFn to be a function.')
}
if (!queryFn.useQueryKey) {
throw new Error('queryFn needs to have useQueryKey property defined.')
}
const rqResult = rqUseQuery({
queryKey: [queryFn.useQueryKey, queryFnArgs],
queryFn: (_key, args) => queryFn(args),
config
})
return rqResult
}

View File

@ -14,6 +14,7 @@
},
"dependencies": {
"cookie-parser": "~1.4.4",
"cors": "^2.8.5",
"debug": "~2.6.9",
"express": "~4.16.1",
"morgan": "~1.9.1"

View File

@ -1,6 +1,7 @@
import express from 'express'
import cookieParser from 'cookie-parser'
import logger from 'morgan'
import cors from 'cors'
import indexRouter from './routes/index.js'
@ -9,6 +10,7 @@ import indexRouter from './routes/index.js'
const app = express()
app.use(cors()) // TODO: Consider configuring CORS to be more restrictive, right now it allows all CORS requests.
app.use(logger('dev'))
app.use(express.json())
app.use(express.urlencoded({ extended: false }))

View File

@ -17,7 +17,9 @@ export default handleRejection(async (req, res) => {
// JSON objects or smth.
// So for now we are just going with POST that has JSON in the body -> generated code is not
// as human-like as it should be though.
const result = await {= queryJsFnIdentifier =}({ args: req.body || {}, context: {} })
res.json({ result })
const args = req.body || {}
const context = {}
const result = await {= queryJsFnIdentifier =}(args, context)
res.json(result)
})

View File

@ -1,68 +1,94 @@
// As seen here, we can import npm packages used in code generated by Wasp, which will be clearly defined.
// In the future, we will of course also be able to specify additional packages as dependencies.
import React from 'react'
import { connect } from 'react-redux'
import React, { useState } from 'react'
// As seen here, we can import specific components/code generated by Wasp.
// These will have well defined and documented APIs and paths.
// Note that Task, NewTaskForm and TaskList are generated based on the declarations
// we made in todoApp.wasp file.
import NewTaskForm from '@wasp/entities/task/components/NewTaskForm'
import TaskList from '@wasp/entities/task/components/TaskList'
import * as taskState from '@wasp/entities/task/state.js'
import * as taskActions from '@wasp/entities/task/actions.js'
import ToggleIsDoneButton from '@wasp/components/ToggleIsDoneButton'
import DeleteDoneButton from '@wasp/components/DeleteDoneButton'
import { useQuery } from '@wasp/queries'
import getTasks from '@wasp/queries/getTasks.js'
import createTask from '@wasp/queries/createTask.js'
class Todo extends React.Component {
// TODO: prop types.
const Todo = (props) => {
const defaultNewTaskDescription = ''
isAnyTaskCompleted = () => this.props.taskList.some(t => t.isDone)
const [newTaskDescription, setNewTaskDescription] = useState(defaultNewTaskDescription)
isThereAnyTask = () => this.props.taskList.length > 0
const { data: tasks, refetch, isFetching, isError, error: tasksError } = useQuery(getTasks)
render = () => {
return (
<div className="todos">
<div className="todos__container">
<h1> Todos </h1>
const isAnyTaskCompleted = () => tasks?.some(t => t.isDone)
<div className="todos__toggleAndInput">
<ToggleIsDoneButton
disabled={!this.isThereAnyTask()}
className="todos__toggleButton"
/>
const isThereAnyTask = () => tasks?.length > 0
<NewTaskForm
className="todos__newTaskForm"
onCreate={task => this.props.addTask(task)}
submitButtonLabel={'Create new task'}
/>
</div>
{ this.isThereAnyTask() && (<>
<TaskList editable />
<div className="todos__footer">
<div className="todos__footer__itemsLeft">
{ this.props.taskList.filter(task => !task.isDone).length } items left
</div>
<div className="todos__footer__clearCompleted">
<DeleteDoneButton
className={this.isAnyTaskCompleted() ? '' : 'hidden' }
/>
</div>
</div>
</>)}
</div>
</div>
)
const createNewTask = async (description) => {
const task = { isDone: false, description }
await createTask(task)
refetch()
}
const handleNewTaskSubmit = async (event) => {
event.preventDefault()
try {
await createNewTask(newTaskDescription)
setNewTaskDescription(defaultNewTaskDescription)
} catch (err) {
console.log(err)
window.alert('Error:' + err.message)
}
}
const Tasks = (props) => {
return <div>
{ props.tasks.map((task, idx) => <Task task={task} key={idx}/>) }
</div>
}
const TasksError = (props) => {
return 'Error during fetching tasks: ' + (tasksError?.message || '')
}
const Task = (props) => {
return <div>
<input type="checkbox" checked={ props.task.isDone ? 'checked' : false } readOnly />
<span> { props.task.description } </span>
</div>
}
return (
<div className="todos">
<div className="todos__container">
<h1> Todos </h1>
<div className="todos__toggleAndInput">
<button
disabled={!isThereAnyTask()}
className="todos__toggleButton"
> </button>
<form onSubmit={handleNewTaskSubmit}>
<input type="text"
value={newTaskDescription}
onChange={e => setNewTaskDescription(e.target.value)}
/>
<input type="submit" value="Create new task"/>
</form>
</div>
{ isFetching && 'Fetching tasks...'}
{ isError && <TasksError/> }
{ isThereAnyTask() && (<>
<Tasks tasks={tasks}/>
<div className="todos__footer">
<div className="todos__footer__itemsLeft">
{ tasks.filter(t => !t.isDone).length } items left
</div>
<div className="todos__footer__clearCompleted">
<button className={isAnyTaskCompleted() ? '' : 'hidden' }> Delete completed </button>
</div>
</div>
</>)}
</div>
</div>
)
}
export default connect(state => ({
taskList: taskState.selectors.all(state)
}), {
addTask: taskActions.add
})(Todo)
export default Todo

View File

@ -0,0 +1,8 @@
import state from './state.js'
export const createTask = (task, context) => {
if (Math.random() < 0.5) {
throw new Error('Failed to create task, random error!')
}
state.tasks = [...(state.tasks || []), task]
}

View File

@ -9,7 +9,9 @@ export default class Main extends Component {
render() {
return (
<Todo/>
<>
<Todo/>
</>
)
}
}

View File

@ -1,4 +1,8 @@
import state from './state.js'
export const getTasks = async ({ args, context }) => {
return [] // TODO: This is dummy for now.
export const getTasks = async (args, context) => {
if (Math.random() < 0.5) {
throw new Error('Random error: getting tasks failed.')
}
return state.tasks || []
}

View File

@ -0,0 +1,6 @@
// This is a super simple, dummy implementation of "global" state, to be used in our examples
// until we can start using the database.
const state = {}
export default state

View File

@ -1,10 +1,3 @@
import Todo from "@ext/Todo.js" // Imports non-wasp code from external code dir (ext/).
entity Task {
isDone :: boolean,
description :: string
}
app todoApp {
title: "ToDo App"
}
@ -31,62 +24,11 @@ page Task {
component: import Task from "@ext/pages/Task"
}
entity-form<Task> NewTaskForm {
fields: {
description: {
show: true,
label: none,
placeholder: "What needs to be done?"
},
isDone: {
show: false,
defaultValue: false
}
},
submit: {
onEnter: true,
button: { show: false }
}
}
entity-list<Task> TaskList {
showHeader: false,
fields: {
description: {
// The contract for render is that user must provide a function that:
// - Receives a task as an input.
// - Returns a React Node or something that can be rendered by JSX.
// - Does not depend on any outer context.
render: {=js (task) => task.isDone ? <s>{task.description}</s> : task.description js=}
}
},
mutuallyExclusiveFilters: {
completed: {=js task => task.isDone js=},
active: {=js task => !task.isDone js=}
}
}
button ToggleIsDoneButton {
label: "✓",
onClick: toggleIsDoneAction
}
button DeleteDoneButton {
label: "Delete completed",
onClick: deleteDoneAction
}
action<Task> toggleIsDoneAction {=js
tasks => {
const areAllDone = tasks.every(t => t.isDone)
return tasks.map(t => ({ ...t, isDone: !areAllDone }))
}
js=}
action<Task> deleteDoneAction {=js
tasks => tasks.filter(t => !t.isDone)
js=}
query getTasks {
fn: import { getTasks } from "@ext/queries.js"
}
// TODO: This should be an action, not query! But we are cheating like this until we add support for actions.
query createTask {
fn: import { createTask } from "@ext/actions.js"
}

View File

@ -17,6 +17,7 @@ import qualified Generator.WebAppGenerator.Common as C
genQueries :: Wasp -> [FileDraft]
genQueries wasp = concat
[ map (genQuery wasp) (Wasp.getQueries wasp)
, [C.makeSimpleTemplateFD (C.asTmplFile [P.relfile|src/queries/index.js|]) wasp]
]
genQuery :: Wasp -> Wasp.Query.Query -> FileDraft