DEV Community

anharu2394
anharu2394

Posted on

5 4

How to Make A Todo App with Flask + Hyperapp

こんにちは、あんはるです。

Flask + HyperappでTodoアプリを作りました

Flaskとは?

Python製の軽量Webアプリケーションフレームワーク。RubyでいうSinatraみたいなもの。

Hyperappとは?

1 KBという超軽量のフロントエンドのフレームワーク。
QiitaのフロントエンドにHyperappが採用されたことから話題になる。

なぜ、Flask + Hyperappか。

Flaskは機械学習モデルをWebAPIにするのによく使われています。
今、機械学習もやっていてプロトタイプとして機械学習モデルをWebAPIにしてみようと思っているので、
Flaskを使う練習としてFlaskを使おうと思いました。

Hyperappは、HyperappでWebAPIからデータを取得したりする処理をしてみたかったので、Hyperappにしました。(普通にHyperappが好き)

こんな感じのTodoアプリを作った

todo_gif.gif

データベースと繋がっているので、ローディングしても、Todoのデータ、完了か未完了かは保持されます。
todo_gif_lo.gif

Github:
https://github.com/anharu2394/flask-hyperapp-todo_app

TodoアプリAPIの実装(バックエンド)

SQLAlchemyというORMでモデルを作る

from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy(api)
class Todo(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    value = db.Column(db.String(20), unique=True)
    completed = db.Column(db.Boolean)

    def __init__(self,value,completed):
        self.value = value
        self.completed = completed

    def __repr__(self):
        return '<Todo ' + str(self.id) + ':' + self.value + '>'
Enter fullscreen mode Exit fullscreen mode

APIはFlaskで。

import json
from flask import Flask, jsonify, request, url_for, abort, Response,render_template
from db import db


api = Flask(__name__)
api.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db'
def createTodo(value):
    create_todo = Todo(value,False)
    db.session.add(create_todo) 
    try: 
        db.session.commit()
        return create_todo
    except:  
        print("this todo is already registered todo.")
        return {"error": "this todo is already registered todo."}

def deleteTodo(todo_id):
    try:
        todo = db.session.query(Todo).filter_by(id=todo_id).first()
        db.session.delete(todo)
        db.session.commit()
        return todo
    except:
        db.session.rollback()
        print("failed to delete this todo.")
        return {"error": "failed to delete this todo."}

def updateTodo(todo_id,completed):
    try:
        todo = db.session.query(Todo).filter_by(id=todo_id).first()
        todo.completed = completed
        db.session.add(todo)
        db.session.commit()
        return todo
    except:
        db.session.rollback()
        print("failed to update this todo.")
        return {"error": "failed to update this todo."}

def getTodo():
    return Todo.query.all()  


@api.route('/')
def index():
    return render_template("index.html")

@api.route('/api')
def api_index():
            return jsonify({'message': "This is the Todo api by Anharu."})

@api.route('/api/todos', methods=['GET'])
def todos():
    todos = []
    for todo in getTodo():
        todo = {"id": todo.id, "value": todo.value,"completed": todo.completed}
        todos.append(todo)

    return jsonify({"todos":todos})

@api.route('/api/todos', methods=['POST'])
def create():
    value = request.form["value"]
    create_todo = createTodo(value)
    if isinstance(create_todo,dict):
        return jsonify({"error": create_todo["error"]})
    else:
        return jsonify({"created_todo": create_todo.value})

@api.route('/api/todos/<int:todo_id>',methods=['PUT'])
def update_completed(todo_id):
    if request.form["completed"] == "true":
        completed = True
    else:
        completed = False
    print(completed)
    update_todo = updateTodo(todo_id,completed)
    if isinstance(update_todo,dict):
        return jsonify({"error": update_todo["error"]})
    else:
        return jsonify({"updated_todo": update_todo.value})

@api.route('/api/todos/<int:todo_id>', methods=['DELETE'])
def delete(todo_id):
    delete_todo = deleteTodo(todo_id)
    if isinstance(delete_todo,dict):
        return jsonify({"error": delete_todo["error"]})
    else:
        return jsonify({"deleted_todo": delete_todo.value})

@api.errorhandler(404)
def not_found(error):
    return jsonify({'error': 'Not found'})
if __name__ == '__main__':
    api.run(host='0.0.0.0', port=3333)
Enter fullscreen mode Exit fullscreen mode

サーバー起動

python main.py
Enter fullscreen mode Exit fullscreen mode

getTodo(全Todo取得)、createTodo(Todoを追加する)、updateTodo(Todoを編集する)、deleteTodo(Todoを消す)という4つの関数を作り、
ルーティングを指定して、各関数を実行し、それの結果をjsonで返すように実装します。
APIはこのような感じです。

path HTTP method 目的
/api GET なし
/api/todos GET 全Todoの一覧を返す
/api/todos POST Todoを追加する
/api/todos/:id PUT Todoを編集する
/api/todos/:id DELETE Todoを消す

/api/todosのレスポンス例

{
  "todos": [
    {
      "completed": false,
      "id": 1,
      "value": "todo1"
    },
    {
      "completed": false,
      "id": 2,
      "value": "todo2"
    },
    {
      "completed": false,
      "id": 3,
      "value": "todo3"
    },
    {
      "completed": false,
      "id": 4,
      "value": "todo4"
    },
    {
      "completed": false,
      "id": 5,
      "value": "todo5"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

フロントエンドの実装

ディレクトリ構成

todo_app
   ├-- main.py
   ├-- index.js
   ├-- index.css
   ├── node_modules
   ├── static
   ├── templates
   |      └── index.html
   ├── package.json
   ├── webpack.config.js
   └── yarn.lock
Enter fullscreen mode Exit fullscreen mode

必要なパッケージの追加

yarn init -y
Enter fullscreen mode Exit fullscreen mode
yarn add hyperapp
Enter fullscreen mode Exit fullscreen mode
yarn add webpack webpack-cli css-loader style-loader babel-loader babel-core babel-preset-env babel-preset-react babel-preset-es2015 babel-plugin-transform-react-jsx -D
Enter fullscreen mode Exit fullscreen mode

babelの設定

{
  "presets": ["es2015"],
  "plugins": [
    [
      "transform-react-jsx",
      {
        "pragma": "h"
      }
    ]
  ]
}
Enter fullscreen mode Exit fullscreen mode

webpackの設定

module.exports = {
  mode: 'development',
  entry: "./index.js",
  output: {
    filename: "bundle.js",
    path: __dirname + "/static"     
  },
  module: {
      rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                ['env', {'modules': false}]
              ]
            }
          }
        ]
      },
      {
        test: /\.css$/,
        loaders: ['style-loader', 'css-loader?modules'],
      }
    ]
  }

}
Enter fullscreen mode Exit fullscreen mode

