fh-pydantic-form 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fh-pydantic-form might be problematic. Click here for more details.

@@ -3,6 +3,7 @@ from datetime import date, time
3
3
  from enum import Enum
4
4
  from typing import (
5
5
  Any,
6
+ List,
6
7
  Optional,
7
8
  get_args,
8
9
  get_origin,
@@ -61,6 +62,8 @@ class BaseFieldRenderer:
61
62
  disabled: bool = False,
62
63
  label_color: Optional[str] = None,
63
64
  spacing: SpacingValue = SpacingTheme.NORMAL,
65
+ field_path: Optional[List[str]] = None,
66
+ form_name: Optional[str] = None,
64
67
  ):
65
68
  """
66
69
  Initialize the field renderer
@@ -73,17 +76,61 @@ class BaseFieldRenderer:
73
76
  disabled: Whether the field should be rendered as disabled
74
77
  label_color: Optional CSS color value for the field label
75
78
  spacing: Spacing theme to use for layout ("normal", "compact", or SpacingTheme enum)
79
+ field_path: Path segments from root to this field (for nested list support)
80
+ form_name: Explicit form name (used for nested list URLs)
76
81
  """
77
82
  self.field_name = f"{prefix}{field_name}" if prefix else field_name
78
83
  self.original_field_name = field_name
79
84
  self.field_info = field_info
80
85
  self.value = value
81
86
  self.prefix = prefix
87
+ self.field_path: List[str] = field_path or []
88
+ self.explicit_form_name: Optional[str] = form_name
82
89
  self.is_optional = _is_optional_type(field_info.annotation)
83
90
  self.disabled = disabled
84
91
  self.label_color = label_color
85
92
  self.spacing = _normalize_spacing(spacing)
86
93
 
94
+ def _is_inline_color(self, color: str) -> bool:
95
+ """
96
+ Determine if a color should be applied as an inline style or CSS class.
97
+
98
+ Args:
99
+ color: The color value to check
100
+
101
+ Returns:
102
+ True if the color should be applied as inline style, False if as CSS class
103
+ """
104
+ # Check if it's a hex color value (starts with #) or basic HTML color name
105
+ return color.startswith("#") or color in [
106
+ "red",
107
+ "blue",
108
+ "green",
109
+ "yellow",
110
+ "orange",
111
+ "purple",
112
+ "pink",
113
+ "cyan",
114
+ "magenta",
115
+ "brown",
116
+ "black",
117
+ "white",
118
+ "gray",
119
+ "grey",
120
+ ]
121
+
122
+ def _get_color_class(self, color: str) -> str:
123
+ """
124
+ Get the appropriate CSS class for a color.
125
+
126
+ Args:
127
+ color: The color name
128
+
129
+ Returns:
130
+ The CSS class string for the color
131
+ """
132
+ return f"text-{color}-600"
133
+
87
134
  def render_label(self) -> FT:
88
135
  """
89
136
  Render label for the field
@@ -118,13 +165,12 @@ class BaseFieldRenderer:
118
165
 
119
166
  # Apply color styling if specified
120
167
  if self.label_color:
121
- # Check if it's a CSS class (contains letters/hyphens) or a color value
122
- if self.label_color.replace("-", "").replace("#", "").isalnum():
123
- # Looks like a CSS class, add it to the class list
124
- cls_attr = f"block text-sm font-medium {self.label_color} {label_gap_class}".strip()
125
- else:
168
+ if self._is_inline_color(self.label_color):
126
169
  # Treat as color value
127
170
  label_attrs["style"] = f"color: {self.label_color};"
171
+ else:
172
+ # Treat as CSS class (includes Tailwind colors like emerald, amber, rose, teal, indigo, lime, violet, etc.)
173
+ cls_attr = f"block text-sm font-medium {self._get_color_class(self.label_color)} {label_gap_class}".strip()
128
174
 
129
175
  # Create and return the label - using standard fh.Label with appropriate styling
130
176
  return fh.Label(
@@ -147,49 +193,38 @@ class BaseFieldRenderer:
147
193
 
148
194
  def render(self) -> FT:
149
195
  """
150
- Render the complete field (label + input) with spacing in a collapsible accordion
196
+ Render the complete field (label + input) with spacing
197
+
198
+ For compact spacing: renders label and input side-by-side
199
+ For normal spacing: renders label above input (traditional)
151
200
 
152
201
  Returns:
