diff --git a/form-designer/form_designer/components/field_editor.py b/form-designer/form_designer/components/field_editor.py index 13d23b7e..274b027d 100644 --- a/form-designer/form_designer/components/field_editor.py +++ b/form-designer/form_designer/components/field_editor.py @@ -1,4 +1,6 @@ """Edit Field modal.""" + +from typing import Any import reflex as rx from .. import constants, routes, utils @@ -19,7 +21,7 @@ class FieldEditorState(AppState): def _user_has_access(self): return self.form_owner_id == self.authenticated_user.id or self.is_admin - def handle_submit(self, form_data: dict[str, str]): + def handle_submit(self, form_data: dict[str, Any]): self.field.name = form_data["field_name"] self.field.type_ = form_data["type_"] self.field.required = bool(form_data.get("required")) @@ -266,20 +268,20 @@ def field_editor_modal(): def field_edit_title(): form_name = rx.cond( rx.State.form_id == "", - utils.quoted_var("New Form"), + "New Form", rx.cond( FormEditorState.form, FormEditorState.form.name, - utils.quoted_var("Unknown Form"), + "Unknown Form", ), ) field_name = rx.cond( rx.State.field_id == "", - utils.quoted_var("New Field"), + "New Field", rx.cond( FieldEditorState.field, FieldEditorState.field.name, - utils.quoted_var("Unknown Field"), + "Unknown Field", ), ) return f"{constants.TITLE} | {form_name} | {field_name}" diff --git a/form-designer/form_designer/components/field_view.py b/form-designer/form_designer/components/field_view.py index 34f3d2ed..cf1363e1 100644 --- a/form-designer/form_designer/components/field_view.py +++ b/form-designer/form_designer/components/field_view.py @@ -6,8 +6,7 @@ class OptionItemCallable: def __call__( self, *children: rx.Component, value: rx.Var[str], **props - ) -> rx.Component: - ... + ) -> rx.Component: ... def option_value_label_id(option: Option) -> rx.Component: @@ -51,9 +50,12 @@ def field_select(field: Field) -> rx.Component: def radio_item(*children: rx.Component, value: rx.Var[str], **props) -> rx.Component: - return rx.hstack( - rx.radio.item(value=value, **props), - *children, + return rx.el.label( + rx.hstack( + rx.radio.item(value=value, **props), + *children, + align="center", + ), ) @@ -132,14 +134,18 @@ def field_prompt(field: Field, show_name: bool = False): ) -def field_view(field: Field): - return rx.card( - rx.hstack( - field_prompt(field), - rx.text(rx.cond(field.required, "*", "")), - ), - rx.hstack( - field_input(field), - flex_wrap="wrap", +def field_view(field: Field, *children: rx.Component, card_props: dict | None = None): + return rx.form.field( + rx.card( + rx.hstack( + field_prompt(field), + rx.text(rx.cond(field.required, "*", "")), + ), + rx.hstack( + field_input(field), + flex_wrap="wrap", + ), + *children, + **(card_props or {}), ), ) diff --git a/form-designer/form_designer/components/form_editor.py b/form-designer/form_designer/components/form_editor.py index b5026999..edcbe6f2 100644 --- a/form-designer/form_designer/components/form_editor.py +++ b/form-designer/form_designer/components/form_editor.py @@ -1,6 +1,6 @@ import reflex as rx -from .. import constants, routes, utils +from .. import constants, routes from ..models import Field, Form from ..state import AppState from .field_view import field_input, field_prompt @@ -160,11 +160,11 @@ def form_editor(): def form_edit_title(): form_name = rx.cond( rx.State.form_id == "", - utils.quoted_var("New Form"), + "New Form", rx.cond( FormEditorState.form, FormEditorState.form.name, - utils.quoted_var("Unknown Form"), + "Unknown Form", ), ) return f"{constants.TITLE} | {form_name}" diff --git a/form-designer/form_designer/form_designer.py b/form-designer/form_designer/form_designer.py index 8c097300..38250562 100644 --- a/form-designer/form_designer/form_designer.py +++ b/form-designer/form_designer/form_designer.py @@ -3,7 +3,7 @@ import reflex_local_auth -from . import constants, routes, utils +from . import constants, routes from .components import ( FieldEditorState, FormEditorState, @@ -24,9 +24,8 @@ app = rx.App(theme=rx.theme(accent_color="blue")) app.add_page(home_page, route="/", title=constants.TITLE) -# Adding a dummy route to register the dynamic route vars. -with contextlib.suppress(ValueError): - app.add_page(lambda: rx.fragment(on_click=rx.event.noop()), route="/_dummy/[form_id]/[field_id]") +# Register the dynamic route vars. +rx.State.setup_dynamic_args(rx.app.get_route_args("/_dummy/[form_id]/[field_id]")) # Authentication via reflex-local-auth app.add_page( @@ -74,7 +73,7 @@ route=routes.FORM_ENTRY, title=rx.cond( rx.State.form_id == "", - utils.quoted_var("Unknown Form"), + "Unknown Form", FormEntryState.form.name, ), on_load=FormEntryState.load_form, diff --git a/form-designer/form_designer/pages/form_editor.py b/form-designer/form_designer/pages/form_editor.py index de1fb63e..5e8a14f0 100644 --- a/form-designer/form_designer/pages/form_editor.py +++ b/form-designer/form_designer/pages/form_editor.py @@ -4,7 +4,6 @@ from ..components import navbar, form_select, form_editor, field_editor_modal - @utils.require_login def form_editor_page() -> rx.Component: return style.layout( @@ -33,5 +32,3 @@ def form_editor_page() -> rx.Component: ), rx.logo(height="3em", margin_bottom="12px"), ) - - diff --git a/form-designer/form_designer/pages/form_entry.py b/form-designer/form_designer/pages/form_entry.py index 163409dd..8507a080 100644 --- a/form-designer/form_designer/pages/form_entry.py +++ b/form-designer/form_designer/pages/form_entry.py @@ -1,10 +1,11 @@ +from typing import Any import reflex as rx from reflex_local_auth import LocalAuthState from .. import routes, style from ..components import field_view, navbar -from ..models import FieldType, FieldValue, Form, Response +from ..models import Field, FieldType, FieldValue, Form, Response Missing = object() @@ -13,6 +14,7 @@ class FormEntryState(rx.State): form: Form = Form() client_token: str = rx.Cookie("") + missing_fields: dict[str, bool] = {} def _ensure_client_token(self): if self.client_token == "": @@ -20,6 +22,7 @@ def _ensure_client_token(self): return self.client_token def load_form(self): + self.missing_fields = {} if self.form_id != "": self.load_form_by_id(self.form_id) else: @@ -29,13 +32,14 @@ def load_form_by_id(self, id_: int): with rx.session() as session: self.form = session.get(Form, id_) - def handle_submit(self, form_data): + def handle_submit(self, form_data: dict[str, Any]): + self.missing_fields = {} response = Response( client_token=self._ensure_client_token(), form_id=self.form.id ) for field in self.form.fields: value = form_data.get(field.name, Missing) - if value is not Missing: + if value and value is not Missing: response.field_values.append( FieldValue( field_id=field.id, @@ -43,13 +47,24 @@ def handle_submit(self, form_data): ) ) elif field.type_ == FieldType.checkbox: + field_values = [] for option in field.options: key = f"{field.name}___{option.value or option.label or option.id}" value = form_data.get(key, Missing) if value is not Missing: - response.field_values.append( + field_values.append( FieldValue(field_id=field.id, value=form_data[key]) ) + if field.required and not field_values: + self.missing_fields[field.prompt or field.name] = True + elif field.required: + self.missing_fields[field.prompt or field.name] = True + if self.missing_fields: + if len(self.missing_fields) == 1: + return rx.toast( + f"Required field '{tuple(self.missing_fields)[0]}' is missing a response" + ) + return rx.toast("Multiple required fields are missing a response") with rx.session() as session: session.add(response) session.commit() @@ -66,6 +81,25 @@ def authenticated_navbar(title_suffix: str | None = None): ) +def validated_field_view(field: Field) -> rx.Component: + return field_view( + field, + rx.form.message( + "This field is required.", + match="valueMissing", + force_match=FormEntryState.missing_fields[field.name], + color=rx.color("tomato", 10), + ), + card_props={ + "--base-card-surface-box-shadow": rx.cond( + FormEntryState.missing_fields[field.name], + f"0 0 0 1px {rx.color('tomato', 10)}", + "inherit", + ), + }, + ) + + def form_entry_page(): return style.layout( authenticated_navbar(title_suffix=f"Preview {FormEntryState.form.id}"), @@ -74,7 +108,7 @@ def form_entry_page(): rx.center(rx.heading(FormEntryState.form.name)), rx.foreach( FormEntryState.form.fields, - field_view, + validated_field_view, ), rx.button("Submit", type="submit"), ), diff --git a/form-designer/form_designer/pages/home.py b/form-designer/form_designer/pages/home.py index d7af8b75..de7fb875 100644 --- a/form-designer/form_designer/pages/home.py +++ b/form-designer/form_designer/pages/home.py @@ -1,3 +1,4 @@ +import importlib.metadata from pathlib import Path import reflex as rx @@ -22,4 +23,9 @@ def home_page() -> rx.Component: rx.markdown(readme_content.read_text()), margin_y="2em", ), + rx.hstack( + rx.logo(), + rx.text(f"v{importlib.metadata.version('reflex')}", size="1"), + align="center", + ), ) diff --git a/form-designer/form_designer/pages/response.py b/form-designer/form_designer/pages/response.py index 56d4aff0..88b04a57 100644 --- a/form-designer/form_designer/pages/response.py +++ b/form-designer/form_designer/pages/response.py @@ -75,7 +75,7 @@ def response(r: Response): def responses_title(): form_name = rx.cond( rx.State.form_id == "", - utils.quoted_var("Unknown Form"), + "Unknown Form", ResponsesState.form.name, ) return f"{constants.TITLE} | {form_name} | Responses" diff --git a/form-designer/form_designer/utils.py b/form-designer/form_designer/utils.py index 6b35642d..40ca51a5 100644 --- a/form-designer/form_designer/utils.py +++ b/form-designer/form_designer/utils.py @@ -5,11 +5,6 @@ from reflex_local_auth import LoginState -def quoted_var(value: str) -> rx.Var: - """Allows a bare string to be used in a page title with other Vars.""" - return rx.Var.create_safe(f"'{value}'", _var_is_string=False, _var_is_local=True) - - def require_login(page: rx.app.ComponentCallable) -> rx.app.ComponentCallable: """Decorator to require authentication before rendering a page.