これで環境は整った。

メインのフロントを書いているindex.js

コードがごちゃごちゃしててすみません、、、

import { h, app } from "hyperapp"
import axios from "axios"
import styles from "./index.css"

const state = {
    todoValue: "",
    todos: [],
    is_got: false
}

const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        })
    },
    setTodo: data => state => ({todos: data}),
    addTodo: todoValue => (state,actions) => {
        console.log(todoValue)
        var params = new URLSearchParams()
        params.append("value",todoValue)
        axios.post("/api/todos",params).then(resp => {
            console.log(resp.data)
         }).catch(error=>{
            console.log(error)
        }
        )
        actions.todoEnd()
        actions.getTodo()
    },
    onInput: value => state => {
        state.todoValue = value
    },
    deleteTodo: id => (state,actions) => {
        console.log(id)
        axios.delete("/api/todos/" + id).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })
        actions.getTodo()
    },
    checkTodo: e => {
        console.log(e)
        console.log(e.path[1].id)
        const id = e.path[1].id
        console.log("/api/todos/" + id)
        var params = new URLSearchParams()
        params.append("completed",e.target.checked)
        axios.put("/api/todos/" + id,params).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })

        if (e.target.checked == true){
            document.getElementById(id).style.opacity ="0.5"
            document.getElementById("button_" + id).style.display = "inline"
        }
        else{
            document.getElementById(id).style.opacity ="1"
            document.getElementById("button_" + id).style.display = "none" 
        }
    },
    todoEnd: () => state => ({todoValue:""})
}