153
- A FastHTML component (mui.Accordion) containing the complete field
202
+ A FastHTML component containing the complete field
154
203
  """
155
- # 1. Get the full label component (fh.Label)
204
+ # 1. Get the label component
156
205
  label_component = self.render_label()
157
206
 
158
- # Apply color styling directly to the Label component if needed
159
- if self.label_color and isinstance(label_component, fh.FT):
160
- if "style" in label_component.attrs:
161
- label_component.attrs["style"] += f" color: {self.label_color};"
162
- else:
163
- label_component.attrs["style"] = f"color: {self.label_color};"
164
-
165
- # 2. Render the input field that will be the accordion content
207
+ # 2. Render the input field
166
208
  input_component = self.render_input()
167
209
 
168
- # 3. Define unique IDs for potential targeting
169
- item_id = f"{self.field_name}_item"
170
- accordion_id = f"{self.field_name}_accordion"
171
-
172
- # 4. Create the AccordionItem with the full label component as title
173
- accordion_item = mui.AccordionItem(
174
- label_component, # Use the entire label component including the "for" attribute
175
- input_component, # Content component (the input field)
176
- open=True, # Open by default
177
- li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
178
- cls=spacing(
179
- "outer_margin_sm", self.spacing
180
- ), # Add spacing between accordion items
181
- )
182
-
183
- # 5. Wrap the single AccordionItem in an Accordion container
184
- accordion_container = mui.Accordion(
185
- accordion_item, # The single item to include
186
- id=accordion_id, # ID for the accordion container (ul)
187
- multiple=True, # Allow multiple open (though only one exists)
188
- collapsible=True, # Allow toggling
189
- cls=f"{spacing('accordion_divider', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(),
190
- )
191
-
192
- return accordion_container
210
+ # 3. Choose layout based on spacing theme
211
+ if self.spacing == SpacingTheme.COMPACT:
212
+ # Horizontal layout for compact mode
213
+ return fh.Div(
214
+ fh.Div(
215
+ label_component,
216
+ input_component,
217
+ cls=f"flex {spacing('horizontal_gap', self.spacing)} {spacing('label_align', self.spacing)} w-full",
218
+ ),
219
+ cls=f"{spacing('outer_margin', self.spacing)} w-full",
220
+ )
221
+ else:
222
+ # Vertical layout for normal mode (existing behavior)
223
+ return fh.Div(
224
+ label_component,
225
+ input_component,
226
+ cls=spacing("outer_margin", self.spacing),
227
+ )
193
228
 
194
229
 
195
230
  # ---- Specific Field Renderers ----
@@ -301,6 +336,29 @@ class BooleanFieldRenderer(BaseFieldRenderer):
301
336
 
302
337
  return mui.CheckboxX(**checkbox_attrs)
303
338
 
339
+ def render(self) -> FT:
340
+ """
341
+ Render the complete field (label + input) with spacing, placing the checkbox next to the label.
342
+
343
+ Returns:
344
+ A FastHTML component containing the complete field
345
+ """
346
+ # Get the label component
347
+ label_component = self.render_label()
348
+
349
+ # Get the checkbox component
350
+ checkbox_component = self.render_input()
351
+
352
+ # Create a flex container to place label and checkbox side by side
353
+ return fh.Div(
354
+ fh.Div(
355
+ label_component,
356
+ checkbox_component,
357
+ cls="flex items-center gap-2 w-full", # Use flexbox to align items horizontally with a small gap
358
+ ),
359
+ cls=f"{spacing('outer_margin', self.spacing)} w-full",
360
+ )
361
+
304
362
 
305
363
  class DateFieldRenderer(BaseFieldRenderer):
306
364
  """Renderer for date fields"""
@@ -559,26 +617,36 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
559
617
  Returns:
560
618
  A FastHTML component (mui.Accordion) containing the accordion structure.
561
619
  """
