├── MANIFEST.in ├── tests ├── pytest.ini ├── meld_test_project │ ├── .env │ ├── requirements.txt │ ├── templates │ │ ├── index.html │ │ └── base.html │ ├── app.py │ ├── meld │ │ ├── components │ │ │ └── input_text.py │ │ └── templates │ │ │ └── input_text.html │ ├── config.py │ └── __init__.py ├── cli │ ├── expectations.py │ └── test_cli.py ├── requirements.txt ├── browser │ └── databinding │ │ ├── lazy │ │ ├── input_text_lazy.py │ │ ├── input_text_lazy.html │ │ └── test_input_text_lazy.py │ │ ├── defer │ │ ├── defer.py │ │ ├── defer.html │ │ └── test_defer.py │ │ ├── debounce │ │ ├── debounce.py │ │ ├── debounce.html │ │ └── test_debounce.py │ │ └── default │ │ ├── default.py │ │ ├── default.html │ │ └── test_default.py ├── unit │ ├── test_message.py │ ├── test_meld.py │ └── test_component.py ├── functional │ └── test_form_validation.py └── conftest.py ├── examples ├── .env ├── requirements.txt ├── app │ ├── meld │ │ ├── templates │ │ │ ├── progress_bar.html │ │ │ ├── long_running_process.html │ │ │ ├── todo.html │ │ │ └── login_form.html │ │ └── components │ │ │ ├── progress_bar.py │ │ │ ├── long_running_process.py │ │ │ ├── login_form.py │ │ │ └── todo.py │ ├── wsgi.py │ ├── templates │ │ ├── index.html │ │ └── base.html │ ├── forms.py │ └── __init__.py └── config.py ├── setup.cfg ├── flask_meld ├── __init__.py ├── tag.py ├── meld.py ├── meld_js_src │ ├── attribute.js │ ├── meld.js │ ├── utils.js │ ├── element.js │ ├── component.js │ ├── morph.js │ └── socket.io.js ├── templates.py ├── cli.py ├── message.py └── component.py ├── .gitignore ├── documentation ├── mkdocs.yml └── docs │ ├── cli.md │ ├── index.md │ ├── getting-started.md │ ├── templates.md │ ├── components.md │ └── features.md ├── .github └── FUNDING.yml ├── README.md ├── LICENSE └── setup.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include flask_meld/meld_js_src/* 2 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | live_server_scope = function 3 | -------------------------------------------------------------------------------- /examples/.env: -------------------------------------------------------------------------------- 1 | 2 | SECRET_KEY=b71c8bd630164269d274d44a4a5bbac6 3 | FLASK_ENV=development 4 | -------------------------------------------------------------------------------- /examples/requirements.txt: -------------------------------------------------------------------------------- 1 | 2 | Flask>=0.9 3 | Flask-Meld>=0.7.0 4 | python-dotenv>=0.17.0 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [options.entry_points] 2 | console_scripts = 3 | meld = flask_meld.cli:meld 4 | -------------------------------------------------------------------------------- /tests/meld_test_project/.env: -------------------------------------------------------------------------------- 1 | 2 | SECRET_KEY=0f7967ab4a910aa764f9528c32ed4bd4 3 | FLASK_ENV=development 4 | -------------------------------------------------------------------------------- /tests/meld_test_project/requirements.txt: -------------------------------------------------------------------------------- 1 | 2 | Flask>=0.9 3 | Flask-Meld>=0.7.0 4 | python-dotenv>=0.17.0 5 | -------------------------------------------------------------------------------- /tests/cli/expectations.py: -------------------------------------------------------------------------------- 1 | expected_requirements = """ 2 | Flask>=0.9 3 | Flask-Meld>=0.7.0 4 | python-dotenv>=0.17.0 5 | """ 6 | -------------------------------------------------------------------------------- /tests/meld_test_project/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | {% meld 'input_text_lazy' %}{% endblock %} 4 | -------------------------------------------------------------------------------- /flask_meld/__init__.py: -------------------------------------------------------------------------------- 1 | from .meld import Meld 2 | from .component import Component 3 | from .message import emit, listen 4 | 5 | __version__ = "0.13.1" 6 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | -e .. 2 | email_validator 3 | Flask-WTF 4 | playwright==1.17.0 5 | pytest==6.2.5 6 | pytest-playwright==0.2.2 7 | pytest-flask==1.2.0 8 | -------------------------------------------------------------------------------- /tests/meld_test_project/app.py: -------------------------------------------------------------------------------- 1 | from tests import meld_test_project 2 | app = meld_test_project.create_app() 3 | 4 | 5 | if __name__ == "__main__": 6 | app.run(port=5001) 7 | -------------------------------------------------------------------------------- /tests/browser/databinding/lazy/input_text_lazy.py: -------------------------------------------------------------------------------- 1 | from flask_meld.component import Component 2 | 3 | 4 | class InputTextLazy(Component): 5 | first_name = "" 6 | last_name = "" 7 | -------------------------------------------------------------------------------- /tests/meld_test_project/meld/components/input_text.py: -------------------------------------------------------------------------------- 1 | from flask_meld.component import Component 2 | from flask import redirect, url_for 3 | 4 | 5 | class InputText(Component): 6 | first_name = "" 7 | -------------------------------------------------------------------------------- /examples/app/meld/templates/progress_bar.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
6 |
7 | -------------------------------------------------------------------------------- /examples/app/wsgi.py: -------------------------------------------------------------------------------- 1 | 2 | from app import create_app 3 | 4 | app = create_app(config_name='production') 5 | socketio = app.socketio 6 | 7 | 8 | if __name__ == "__main__": 9 | socketio.run(app=app) 10 | -------------------------------------------------------------------------------- /tests/meld_test_project/meld/templates/input_text.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | {{ first_name }} 6 |
7 |
8 | -------------------------------------------------------------------------------- /tests/browser/databinding/defer/defer.py: -------------------------------------------------------------------------------- 1 | from flask_meld.component import Component 2 | from flask import redirect, url_for 3 | 4 | 5 | class Defer(Component): 6 | first_name = "" 7 | 8 | foo = True 9 | bar = [] 10 | baz = "" 11 | -------------------------------------------------------------------------------- /tests/browser/databinding/debounce/debounce.py: -------------------------------------------------------------------------------- 1 | from flask_meld.component import Component 2 | from flask import redirect, url_for 3 | 4 | 5 | class Debounce(Component): 6 | first_name = "" 7 | 8 | foo = True 9 | bar = [] 10 | baz = "" 11 | -------------------------------------------------------------------------------- /examples/app/meld/components/progress_bar.py: -------------------------------------------------------------------------------- 1 | from flask_meld import Component, listen 2 | 3 | 4 | class ProgressBar(Component): 5 | progress = 0 6 | 7 | @listen("progress") 8 | def set_progress(self, progress): 9 | self.progress = progress 10 | -------------------------------------------------------------------------------- /tests/browser/databinding/default/default.py: -------------------------------------------------------------------------------- 1 | from flask_meld.component import Component 2 | 3 | 4 | class Default(Component): 5 | first_name = "" 6 | description = "" 7 | foo = True 8 | 9 | bar = [] 10 | baz = "" 11 | 12 | radio = "" 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | venv 4 | *.pyc 5 | *.egg-info 6 | .eggs 7 | .idea 8 | /documentation/site/* 9 | tests/meld_test_project/meld/components 10 | tests/meld_test_project/meld/templates 11 | tests/meld_test_project/templates/index.html 12 | tests/meld_test_project/ 13 | -------------------------------------------------------------------------------- /tests/browser/databinding/lazy/input_text_lazy.html: -------------------------------------------------------------------------------- 1 |
2 |

Test databinding lazy

3 | 4 | 5 |
6 | {{ first_name }} 7 |
8 |
9 | -------------------------------------------------------------------------------- /documentation/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Flask-Meld Documentation 2 | 3 | theme: readthedocs 4 | 5 | nav: 6 | - Introduction: index.md 7 | - Getting Started: getting-started.md 8 | - Components: components.md 9 | - Templates: templates.md 10 | - Commands: cli.md 11 | - Features: features.md 12 | 13 | markdown_extensions: 14 | - toc: 15 | permalink: # 16 | -------------------------------------------------------------------------------- /examples/app/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | 4 | {% block content %} 5 |
6 |

Flask-Meld Emit Demo

7 |
8 | {% meld "todo" %} 9 |
10 | {% meld "progress_bar" %} 11 | {% meld "long_running_process" %} 12 | 13 | {% meld "login_form" %} 14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /examples/app/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, PasswordField, BooleanField 3 | from wtforms.validators import DataRequired, Email, Length 4 | 5 | 6 | class LoginForm(FlaskForm): 7 | email = StringField('Email', validators=[DataRequired(), Email()]) 8 | password = PasswordField('Password', validators=[DataRequired(), Length(5)]) 9 | show_password = BooleanField() 10 | -------------------------------------------------------------------------------- /examples/app/meld/components/long_running_process.py: -------------------------------------------------------------------------------- 1 | from flask_meld import Component, emit 2 | import time 3 | 4 | 5 | class LongRunningProcess(Component): 6 | value = 0 7 | 8 | def start(self): 9 | self.value = 0 10 | sleep_time = .05 11 | step_size = .5 12 | for count in range(int(100 / step_size)): 13 | time.sleep(sleep_time) 14 | self.value += step_size 15 | emit("progress", progress=self.value) 16 | emit("progress", progress=0) 17 | -------------------------------------------------------------------------------- /examples/config.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import secrets 4 | 5 | 6 | class Config: 7 | SECRET_KEY = os.environ.get('SECRET_KEY') or secrets.token_hex(16) 8 | 9 | 10 | class DevelopmentConfig(Config): 11 | DEBUG = True 12 | 13 | 14 | class ProductionConfig(Config): 15 | DEBUG = False 16 | 17 | 18 | class TestingConfig(Config): 19 | TESTING = True 20 | 21 | 22 | config = { 23 | 'development': DevelopmentConfig, 24 | 'testing': TestingConfig, 25 | 'production': ProductionConfig, 26 | } 27 | -------------------------------------------------------------------------------- /examples/app/meld/components/login_form.py: -------------------------------------------------------------------------------- 1 | from flask_meld.component import Component 2 | from flask import redirect, url_for, request 3 | from app.forms import LoginForm as Form 4 | 5 | 6 | class LoginForm(Component): 7 | form = Form() 8 | submitted = False 9 | 10 | def updated(self, field): 11 | self.validate(field) 12 | self.submitted = False 13 | 14 | def save(self): 15 | self.submitted = True 16 | if self.validate(): 17 | return redirect(url_for("index")) 18 | -------------------------------------------------------------------------------- /tests/meld_test_project/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Welcome to Flask-Meld 5 | 6 | 7 | 8 | {% block head_scripts %} 9 | {% endblock %} 10 | 11 | 12 | {% meld_scripts %} 13 | 14 | {% block content %} 15 | 16 | {% endblock %} 17 | 18 | {% block page_scripts %} 19 | {% endblock %} 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/meld_test_project/config.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import secrets 4 | 5 | 6 | class Config: 7 | SECRET_KEY = os.environ.get('SECRET_KEY') or secrets.token_hex(16) 8 | 9 | 10 | class DevelopmentConfig(Config): 11 | DEBUG = True 12 | 13 | 14 | class ProductionConfig(Config): 15 | DEBUG = False 16 | 17 | 18 | class TestingConfig(Config): 19 | TESTING = True 20 | 21 | 22 | config = { 23 | 'development': DevelopmentConfig, 24 | 'testing': TestingConfig, 25 | 'production': ProductionConfig, 26 | } 27 | -------------------------------------------------------------------------------- /examples/app/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from flask import Flask, render_template 3 | from config import config 4 | from flask_meld import Meld 5 | # from .db import db 6 | # from app import models 7 | 8 | # extensions 9 | meld = Meld() 10 | 11 | 12 | def create_app(config_name="development"): 13 | app = Flask(__name__) 14 | app.config.from_object(config[config_name]) 15 | # db.init_app(app) 16 | 17 | meld.init_app(app) 18 | 19 | @app.route("/") 20 | def index(): 21 | return render_template("index.html") 22 | 23 | return app 24 | -------------------------------------------------------------------------------- /tests/meld_test_project/__init__.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from flask import Flask, render_template 3 | from flask_meld import Meld 4 | from flask_socketio import SocketIO 5 | 6 | # extensions 7 | meld = Meld() 8 | 9 | 10 | def create_app(): 11 | app = Flask(__name__) 12 | 13 | app.config["SECRET_KEY"] = secrets.token_hex(16) 14 | app.config["DEBUG"] = False 15 | socketio = SocketIO(app, async_mode="threading") 16 | meld.init_app(app, socketio) 17 | 18 | @app.route("/") 19 | def index(): 20 | return render_template("index.html") 21 | 22 | return app 23 | -------------------------------------------------------------------------------- /examples/app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Welcome to Flask-Meld 5 | 6 | 7 | 8 | {% block head_scripts %} 9 | {% endblock %} 10 | 11 | 12 | {% meld_scripts %} 13 | 14 | {% block content %} 15 | 16 | {% endblock %} 17 | 18 | {% block page_scripts %} 19 | {% endblock %} 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/browser/databinding/lazy/test_input_text_lazy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import url_for 3 | 4 | 5 | @pytest.mark.usefixtures('live_server') 6 | def test_input_text(browser_client, page): 7 | page.goto(url_for('index', _external=True)) 8 | # wait for component.loaded 9 | # Click input 10 | page.click("input") 11 | # Fill input 12 | fill_text = "flask-meld input_text_lazy" 13 | page.fill("input", fill_text) 14 | page.wait_for_timeout(50) 15 | assert page.inner_text("#bound-data") == "" 16 | page.click("#input-last") 17 | page.wait_for_timeout(200) 18 | assert page.inner_text("#bound-data") == fill_text 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mikeabrahamsen] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /examples/app/meld/templates/long_running_process.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 16 |
17 | -------------------------------------------------------------------------------- /tests/browser/databinding/debounce/debounce.html: -------------------------------------------------------------------------------- 1 |
2 |

Test databinding debounce

3 | 4 |
5 |
6 | {{ first_name }} 7 |
8 |
9 | 10 | {{ foo }} 11 | 12 | 13 | 14 | 15 | 16 | {{ bar }} 17 | 18 | 19 | {{ baz }} 20 |
21 |
22 | -------------------------------------------------------------------------------- /tests/browser/databinding/defer/defer.html: -------------------------------------------------------------------------------- 1 |
2 |

Test databinding defer

3 | 4 |
5 |
6 | {{ first_name }} 7 |
8 | 9 |
10 | 11 | {{ foo }} 12 | 13 | 14 | 15 | 16 | 17 | {{ bar }} 18 | 19 | 20 | {{ baz }} 21 |
22 |
23 | -------------------------------------------------------------------------------- /examples/app/meld/components/todo.py: -------------------------------------------------------------------------------- 1 | from flask_meld.component import Component 2 | 3 | 4 | class Todo(Component): 5 | todo = "" 6 | todos = [] 7 | completed_todos = [] 8 | edit_todo_index = None 9 | updated_todo = "" 10 | 11 | def add_todo(self): 12 | if self.todo: 13 | self.todos.append(self.todo) 14 | self.todo = "" 15 | 16 | def complete_todo(self, index): 17 | todo = self.todos.pop(index) 18 | self.completed_todos.append(todo) 19 | 20 | def remove_todo(self, index): 21 | self.todos.pop(index) 22 | 23 | def set_edit_todo(self, index): 24 | self.edit_todo_index = index 25 | self.updated_todo = self.todos[index] 26 | 27 | def edit_todo(self): 28 | self.todos[self.edit_todo_index] = self.updated_todo 29 | self.edit_todo_index = None 30 | 31 | def undo(self, index): 32 | todo = self.completed_todos.pop(index) 33 | self.todos.append(todo) 34 | -------------------------------------------------------------------------------- /tests/unit/test_message.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_meld.message import parse_call_method_name, listen 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ["message_name", "expected_params"], 8 | [ 9 | ("call(hello)", ["hello"]), 10 | ("call(!)", ["!"]), 11 | ("call('hello')", ["hello"]), 12 | ("call('hello, world')", ["hello, world"]), 13 | ("call(hello, world)", ["hello", "world"]), 14 | ("call(1)", [1]), 15 | ("call(1, 2)", [1, 2]), 16 | ("call(1, 2, 'hello')", [1, 2, "hello"]), 17 | # ("call(1, 2, hello)", [1, 2, "hello"]), # should this be supported? 18 | ], 19 | ) 20 | def test_parse(message_name, expected_params): 21 | method_name, params = parse_call_method_name(message_name) 22 | assert method_name == "call" 23 | assert params == expected_params 24 | 25 | @pytest.mark.parametrize( 26 | ["event_names"], [([],), (["foo"],), (["foo", "bar"],)], 27 | ) 28 | def test_listen(event_names): 29 | listen(*event_names)(lambda: None)._meld_event_names == event_names 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask-Meld 2 | 3 | Official Website - [Flask-Meld.dev](https://www.flask-meld.dev) 4 | 5 | Project inspiration (outdated examples) - [Ditch Javascript Frameworks For Pure Python Joy](https://michaelabrahamsen.com/posts/flask-meld-ditch-javascript-frameworks-for-pure-python-joy/) 6 | 7 | Join the community on Discord - https://discord.gg/DMgSwwdahN 8 | 9 | Meld is a framework for Flask to meld your frontend and backend code. What does 10 | that mean? It means you can enjoy writing dynamic user interfaces in pure Python. 11 | 12 | Less context switching. 13 | No need to write javascript. 14 | More fun! 15 | 16 | # Flask-Meld Developer information 17 | 18 | ## Tests 19 | 20 | ### Installing test requirements 21 | 22 | ```sh 23 | pip install -r tests/requirements.txt 24 | playwright install 25 | ``` 26 | 27 | 28 | ### Run with browser tests 29 | 30 | ```sh 31 | # run tests 32 | pytest 33 | 34 | # to watch the browser tests 35 | pytest --headed 36 | ``` 37 | 38 | ### Run without browser tests 39 | 40 | ```sh 41 | pytest --ignore=tests/browser 42 | ``` 43 | -------------------------------------------------------------------------------- /tests/unit/test_meld.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask_meld.component import get_component_class 3 | 4 | 5 | def test_module_load_with_app_factory(app_factory_ctx): 6 | component_class = get_component_class("search") 7 | assert component_class.__name__ == "Search" 8 | 9 | 10 | def test_module_load_with_single_file_app(app_ctx): 11 | component_class = get_component_class("search") 12 | assert component_class.__name__ == "Search" 13 | 14 | 15 | def test_module_load_exception_with_single_file_app(app_ctx): 16 | with pytest.raises(FileNotFoundError): 17 | get_component_class("non-existant-module") 18 | 19 | 20 | def test_module_load_exception_with_app_factory(app_factory_ctx): 21 | with pytest.raises(FileNotFoundError): 22 | get_component_class("non-existant-module") 23 | 24 | 25 | def test_module_load_exception_without_user_specified_dir(app): 26 | app.config["MELD_COMPONENT_DIR"] = None 27 | with app.app_context(): 28 | with pytest.raises(FileNotFoundError): 29 | get_component_class("non-existant-module") 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Michael Abrahamsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /documentation/docs/cli.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | You've already learned how to use the command-line interface to do some things. 4 | This chapter documents all the available commands. 5 | 6 | To get help from the command-line, simply call `meld` to see the complete list of commands, 7 | then `--help` combined with any of those can give you more information. 8 | 9 | ## new 10 | 11 | ### meld new project 12 | This command will help you kickstart your new Meld project by creating 13 | a directory structure suitable for most projects. 14 | 15 | ```bash 16 | meld new project meld-example 17 | ``` 18 | 19 | will create a folder as follows: 20 | 21 | ```text 22 | meld-example 23 | ├── app 24 | │ └── __init__.py 25 | │ └── meld 26 | │ └── static 27 | │ └── templates 28 | │ └── wsgi.py 29 | ├── tests 30 | ├── config.py 31 | └── requirements.txt 32 | ``` 33 | 34 | ### meld new component 35 | This command will create a template and a component file with the given name. 36 | 37 | ```bash 38 | meld new component meld_component 39 | ``` 40 | 41 | ```text 42 | meld-example 43 | ├── app 44 | │ └── meld 45 | │ └── components 46 | │ └── meld_component.py 47 | │ └── templates 48 | │ └── meld_component.html 49 | ``` 50 | -------------------------------------------------------------------------------- /tests/browser/databinding/default/default.html: -------------------------------------------------------------------------------- 1 |
2 |

Test Databinding

3 | 4 | 5 |
6 | {{ first_name }} 7 | {{ description }} 8 | 9 |
10 | 11 | {{ foo }} 12 | 13 | 14 | 15 | 16 | 17 | {{ bar }} 18 | 19 | 20 | {{ baz }} 21 |
22 |
23 |

Radio buttons

24 | 25 | 26 | 27 | 28 | {{ radio }} 29 |
30 |
31 | -------------------------------------------------------------------------------- /documentation/docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | Flask-Meld is a library to provide server rendered templates over websockets for Flask 3 | applications. Meld gives you tools to dynamic frontend experiences without the need 4 | to write any Javascript. 5 | 6 | Instead of syncing data between the client and the server via HTTP requests, 7 | Meld uses a persistent WebSocket connection. When data is updated on the 8 | client, the data is sent to the server where the Component renders the new HTML 9 | and sends it back to the client. 10 | 11 | The page HTML is written using Jinja templates, just as you would with Flask. 12 | Meld utilizes Morphdom to intelligently update the DOM so only elements on 13 | the page that have been changed will be updated. This gives a fast, smooth, 14 | reactive experience for the user with server-side rendered templates. 15 | 16 | 17 | ## Installation 18 | 19 | Install Flask-Meld in your virtual environment. 20 | 21 | ```bash 22 | pip install flask-meld 23 | ``` 24 | 25 | Meld uses the [Application Factory](https://flask.palletsprojects.com/en/2.0.x/tutorial/factory/) 26 | approach to structuring an application and gives the user a CLI tool to get 27 | their project setup quickly by automating much of the boilerplate code for 28 | you. 29 | 30 | ## Updating Flask-Meld 31 | 32 | ```bash 33 | pip install --upgrade flask-meld 34 | ``` 35 | -------------------------------------------------------------------------------- /documentation/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Project setup 4 | 5 | ### Create a new project 6 | First, let's create our new project, let's call it `meld-example`: 7 | 8 | ```bash 9 | meld new project meld-example 10 | ``` 11 | 12 | This will create the `meld-example` directory with the following content: 13 | 14 | ```text 15 | meld-example 16 | ├── app 17 | │ └── __init__.py 18 | │ └── meld 19 | │ │ └── components 20 | │ │ └── templates 21 | │ └── static 22 | │ └── templates 23 | │ └── wsgi.py 24 | ├── tests 25 | ├── config.py 26 | └── requirements.txt 27 | ``` 28 | 29 | ### Use Meld in an existing project 30 | Meld can be added to an existing application by completing the following steps: 31 | 32 | - Import Meld into your application with `from flask_meld import Meld` 33 | - Initialize the Meld extension. 34 | - If you are using the Application Factory pattern, this means adding 35 | `meld = Meld()` and `meld.init_app(app)` in your `__init__.py` file. 36 | - If using a single `app.py` instead of using the `init_app` you can simply 37 | initialize Meld by using `Meld(app) 38 | - Add `{% meld_scripts %}` in the `body` of your base HTML template 39 | - Use the socketio server to serve your application with `socketio.run(app)` or to 40 | specify a port and debugging use `socketio.run(app=app, port=5000, debug=True)` 41 | 42 | -------------------------------------------------------------------------------- /tests/browser/databinding/debounce/test_debounce.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import url_for 3 | 4 | 5 | @pytest.mark.usefixtures('live_server') 6 | def test_input_debounce(browser_client, page): 7 | page.goto(url_for('index', _external=True)) 8 | # Click input 9 | page.click("input") 10 | # Fill input 11 | page.fill("input", "flask-debounce test") 12 | assert page.inner_text('#bound-data') == '' 13 | page.wait_for_timeout(100) 14 | assert page.inner_text('#bound-data') == '' 15 | page.wait_for_timeout(200) 16 | assert page.inner_text('#bound-data') == 'flask-debounce test' 17 | 18 | 19 | @pytest.mark.usefixtures('live_server') 20 | def test_checkbox_debounce(browser_client, page): 21 | page.goto(url_for('index', _external=True)) 22 | foo_id = "#foo-id" 23 | foo = page.locator("#foo-id") 24 | 25 | assert page.inner_text("#bound-foo") == 'True' 26 | assert foo.is_checked() 27 | 28 | page.uncheck(foo_id) 29 | page.wait_for_timeout(200) 30 | assert page.inner_text("#bound-foo") == 'True' 31 | page.wait_for_timeout(200) 32 | assert foo.is_checked() is False 33 | assert page.inner_text("#bound-foo") == 'False' 34 | 35 | # test_multiple_checkboxes 36 | page.check("#bar-a") 37 | page.wait_for_timeout(200) 38 | assert page.inner_text("#bound-bar") == "[]" 39 | page.wait_for_timeout(200) 40 | assert page.inner_text("#bound-bar") == "['q']" 41 | page.check("#bar-b") 42 | assert page.inner_text("#bound-bar") == "['q']" 43 | page.wait_for_timeout(300) 44 | assert page.inner_text("#bound-bar") == "['q', 'v']" 45 | 46 | # test checkbox with int value 47 | page.check("#baz-id") 48 | page.wait_for_timeout(100) 49 | assert page.inner_text("#bound-baz") == "" 50 | page.wait_for_timeout(100) 51 | assert page.inner_text("#bound-baz") == "2" 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask-Meld 3 | -------------- 4 | A way to meld your frontend and backend code 5 | """ 6 | import pathlib 7 | from setuptools import setup 8 | 9 | with open("flask_meld/__init__.py", "r") as f: 10 | version = [ 11 | line.split(" = ")[1].strip() 12 | for line in f.readlines() 13 | if line.startswith("__version__") 14 | ][0].strip('"') 15 | 16 | HERE = pathlib.Path(__file__).parent 17 | README = (HERE / "README.md").read_text() 18 | 19 | setup( 20 | name="Flask-Meld", 21 | version=version, 22 | url="http://github.com/mikeabrahamsen/Flask-Meld/", 23 | license="MIT", 24 | author="Michael Abrahamsen", 25 | author_email="mail@michaelabrahamsen.com", 26 | description="Meld is a framework for Flask that allows you to create dynamic user interfaces using Python and the Jinja2 templating engine.", 27 | long_description=README, 28 | long_description_content_type="text/markdown", 29 | packages=["flask_meld"], 30 | zip_safe=False, 31 | include_package_data=True, 32 | platforms="any", 33 | install_requires=[ 34 | "Flask>=0.9", 35 | "beautifulsoup4>=4", 36 | "orjson>=3.4.6", 37 | "flask-socketio>=5", 38 | "gevent-websocket>=0.10.1", 39 | "jinja2-simple-tags==0.3.1", 40 | "click==7.1.2" 41 | ], 42 | tests_require=["pytest"], 43 | test_suite="tests", 44 | classifiers=[ 45 | "Environment :: Web Environment", 46 | "Intended Audience :: Developers", 47 | "License :: OSI Approved :: MIT License", 48 | "Operating System :: OS Independent", 49 | "Programming Language :: Python", 50 | "Programming Language :: Python :: 3", 51 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 52 | "Topic :: Software Development :: Libraries :: Python Modules", 53 | ], 54 | ) 55 | -------------------------------------------------------------------------------- /flask_meld/tag.py: -------------------------------------------------------------------------------- 1 | from jinja2 import nodes 2 | from jinja2.ext import Extension 3 | from .component import get_component_class 4 | 5 | from jinja2_simple_tags import StandaloneTag 6 | 7 | 8 | class MeldTag(StandaloneTag): 9 | tags = {"meld"} 10 | 11 | def render(self, component_name, **kwargs): 12 | mn = MeldNode(component_name) 13 | return mn.render(**kwargs) 14 | 15 | 16 | class MeldScriptsTag(Extension): 17 | """ 18 | Create a {% meld_scripts %} tag. 19 | Used to add the necessary js files to init meld 20 | """ 21 | 22 | tags = {"meld_scripts"} 23 | 24 | def parse(self, parser): 25 | lineno = parser.stream.expect("name:meld_scripts").lineno 26 | 27 | call = self.call_method("_render", lineno=lineno) 28 | return nodes.Output([nodes.MarkSafe(call)]).set_lineno(lineno) 29 | 30 | def _render(self): 31 | files = ["morphdom-umd.js", "socket.io.js"] 32 | msg_url = "message" 33 | base_js_url = "/meld_js_src" 34 | scripts = "" 35 | for f in files: 36 | url = f"{base_js_url}/{f}" 37 | scripts += f'' 38 | 39 | meld_url = f"{base_js_url}/meld.js" 40 | meld_import = f'import {{Meld}} from "{meld_url}";' 41 | scripts += f'' 42 | scripts += ( 43 | '' 45 | ) 46 | 47 | return scripts 48 | 49 | 50 | class MeldNode: 51 | def __init__(self, component): 52 | self.component_name = component 53 | 54 | def render(self, **kwargs): 55 | Component = get_component_class(self.component_name) 56 | component = Component(**kwargs) 57 | rendered_component = component.render(self.component_name) 58 | 59 | return rendered_component 60 | -------------------------------------------------------------------------------- /tests/browser/databinding/defer/test_defer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import url_for 3 | 4 | 5 | @pytest.mark.usefixtures('live_server') 6 | def test_input_defer(browser_client, page): 7 | page.goto(url_for('index', _external=True)) 8 | # Click input 9 | page.click("input") 10 | # Fill input 11 | page.fill("input", "flask-defer test") 12 | assert page.inner_text('#bound-data-defer') == '' 13 | page.click("#button") 14 | page.wait_for_timeout(100) 15 | assert page.inner_text('#bound-data-defer') == 'flask-defer test' 16 | 17 | 18 | @pytest.mark.usefixtures('live_server') 19 | def test_checkbox_defer(browser_client, page): 20 | page.goto(url_for('index', _external=True)) 21 | foo_id = "#foo-id" 22 | foo = page.locator("#foo-id") 23 | 24 | assert page.inner_text("#bound-foo") == 'True' 25 | assert foo.is_checked() 26 | 27 | page.uncheck(foo_id) 28 | assert page.inner_text("#bound-foo") == 'True' 29 | page.click("#button") 30 | page.wait_for_timeout(200) 31 | assert foo.is_checked() is False 32 | assert page.inner_text("#bound-foo") == 'False' 33 | page.click("#button") 34 | 35 | # test_multiple_checkboxes 36 | page.check("#bar-a") 37 | assert page.inner_text("#bound-bar") == "[]" 38 | page.click("#button") 39 | page.wait_for_timeout(200) 40 | assert page.inner_text("#bound-bar") == "['q']" 41 | page.check("#bar-b") 42 | page.click("#button") 43 | page.wait_for_timeout(200) 44 | assert page.inner_text("#bound-bar") == "['q', 'v']" 45 | 46 | # test checkbox with int value 47 | page.check("#baz-id") 48 | page.click("#button") 49 | page.wait_for_timeout(50) 50 | assert page.inner_text("#bound-baz") == "" 51 | page.wait_for_timeout(300) 52 | assert page.inner_text("#bound-baz") == "2" 53 | 54 | # test multiple checkboxes in same request 55 | page.check("#bar-a") 56 | page.check("#bar-b") 57 | page.check("#bar-c") 58 | page.click("#button") 59 | page.wait_for_timeout(200) 60 | assert page.inner_text("#bound-bar") == "['q', 'v', 'c']" 61 | -------------------------------------------------------------------------------- /flask_meld/meld.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import jinja2 5 | import pkg_resources 6 | from flask import send_from_directory 7 | from flask_socketio import SocketIO 8 | 9 | from .message import process_init, process_message 10 | from .tag import MeldScriptsTag, MeldTag 11 | 12 | 13 | class Meld: 14 | def __init__(self, app=None, socketio=None, **kwargs): 15 | self.app = app 16 | 17 | if app is not None: 18 | self.init_app(app, socketio=socketio, **kwargs) 19 | 20 | def send_static_file(self, filename): 21 | """Send a static file from the flask-meld js directory.""" 22 | directory = Path(pkg_resources.resource_filename('flask_meld', 'meld_js_src')) 23 | return send_from_directory(directory, filename) 24 | 25 | def init_app(self, app, socketio=None, **kwargs): 26 | app.jinja_env.add_extension(MeldTag) 27 | app.jinja_env.add_extension(MeldScriptsTag) 28 | 29 | # Load templates from template dir or app/meld/templates 30 | custom_template_loader = jinja2.ChoiceLoader([ 31 | app.jinja_loader, 32 | jinja2.FileSystemLoader(os.path.join(app.root_path, 'meld/templates')), 33 | ]) 34 | 35 | app.jinja_loader = custom_template_loader 36 | if socketio: 37 | app.socketio = socketio 38 | else: 39 | app.socketio = SocketIO(app, **kwargs) 40 | 41 | meld_dir = app.config.get("MELD_COMPONENT_DIR", None) 42 | if meld_dir: 43 | if not os.path.isabs(meld_dir): 44 | directory = os.path.abspath(os.path.join(app.root_path, meld_dir)) 45 | app.config["MELD_COMPONENT_DIR"] = directory 46 | 47 | if not app.config.get("SECRET_KEY"): 48 | raise RuntimeError( 49 | "The Flask-Meld requires the 'SECRET_KEY' config " "variable to be set" 50 | ) 51 | 52 | @app.route("/meld_js_src/") 53 | def meld_static_file(filename): 54 | return self.send_static_file(filename) 55 | 56 | @app.socketio.on("meld-message") 57 | def meld_message(message): 58 | """meldID, action, componentName""" 59 | result = process_message(message) 60 | app.socketio.emit("meld-response", result) 61 | 62 | @app.socketio.on("meld-init") 63 | def meld_init(message): 64 | return process_init(message) 65 | -------------------------------------------------------------------------------- /tests/browser/databinding/default/test_default.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import url_for 3 | 4 | 5 | @pytest.mark.usefixtures('live_server') 6 | def test_input_text(page, browser_client): 7 | page.goto(url_for('index', _external=True)) 8 | # Click input 9 | page.click("#input") 10 | # Fill input 11 | fill_text = "flask-meld input_text" 12 | page.fill("input", fill_text) 13 | page.wait_for_timeout(200) 14 | assert page.inner_text("#bound-data-input") == fill_text 15 | 16 | # make sure the text in the input does not disappear 17 | assert page.input_value("#input") == fill_text 18 | 19 | 20 | @pytest.mark.usefixtures('live_server') 21 | def test_text_area(browser_client, page): 22 | page.goto(url_for('index', _external=True)) 23 | # Click text area 24 | page.click("#text-area") 25 | # Fill input 26 | fill_text = "flask-meld textarea input" 27 | page.fill("#text-area", fill_text) 28 | page.wait_for_timeout(200) 29 | assert page.inner_text("#bound-data-textarea") == fill_text 30 | 31 | # make sure the text in the input does not disappear 32 | assert page.input_value("#text-area") == fill_text 33 | 34 | 35 | @pytest.mark.usefixtures('live_server') 36 | def test_checkbox(browser_client, page): 37 | page.goto(url_for('index', _external=True)) 38 | foo_id = "#foo-id" 39 | foo = page.locator(foo_id) 40 | 41 | assert page.inner_text("#bound-foo") == 'True' 42 | assert foo.is_checked() 43 | 44 | page.uncheck(foo_id) 45 | page.wait_for_timeout(200) 46 | 47 | assert foo.is_checked() is False 48 | assert page.inner_text("#bound-foo") == 'False' 49 | 50 | # test_multiple_checkboxes 51 | page.check("#bar-a") 52 | page.wait_for_timeout(200) 53 | page.check("#bar-b") 54 | page.wait_for_timeout(200) 55 | assert page.inner_text("#bound-bar") == "['q', 'v']" 56 | 57 | # test checkbox with int value 58 | page.check("#baz-id") 59 | page.wait_for_timeout(200) 60 | assert page.inner_text("#bound-baz") == "2" 61 | 62 | 63 | @pytest.mark.usefixtures('live_server') 64 | def test_radio_field(browser_client, page): 65 | page.goto(url_for('index', _external=True)) 66 | page.click("#html") 67 | page.wait_for_timeout(200) 68 | 69 | assert page.inner_text("#bound-radio") == 'HTML' 70 | 71 | page.click("#python") 72 | page.wait_for_timeout(200) 73 | assert page.inner_text("#bound-radio") == 'Python' 74 | -------------------------------------------------------------------------------- /examples/app/meld/templates/todo.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 | {% if todos %} 8 |
    9 | {% for todo in todos %} 10 |
  • 11 |
    12 | 13 | {% if edit_todo_index == loop.index - 1%} 14 | 17 | {% else %} 18 |
    {{todo}}
    19 | {% endif %} 20 |
    21 |
    22 | 24 |
    25 |
  • 26 | {% endfor %} 27 |
28 | {% endif %} 29 | {% if completed_todos %} 30 |
31 |
    32 | {% for todo in completed_todos %} 33 |
  • 34 | {{todo}} 35 | 37 |
38 | 39 | {% endfor %} 40 |
    41 |
42 | {% endif %} 43 | {% if not todos and not completed_todos %} 44 |

45 | You have nothing on your todo list 46 |

47 | {% endif %} 48 | 49 | 50 |
51 | 52 | -------------------------------------------------------------------------------- /flask_meld/meld_js_src/attribute.js: -------------------------------------------------------------------------------- 1 | import { contains, print } from "./utils.js"; 2 | 3 | /** 4 | * Encapsulate DOM element attribute for meld-related information. 5 | */ 6 | export class Attribute { 7 | constructor(attribute) { 8 | this.attribute = attribute; 9 | this.name = this.attribute.name; 10 | this.value = this.attribute.value; 11 | this.isMeld = false; 12 | this.isModel = false; 13 | this.isField = false; 14 | this.isPoll = false; 15 | this.isLoading = false; 16 | this.isTarget = false; 17 | this.isKey = false; 18 | this.isPK = false; 19 | this.isError = false; 20 | this.modifiers = {}; 21 | this.eventType = null; 22 | 23 | this.init(); 24 | } 25 | 26 | /** 27 | * Init the attribute. 28 | */ 29 | init() { 30 | if (this.name.startsWith("meld:")) { 31 | this.isMeld = true; 32 | 33 | // Use `contains` when there could be modifiers 34 | if (contains(this.name, ":model")) { 35 | this.isModel = true; 36 | } else if (contains(this.name, ":field")) { 37 | this.isField = true; 38 | } else if (contains(this.name, ":db")) { 39 | this.isDb = true; 40 | } else if (contains(this.name, ":poll")) { 41 | this.isPoll = true; 42 | } else if (contains(this.name, ":loading")) { 43 | this.isLoading = true; 44 | } else if (contains(this.name, ":target")) { 45 | this.isTarget = true; 46 | } else if (this.name === "meld:key") { 47 | this.isKey = true; 48 | } else if (this.name === "meld:pk") { 49 | this.isPK = true; 50 | } else if (contains(this.name, ":error:")) { 51 | this.isError = true; 52 | } else { 53 | const actionEventType = this.name 54 | .replace("meld:", "") 55 | 56 | if ( 57 | actionEventType !== "id" && 58 | actionEventType !== "name" && 59 | actionEventType !== "checksum" 60 | ) { 61 | this.eventType = actionEventType; 62 | } 63 | } 64 | 65 | let potentialModifiers = this.name; 66 | 67 | if (this.eventType) { 68 | potentialModifiers = this.eventType; 69 | } 70 | 71 | // Find modifiers and any potential arguments 72 | potentialModifiers 73 | .split(".") 74 | .slice(1) 75 | .forEach((modifier) => { 76 | const modifierArgs = modifier.split("-"); 77 | this.modifiers[modifierArgs[0]] = 78 | modifierArgs.length > 1 ? modifierArgs[1] : true; 79 | 80 | // Remove any modifier from the event type 81 | if (this.eventType) { 82 | this.eventType = this.eventType.replace(`.${modifier}`, ""); 83 | } 84 | }); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/functional/test_form_validation.py: -------------------------------------------------------------------------------- 1 | from flask_meld.component import Component 2 | from flask_wtf import FlaskForm 3 | from wtforms import Form, StringField, PasswordField, SubmitField 4 | from wtforms.validators import ValidationError, DataRequired, Email, EqualTo 5 | 6 | 7 | class RegistrationForm(Form): 8 | email = StringField(("Email"), validators=[DataRequired(), Email()]) 9 | password = PasswordField(("Password"), validators=[DataRequired()]) 10 | password_confirm = PasswordField( 11 | ("Confirm Password"), validators=[DataRequired(), EqualTo("password")] 12 | ) 13 | submit = SubmitField("Submit") 14 | 15 | 16 | class FormComponent(Component): 17 | form = RegistrationForm() 18 | 19 | 20 | def test_component_has_form(): 21 | component = FormComponent() 22 | assert component._form 23 | 24 | 25 | def test_form_is_part_of_a_meld_component(): 26 | component = FormComponent() 27 | assert "meld:model" in component._form.email.__call__() 28 | 29 | 30 | def test_set_form_data(): 31 | form_data = {"email": "test@test.com"} 32 | component = FormComponent() 33 | component._set_field_data("email", form_data["email"]) 34 | assert component._form.email.data == "test@test.com" 35 | 36 | 37 | def test_component_init_sets_form_data(): 38 | form_data = {"email": "test@test.com"} 39 | component = FormComponent(**form_data) 40 | assert component._form.email.data == "test@test.com" 41 | 42 | 43 | def test_form_validate_is_true(): 44 | form_data = { 45 | "email": "test@test.com", 46 | "password": "somepass", 47 | "password_confirm": "somepass", 48 | } 49 | component = FormComponent(**form_data) 50 | assert component.validate() 51 | 52 | 53 | def test_form_validate_has_errors_if_failed(): 54 | form_data = { 55 | "email": "test@test.com", 56 | "password": "somepass", 57 | "password_confirm": "nomatch", 58 | } 59 | component = FormComponent(**form_data) 60 | assert not component.validate() 61 | assert component._form.password_confirm.errors 62 | 63 | 64 | def test_component_has_errors_if_validation_fails(): 65 | form_data = {"email": "", "password": "somepass", "password_confirm": "nomatch"} 66 | component = FormComponent(**form_data) 67 | component.validate() 68 | assert len(component.errors) == 2 69 | 70 | 71 | def test_form_fields_are_attributes(): 72 | component = FormComponent(RegistrationForm) 73 | assert getattr(component, "email") is None 74 | 75 | 76 | def test_form_validates(): 77 | component = FormComponent(RegistrationForm) 78 | form = component._form 79 | setattr(form["email"], "data", "help") 80 | assert getattr(component, "email") is None 81 | 82 | 83 | def test_form_submit_model_is_not_set(): 84 | component = FormComponent() 85 | assert "meld:model" not in component._form.submit.__call__() 86 | -------------------------------------------------------------------------------- /flask_meld/templates.py: -------------------------------------------------------------------------------- 1 | from string import Template 2 | 3 | requirements_template = Template( 4 | """ 5 | Flask>=0.9 6 | Flask-Meld>=0.7.0 7 | python-dotenv>=0.17.0 8 | """ 9 | ) 10 | 11 | config_template = Template( 12 | """ 13 | import os 14 | import secrets 15 | 16 | 17 | class Config: 18 | SECRET_KEY = os.environ.get('SECRET_KEY') or secrets.token_hex(16) 19 | 20 | 21 | class DevelopmentConfig(Config): 22 | DEBUG = True 23 | 24 | 25 | class ProductionConfig(Config): 26 | DEBUG = False 27 | 28 | 29 | class TestingConfig(Config): 30 | TESTING = True 31 | 32 | 33 | config = { 34 | 'development': DevelopmentConfig, 35 | 'testing': TestingConfig, 36 | 'production': ProductionConfig, 37 | } 38 | """ 39 | ) 40 | 41 | init_template = Template( 42 | """ 43 | from flask import Flask, render_template 44 | from config import config 45 | from flask_meld import Meld 46 | # from .db import db 47 | # from app import models 48 | 49 | # extensions 50 | meld = Meld() 51 | 52 | 53 | def create_app(config_name="development"): 54 | app = Flask(__name__) 55 | app.config.from_object(config[config_name]) 56 | # db.init_app(app) 57 | 58 | meld.init_app(app) 59 | 60 | @app.route("/") 61 | def index(): 62 | return render_template("index.html") 63 | 64 | return app 65 | """ 66 | ) 67 | 68 | env_template = Template( 69 | """ 70 | SECRET_KEY=$secret_key 71 | FLASK_ENV=development 72 | """ 73 | ) 74 | 75 | wsgi_template = Template( 76 | """ 77 | from app import create_app 78 | 79 | app = create_app(config_name='production') 80 | socketio = app.socketio 81 | 82 | 83 | if __name__ == "__main__": 84 | socketio.run(app=app) 85 | """ 86 | ) 87 | 88 | base_html_template = Template( 89 | """ 90 | 91 | 92 | Welcome to Flask-Meld 93 | 94 | 95 | 96 | {% block head_scripts %} 97 | {% endblock %} 98 | 99 | 100 | {% meld_scripts %} 101 | 102 | {% block content %} 103 | 104 | {% endblock %} 105 | 106 | {% block page_scripts %} 107 | {% endblock %} 108 | 109 | 110 | """ 111 | ) 112 | 113 | index_html_template = Template( 114 | """ 115 | {% extends "base.html" %} 116 | 117 | {% block content %} 118 |

Flask-Meld

119 | {% endblock %} 120 | """ 121 | ) 122 | 123 | components = Template( 124 | """ 125 | from flask_meld.component import Component 126 | 127 | 128 | class $class_name(Component): 129 | example_variable = False 130 | 131 | def example_function(self): 132 | self.example_variable = not self.example_variable 133 | """ 134 | ) 135 | 136 | components_template = Template( 137 | """ 138 |
139 | 140 | 141 |
142 | """ 143 | ) 144 | -------------------------------------------------------------------------------- /tests/unit/test_component.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | from jinja2 import Template 3 | 4 | from flask_meld.component import Component 5 | from flask_meld.message import listen 6 | 7 | 8 | class ExampleComponent(Component): 9 | test_var = "test" 10 | test_var_2 = 12 11 | 12 | def test_method(self): 13 | return "method_test" 14 | 15 | 16 | def test_component_variables_are_valid(): 17 | component = ExampleComponent() 18 | expected_attributes = ["errors", "test_var", "test_var_2"] 19 | assert list(component._attributes().keys()) == expected_attributes 20 | 21 | 22 | def test_component_methods_are_valid(): 23 | component = ExampleComponent() 24 | expected_methods = ["test_method"] 25 | assert list(component._functions().keys()) == expected_methods 26 | 27 | 28 | class DirectTemplateComponent(Component): 29 | text = "" 30 | texts = [] 31 | 32 | def __init__(self, template_string: str, **kwargs): 33 | super().__init__(**kwargs) 34 | self._template = Template(template_string) 35 | 36 | def _render_template(self, template_name: str, context_variables: dict): 37 | return self._template.render(context_variables) 38 | 39 | 40 | def test_render_model_value(app): 41 | # GIVEN 42 | template = """ 43 |
44 | 45 | 46 | {{ text }} 47 |
48 | """ 49 | component = DirectTemplateComponent(template) 50 | 51 | # When 52 | app.config["SERVER_NAME"] = "localhost" 53 | with app.app_context(): 54 | component.text = "hello" 55 | rendered_html = component.render("DirectTemplateComponent") 56 | 57 | # Then 58 | soup = BeautifulSoup(rendered_html, features="html.parser") 59 | assert soup.find("span").text == "hello" 60 | 61 | 62 | def test_render_model_defer_value(app): 63 | # GIVEN 64 | template = """ 65 |
66 | 67 | 68 | {{ text }} 69 |
70 | """ 71 | component = DirectTemplateComponent(template) 72 | 73 | # When 74 | app.config["SERVER_NAME"] = "localhost" 75 | with app.app_context(): 76 | component.text = "hello" 77 | rendered_html = component.render("DirectTemplateComponent") 78 | 79 | # Then 80 | soup = BeautifulSoup(rendered_html, features="html.parser") 81 | assert soup.find("span").text == "hello" 82 | 83 | 84 | class CustomEventComponent(Component): 85 | @listen("foo") 86 | def foo(self): 87 | return "foo" 88 | 89 | @listen("foo", "bar") 90 | def bar(self): 91 | return "bar" 92 | 93 | def baz(self): 94 | return "baz" 95 | 96 | def test_listeners(): 97 | assert CustomEventComponent._listeners() == { 98 | "foo": ["foo", "bar"], 99 | "bar": ["bar"] 100 | } 101 | -------------------------------------------------------------------------------- /tests/cli/test_cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from flask_meld.component import get_component_class 3 | from flask_meld.cli import ( 4 | generate_meld_app, 5 | generate_file_with_content, 6 | generate_meld_component, 7 | ) 8 | from flask_meld.templates import requirements_template 9 | from expectations import expected_requirements 10 | 11 | 12 | def test_generate_file_content(tmpdir_factory): 13 | test_dir = tmpdir_factory.mktemp("test_file_content_generator") 14 | path = generate_file_with_content( 15 | test_dir, "requirements.txt", requirements_template.template 16 | ) 17 | with path.open("r") as f: 18 | contents = f.read() 19 | assert contents == expected_requirements 20 | 21 | 22 | def test_creates_component_dir(generate_app): 23 | # generate_app fixture creates directory structure of project 24 | expected_path = Path(generate_app / "test_project" / "app" / "meld" / "components") 25 | assert expected_path.is_dir() 26 | 27 | 28 | def test_creates_tests_dir(generate_app): 29 | expected_path = Path(generate_app / "test_project" / "tests") 30 | assert expected_path.is_dir() 31 | 32 | 33 | def test_creates_tests_static(generate_app): 34 | expected_path = Path(generate_app / "test_project" / "app" / "static" / "images") 35 | assert expected_path.is_dir() 36 | 37 | 38 | def test_creates_templates_dir(generate_app): 39 | expected_path = Path(generate_app / "test_project" / "app" / "meld" / "templates") 40 | 41 | assert expected_path.is_dir() 42 | 43 | 44 | def test_creates_config_file(generate_app): 45 | expected_path = Path(generate_app / "test_project" / "config.py") 46 | assert expected_path.exists() 47 | 48 | 49 | def test_creates_init_file(generate_app): 50 | expected_path = Path(generate_app / "test_project" / "app" / "__init__.py") 51 | assert expected_path.exists() 52 | 53 | 54 | def test_creates_wsgi_file(generate_app): 55 | expected_path = Path(generate_app / "test_project" / "app" / "wsgi.py") 56 | assert expected_path.exists() 57 | 58 | 59 | def test_creates_env_file(generate_app): 60 | expected_path = Path(generate_app / "test_project" / ".env") 61 | assert expected_path.exists() 62 | 63 | 64 | def test_creates_base_html_file(generate_app): 65 | expected_path = Path( 66 | generate_app / "test_project" / "app" / "templates" / "base.html" 67 | ) 68 | assert expected_path.exists() 69 | 70 | 71 | def test_generate_component_file_exists(app_factory_ctx, tmpdir_factory): 72 | components_path = Path( 73 | tmpdir_factory.getbasetemp() / "app_factory_project" / "app" / "meld" 74 | ) 75 | component_name = "test_one" 76 | generate_meld_component(component_name) 77 | generated_component_path = Path( 78 | components_path / "components" / f"{component_name}.py" 79 | ) 80 | component_class = get_component_class("test_one") 81 | assert component_class.__name__ == "TestOne" 82 | assert generated_component_path.exists() 83 | 84 | component_name = "test_two" 85 | generate_meld_component(component_name) 86 | generated_component_path = Path( 87 | components_path / "components" / f"{component_name}.py" 88 | ) 89 | component_class = get_component_class("test_two") 90 | assert component_class.__name__ == "TestTwo" 91 | assert generated_component_path.exists() 92 | 93 | component_name = "Test_Two" 94 | component = generate_meld_component(component_name) 95 | assert not component 96 | -------------------------------------------------------------------------------- /documentation/docs/templates.md: -------------------------------------------------------------------------------- 1 | ## Templates 2 | 3 | Here is an example for counter: 4 | 5 | ```html 6 | {# app/meld/templates/counter.html #} 7 |
8 | 9 | 10 | 11 |
12 | ``` 13 | Let's take a look at that template file in more detail. 14 | 15 | The buttons use `meld:click` to call the `add` or `subtract` function of the 16 | Counter component. 17 | The input uses `meld:model` to bind the input to the `count` property on the 18 | Counter component. 19 | 20 | Note, to avoid errors, when adding a comment to a component template use the 21 | Jinja syntax, `{# comment here #}`, rather than the HTML syntax. 22 | 23 | ### Pass data to a component 24 | 25 | You can, of course, pass data to your meld component. Meld is passing **kwargs 26 | to the render function of the *meld* templatetag, so you can pass any number of 27 | named arguments. The component is found based on the first parameter, aka name 28 | of the component, and any number of data passed afterwards. 29 | 30 | Providing a very basic component as an example to display a greeting message using 31 | the passed value for the keyword "name" in the corresponding template. 32 | 33 | ```html 34 | {# app/meld/templates/greeter.html #} 35 |
36 | Hello, {{name or "Nobody"}} 37 |
38 | ``` 39 | which can be invoked using: 40 | 41 | ```html 42 | {# app/templates/base.html #} 43 | {% meld 'greeter', name="John Doe" %} 44 | ``` 45 | 46 | ### Use passed values in a component 47 | 48 | You may want to have the ability to access a passed in value within a component. 49 | 50 | Using the same example as above, pass in a `name` to the component. 51 | 52 | ```html 53 | {# app/templates/base.html #} 54 | {% meld 'greeter', name="John Doe" %} 55 | ``` 56 | 57 | Access the `name` attribute within the component with `self.name`. 58 | 59 | ```py 60 | class Greeter(Component): 61 | 62 | def get_name(self): 63 | return self.name 64 | ``` 65 | 66 | ```html 67 |
68 | Hello, {{name}} 69 |
70 | ``` 71 | 72 | ### Modifiers 73 | 74 | Use modifiers to change how Meld handles network requests. 75 | 76 | * `lazy`: `` To prevent updates from happening on every input, you can append a lazy modifier to the end of meld:model. That will only update the component when a blur event happens. 77 | 78 | * `debounce`: `` Delay network requests for an amount of time after a keypress. Used to increase performance and sync when the user has paused typing for an amount of time. `debounce-250` will wait 250ms before it syncs with the server. The default is 150ms. 79 | 80 | * `defer`: `` Pass the search field with the next network request. Used to improve performance when realtime databinding is not necessary. 81 | 82 | * `prevent`: Use to prevent a default action. The following example uses `defer` to delay sending a network request until the form is submitted. An idea of how this can be used: instead of adding a keydown event listener to the input field to capture the press of the `enter` key, a form with `meld:submit.prevent="search"` can be used to to invoke a component's `search` function instead of the default form handler on form submission. 83 | 84 | ```html 85 |
86 | 87 | 88 | 89 | 93 |
94 | ``` 95 | -------------------------------------------------------------------------------- /examples/app/meld/templates/login_form.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | Workflow 5 |

6 | Sign in to your account 7 |

8 |
9 | 10 |
11 | 73 | 74 |
75 |
76 |
77 | -------------------------------------------------------------------------------- /flask_meld/meld_js_src/meld.js: -------------------------------------------------------------------------------- 1 | import { Component } from "./component.js"; 2 | import { Element } from "./element.js"; 3 | import { Attribute } from "./attribute.js"; 4 | import { contains, hasValue, isEmpty, sendMessage, socketio, print } from "./utils.js"; 5 | import { morph } from "./morph.js" 6 | 7 | export var Meld = (function () { 8 | var meld = {}; // contains all methods exposed publicly in the meld object 9 | var messageUrl = "meld-message"; 10 | var csrfTokenHeaderName = 'X-CSRFToken'; 11 | var data = {}; 12 | const components = {}; 13 | 14 | /* 15 | Initializes the meld object. 16 | */ 17 | meld.init = function (_messageUrl) { 18 | messageUrl = _messageUrl; 19 | 20 | socketio.on('meld-response', function(responseJson) { 21 | if (!responseJson) { 22 | return 23 | } 24 | if (responseJson.error) { 25 | console.error(responseJson.error); 26 | return 27 | } 28 | if (!components[responseJson.id]) 29 | return 30 | else if(components[responseJson.id].actionQueue.length > 0) 31 | return 32 | 33 | if (responseJson.redirect) { 34 | window.location.href = responseJson.redirect.url; 35 | } 36 | 37 | 38 | 39 | updateData(components[responseJson.id], responseJson.data); 40 | var dom = responseJson.dom; 41 | 42 | var morphdomOptions = { 43 | childrenOnly: false, 44 | getNodeKey: function (node) { 45 | // A node's unique identifier. Used to rearrange elements rather than 46 | // creating and destroying an element that already exists. 47 | if (node.attributes) { 48 | var key = node.getAttribute("meld:key") || node.id; 49 | if (key) { 50 | return key; 51 | } 52 | } 53 | }, 54 | } 55 | var componentRoot = $('[meld\\:id="' + responseJson.id + '"]'); 56 | morph(componentRoot, dom); 57 | components[responseJson.id].refreshEventListeners() 58 | }); 59 | 60 | socketio.on('meld-event', function(payload) { 61 | var event = new CustomEvent(payload.event, { detail: payload.message }) 62 | document.dispatchEvent(event) 63 | }); 64 | } 65 | 66 | function updateData(component, newData){ 67 | data = JSON.parse(newData); 68 | for (var key in data) { 69 | component.data[key] = data[key]; 70 | } 71 | } 72 | 73 | /** 74 | * Checks if a string has the search text. 75 | */ 76 | function contains(str, search) { 77 | if (!str) { 78 | return false; 79 | } 80 | 81 | return str.indexOf(search) > -1; 82 | } 83 | 84 | 85 | /* 86 | Initializes the component. 87 | */ 88 | meld.componentInit = function (args) { 89 | const component = new Component(args); 90 | components[component.id] = component; 91 | }; 92 | function toKebabCase(str) { 93 | if (!str) { 94 | return ""; 95 | } 96 | 97 | const match = str.match( 98 | /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g 99 | ); 100 | 101 | if (!match) { 102 | return str; 103 | } 104 | 105 | return match.map((x) => x.toLowerCase()).join("-"); 106 | } 107 | 108 | /* 109 | Get the CSRF token used by Django. 110 | */ 111 | function getCsrfToken() { 112 | var csrfToken = ""; 113 | var csrfElements = document.getElementsByName('csrfmiddlewaretoken'); 114 | 115 | if (csrfElements.length > 0) { 116 | csrfToken = csrfElements[0].getAttribute('value'); 117 | } 118 | 119 | if (!csrfToken) { 120 | console.error("CSRF token is missing. Do you need to add {% csrf_token %}?"); 121 | } 122 | 123 | return csrfToken; 124 | } 125 | 126 | /* 127 | Traverse the DOM looking for child elements. 128 | */ 129 | function walk(el, callback) { 130 | var walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT, null, false); 131 | 132 | while (walker.nextNode()) { 133 | // TODO: Handle sub-components 134 | callback(walker.currentNode); 135 | } 136 | } 137 | 138 | /* 139 | A simple shortcut for querySelector that everyone loves. 140 | */ 141 | function $(selector, scope) { 142 | if (scope == undefined) { 143 | scope = document; 144 | } 145 | 146 | return scope.querySelector(selector); 147 | } 148 | 149 | return meld; 150 | }()); 151 | -------------------------------------------------------------------------------- /flask_meld/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | from pathlib import Path 4 | from flask.cli import with_appcontext 5 | from flask import current_app 6 | 7 | import click 8 | 9 | from flask_meld.templates import ( 10 | base_html_template, 11 | config_template, 12 | components, 13 | components_template, 14 | env_template, 15 | index_html_template, 16 | init_template, 17 | requirements_template, 18 | wsgi_template, 19 | ) 20 | 21 | 22 | @click.group() 23 | def meld(): 24 | """Flask-Meld specific commands""" 25 | 26 | 27 | @meld.group() 28 | def new(): 29 | """Commands for new keyword""" 30 | 31 | 32 | @new.command("project") 33 | @click.argument("name") 34 | def project(name): 35 | """Create a new flask-meld app with application defaults""" 36 | click.echo(f"Creating app {name}") 37 | generate_meld_app(name) 38 | 39 | 40 | @new.command("component") 41 | @click.argument("name") 42 | @with_appcontext 43 | def component(name): 44 | """Create a new component""" 45 | click.echo(f"Creating component '{name}'.") 46 | generate_meld_component(name) 47 | 48 | 49 | def generate_meld_app(name, base_dir=None): 50 | try: 51 | if not base_dir: 52 | base_dir = Path.cwd() / name 53 | os.makedirs(base_dir / "app" / "meld" / "components") 54 | os.makedirs(base_dir / "app" / "meld" / "templates") 55 | os.makedirs(base_dir / "app" / "templates") 56 | os.makedirs(base_dir / "app" / "static" / "images") 57 | os.makedirs(base_dir / "app" / "static" / "css") 58 | os.makedirs(base_dir / "tests") 59 | generate_file_with_content( 60 | base_dir, "requirements.txt", requirements_template.template 61 | ) 62 | generate_file_with_content(base_dir, "config.py", config_template.template) 63 | generate_file_with_content(base_dir, "app/__init__.py", init_template.template) 64 | generate_file_with_content(base_dir, "app/wsgi.py", wsgi_template.template) 65 | generate_file_with_content( 66 | base_dir, "app/templates/base.html", base_html_template.template 67 | ) 68 | generate_file_with_content( 69 | base_dir, "app/templates/index.html", index_html_template.template 70 | ) 71 | 72 | generated_secret_key = secrets.token_hex(16) 73 | generate_file_with_content( 74 | base_dir, ".env", env_template.substitute(secret_key=generated_secret_key) 75 | ) 76 | except OSError: 77 | pass 78 | 79 | 80 | def generate_meld_component(name): 81 | name = name.lower() 82 | try: 83 | base_dir = Path(current_app.root_path) 84 | components_dir = base_dir / "meld" / "components" 85 | templates_dir = base_dir / "meld" / "templates" 86 | 87 | if not (os.path.exists(components_dir) and os.path.exists(templates_dir)): 88 | click.echo(f"Failed. Could not find: {components_dir} or {templates_dir}") 89 | return False 90 | 91 | name_split = name.split("_") 92 | 93 | class_name = "" 94 | for name_seq in name_split: 95 | class_name += name_seq.capitalize() 96 | 97 | component = components_dir / f"{name}.html" 98 | if os.path.exists(component): 99 | click.echo(f"Failed. Component '{name}' already exists.") 100 | return False 101 | 102 | template = templates_dir / f"{name}.html" 103 | if os.path.exists(template): 104 | click.echo(f"Failed. Template '{template}' already exists.") 105 | return False 106 | 107 | generate_file_with_content( 108 | components_dir, f"{name}.py", components.substitute(class_name=class_name) 109 | ) 110 | generate_file_with_content( 111 | templates_dir, f"{name}.html", components_template.template 112 | ) 113 | click.echo(f"Component '{name}' successfully created.") 114 | 115 | except OSError: 116 | click.echo( 117 | "Failed. Unable to write to disk. Verify you have sufficient permissions." 118 | ) 119 | return False 120 | 121 | 122 | def generate_file_with_content(path, filename, file_contents): 123 | path = Path(f"{path}/{filename}") 124 | with open(path, "w") as f: 125 | f.write(file_contents) 126 | return path 127 | 128 | 129 | if __name__ == "__main__": 130 | meld() 131 | -------------------------------------------------------------------------------- /flask_meld/message.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from werkzeug.wrappers.response import Response 3 | import functools 4 | 5 | from .component import get_component_class 6 | from flask import jsonify, current_app 7 | import orjson 8 | 9 | 10 | def process_message(message): 11 | meld_id = message["id"] 12 | component_name = message["componentName"] 13 | action_queue = message["actionQueue"] 14 | 15 | data = message["data"] 16 | Component = get_component_class(component_name) 17 | component = Component(meld_id, **data) 18 | return_data = None 19 | 20 | for action in action_queue: 21 | payload = action.get("payload", None) 22 | if "syncInput" in action["type"]: 23 | if hasattr(component, payload["name"]): 24 | setattr(component, payload["name"], payload["value"]) 25 | if component._form: 26 | field_name = payload.get("name") 27 | if field_name in component._form._fields: 28 | field = getattr(component._form, field_name) 29 | component._set_field_data(field_name, payload["value"]) 30 | component.updated(field) 31 | component.errors[field_name] = field.errors or "" 32 | else: 33 | component.updated(payload["name"]) 34 | 35 | elif "callMethod" in action["type"]: 36 | call_method_name = payload.get("name", "") 37 | method_name, params = parse_call_method_name(call_method_name) 38 | message = payload.get("message") 39 | 40 | if method_name is not None and hasattr(component, method_name): 41 | func = getattr(component, method_name) 42 | if params: 43 | return_data = func(*params) 44 | elif message: 45 | return_data = func(**message) 46 | else: 47 | return_data = func() 48 | if component._form: 49 | component._bind_form(component._attributes()) 50 | 51 | rendered_component = component.render(component_name) 52 | 53 | res = { 54 | "id": meld_id, 55 | "dom": rendered_component, 56 | "data": orjson.dumps(jsonify(component._attributes()).json).decode("utf-8"), 57 | } 58 | 59 | if type(return_data) is Response and return_data.status_code == 302: 60 | res["redirect"] = {"url": return_data.location} 61 | return res 62 | 63 | 64 | def process_init(component_name): 65 | Component = get_component_class(component_name) 66 | return Component._listeners() 67 | 68 | 69 | def parse_call_method_name(call_method_name: str): 70 | params = None 71 | method_name = call_method_name 72 | 73 | if "(" in call_method_name and call_method_name.endswith(")"): 74 | param_idx = call_method_name.index("(") 75 | params_str = call_method_name[param_idx:] 76 | 77 | # Remove the arguments from the method name 78 | method_name = call_method_name.replace(params_str, "") 79 | 80 | # Remove parenthesis 81 | params_str = params_str[1:-1] 82 | if params_str != "": 83 | try: 84 | params = ast.literal_eval("[" + params_str + "]") 85 | except (ValueError, SyntaxError): 86 | params = list(map(str.strip, params_str.split(","))) 87 | 88 | return method_name, params 89 | 90 | 91 | def listen(*event_names: str): 92 | """ 93 | Decorator to indicate that the decorated method should listen for custom events. 94 | It can be called using `flask_meld.emit`. Keyword arguments from `flask_meld.emit` 95 | will be passed as keyword arguments to the decorated method. 96 | 97 | Params: 98 | *event_names (str): One or more event names to listen for. 99 | """ 100 | def dec(func): 101 | func._meld_event_names = event_names 102 | return func 103 | return dec 104 | 105 | 106 | def emit(event_name: str, **kwargs): 107 | """ 108 | Emit a custom event which will call any Component methods with the `@listen` 109 | decorator that are listening for the given event. Keyword arguments to this 110 | function are passed as keyword arguments to each of the decorated methods. 111 | 112 | Params: 113 | event_name (str): The name of the custom event to emit. 114 | **kwargs: Arguments to be passed as keyword arguments to the listening 115 | methods. 116 | """ 117 | current_app.socketio.emit("meld-event", {"event": event_name, "message": kwargs}) 118 | -------------------------------------------------------------------------------- /flask_meld/meld_js_src/utils.js: -------------------------------------------------------------------------------- 1 | export var socketio = io(); 2 | /* 3 | Handles calling the message endpoint and merging the results into the document. 4 | */ 5 | export function sendMessage(component) { 6 | // Prevent network call when there isn't an action 7 | if (component.actionQueue.length === 0) { 8 | return; 9 | } 10 | 11 | // Prevent network call when the action queue gets repeated 12 | if (component.currentActionQueue === component.actionQueue) { 13 | return; 14 | } 15 | 16 | component.currentActionQueue = component.actionQueue; 17 | component.actionQueue = []; 18 | 19 | socketio.emit('meld-message', {'id': component.id, 'actionQueue': component.currentActionQueue, 'componentName': component.name, 'data': component.data}); 20 | } 21 | 22 | /** 23 | * Handles loading elements in the component. 24 | * @param {Component} component Component. 25 | * @param {Element} targetElement Targetted element. 26 | */ 27 | export function handleLoading(component, targetElement) { 28 | targetElement.handleLoading(); 29 | 30 | // Look at all elements with a loading attribute 31 | component.loadingEls.forEach((loadingElement) => { 32 | if (loadingElement.target) { 33 | let targetedEl = $(`#${loadingElement.target}`, component.root); 34 | 35 | if (!targetedEl) { 36 | component.keyEls.forEach((keyElement) => { 37 | if (!targetedEl && keyElement.key === loadingElement.target) { 38 | targetedEl = keyElement.el; 39 | } 40 | }); 41 | } 42 | 43 | if (targetedEl) { 44 | if (targetElement.el.isSameNode(targetedEl)) { 45 | if (loadingElement.loading.hide) { 46 | loadingElement.hide(); 47 | } else if (loadingElement.loading.show) { 48 | loadingElement.show(); 49 | } 50 | } 51 | } 52 | } else if (loadingElement.loading.hide) { 53 | loadingElement.hide(); 54 | } else if (loadingElement.loading.show) { 55 | loadingElement.show(); 56 | } 57 | }); 58 | } 59 | 60 | /* 61 | Traverse the DOM looking for child elements. 62 | */ 63 | export function walk(el, callback) { 64 | var walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT, null, false); 65 | 66 | while (walker.nextNode()) { 67 | // TODO: Handle sub-components 68 | callback(walker.currentNode); 69 | } 70 | } 71 | 72 | /* 73 | A simple shortcut for querySelector that everyone loves. 74 | */ 75 | export function $(selector, scope) { 76 | if (scope == undefined) { 77 | scope = document; 78 | } 79 | 80 | return scope.querySelector(selector); 81 | } 82 | 83 | /** 84 | * Checks if a string has the search text. 85 | */ 86 | export function contains(str, search) { 87 | if (!str) { 88 | return false; 89 | } 90 | 91 | return str.indexOf(search) > -1; 92 | } 93 | 94 | /** 95 | * Checks if an object has a value. 96 | */ 97 | export function hasValue(obj) { 98 | return !isEmpty(obj); 99 | } 100 | 101 | /** 102 | * Checks if an object is empty. Useful to check if a dictionary has a value. 103 | */ 104 | export function isEmpty(obj) { 105 | return ( 106 | typeof obj === "undefined" || 107 | obj === null || 108 | (Object.keys(obj).length === 0 && obj.constructor === Object) 109 | ); 110 | } 111 | 112 | /* 113 | Allow python print 114 | */ 115 | export function print(msg) { 116 | var args = [].slice.apply(arguments).slice(1); 117 | console.log(msg, ...args); 118 | } 119 | 120 | /** 121 | * Returns a function, that, as long as it continues to be invoked, will not 122 | * be triggered. The function will be called after it stops being called for 123 | * N milliseconds. If `immediate` is passed, trigger the function on the 124 | * leading edge, instead of the trailing. 125 | * Derived from underscore.js's implementation in https://davidwalsh.name/javascript-debounce-function. 126 | */ 127 | export function debounce(func, wait, component, immediate) { 128 | let timeout; 129 | 130 | if (typeof immediate === "undefined") { 131 | immediate = true; 132 | } 133 | 134 | return (...args) => { 135 | const context = this; 136 | 137 | const later = () => { 138 | timeout = null; 139 | if (!immediate) { 140 | if (component.activeDebouncers === 1){ 141 | component.activeDebouncers = 0; 142 | func.apply(context, args); 143 | } 144 | else{ 145 | component.activeDebouncers -= 1; 146 | } 147 | } 148 | }; 149 | 150 | const callNow = immediate && !timeout; 151 | clearTimeout(timeout); 152 | timeout = setTimeout(later, wait); 153 | 154 | if (callNow) { 155 | func.apply(context, args); 156 | } 157 | }; 158 | } 159 | -------------------------------------------------------------------------------- /documentation/docs/components.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | Components are Python classes stored in `meld/components` either within your application folder or in the base directory 4 | of your project. 5 | 6 | Combined with a Jinja template, components enable you to create dynamic content without the need to write JavaScript. 7 | 8 | The best way to start to understand how components work is to look at an example. 9 | 10 | ```py 11 | # app/meld/components/counter.py 12 | 13 | from flask_meld import Component 14 | 15 | 16 | class Counter(Component): 17 | count = 0 18 | 19 | def add(self): 20 | self.count = int(self.count) + 1 21 | 22 | def subtract(self): 23 | self.count = int(self.count) - 1 24 | ``` 25 | 26 | The class above creates a property named `count` and defines the `add` and 27 | `subtract` functions which will modify the `count` property. Combining the use of properties and functions in this way 28 | allows you to customize the behavior of your components. 29 | 30 | ```html 31 | {# app/meld/templates/counter.html #} 32 |
33 | 34 | 35 | 36 |
37 | ``` 38 | 39 | The template includes two buttons and an input field. The buttons bind to the functions using `meld:click="add"` 40 | and `meld:click:"subtract"` while the input binds to the 41 | `count` property with `meld:model="count"`. 42 | 43 | Components can be included in your Jinja templates using the `meld` tag referring to the name of your component. 44 | 45 | ```html 46 | {# app/templates/index.html #} 47 | 48 | 49 |

Counter Page

50 | {% meld 'counter' %} 51 | 52 | 53 | ``` 54 | 55 | ## Properties 56 | 57 | Components store model data for the class using `properties`. 58 | 59 | ``` 60 | class Counter(Component): 61 | count = 0 62 | ``` 63 | 64 | ## Data Binding 65 | 66 | You can bind a compenent property to an html element with `meld:model`. For instance, you can easily update a property 67 | by binding it to an `input` element. When a user types text in the input field, the property is automatically updated in 68 | the component. 69 | 70 | ``` 71 | class Person(Component): 72 | name = "" 73 | --------------------------------------------- 74 |
75 | 76 | 77 |

Hello {{ name }}

78 |
79 | ``` 80 | 81 | You can use `meld:model` on the following elements: 82 | 83 | ``` 84 | 85 | 86 | 87 |