signup form

This commit is contained in:
Will McGugan
2023-08-16 16:25:19 +01:00
parent a812744292
commit 3a8f7269b9
4 changed files with 268 additions and 79 deletions

131
poetry.lock generated
View File

@@ -158,6 +158,29 @@ files = [
[package.dependencies]
typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""}
[[package]]
name = "anyio"
version = "3.7.1"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"},
{file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"},
]
[package.dependencies]
exceptiongroup = {version = "*", markers = "python_version < \"3.11\""}
idna = ">=2.8"
sniffio = ">=1.1"
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
[package.extras]
doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"]
test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
trio = ["trio (<0.22)"]
[[package]]
name = "async-timeout"
version = "4.0.3"
@@ -207,6 +230,18 @@ docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-
tests = ["attrs[tests-no-zope]", "zope-interface"]
tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
[[package]]
name = "certifi"
version = "2023.7.22"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"},
{file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"},
]
[[package]]
name = "charset-normalizer"
version = "3.2.0"
@@ -320,6 +355,21 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "exceptiongroup"
version = "1.1.3"
description = "Backport of PEP 654 (exception groups)"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"},
{file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"},
]
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "frozenlist"
version = "1.3.3"
@@ -404,6 +454,67 @@ files = [
{file = "frozenlist-1.3.3.tar.gz", hash = "sha256:58bcc55721e8a90b88332d6cd441261ebb22342e238296bb330968952fbb3a6a"},
]
[[package]]
name = "h11"
version = "0.14.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
[package.dependencies]
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
[[package]]
name = "httpcore"
version = "0.17.3"
description = "A minimal low-level HTTP client."
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"},
{file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"},
]
[package.dependencies]
anyio = ">=3.0,<5.0"
certifi = "*"
h11 = ">=0.13,<0.15"
sniffio = ">=1.0.0,<2.0.0"
[package.extras]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]]
name = "httpx"
version = "0.24.1"
description = "The next generation HTTP client."
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"},
{file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"},
]
[package.dependencies]
certifi = "*"
httpcore = ">=0.15.0,<0.18.0"
idna = "*"
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]]
name = "idna"
version = "3.4"
@@ -922,16 +1033,28 @@ typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "sniffio"
version = "1.3.0"
description = "Sniff out which async library your code is running under"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
{file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
]
[[package]]
name = "textual"
version = "0.32.0"
version = "0.33.0"
description = "Modern Text User Interface framework"
category = "main"
optional = false
python-versions = ">=3.7,<4.0"
files = [
{file = "textual-0.32.0-py3-none-any.whl", hash = "sha256:81fc68406c8806bc864e2f035874a868b4ff0cf466289dce5f7b31869949383b"},
{file = "textual-0.32.0.tar.gz", hash = "sha256:f7b6683bc18faee6fd3c47cfbad43fbf8273c5fecc12230d52ce5ee089021327"},
{file = "textual-0.33.0-py3-none-any.whl", hash = "sha256:698a093add0fd21c786232bcacde9ff427a9d5dc9ea5deca93437d9453e4ead1"},
{file = "textual-0.33.0.tar.gz", hash = "sha256:e0a98b1d9c4458c5bb4269c65d0a7f3e926f197411242d2f8faf80183d47a728"},
]
[package.dependencies]
@@ -1132,4 +1255,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
[metadata]
lock-version = "2.0"
python-versions = "^3.7"
content-hash = "770b4f563889262e7e305670bcd95cd81f574cdcccf9663b982dbee509c5b1b9"
content-hash = "82e8c50344f547993eff43d20c1d7431707725ae6a564672e5aa252a5c6272ae"

View File

@@ -18,6 +18,7 @@ pydantic = "^2.1.1"
xdg = "^6.0.0"
msgpack = "^1.0.5"
importlib-metadata = ">=4.11.3"
httpx = ">=0.24.1"
[build-system]
requires = ["poetry-core"]

View File

