Coming soon Rapidly build modern web apps all by yourself
using reusable UI components written in Python and HTML
using reusable UI components written in Python and HTML
Python web framework build on top of Flask, designed for:
Filter list by typing project name in search box.
from jembe import Component
from ..jmb import jmb
from ..db import db
from ..models import Project
@jmb.page("projects")
class Projects(Component):
"""Displays searchable list of projects"""
def __init__(self, search: str = ""):
"""
Defines "search" to be a component state variable.
"Search" state variables is accessible and editable using self.state.search
"""
super().__init__()
def display(self):
self.projects = db.session.query(Project).order_by(Project.id.desc())
if self.state.search != "":
self.projects = self.projects.filter(
Project.name.ilike("%{}%".format(self.state.search))
)
return super().display()
<html>
<head>
<title>Projects</title>
</head>
<body>
<h1>Projects</h1>
<!-- Associates "search" state variable with value of input field,
forcing a component to redisplay when value changes. -->
<input jmb-model=”search” type=”text” placeholder=”Search...”/>
<ul>
{% for project in projects %}
<li>{{project.name}}</li>
{% endfor %}
</ul>
<script src="{{ url_for('jembe.static', filename='js/jembe.js') }}" defer></script>
</body>
</html>
from ..db import db
import sqlalchemy as sa
class Project(db.Model):
__tablename__ = "projects"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String(150), nullable=False, unique=True)
description = sa.Column(sa.Text)
def __str__(self) -> str:
return self.name
Adding pagination to project list.
from math import ceil
from jembe import Component
from ..jmb import jmb
from ..db import db
from ..models import Project
@jmb.page(
"projects_paginated",
Component.Config(url_query_params={"p": "page", "s": "search"})
)
class ProjectsPaginated(Component):
"""
Displays searchable and paginated list of projects.
@jmb.page decorator, configures "page" and "search" state variables
to be used as query parameters "p" and "s" when building url.
"""
page_size = 10
def __init__(self, search: str = "", page: int = 0):
"""Defines "search" and "page" state variables"""
super().__init__()
def display(self):
self.projects = db.session.query(Project).order_by(Project.id.desc())
# apply search filter
if self.state.search != "":
self.projects = self.projects.filter(
Project.name.ilike("%{}%".format(self.state.search))
)
# apply pagination manually
self.total_records = self.projects.count()
self.total_pages = ceil(self.total_records / self.page_size)
if self.state.page > self.total_pages - 1:
self.state.page = self.total_pages - 1
if self.state.page < 0:
self.state.page = 0
self.start_record_index = self.state.page * self.page_size
self.end_record_index = self.start_record_index + self.page_size
if self.end_record_index > self.total_records:
self.end_record_index = self.total_records
self.projects = self.projects[self.start_record_index : self.end_record_index]
return super().display()
<html>
<head>
<title>Projects Paginated</title>
</head>
<body>
<h1>Projects</h1>
<!-- Explicitly setting "search" state variable to input value and "page" to zero
when input event is fired (when value of input has been changed) -->
<input jmb-on:input="page=0;search=$self.value;" value="{{search}}" type="text" placeholder="Search...">
<ul>
{% for project in projects %}
<li>{{project.name}}</li>
{% endfor %}
</ul>
<nav>
<button jmb-on:click="page = page - 1" {% if page == 0 %} disabled {% endif %}>
Prev
</button>
Page {{page + 1 if total_pages > 0 else 0}} of {{total_pages}} ({{total_records}} projects)
<button jmb-on:click="page = page + 1" {% if page == total_pages - 1 or total_pages == 0 %}disabled{% endif %}>
Next
</button>
</nav>
<script src="{{ url_for('jembe.static', filename='js/jembe.js') }}" defer></script>
</body>
</html>
from ..db import db
import sqlalchemy as sa
class Project(db.Model):
__tablename__ = "projects"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String(150), nullable=False, unique=True)
description = sa.Column(sa.Text)
def __str__(self) -> str:
return self.name
Add edit component to list of projects.
from typing import Optional
from functools import cached_property
from jembe import Component, listener, action
from ..jmb import jmb
from ..db import db
from ..models import Project
from .projects_paginated import ProjectsPaginated
class ProjectEdit(Component):
"""Displays edit form and save changes to database"""
def __init__(
self, id: int, name: Optional[str] = None, description: Optional[str] = None
):
self.error = None
super().__init__()
@cached_property
def project(self):
return db.session.query(Project).get(self.state.id)
@action
def save(self):
"""
Saves changes to database.
Actions can be called directly from html,
similary to changing state variables
"""
if self.state.name != "":
self.project.name = self.state.name
self.project.description = self.state.description
db.session.commit()
self.emit("save", project=self.project)
# when an action returns False
# component will not be redisplayed even if
# state variables are changed
return False
self.error = "Name is required"
@action
def cancel(self):
self.emit("cancel")
return False
def display(self):
if self.state.name is None:
# when displaed for the first time initialise name and description
self.state.name = self.project.name
self.state.description = self.project.description
return super().display()
@jmb.page(
"projects_editable", Component.Config(components={"edit": ProjectEdit}),
)
class ProjectsEditable(ProjectsPaginated):
"""
Extends existing ProjectsPaginated component
to add support for displaying "edit" subcomponent.
@jmb.page decorators add "ProjectEdit" as "edit" subcomponent.
"""
def __init__(
self, search: str = "", page: int = 0, display_mode: Optional[str] = None
):
"""display_mode: defines if project list or edit component are displayed"""
super().__init__(search=search, page=page)
if (
display_mode is not None
and display_mode not in self._config.components.keys()
):
display_mode = None
@listener(event="_display", source="./edit")
def on_display_edit(self, event):
"""When "edit" subcomponent is displayed update display_mode"""
self.state.display_mode = "edit"
@listener(event=["cancel", "save"], source="./edit")
def on_child_save_or_cancel(self, event):
"""When "edit" subcomponent emits "save" or "cancel" update display_mode"""
self.state.display_mode = None
def display(self):
if self.state.display_mode is not None:
# no need to query database and recalculate pagination
# if only edit component is displayed
return self.render_template()
else:
return super().display()
<html>
<head>
<title>Projects Editable</title>
</head>
<body>
{% if display_mode is none %}
{% include "projects_editable/_list.html" %}
{% else %}
{{component(display_mode)}}
{% endif %}
<script src="{{ url_for('jembe.static', filename='js/jembe.js') }}" defer></script>
</body>
</html>
<h1>Projects</h1>
<input jmb-on:input="page=0;search=$self.value;" value="{{search}}" type="text" placeholder="Search...">
<ul>
{% for project in projects %}
<li>{{project.name}}</li>
{% endfor %}
</ul>
<nav>
<button jmb-on:click="page = page - 1" {% if page == 0 %} disabled {% endif %}>
Prev
</button>
Page {{page + 1 if total_pages > 0 else 0}} of {{total_pages}} ({{total_records}} projects)
<button jmb-on:click="page = page + 1" {% if page == total_pages - 1 or total_pages == 0 %}disabled{% endif %}>
Next
</button>
</nav>
<h1>Edit: {{project.name}}</h1>
<label>
Name
<!-- defer modifier dellays sending request to server and redisplaying component -->
<input type="text" value="{{name}}" jmb-on:change.defer="name=$self.value">
{% if error %}<div style="color:red;">{{error}}</div>{% endif %}
</label>
<label>
Description
<textarea jmb-model.defer="description"></textarea>
</label>
<div>
<button jmb-on:click="save()">Save</button>
<button jmb-on:click="cancel()">Cancel</button>
</div>
Build complex apps using reusable components.
Reusable components add extra configuration params.
from jembe import config, Component
from ..jmb import jmb
from ..components import CPage, CCrudListRecords, CEdit, FormBase, CView, CCreate, CDelete,
from ..db import db
from ..models import Project, Note
from wtforms import validators as v, StringField, TextAreaField, SelectField
import sqlalchemy as sa
class ProjectForm(FormBase):
"""Defines project form using wtforms library"""
name = StringField(
validators=[v.DataRequired(), v.Length(max=Project.name.type.length)]
)
description = TextAreaField()
def mount(self, component: "Component") -> "FormBase":
if isinstance(component, CView):
# disable all fields when form is used in view component
self.disable_field(self.name)
self.disable_field(self.description)
return super().mount(component)
@config(
CCrudListRecords.Config(
# database engine
db=db,
# db query to list records
query=sa.orm.Query([Project.id, Project.name]).order_by(Project.id.desc()),
# search condition to be add to query
search_filter=lambda search: Project.name.ilike("%{}%".format(search)),
# links in component header
actions=[lambda self: ("Create", self.component("create"))],
# links for every record in list
record_actions=[
lambda self, record: ("View", self.component("view", id=record.id)),
lambda self, record: ("Edit", self.component("edit", id=record.id)),
lambda self, record: ("Delete", self.component("delete", id=record.id)),
],
# subcomonents and its configuration
components={
"edit": (CEdit, CEdit.Config(db=db, model=Project, form=ProjectForm)),
"create": (CCreate, CCreate.Config(db=db, model=Project, form=ProjectForm)),
"view": (CView, CView.Config(db=db, model=Project, form=ProjectForm)),
"delete": (CDelete, CDelete.Config(db=db, model=Project)),
},
)
)
class CListProjects(CCrudListRecords):
pass
class NoteForm(FormBase):
name = StringField(
validators=[v.DataRequired(), v.Length(max=Project.name.type.length)]
)
description = TextAreaField()
project_id = SelectField("Project", coerce=int)
def mount(self, component: "Component") -> "FormBase":
if isinstance(component, CView):
self.disable_field(self.name)
self.disable_field(self.description)
self.disable_field(self.project_id)
# displays only selected project inside view component
self.project_id.choices = [
(p.id, p.name)
for p in db.session.query(Project).filter(
Project.id == self.project_id.data
)
]
else:
# add projects in select project field
self.project_id.choices = [
(p.id, p.name) for p in db.session.query(Project)
]
return super().mount(component)
@config(
CCrudListRecords.Config(
db=db,
query=(
sa.orm.Query(
[
Note.id,
Note.name,
sa.sql.func.substr(Project.name, 1, 25).label("project"),
]
)
.join(Note.project)
.order_by(Note.id.desc())
),
search_filter=lambda search: Note.name.ilike("%{}%".format(search)),
actions=[lambda self: ("Create", self.component("create"))],
record_actions=[
lambda self, record: ("View", self.component("view", id=record.id),),
lambda self, record: ("Edit", self.component("edit", id=record.id),),
lambda self, record: ("Delete", self.component("delete", id=record.id),),
],
components={
"edit": (CEdit, CEdit.Config(db=db, model=Note, form=NoteForm)),
"create": (CCreate, CCreate.Config(db=db, model=Note, form=NoteForm),),
"view": (CView, CView.Config(db=db, model=Note, form=NoteForm)),
"delete": (CDelete, CDelete.Config(db=db, model=Note)),
},
)
)
class CListNotes(CCrudListRecords):
pass
@jmb.page(
"demo_reusable",
CPage.Config(components={"projects": CListProjects, "notes": CListNotes}),
)
class DemoReusableComponents(CPage):
pass
"""
Example on how reusable component can be build.
Source code of all components are avaiable on:
https://github.com/Jembe/jembe-io-examples
"""
from functools import cached_property
from typing import Any, TYPE_CHECKING, Optional, Type, Union, Iterable, Dict, Callable, Tuple
from jembe import Component, run_only_once, action
from .form import FormBase
if TYPE_CHECKING:
from flask_sqlalchemy import SQLAlchemy, Model
from jembe import RedisplayFlag, ComponentConfig, ComponentRef
import sqlalchemy as sa
class CEdit(Component):
class Config(Component.Config):
"""To add new configuration parameters we extends Component.Config"""
default_template = "components/edit.html"
def __init__(
self,
db: "SQLAlchemy",
form: Type["FormBase"],
model: "Model",
template: Optional[Union[str, Iterable[str]]] = None,
components: Optional[Dict[str, "ComponentRef"]] = None,
inject_into_components: Optional[
Callable[["Component", "ComponentConfig"], dict]
] = None,
redisplay: Tuple["RedisplayFlag", ...] = (),
changes_url: bool = True,
url_query_params: Optional[Dict[str, str]] = None,
):
"""
New configuration parameters are added as __init__ arguments.
We are relaying on code editor to help us create this __init__ method.
"""
self.db = db
self.form = form
self.model = model
if template is None:
# when template is list,
# first avaiable template is used
template = ("", self.default_template)
super().__init__(
template=template,
components=components,
inject_into_components=inject_into_components,
redisplay=redisplay,
changes_url=changes_url,
url_query_params=url_query_params,
)
# This is to help code editor autocomplete suggestions
_config: Config
def __init__(self, id: int, form: Optional[FormBase] = None):
"""Defines "id" and "form" state variables"""
super().__init__()
@classmethod
def load_init_param(cls, config: "ComponentConfig", name: str, value: Any) -> Any:
""" "Deserielise" form recived from client"""
if name == "form":
# otherwise it will use FormBase.load_init_param
return config.form.load_init_param(value)
return super().load_init_param(config, name, value)
@cached_property
def record(self) -> "Model":
return self._config.db.session.query(self._config.model).get(self.state.id)
@run_only_once
def mount(self):
if self.state.form is None:
self.state.form = self._config.form(obj=self.record)
self.state.form.mount(self)
@action
def save(self):
self.mount()
if self.state.form.validate():
try:
self.state.form.populate_obj(self.record)
self._config.db.session.commit()
self.emit("save", record=self.record, id=self.record.id)
self.emit(
"pushNotification", message="{} saved".format(str(self.record))
)
# do not execute display after sucessfull save
return False
except sa.exc.SQLAlchemyError as error:
self.emit(
"pushNotification",
message=str(getattr(error, "orig", error)),
level="error",
)
self._config.db.session.rollback()
# force redisplay of this edit component
return True
@action
def cancel(self):
self.emit("cancel")
# force not redisplaying this edit component
return False
def display(self):
self.mount()
return super().display()
@property
def title(self):
return "Edit: {}".format(self.record)
In the 2022, as soon as documentation is written.
Sign up to be notified when Jembe launches.
I'll send only information about Jembe project, no spam. Unsubcribe at any time.