562
- # 1. Get the label content (the inner Span with text/tooltip)
563
- label_component = self.render_label()
564
- if isinstance(label_component, fh.FT) and label_component.children:
565
- label_content = label_component.children[0]
566
- # Extract label style if present
567
- label_style = label_component.attrs.get("style", "")
568
- # Apply label style directly to the span if needed
569
- if label_style:
570
- # Check if label_content is already a Span, otherwise wrap it
571
- if isinstance(label_content, fh.Span):
572
- label_content.attrs["style"] = label_style
573
- else:
574
- # This case is less likely if render_label returns Label(Span(...))
575
- label_content = fh.Span(label_content, style=label_style)
620
+
621
+ # Extract the label text and apply color styling
622
+ label_text = self.original_field_name.replace("_", " ").title()
623
+
624
+ # Create the title component with proper color styling
625
+ if self.label_color:
626
+ if self._is_inline_color(self.label_color):
627
+ # Color value - apply as inline style
628
+ title_component = fh.Span(
629
+ label_text,
630
+ style=f"color: {self.label_color};",
631
+ cls="text-sm font-medium",
632
+ )
633
+ else:
634
+ # CSS class - apply as Tailwind class (includes emerald, amber, rose, teal, indigo, lime, violet, etc.)
635
+ title_component = fh.Span(
636
+ label_text,
637
+ cls=f"text-sm font-medium {self._get_color_class(self.label_color)}",
638
+ )
576
639
  else:
577
- # Fallback if structure is different (should not happen ideally)
578
- label_content = self.original_field_name.replace("_", " ").title()
579
- label_style = f"color: {self.label_color};" if self.label_color else ""
580
- if label_style:
581
- label_content = fh.Span(label_content, style=label_style)
640
+ # No color specified - use default styling
641
+ title_component = fh.Span(
642
+ label_text, cls="text-sm font-medium text-gray-700"
643
+ )
644
+
645
+ # Add tooltip if description is available
646
+ description = getattr(self.field_info, "description", None)
647
+ if description:
648
+ title_component.attrs["uk-tooltip"] = description
649
+ title_component.attrs["title"] = description
582
650
 
583
651
  # 2. Render the nested input fields that will be the accordion content
584
652
  input_component = self.render_input()
@@ -588,13 +656,8 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
588
656
  accordion_id = f"{self.field_name}_accordion"
589
657
 
590
658
  # 4. Create the AccordionItem using the MonsterUI component
591
- # - Pass label_content as the title.
592
- # - Pass input_component as the content.
593
- # - Set 'open=True' to be expanded by default.
594
- # - Pass item_id via li_kwargs.
595
- # - Add 'mb-4' class for bottom margin spacing.
596
659
  accordion_item = mui.AccordionItem(
597
- label_content, # Title component (already potentially styled Span)
660
+ title_component, # Title component with proper color styling
598
661
  input_component, # Content component (the Card with nested fields)
599
662
  open=True, # Open by default
600
663
  li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
@@ -609,7 +672,7 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
609
672
  id=accordion_id, # ID for the accordion container (ul)
610
673
  multiple=True, # Allow multiple open (though only one exists)
611
674
  collapsible=True, # Allow toggling
612
- cls=f"{spacing('accordion_divider', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(),
675
+ cls=f"{spacing('accordion_divider', self.spacing)} {spacing('accordion_content', self.spacing)} w-full".strip(),
613
676
  )
614
677
 
615
678
  return accordion_container
@@ -695,6 +758,8 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
695
758
  prefix=nested_prefix,
696
759
  disabled=self.disabled, # Propagate disabled state to nested fields
697
760
  spacing=self.spacing, # Propagate spacing to nested fields
761
+ field_path=self.field_path + [nested_field_name], # Propagate path
762
+ form_name=self.explicit_form_name, # Propagate form name
698
763
  )
699
764
 
700
765
  nested_inputs.append(renderer.render())
@@ -729,6 +794,54 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
729
794
  class ListFieldRenderer(BaseFieldRenderer):
730
795
  """Renderer for list fields containing any type"""
731
796
 