@@ -1,10 +1,12 @@
import re
import unicodedata
import httpx
from rich.console import RenderableType
from textual import on
from textual import work
from textual.app import App, ComposeResult
from textual import events
from textual.containers import Vertical, Container
@@ -21,9 +23,13 @@ class Form(Container):
color: $text;
width: auto;
height: auto;
background: $boost;
padding: 1 2;
background: $boost;
padding: 1 2;
layout: grid;
grid-size: 2;
grid-columns: auto 50;
grid_rows: auto;
grid-gutter: 1;
}
Form .title {
@@ -31,12 +37,14 @@ class Form(Container):
text-align: center;
text-style: bold;
margin-bottom: 2;
width: 100%;
width: 100%;
column-span: 2;
}
Form Button {
width: 100%;
margin: 2 1 0 1;
margin: 1 1 0 0;
column-span: 2;
}
LoadingIndicator {
width: 100%;
@@ -46,67 +54,43 @@ class Form(Container):
}
Form:disabled Button {
display: none;
}
Form:disabled LoadingIndicator {
display: block;
}
"""
class FormField(Container):
DEFAULT_CSS = """
FormField {
layout: horizontal;
height: auto;
width: 80;
margin: 1 0;
}
FormField Input {
border: tall transparent;
}
Form:disabled FormField Label{
opacity:0.5;
}
FormField:focus-within > Label {
color: $text;
column-span: 2;
}
FormField > Label {
width: 1fr;
margin: 1 2;
text-align: right;
color: $text-muted;
}
FormField .group PasswordStrength {
margin: 0 1 0 1;
color: $text-muted;
}
FormField > Input, FormField > Select {
width: 2fr;
}
FormField Vertical.group {
height: auto;
width: 2fr;
}
FormField Vertical.group Input {
Form Label {
width: 100%;
text-align: right;
padding: 1 0 0 1;
}
Form .group {
height: auto;
width: 100%;
}
Form .group > * {
padding: 0 1;
color: $text-muted;
}
Form Input {
border: tall transparent;
}
"""
class PasswordStrength(Widget):
DEFAULT_CSS = """
PasswordStrength {
margin: 1;
PasswordStrength {
height: 1;
padding-left: 0;
}
PasswordStrength > .password-strength--highlight {
color: $error;
@@ -147,7 +131,7 @@ class PasswordStrength(Widget):
highlight_style=highlight_style,
)
else:
return "Minimum 8 character, no common words"
return "Minimum 8 characters, no common words"
def slugify(value: str, allow_unicode=False) -> str:
@@ -170,39 +154,115 @@ def slugify(value: str, allow_unicode=False) -> str:
return re.sub(r"[-\s]+", "-", value).strip("-_")
class SignupInput(Vertical):
DEFAULT_CSS = """
SignupInput {
width: 100%;
height: auto;
}
"""
class ErrorLabel(Label):
DEFAULT_CSS = """
SignupScreen ErrorLabel {
color: $error;
text-align: left !important;
padding-left: 1;
padding-top: 0;
display: none;
}
SignupScreen ErrorLabel.-show-error {
display: block;
}
"""
class SignupScreen(Screen):
def compose(self) -> ComposeResult:
with Form():
yield Label("Textual-web Signup", classes="title")
with FormField():
yield Label("Your name*")
yield Input(id="name")
with FormField():
yield Label("Account slug*")
yield Input(id="org-name", placeholder="Identifier used in URLs")
with FormField():
yield Label("Email*")
yield Input(id="email")
with FormField():
yield Label("Password*")
yield Label("Your name*")
with SignupInput(id="name"):
yield Input()
yield ErrorLabel()
yield Label("Account slug*")
with SignupInput(id="account_slug"):
yield Input(placeholder="Identifier used in URLs")
yield ErrorLabel()
yield Label("Email*")
with SignupInput(id="email"):
yield Input()
yield ErrorLabel()
yield Label("Password*")
with SignupInput(id="password"):
with Vertical(classes="group"):
yield Input(password=True, id="password")
yield Input(password=True)
yield PasswordStrength()
with FormField():
yield Label("Password (again)")
yield Input(password=True, id="password-check")
yield ErrorLabel()
yield Label("Password (again)")
with SignupInput(id="password_check"):
yield Input(password=True)
yield ErrorLabel()
yield Button("Signup", variant="primary", id="signup")
yield LoadingIndicator()
@on(Button.Pressed, "#signup")
def signup(self):
self.disabled = True
data = {
input.id: input.query_one(Input).value for input in self.query(SignupInput)
}
self.send_signup(data)
@on(Input.Changed, "#password")
@work
async def send_signup(self, data: dict[str, str]) -> None:
try:
async with httpx.AsyncClient() as client:
response = await client.post(
"http://127.0.0.1:8080/api/signup/", data=data
)
result = response.json()
except Exception as error:
self.notify("Unable to reach server", severity="error")
self.log(error)
return
finally:
self.disabled = False
self.notify("There are errors in the form. Please try again.")
self.query_one(Form).add_class("-show-errors")
for error_label in self.query(ErrorLabel):
error_label.update("")
error_label.remove_class("-show-error")
for error in result:
if error["loc"]:
error_label = self.query_one(
f"#{error['loc'][0]} ErrorLabel", ErrorLabel
)
error_label.add_class("-show-error")
error_label.update(error["ctx"].get("error", error["msg"]))
else:
self.notify(
error["ctx"].get("error", error["msg"]), severity="error", timeout=5
)
self.app.log(result)
@on(Input.Changed, "#password Input")
def input_changed(self, event: Input.Changed):
self.query_one(PasswordStrength).password = event.input.value
@on(events.DescendantBlur, "#password-check")
@on(events.DescendantBlur, "#password_check")
def password_check(self, event: Input.Changed) -> None:
password = self.query_one("#password", Input).value
if password:
@@ -210,12 +270,14 @@ class SignupScreen(Screen):
if password != password_check:
self.notify("Passwords do not match", severity="error")
@on(events.DescendantFocus, "#org-name")
def update_org_name(self) -> None:
org_name = self.query_one("#org-name", Input).value
@on(events.DescendantFocus, "#account_slug Input")
def update_account_slug(self) -> None:
org_name = self.query_one("#account_slug Input", Input).value
if not org_name:
name = self.query_one("#name", Input).value
self.query_one("#org-name", Input).insert_text_at_cursor(slugify(name))
name = self.query_one("#name Input", Input).value
self.query_one("#account_slug Input", Input).insert_text_at_cursor(
slugify(name)
)
class SignUpApp(App):

View File

@@ -8,6 +8,7 @@ class Environment:
"""Data structure to describe the environment (dev, prod, local)."""
name: str
api_url: str
url: str
@@ -18,10 +19,12 @@ ENVIRONMENTS = {
# ),
"local": Environment(
name="local",
api_url="ws://127.0.0.1:8080/api",
url="ws://127.0.0.1:8080/app-service/",
),
"dev": Environment(
name="dev",
api_url="https://textualize-dev.io/api",
url="wss://textualize-dev.io/app-service/",
),
}