const Todos = () => (state, actions) => (
    <div class={styles.todos}>
        <h1>Todoリスト</h1>
        <h2>Todoを追加</h2>
        <input type="text" value={state.todoValue} oninput={e => actions.onInput(e.target.value)} onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' }  />
        <p>{state.todos.length}個のTodo</p>
        <ul>
        {
         state.todos.map((todo) => {
            if (todo.completed){
                return (
                        <li class={styles.checked} id={ todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />{todo.value}<button class={styles.checked}id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
            else{
                return (
                        <li id={todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)}/>{todo.value}<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
        })
        }
        </ul>
    </div>
)
const view = (state, actions) => {
    if (state.is_got == false){
        actions.getTodo()
        actions.todoGot()
    }
    return (<Todos />) 
}

app(state, actions, view, document.body)
Enter fullscreen mode Exit fullscreen mode

CSS

body {
}
.todos {
    margin:auto;
}
ul{
  padding: 0;
  position: relative;
  width: 50%;
}

ul li {
  color: black;
  border-left: solid 8px orange;
  background: whitesmoke;
  margin-bottom: 5px;
  line-height: 1.5;
  border-radius: 0 15px 15px 0;
  padding: 0.5em;
  list-style-type: none!important;
}
li.checked {
    opacity: 0.5;
}
button {
    display: none;
}
button.checked {
    display: inline;
}
Enter fullscreen mode Exit fullscreen mode

HTML

<html>
  <head>
    <meta charset="utf-8">
    <title>The Todo App with Flask and Hyperapp</title>
  </head>
  <body>
    <script src="/static/bundle.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

webpackでビルドして、サーバー起動

yarn run webpack; python main.py
Enter fullscreen mode Exit fullscreen mode

 機能の仕組みの解説

Todo一覧を表示する機能

const Todos = () => (state, actions) => (
    <div class={styles.todos}>
        <h1>Todoリスト</h1>
        <h2>Todoを追加</h2>
        <input type="text" value={state.todoValue} oninput={e => actions.onInput(e.target.value)} onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' }  />
        <p>{state.todos.length}個のTodo</p>
        <ul>
        {
         state.todos.map((todo) => {
            if (todo.completed){
                return (
                        <li class={styles.checked} id={ todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />{todo.value}<button class={styles.checked}id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
            else{
                return (
                        <li id={todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)}/>{todo.value}<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
        })
        }
        </ul>
    </div>
)

const view = (state, actions) => {
    if (state.is_got == false){
        actions.getTodo()
        actions.todoGot()
    }
    return (<Todos />) 
}
Enter fullscreen mode Exit fullscreen mode
const state = {
    todoValue: "",
    todos: [],
    is_got: false
}
Enter fullscreen mode Exit fullscreen mode
const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        }).catch(error => {
            console.log(error)
        })
    },
    setTodo: data => state => ({todos: data}),
    todoGot: () => state => ({is_got:true})
}

Enter fullscreen mode Exit fullscreen mode

actions.getTodo()を実行して、state.todosをセットし、その後Todosコンポーネントで表示します。
actions.getTodo()はaxiosでAPIにGETしていますが、fetchでもできます。


view の部分を

if (state.is_got == false){
    actions.getTodo()
    actions.todoGot()
}
Enter fullscreen mode Exit fullscreen mode

こうしてるのは、そのまま、

actions.getTodo()
Enter fullscreen mode Exit fullscreen mode

とすると、Stateが変更されるアクションなので、再レンダリングされ、また、actions.getTodo()が実行され、っと、無限に再レンダリングされてしまうので、is_gotというstateを作って、一回しか実行されないようにします。

Todoを追加する機能

<input type="text" value={state.todoValue} oninput={e => actions.onInput(e.target.value)} onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' }  />
Enter fullscreen mode Exit fullscreen mode
const state = {
    todoValue: ""
}
Enter fullscreen mode Exit fullscreen mode

oninput={e => actions.onInput(e.target.value)}

で、入力するやいなや、actions.onInputを実行させ、state.todoValueを更新しています。

const actions = {
    onInput: value => state => {
        state.todoValue = value
    }
}
Enter fullscreen mode Exit fullscreen mode

onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' }

Enterキーを押した時(Keyコードが13)に、actions.addTodo()を実行します。

const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        })
    },
    addTodo: todoValue => (state,actions) => {
        console.log(todoValue)
        var params = new URLSearchParams()
        params.append("value",todoValue)
        axios.post("/api/todos",params).then(resp => {
            console.log(resp.data)
         }).catch(error=>{
            console.log(error)
        }
        )
        actions.todoEnd()
        actions.getTodo()
    },
    todoEnd: () => state => ({todoValue:""})
}
Enter fullscreen mode Exit fullscreen mode

