-
Notifications
You must be signed in to change notification settings - Fork 14
Enhancement/shipping form example #135
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,307 @@ | ||||||
| # Build a Shipping Form | ||||||
|
|
||||||
| Build a shipping address form with cascading country, state/province, and city selectors where each dropdown's options dynamically depend on the previous selection, with default country loaded from environment variables. | ||||||
|
|
||||||
|  | ||||||
|
|
||||||
| ## Input | ||||||
|
|
||||||
| Ask Claude Code to create a Param-based shipping form: | ||||||
|
|
||||||
| ```text | ||||||
| Plan how to create a ShippingForm param class with: | ||||||
| - country: selector with at least 3 countries (USA, Canada, Germany) | ||||||
| - state_province: selector whose options depend on the selected country | ||||||
| - city: selector whose options depend on the selected state/province | ||||||
| - street_address: string for the street address | ||||||
| - postal_code: string with format validation (different patterns per country) | ||||||
| - recipient_name: string with minimum 2 characters | ||||||
|
|
||||||
| Requirements: | ||||||
| - Load the default country from the DEFAULT_COUNTRY environment variable (fallback to "USA") | ||||||
| - When country changes, update state/province options and reset to first valid option | ||||||
| - When state/province changes, update city options and reset to first valid option | ||||||
| - Add postal code validation that varies by country (US: 5 digits, Canada: A1A 1A1 pattern, Germany: 5 digits) | ||||||
| - Include a method to get the full formatted address as a string | ||||||
| - Include a method to validate the complete form and return any errors | ||||||
| - Add proper docstrings and type hints | ||||||
| - Create a simple Panel UI form to fill in the shipping form, submit and display the formatted address | ||||||
| - Catch ValueError during validation and shown them to the user in the UI | ||||||
|
|
||||||
| Output should be a single Python file app.py. Add tests in test_app.py and make sure all tests pass. | ||||||
| ``` | ||||||
|
|
||||||
| !!! tip "Using the Param Skill" | ||||||
| Claude Code has access to the HoloViz MCP server which includes a **param skill** with best practices for creating Parameterized classes. The skill guides Claude on: | ||||||
|
|
||||||
| - Using `@param.depends(watch=True, on_init=True)` for cascading dependent selectors | ||||||
| - Dynamically updating `param.Selector.objects` when parent selections change | ||||||
| - Reading environment variables for default values at class definition time | ||||||
| - Implementing custom parameter types with `_validate_value` for format validation | ||||||
|
|
||||||
| ## Result | ||||||
|
|
||||||
| Claude leverages the `param` and `panel` skills to create a well-structured `ShippingForm` class with cascading selectors, country-specific validation, and environment-based defaults. | ||||||
|
|
||||||
|  | ||||||
|
|
||||||
| Claude created comprehensive tests covering the validation scenarios: | ||||||
|
|
||||||
|  | ||||||
|
|
||||||
| <details><summary>Code</summary> | ||||||
|
|
||||||
| ```python | ||||||
| """ShippingForm - A Param class with cascading selectors and Panel UI form.""" | ||||||
|
|
||||||
| import os | ||||||
| import re | ||||||
|
|
||||||
| import param | ||||||
| import panel as pn | ||||||
|
|
||||||
| pn.extension() | ||||||
|
|
||||||
| # Location data for cascading selectors | ||||||
| LOCATIONS = { | ||||||
| "USA": { | ||||||
| "California": ["Los Angeles", "San Francisco", "San Diego"], | ||||||
| "New York": ["New York City", "Buffalo", "Albany"], | ||||||
| "Texas": ["Houston", "Austin", "Dallas"], | ||||||
| }, | ||||||
| "Canada": { | ||||||
| "Ontario": ["Toronto", "Ottawa", "Hamilton"], | ||||||
| "Quebec": ["Montreal", "Quebec City", "Laval"], | ||||||
| "British Columbia": ["Vancouver", "Victoria", "Burnaby"], | ||||||
| }, | ||||||
| "Germany": { | ||||||
| "Bavaria": ["Munich", "Nuremberg", "Augsburg"], | ||||||
| "Berlin": ["Berlin"], | ||||||
| "Hamburg": ["Hamburg"], | ||||||
| }, | ||||||
| } | ||||||
|
|
||||||
| # Postal code validation patterns | ||||||
| POSTAL_PATTERNS = { | ||||||
| "USA": r"^\d{5}$", | ||||||
| "Canada": r"^[A-Za-z]\d[A-Za-z] \d[A-Za-z]\d$", | ||||||
| "Germany": r"^\d{5}$", | ||||||
| } | ||||||
|
|
||||||
| # Default country from environment variable | ||||||
| DEFAULT_COUNTRY = os.environ.get("DEFAULT_COUNTRY", "USA") | ||||||
| if DEFAULT_COUNTRY not in LOCATIONS: | ||||||
| DEFAULT_COUNTRY = "USA" | ||||||
|
|
||||||
|
|
||||||
| class MinLengthString(param.String): | ||||||
| """A String parameter with minimum length validation.""" | ||||||
|
|
||||||
| __slots__ = ["min_length"] | ||||||
|
|
||||||
| def __init__(self, min_length=0, **params): | ||||||
| self.min_length = min_length | ||||||
| super().__init__(**params) | ||||||
|
|
||||||
| def _validate_value(self, val, allow_None): | ||||||
| super()._validate_value(val, allow_None) | ||||||
| if val and len(val) < self.min_length: | ||||||
| raise ValueError( | ||||||
| f"Parameter {self.name!r} must be at least {self.min_length} characters." | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| class ShippingForm(param.Parameterized): | ||||||
| """A shipping form with cascading location selectors and validation.""" | ||||||
|
|
||||||
| country = param.Selector( | ||||||
| default=DEFAULT_COUNTRY, | ||||||
| objects=list(LOCATIONS.keys()), | ||||||
| doc="Shipping country", | ||||||
| ) | ||||||
|
|
||||||
| state_province = param.Selector( | ||||||
| default=None, | ||||||
| objects=[], | ||||||
| doc="State or province", | ||||||
| ) | ||||||
|
|
||||||
| city = param.Selector( | ||||||
| default=None, | ||||||
| objects=[], | ||||||
| doc="City", | ||||||
| ) | ||||||
|
|
||||||
| street_address = param.String( | ||||||
| default="", | ||||||
| doc="Street address", | ||||||
| ) | ||||||
|
|
||||||
| postal_code = param.String( | ||||||
| default="", | ||||||
| doc="Postal/ZIP code", | ||||||
| ) | ||||||
|
|
||||||
| recipient_name = MinLengthString( | ||||||
| default="", | ||||||
| min_length=2, | ||||||
| doc="Recipient name (minimum 2 characters)", | ||||||
| ) | ||||||
|
|
||||||
| @param.depends("country", watch=True, on_init=True) | ||||||
| def _update_states(self): | ||||||
| """Update state/province options when country changes.""" | ||||||
| states = list(LOCATIONS[self.country].keys()) | ||||||
| self.param.state_province.objects = states | ||||||
| if self.state_province not in states: | ||||||
| self.state_province = states[0] | ||||||
|
|
||||||
| @param.depends("state_province", watch=True, on_init=True) | ||||||
| def _update_cities(self): | ||||||
| """Update city options when state/province changes.""" | ||||||
| if self.state_province is None: | ||||||
| return | ||||||
| cities = LOCATIONS[self.country][self.state_province] | ||||||
| self.param.city.objects = cities | ||||||
| if self.city not in cities: | ||||||
| self.city = cities[0] | ||||||
|
|
||||||
| def validate_postal_code(self): | ||||||
| """Validate postal code against country-specific pattern. | ||||||
|
|
||||||
| Raises: | ||||||
| ValueError: If postal code doesn't match the expected pattern. | ||||||
| """ | ||||||
| if not self.postal_code: | ||||||
| raise ValueError("Postal code is required.") | ||||||
|
|
||||||
| pattern = POSTAL_PATTERNS.get(self.country) | ||||||
| if pattern and not re.match(pattern, self.postal_code): | ||||||
| if self.country == "USA": | ||||||
| raise ValueError("USA postal code must be 5 digits (e.g., 12345).") | ||||||
| elif self.country == "Canada": | ||||||
| raise ValueError( | ||||||
| "Canada postal code must be in format A1A 1A1 (e.g., K1A 0B1)." | ||||||
| ) | ||||||
| elif self.country == "Germany": | ||||||
| raise ValueError("Germany postal code must be 5 digits (e.g., 10115).") | ||||||
| else: | ||||||
| raise ValueError("Invalid postal code format.") | ||||||
|
|
||||||
| def validate(self): | ||||||
| """Validate all form fields. | ||||||
|
|
||||||
| Returns: | ||||||
| list[str]: List of validation error messages. Empty if valid. | ||||||
| """ | ||||||
| errors = [] | ||||||
|
|
||||||
| if not self.recipient_name: | ||||||
| errors.append("Recipient name is required.") | ||||||
| elif len(self.recipient_name) < 2: | ||||||
| errors.append("Recipient name must be at least 2 characters.") | ||||||
|
|
||||||
| if not self.street_address: | ||||||
| errors.append("Street address is required.") | ||||||
|
|
||||||
| try: | ||||||
| self.validate_postal_code() | ||||||
| except ValueError as e: | ||||||
| errors.append(str(e)) | ||||||
|
|
||||||
| return errors | ||||||
|
|
||||||
| def formatted_address(self): | ||||||
| """Return a formatted multi-line address string. | ||||||
|
|
||||||
| Returns: | ||||||
| str: Formatted address. | ||||||
| """ | ||||||
| lines = [ | ||||||
| self.recipient_name, | ||||||
| self.street_address, | ||||||
| f"{self.city}, {self.state_province} {self.postal_code}", | ||||||
| self.country, | ||||||
| ] | ||||||
| return "\n".join(lines) | ||||||
|
|
||||||
|
|
||||||
| def create_shipping_form(): | ||||||
| """Create a Panel UI for the shipping form. | ||||||
|
|
||||||
| Returns: | ||||||
| pn.Column: Panel column containing the form UI. | ||||||
| """ | ||||||
| form = ShippingForm() | ||||||
|
|
||||||
| # Create widgets from parameters | ||||||
| country_widget = pn.widgets.Select.from_param(form.param.country, name="Country") | ||||||
| state_widget = pn.widgets.Select.from_param( | ||||||
| form.param.state_province, name="State/Province" | ||||||
| ) | ||||||
| city_widget = pn.widgets.Select.from_param(form.param.city, name="City") | ||||||
| street_widget = pn.widgets.TextInput.from_param( | ||||||
| form.param.street_address, name="Street Address", placeholder="123 Main St" | ||||||
| ) | ||||||
| postal_widget = pn.widgets.TextInput.from_param( | ||||||
| form.param.postal_code, name="Postal Code", placeholder="12345" | ||||||
| ) | ||||||
| name_widget = pn.widgets.TextInput.from_param( | ||||||
| form.param.recipient_name, name="Recipient Name", placeholder="John Doe" | ||||||
| ) | ||||||
|
|
||||||
| # Error display | ||||||
| error_pane = pn.pane.Alert(visible=False, alert_type="danger") | ||||||
|
|
||||||
| # Address display | ||||||
| address_pane = pn.pane.Markdown("") | ||||||
|
|
||||||
| # Submit button | ||||||
| submit_btn = pn.widgets.Button(name="Submit", button_type="primary") | ||||||
|
|
||||||
| def on_submit(event): | ||||||
| errors = form.validate() | ||||||
| if errors: | ||||||
| error_pane.object = "\n".join(errors) | ||||||
| error_pane.visible = True | ||||||
| address_pane.object = "" | ||||||
| else: | ||||||
| error_pane.visible = False | ||||||
| address_pane.object = ( | ||||||
| f"**Formatted Address:**\n```\n{form.formatted_address()}\n```" | ||||||
|
Comment on lines
+270
to
+271
|
||||||
| ) | ||||||
|
|
||||||
| submit_btn.on_click(on_submit) | ||||||
|
|
||||||
| return pn.Column( | ||||||
| pn.pane.Markdown("# Shipping Form"), | ||||||
| pn.pane.Markdown("Please fill in your shipping details:"), | ||||||
| name_widget, | ||||||
| street_widget, | ||||||
| country_widget, | ||||||
| state_widget, | ||||||
| city_widget, | ||||||
| postal_widget, | ||||||
| submit_btn, | ||||||
| error_pane, | ||||||
| address_pane, | ||||||
| width=400, | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| # Create the app for Panel serve | ||||||
| app = create_shipping_form() | ||||||
| app.servable() | ||||||
| ``` | ||||||
|
|
||||||
| </details> | ||||||
|
|
||||||
| Key features demonstrated: | ||||||
|
|
||||||
| - **Cascading Selectors**: Country -> State/Province -> City with `@param.depends(watch=True, on_init=True)` | ||||||
| - **Dynamic Options**: Updates `param.Selector.objects` when parent selection changes | ||||||
| - **Environment Defaults**: Reads `DEFAULT_COUNTRY` from environment variables at class definition | ||||||
| - **Custom Validation**: Country-specific postal code patterns (US: 5 digits, Canada: A1A 1A1, Germany: 5 digits) | ||||||
| - **Custom Parameter Types**: `MinLengthString` for recipient name validation | ||||||
| - **Form Methods**: `get_formatted_address()` and `validate()` for complete form handling | ||||||
|
||||||
| - **Form Methods**: `get_formatted_address()` and `validate()` for complete form handling | |
| - **Form Methods**: `formatted_address()` and `validate()` for complete form handling |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -109,6 +109,7 @@ nav: | |
| - Plot from Picture: examples/generate-plot-from-picture.md | ||
| - Dashboard from Picture: examples/generate-dashboard-from-picture.md | ||
| - Create ML Experiment Tracker: examples/create-ml-experiment-tracker.md | ||
| - Build A Shipping Form: examples/build-a-shipping-form.md | ||
|
||
| - Convert Excel Sheet: examples/convert-excel-app.md | ||
| - Convert Streamlit App: examples/convert-streamlit-app.md | ||
| - How-To Guides: | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -483,15 +483,16 @@ def __init__(self, *, data_dir: Optional[Path] = None, repos_dir: Optional[Path] | |||||
| self.repos_dir.mkdir(parents=True, exist_ok=True) | ||||||
|
|
||||||
| # Use configurable directory for vector database path | ||||||
| vector_db_path = vector_dir or config.server.vector_db_path | ||||||
| vector_db_path.parent.mkdir(parents=True, exist_ok=True) | ||||||
| self._vector_db_path = vector_dir or config.server.vector_db_path | ||||||
| self._vector_db_path.parent.mkdir(parents=True, exist_ok=True) | ||||||
|
|
||||||
| # Disable ChromaDB telemetry based on config | ||||||
| if not config.server.anonymized_telemetry: | ||||||
| os.environ["ANONYMIZED_TELEMETRY"] = "False" | ||||||
|
|
||||||
| # Initialize ChromaDB | ||||||
| self.chroma_client = chromadb.PersistentClient(path=str(vector_db_path)) | ||||||
| self.chroma_client = chromadb.PersistentClient(path=str(self._vector_db_path)) | ||||||
|
|
||||||
| self.collection = self.chroma_client.get_or_create_collection("holoviz_docs", configuration=_CROMA_CONFIGURATION) | ||||||
|
|
||||||
| # Lazy-initialized async lock for database operations to prevent corruption from concurrent access | ||||||
|
|
@@ -929,7 +930,7 @@ async def index_documentation(self, ctx: Context | None = None): | |||||
| ) | ||||||
|
|
||||||
| await log_info(f"✅ Successfully indexed {len(all_docs)} documents", ctx) | ||||||
| await log_info(f"📊 Vector database stored at: {self.data_dir / 'chroma'}", ctx) | ||||||
| await log_info(f"📊 Vector database stored at: {self._vector_db_path}", ctx) | ||||||
| await log_info(f"🔍 Index contains {self.collection.count()} total documents", ctx) | ||||||
|
|
||||||
| # Show detailed summary table | ||||||
|
|
@@ -1245,7 +1246,7 @@ async def run_indexer(indexer=self): | |||||
| logger.info(f"📦 Default config: {indexer._holoviz_mcp_config.config_file_path(location='default')}") | ||||||
| logger.info(f"🏠 User config: {indexer._holoviz_mcp_config.config_file_path(location='user')}") | ||||||
| logger.info(f"📁 Repository directory: {indexer.repos_dir}") | ||||||
| logger.info(f"💾 Vector database: {indexer.data_dir / 'chroma'}") | ||||||
| logger.info(f"💾 Vector database: {self._vector_db_path}") | ||||||
|
||||||
| logger.info(f"💾 Vector database: {self._vector_db_path}") | |
| logger.info(f"💾 Vector database: {indexer._vector_db_path}") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The requirement text has a grammar issue: "shown them" should be "show them" (or "display them").