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 |
94 | ```
95 |
--------------------------------------------------------------------------------
/examples/app/meld/templates/login_form.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Sign in to your account
7 |
8 |
9 |
10 |
11 |
12 |
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 |