actions.addTodo()では、

/api/todos
Enter fullscreen mode Exit fullscreen mode

にPOSTし、新しいTodoを作ります。
actions.todoEnd()でstate.todoValueを空白にさせ次のTodoを入力しやすいようにします。
actions.getTodo()を実行させ、追加されたTodoも取得し表示させます。

Todoの完了未完了を設定する機能

<input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />
Enter fullscreen mode Exit fullscreen mode

チェックボックスをチェックした時(clickした時、)にactions.checkTodo()を実行します。
eは、elementの略で、その時の要素のオブジェクトを返します。

const actions = {
    checkTodo: e => {
        console.log(e)
        console.log(e.path[1].id)
        const id = e.path[1].id
        console.log("/api/todos/" + id)
        var params = new URLSearchParams()
        params.append("completed",e.target.checked)
        axios.put("/api/todos/" + id,params).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })

        if (e.target.checked == true){
            document.getElementById(id).style.opacity ="0.5"
            document.getElementById("button_" + id).style.display = "inline"
        }
        else{
            document.getElementById(id).style.opacity ="1"
            document.getElementById("button_" + id).style.display = "none" 
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

e.path[1].idから、チェックされたTodoを見つけ、e.target.checkedで、完了か未完了かを、取得し、

/api/todos/1(id)
Enter fullscreen mode Exit fullscreen mode


 
へPUTします。

その後、完了のtodoは濃さをを薄くし消去ボタンを表示させ、未完了のtodoは濃さを正常にして、消去ボタンを見えなくします。

        <ul>
        {
         state.todos.map((todo) => {
            if (todo.completed){
                return (
                        <li class={styles.checked} id={ todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />{todo.value}<button class={styles.checked}id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
            else{
                return (
                        <li id={todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)}/>{todo.value}<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
        })
        }
        </ul>
Enter fullscreen mode Exit fullscreen mode

ローディングしてもそのままの状態を保持するため、完了か未完了かで条件分岐しています。

Todoを消す機能

<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button>
Enter fullscreen mode Exit fullscreen mode

clickした時、 actions.deleteTodo()を実行します。

const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        })
    },
    deleteTodo: id => (state,actions) => {
        console.log(id)
        axios.delete("/api/todos/" + id).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })
        actions.getTodo()
    }
}
Enter fullscreen mode Exit fullscreen mode

actions.deleteTodo()では、引数のidのTodoを消去するため、

/api/todos
Enter fullscreen mode Exit fullscreen mode

へDELETEします。
そして、actions.getTodo()実行し、Todoの一覧を再取得しています。

ソースコード

Github:
https://github.com/anharu2394/flask-hyperapp-todo_app

感想

自分でAPIを書くこと(Railsだと自動でできたりする)、フロントのフレームワークでAPIを叩くことなかったのでとても楽しかったです。

FlaskではRailsのActiveRecordがない(MVCではない)ので、RailsでWebアプリを作るのとは違った感覚でした。

もちろんRails APIで書いた方が早い
ただ楽しい

Todoアプリのdbはテーブルがひとつしかないので、もう少し複雑なアプリもflask + Hyperappで作ってみたい。

Rails API + Hyperappもやってみたい

今、作りたい機械学習のモデルがあって、それをWebAPI化するのに、この経験を活かせると思います。

 ぜひ、Flask + Hyperappで簡単なWebアプリを作ってみてください!

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (0)

SurveyJS custom survey software

JavaScript Form Builder UI Component

Generate dynamic JSON-driven forms directly in your JavaScript app (Angular, React, Vue.js, jQuery) with a fully customizable drag-and-drop form builder. Easily integrate with any backend system and retain full ownership over your data, with no user or form submission limits.

Learn more

Best practices for optimal infrastructure performance with Magento

Running a Magento store? Struggling with performance bottlenecks? Join us and get actionable insights and real-world strategies to keep your store fast and reliable.

Tune in to the full event

DEV is partnering to bring live events to the community. Join us or dismiss this billboard if you're not interested. ❤️