797
+ def _container_id(self) -> str:
798
+ """
799
+ Return a DOM-unique ID for the list's <ul> / <div> wrapper.
800
+
801
+ Format: <formname>_<hierarchy>_items_container
802
+ Example: main_form_compact_tags_items_container
803
+ """
804
+ base = "_".join(self.field_path) # tags or main_address_tags
805
+ if self._form_name: # already resolved in property
806
+ return f"{self._form_name}_{base}_items_container"
807
+ return f"{base}_items_container" # fallback (shouldn't happen)
808
+
809
+ @property
810
+ def _form_name(self) -> str:
811
+ """Get form name - prefer explicit form name if provided"""
812
+ if self.explicit_form_name:
813
+ return self.explicit_form_name
814
+
815
+ # Fallback to extracting from prefix (for backward compatibility)
816
+ # The prefix always starts with the form name followed by underscore
817
+ # e.g., "main_form_compact_" or "main_form_compact_main_address_tags_"
818
+ # We need to extract just "main_form_compact"
819
+ if self.prefix:
820
+ # For backward compatibility with existing non-nested lists
821
+ # Split by underscore and rebuild the form name by removing known field components
822
+ parts = self.prefix.rstrip("_").split("_")
823
+
824
+ # For a simple heuristic: form names typically have 2-3 parts (main_form_compact)
825
+ # Field paths are at the end, so we find where the form name ends
826
+ # This is imperfect but works for most cases
827
+ if len(parts) >= 3 and parts[1] == "form":
828
+ # Standard pattern: main_form_compact
829
+ form_name = "_".join(parts[:3])
830
+ elif len(parts) >= 2:
831
+ # Fallback: take first 2 parts
832
+ form_name = "_".join(parts[:2])
833
+ else:
834
+ # Single part
835
+ form_name = parts[0] if parts else ""
836
+
837
+ return form_name
838
+ return ""
839
+
840
+ @property
841
+ def _list_path(self) -> str:
842
+ """Get the hierarchical path for this list field"""
843
+ return "/".join(self.field_path)
844
+
732
845
  def render(self) -> FT:
733
846
  """
734
847
  Render the complete field (label + input) with spacing, adding a refresh icon for list fields.
@@ -738,13 +851,42 @@ class ListFieldRenderer(BaseFieldRenderer):
738
851
  A FastHTML component containing the complete field with refresh icon
739
852
  """
740
853
  # Extract form name from prefix (removing trailing underscore if present)
741
- form_name = self.prefix.rstrip("_") if self.prefix else None
854
+ # form_name = self.prefix.rstrip("_") if self.prefix else None
855
+ form_name = self._form_name or None
856
+
857
+ # Create the label text with proper color styling
858
+ label_text = self.original_field_name.replace("_", " ").title()
859
+
860
+ # Create the styled label span
861
+ if self.label_color:
862
+ if self._is_inline_color(self.label_color):
863
+ # Color value - apply as inline style
864
+ label_span = fh.Span(
865
+ label_text,
866
+ style=f"color: {self.label_color};",
867
+ cls=f"block text-sm font-medium {spacing('label_gap', self.spacing)}",
868
+ )
869
+ else:
870
+ # CSS class - apply as Tailwind class (includes emerald, amber, rose, teal, indigo, lime, violet, etc.)
871
+ label_span = fh.Span(
872
+ label_text,
873
+ cls=f"block text-sm font-medium {self._get_color_class(self.label_color)} {spacing('label_gap', self.spacing)}",
874
+ )
875
+ else:
876
+ # No color specified - use default styling
877
+ label_span = fh.Span(
878
+ label_text,
879
+ cls=f"block text-sm font-medium text-gray-700 {spacing('label_gap', self.spacing)}",
880
+ )
742
881
 
743
- # Get the original label
744
- original_label = self.render_label()
882
+ # Add tooltip if description is available
883
+ description = getattr(self.field_info, "description", None)
884
+ if description:
885
+ label_span.attrs["uk-tooltip"] = description
886
+ label_span.attrs["title"] = description
745
887
 
746
888
  # Construct the container ID that will be generated by render_input()
747
- container_id = f"{self.prefix}{self.original_field_name}_items_container"
889
+ container_id = self._container_id()
748
890
 
749
891
  # Only add refresh icon if we have a form name
750
892
  if form_name:
@@ -761,21 +903,23 @@ class ListFieldRenderer(BaseFieldRenderer):
761
903
  hx_post=f"/form/{form_name}/refresh",
762
904
  hx_target=f"#{form_name}-inputs-wrapper",
763
905
  hx_swap="innerHTML",
764
- hx_include=f"#{form_name}-form",
906
+ hx_include="closest form", # ← key change
765
907
  uk_tooltip="Refresh form display to update list summaries",
908
+ # Prevent 'toggleListItems' on the parent from firing
909
+ onclick="event.stopPropagation();",
766
910
  )
767
911
 
768
912
  # Combine label and icon
769
913
  label_with_icon = fh.Div(
770
- original_label,
914
+ label_span, # Use the properly styled label span
771
915
  refresh_icon_trigger,
772
916
  cls="flex items-center cursor-pointer", # Added cursor-pointer
773
917
  onclick=f"toggleListItems('{container_id}'); return false;", # Add click handler
774
918
  )
775
919
  else:
776
- # If no form name, just use the original label but still make it clickable
920
+ # If no form name, just use the styled label but still make it clickable
777
921
  label_with_icon = fh.Div(
778
- original_label,
922
+ label_span, # Use the properly styled label span
779
923
  cls="flex items-center cursor-pointer", # Added cursor-pointer
780
924
  onclick=f"toggleListItems('{container_id}'); return false;", # Add click handler
781
925
  uk_tooltip="Click to toggle all items open/closed",
@@ -785,7 +929,7 @@ class ListFieldRenderer(BaseFieldRenderer):
785
929
  return fh.Div(
786
930
  label_with_icon,
787
931
  self.render_input(),
788
- cls=spacing("outer_margin", self.spacing),
932
+ cls=f"{spacing('outer_margin', self.spacing)} w-full",
789
933
  )
790
934
 
791
935
  def render_input(self) -> FT:
@@ -839,8 +983,8 @@ class ListFieldRenderer(BaseFieldRenderer):
839
983
  )
840
984
  )
841
985
 
842
- # Container for list items with form-specific prefix in ID
843
- container_id = f"{self.prefix}{self.original_field_name}_items_container"
986
+ # Container for list items using hierarchical field path
987
+ container_id = self._container_id()
844
988
 
845
989
  # Use mui.Accordion component
846
990
  accordion = mui.Accordion(
@@ -854,13 +998,10 @@ class ListFieldRenderer(BaseFieldRenderer):
854
998
  # Empty state message if no items
855
999
  empty_state = ""
856
1000
  if not items:
857
- # Extract form name from prefix if available
858
- form_name = self.prefix.rstrip("_") if self.prefix else None
859
-
860
- # Check if it's a simple type or BaseModel
1001
+ # Use hierarchical path for URL
861
1002
  add_url = (
862
- f"/form/{form_name}/list/add/{self.original_field_name}"
863
- if form_name
1003
+ f"/form/{self._form_name}/list/add/{self._list_path}"
1004
+ if self._form_name
864
1005
  else f"/list/add/{self.field_name}"
865
1006
  )
866
1007
 
@@ -913,9 +1054,6 @@ class ListFieldRenderer(BaseFieldRenderer):
913
1054
  item_id = f"{self.field_name}_{idx}"
914
1055
  item_card_id = f"{item_id}_card"
915
1056
 
916
- # Extract form name from prefix if available
917
- form_name = self.prefix.rstrip("_") if self.prefix else None
918
-
919
1057
  # Check if it's a simple type or BaseModel
920
1058
  is_model = hasattr(item_type, "model_fields")
921
1059
 
@@ -1060,6 +1198,11 @@ class ListFieldRenderer(BaseFieldRenderer):
1060
1198
  prefix=name_prefix,
1061
1199
  disabled=self.disabled, # Propagate disabled state
1062
1200
  spacing=self.spacing, # Propagate spacing
1201
+ field_path=self.field_path
1202
+ + [
1203
+ str(idx),
1204
+ nested_field_name,
1205
+ ], # Propagate path with index
1063
1206
  )
1064
1207
 
1065
1208
  # Add rendered field to valid fields
@@ -1095,21 +1238,23 @@ class ListFieldRenderer(BaseFieldRenderer):
1095
1238
  prefix=self.prefix, # Correct: Provide the form prefix
1096
1239
  disabled=self.disabled, # Propagate disabled state
1097
1240
  spacing=self.spacing, # Propagate spacing
1241
+ field_path=self.field_path
1242
+ + [str(idx)], # Propagate path with index
1098
1243
  )
1099
1244
  input_element = simple_renderer.render_input()
1100
1245
  item_content_elements.append(fh.Div(input_element))
1101
1246
 
1102
1247
  # --- Create action buttons with form-specific URLs ---
1103
- # Generate HTMX endpoints with form name if available
1248
+ # Generate HTMX endpoints using hierarchical paths
1104
1249
  delete_url = (
1105
- f"/form/{form_name}/list/delete/{self.original_field_name}"
1106
- if form_name
1250
+ f"/form/{self._form_name}/list/delete/{self._list_path}"
1251
+ if self._form_name
1107
1252
  else f"/list/delete/{self.field_name}"
1108
1253
  )
1109
1254
 
1110
1255
  add_url = (
1111
- f"/form/{form_name}/list/add/{self.original_field_name}"
1112
- if form_name
1256
+ f"/form/{self._form_name}/list/add/{self._list_path}"
1257
+ if self._form_name
1113
1258
  else f"/list/add/{self.field_name}"
1114
1259
  )
1115
1260