diff --git a/.gitignore b/.gitignore index b2990a2..06a9128 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,7 @@ test-results/ efile_app/screenshots efile_app/playwright-report efile_app/tmp + +# Cookie jars left behind by curl-based manual testing +session_cookies.txt +*.cookies diff --git a/README.md b/README.md index 5830f80..27dcae6 100644 --- a/README.md +++ b/README.md @@ -273,12 +273,10 @@ The Playwright configuration includes several important settings: npx playwright test ``` -- __Run specific tests__: +- __Run one spec__: ```bash cd efile_app - npx playwright test tests/expert-form-name-change.spec.js - npx playwright test tests/expert-form-order-of-protection.spec.js - npx playwright test tests/expert-form-forfeiture-of-seized-property.spec.js + npx playwright test tests/reorganized-filing-matrix.spec.js ``` - __Run with UI mode__ (interactive): @@ -313,17 +311,17 @@ The `test-utils.js` module provides two login methods: ### Available tests -- **`expert-form-name-change.spec.js`**: Tests the complete workflow for filing a name change case -- **`expert-form-order-of-protection.spec.js`**: Tests the complete workflow for filing an order of protection case -- **`expert-form-forfeiture-of-seized-property.spec.js`**: Tests the complete workflow for filing a forfeiture of seized property case - -All tests: -1. Use shared login utilities from `test-utils.js` -2. Navigate to the appropriate expert form section -3. Fill out court selection and case details -4. Complete required party information -5. Verify the document upload page loads correctly -6. Take screenshots for visual verification +- **`reorganized-filing-matrix.spec.js`**: Files one envelope per scenario through + the whole workflow -- start a filing, upload a document, confirm the case codes, + answer the people and fees screens, submit -- for both new cases and filings into + an existing case, across a matrix of Illinois courts and case types. + +It really files, in the Tyler test EFSP, so it is skipped unless you ask for it: + +```bash +cd efile_app +RUN_FILING_MATRIX=1 npx playwright test tests/reorganized-filing-matrix.spec.js +``` Screenshots are saved to `screenshots/` directory and excluded from git via `.gitignore`. diff --git a/efile_app/efile/api/filing_views.py b/efile_app/efile/api/filing_views.py index 2bffe3d..b6f9317 100644 --- a/efile_app/efile/api/filing_views.py +++ b/efile_app/efile/api/filing_views.py @@ -21,10 +21,6 @@ logger = logging.getLogger(__name__) -# TODO(brycew): this file doesn't work in it's current state. Keeping -# around for later refactors, when we inevitably want to start letting users -# handle filings themselves / see current status, etc. - def get_tyler_token(request, jurisdiction=None): """Helper method to retrieve Tyler token from various sources""" @@ -120,6 +116,16 @@ def get_filings(request): def convert_filing_data(filing): data = {} data["filing_status"] = filing.get("filingStatus").get("filingStatusCode") + # Tyler identifies the same filing two ways: the envelope number a clerk + # quotes over the phone, and the FILINGID the detail endpoint wants. The + # detail endpoint answers 422 to an envelope number, so keep both. + for identifier in filing.get("documentIdentification") or []: + category = ((identifier.get("identificationCategory") or {}).get("value") or {}).get("value", "") + value = (identifier.get("identificationID") or {}).get("value", "") + if str(category).upper() == "FILINGID": + data["filing_id"] = value + elif str(category).upper() == "ENVELOPEID": + data["envelope_id"] = value data["filing_status_text"] = next(iter(filing.get("filingStatus").get("statusDescriptionText"))).get("value") data["case_tracking_id"] = filing.get("caseTrackingID").get("value") data["filed_timestamp"] = filing.get("documentFiledDate").get("dateRepresentation").get("value").get("value") @@ -138,42 +144,6 @@ def convert_filing_data(filing): # TODO(brycew): submitted by? return data - @staticmethod - @require_http_methods(["POST"]) - @csrf_exempt - def create_filing(request): - """Create a new filing""" - try: - data = json.loads(request.body) - - # Validate required fields - required_fields = ["case_category", "case_type", "filing_type", "county"] - missing_fields = [field for field in required_fields if not data.get(field)] - - if missing_fields: - return FilingAPIViews.error_response(f"Missing required fields: {', '.join(missing_fields)}") - - # API call to create filing - api_url = "https://suffolkefile.com/api/filings" - logger.debug("POST %s payload keys=%s", api_url, list(data.keys())) - response = requests.post(api_url, json=data, timeout=30) - logger.debug( - "Create filing response: status=%s content_type=%s", - response.status_code, - response.headers.get("Content-Type"), - ) - - if response.status_code == 201: - filing_data = response.json() - return FilingAPIViews.success_response(filing_data, "Filing created successfully") - else: - return FilingAPIViews.error_response("Failed to create filing") - - except json.JSONDecodeError: - return FilingAPIViews.error_response("Invalid JSON data") - except Exception as e: - return FilingAPIViews.error_response(f"Error: {str(e)}") - @staticmethod @require_http_methods(["POST"]) @csrf_exempt @@ -249,93 +219,7 @@ def payment_fees(request): except Exception as e: return FilingAPIViews.error_response(f"Error: {str(e)}") - @staticmethod - @require_http_methods(["GET"]) - def get_filing_detail(request, filing_id): - """Get details for a specific filing""" - try: - # API call to get filing details - api_url = f"https://suffolkefile.com/api/filings/{filing_id}" - response = requests.get(api_url, timeout=30) - logger.debug( - "Filing detail response: status=%s content_type=%s", - response.status_code, - response.headers.get("Content-Type"), - ) - - if response.status_code == 200: - filing_data = response.json() - return FilingAPIViews.success_response(filing_data) - elif response.status_code == 404: - return FilingAPIViews.error_response("Filing not found", 404) - else: - return FilingAPIViews.error_response("Failed to fetch filing details") - - except Exception as e: - return FilingAPIViews.error_response(f"Error: {str(e)}") - - @staticmethod - @require_http_methods(["PUT"]) - @csrf_exempt - def update_filing(request, filing_id): - """Update an existing filing""" - try: - data = json.loads(request.body) - - # API call to update filing - api_url = f"https://suffolkefile.com/api/filings/{filing_id}" - logger.debug("PUT %s payload keys=%s", api_url, list(data.keys())) - response = requests.put(api_url, json=data, timeout=30) - logger.debug( - "Update filing response: status=%s content_type=%s", - response.status_code, - response.headers.get("Content-Type"), - ) - - if response.status_code == 200: - filing_data = response.json() - return FilingAPIViews.success_response(filing_data, "Filing updated successfully") - elif response.status_code == 404: - return FilingAPIViews.error_response("Filing not found", 404) - else: - return FilingAPIViews.error_response("Failed to update filing") - - except json.JSONDecodeError: - return FilingAPIViews.error_response("Invalid JSON data") - except Exception as e: - return FilingAPIViews.error_response(f"Error: {str(e)}") - - @staticmethod - @require_http_methods(["DELETE"]) - @csrf_exempt - def delete_filing(request, filing_id): - """Delete a filing""" - try: - # API call to delete filing - api_url = f"https://suffolkefile.com/api/filings/{filing_id}" - logger.debug("DELETE %s", api_url) - response = requests.delete(api_url, timeout=30) - logger.debug( - "Delete filing response: status=%s content_type=%s", - response.status_code, - response.headers.get("Content-Type"), - ) - - if response.status_code == 204: - return FilingAPIViews.success_response({}, "Filing deleted successfully") - elif response.status_code == 404: - return FilingAPIViews.error_response("Filing not found", 404) - else: - return FilingAPIViews.error_response("Failed to delete filing") - - except Exception as e: - return FilingAPIViews.error_response(f"Error: {str(e)}") - # Individual view functions for URL mapping get_filings = FilingAPIViews.get_filings -create_filing = FilingAPIViews.create_filing payment_fees = FilingAPIViews.payment_fees -get_filing_detail = FilingAPIViews.get_filing_detail -update_filing = FilingAPIViews.update_filing -delete_filing = FilingAPIViews.delete_filing diff --git a/efile_app/efile/api/urls.py b/efile_app/efile/api/urls.py index d786d07..2c860db 100644 --- a/efile_app/efile/api/urls.py +++ b/efile_app/efile/api/urls.py @@ -25,7 +25,7 @@ get_optional_services, get_party_types, ) -from .filing_views import create_filing, delete_filing, get_filing_detail, get_filings, payment_fees, update_filing +from .filing_views import get_filings, payment_fees from .s3_upload import ( mock_s3_upload, simple_s3_upload, @@ -40,7 +40,6 @@ path("simple-s3-upload/", simple_s3_upload, name="simple_s3_upload"), path("mock-s3-upload/", mock_s3_upload, name="mock_s3_upload"), path("test-s3-connection/", test_s3_connection, name="test_s3_connection"), - # path("api/create-filing/", create_filing, name="create_filing"), # Dropdown API endpoints path("dropdowns/case-categories/", get_case_categories, name="case_categories"), path("dropdowns/case-types/", get_case_types, name="case_types"), @@ -68,8 +67,4 @@ path("payment-fees/", payment_fees, name="payment_fees"), # Filing API endpoints path("filings/", get_filings, name="get_filings"), - path("filings/create/", create_filing, name="create_filing"), - path("filings//", get_filing_detail, name="filing_detail"), - path("filings//update/", update_filing, name="update_filing"), - path("filings//delete/", delete_filing, name="delete_filing"), ] diff --git a/efile_app/efile/migrations/0013_archived_cases.py b/efile_app/efile/migrations/0013_archived_cases.py new file mode 100644 index 0000000..49325f0 --- /dev/null +++ b/efile_app/efile/migrations/0013_archived_cases.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.5 on 2026-08-18 00:37 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('efile', '0012_filing_plans'), + ] + + operations = [ + migrations.CreateModel( + name='ArchivedCase', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('jurisdiction', models.CharField(db_index=True, max_length=40)), + ('case_tracking_id', models.CharField(max_length=255)), + ('docket_number', models.CharField(blank=True, max_length=255)), + ('case_title', models.CharField(blank=True, max_length=500)), + ('archived_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='archived_cases', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-archived_at'], + 'constraints': [models.UniqueConstraint(fields=('user', 'jurisdiction', 'case_tracking_id'), name='unique_archived_case_per_user')], + }, + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index f849131..4946149 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -119,6 +119,44 @@ def is_linked_to_a_case(self) -> bool: return bool(self.case_tracking_id and self.docket_number) +class ArchivedCase(models.Model): + """A court case the filer has told us to keep out of the way. + + "My cases" is built from the court's own filing history, which we do not + own and cannot write to: an attorney with three hundred filings sees all + three hundred, forever. Archiving is therefore ours to remember -- one row + per case a filer has tidied away, and the case itself is untouched. Nothing + is deleted or hidden from the court; the list simply stops leading with it, + and the filer can still ask to see everything. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="archived_cases", + ) + jurisdiction = models.CharField(max_length=40, db_index=True) + # Tyler's permanent identifier for the case. Docket numbers are the human + # name for it and can be reissued or corrected, so they are display only. + case_tracking_id = models.CharField(max_length=255) + docket_number = models.CharField(max_length=255, blank=True) + case_title = models.CharField(max_length=500, blank=True) + + archived_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-archived_at"] + constraints = [ + models.UniqueConstraint( + fields=["user", "jurisdiction", "case_tracking_id"], + name="unique_archived_case_per_user", + ) + ] + + def __str__(self): + return self.docket_number or self.case_title or f"Archived case #{self.pk}" + + class FilingDraft(models.Model): """Durable aggregate for a single in-progress or submitted court filing.""" diff --git a/efile_app/efile/services/filings.py b/efile_app/efile/services/filings.py new file mode 100644 index 0000000..6329182 --- /dev/null +++ b/efile_app/efile/services/filings.py @@ -0,0 +1,449 @@ +"""What the court says about filings this account has already sent. + +Everything here reads the EFSP's record rather than ours. Two shapes come back +from it: the *filing list*, which is thin (status, dates, case number) and is +what "My cases" is built from, and the *filing detail*, which is where a clerk's +rejection comment and the links to the actual documents live. + +The detail payload is ECF 4 XML rendered into JSON, so it is nested three or +four levels deep in ``{"value": {"value": ...}}`` wrappers and every branch can +be null. ``describe_filing_detail`` flattens exactly the parts a filer needs +into plain dictionaries; screens never walk the raw payload themselves. + +The document links come from Tyler, are unauthenticated but unguessable, and +stop working 90 days after filing -- which is why the filer is told that rather +than left to find out. We do not keep copies of filed documents ourselves yet. +""" + +from __future__ import annotations + +import logging +import time +from datetime import date, datetime, timezone +from typing import Any + +import requests +from django.conf import settings + +from efile.api.filing_views import get_tyler_token, list_filing_data +from efile.models import ArchivedCase +from efile.utils.config_loader import config_loader +from efile.utils.proxy_connection import get_headers + +logger = logging.getLogger(__name__) + +# How far back "My cases" looks. Tyler requires a start date, and a filer who +# has been with a matter for years should still see where it started. +CASE_HISTORY_YEARS = 5 + +# Tyler deletes the download links it hands out this long after a filing. +DOCUMENT_LINK_DAYS = 90 + +# Court code lists change rarely and cost a round trip each time. +_COURT_NAME_TTL_SECONDS = 60 * 60 +_court_name_cache: dict[str, tuple[float, dict[str, str]]] = {} + + +STATUS_PRESENTATION: dict[str, dict[str, str]] = { + "accepted": {"label": "Accepted", "icon": "fa-file-circle-check", "tone": "accepted"}, + "rejected": {"label": "Rejected", "icon": "fa-file-circle-exclamation", "tone": "rejected"}, + "returned": {"label": "Returned for changes", "icon": "fa-file-circle-exclamation", "tone": "rejected"}, + "under-review": {"label": "Under review", "icon": "fa-file-pen", "tone": "pending"}, + "reviewed": {"label": "Reviewed", "icon": "fa-file-pen", "tone": "pending"}, + "submitted": {"label": "Waiting on the court", "icon": "fa-file", "tone": "pending"}, + "submitting": {"label": "Sending to the court", "icon": "fa-paper-plane", "tone": "pending"}, + "receipted": {"label": "Received by the court", "icon": "fa-file", "tone": "pending"}, + "served": {"label": "Served", "icon": "fa-envelope-circle-check", "tone": "accepted"}, + "cancelled": {"label": "Cancelled", "icon": "fa-file-circle-xmark", "tone": "muted"}, + "failed": {"label": "Did not go through", "icon": "fa-file-circle-xmark", "tone": "rejected"}, +} + +UNKNOWN_STATUS = {"label": "Sent to the court", "icon": "fa-file", "tone": "pending"} + + +def status_presentation(status_code: Any) -> dict[str, str]: + """How one filing status is named and coloured for a filer. + + Tyler publishes a longer list of codes than any one court uses, and adds to + it. An unrecognized code still describes a real filing, so it gets neutral + wording instead of being dropped. + """ + + return STATUS_PRESENTATION.get(str(status_code or "").strip().lower(), UNKNOWN_STATUS) + + +def _dig(node: Any, *keys: str) -> Any: + """Follow a path through the payload, stopping at the first missing branch.""" + + for key in keys: + if not isinstance(node, dict): + return None + node = node.get(key) + return node + + +def _text(node: Any, *keys: str) -> str: + """Read one ``{"value": ...}``-wrapped string, or "" when it is not there.""" + + value = _dig(node, *keys) if keys else node + if isinstance(value, dict): + value = value.get("value") + if isinstance(value, dict): + value = value.get("value") + return "" if value is None else str(value) + + +def _timestamp(node: Any) -> datetime | None: + """Turn one of the payload's epoch-millisecond dates into a datetime.""" + + raw = _dig(node, "dateRepresentation", "value", "value") + if raw is None: + return None + try: + return datetime.fromtimestamp(int(raw) / 1000, tz=timezone.utc) + except (TypeError, ValueError, OSError, OverflowError): + return None + + +def history_start_date() -> str: + """The oldest filing date "My cases" asks the court for.""" + + today = date.today() + try: + return today.replace(year=today.year - CASE_HISTORY_YEARS).isoformat() + except ValueError: # February 29 + return today.replace(year=today.year - CASE_HISTORY_YEARS, day=28).isoformat() + + +def court_names(jurisdiction: str) -> dict[str, str]: + """Map court codes to the names a person recognizes. + + The filing list names courts by code ("cook:cd1"). This is display only, so + a failure here costs the reader a friendly name and nothing else. + """ + + cached = _court_name_cache.get(jurisdiction) + now = time.monotonic() + if cached and now - cached[0] < _COURT_NAME_TTL_SECONDS: + return cached[1] + + url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/" + try: + response = requests.get(url, params={"with_names": True}, timeout=10) + response.raise_for_status() + data = response.json() + except (OSError, ValueError): + logger.warning("Could not load court names for jurisdiction %s", jurisdiction) + return cached[1] if cached else {} + + names = { + str(court["code"]): str(court.get("name") or court["code"]) + for court in data + if isinstance(court, dict) and court.get("code") + } + _court_name_cache[jurisdiction] = (now, names) + return names + + +def court_contact(jurisdiction: str, court_code: str) -> dict[str, str]: + """Who to ask about a filing at this court. + + A filer whose filing was rejected needs a person, not a status code. The + court code lists carry no contact details at all, so this comes from the + partner's configuration: a ``contact`` block under the court in + ``court_specific_requirements``, falling back to the jurisdiction-wide help + line. Nothing is invented -- a court with no configured contact simply shows + no contact. + """ + + config = config_loader.load_jurisdiction_config(jurisdiction) or {} + court = (config.get("court_specific_requirements") or {}).get(court_code) or {} + contact = dict(court.get("contact") or {}) + + jurisdiction_help = config.get("jurisdiction") or {} + contact.setdefault("url", jurisdiction_help.get("help_url", "")) + contact.setdefault("phone", jurisdiction_help.get("help_number", "")) + return {key: str(value) for key, value in contact.items() if value} + + +def archived_case_ids(user, jurisdiction: str) -> set[str]: + return set( + ArchivedCase.objects.filter(user=user, jurisdiction=jurisdiction).values_list("case_tracking_id", flat=True) + ) + + +def archive_case(user, jurisdiction: str, case_tracking_id: str, *, docket_number: str = "", case_title: str = ""): + """Tidy one case away. Archiving twice is not an error.""" + + archived, _ = ArchivedCase.objects.get_or_create( + user=user, + jurisdiction=jurisdiction, + case_tracking_id=case_tracking_id, + defaults={"docket_number": docket_number[:255], "case_title": case_title[:500]}, + ) + return archived + + +def unarchive_case(user, jurisdiction: str, case_tracking_id: str) -> int: + deleted, _ = ArchivedCase.objects.filter( + user=user, jurisdiction=jurisdiction, case_tracking_id=case_tracking_id + ).delete() + return deleted + + +def describe_filing(filing: dict[str, Any], names: dict[str, str] | None = None) -> dict[str, Any]: + """One row of the filing list, ready to render.""" + + names = names or {} + court_code = str(filing.get("court_code") or "") + return { + "filing_id": filing.get("filing_id", ""), + "envelope_id": filing.get("envelope_id", ""), + "court_code": court_code, + "court_name": names.get(court_code, court_code), + "filing_code": filing.get("filing_code", ""), + "status": filing.get("filing_status", ""), + "status_presentation": status_presentation(filing.get("filing_status")), + "received_at": _epoch_millis_to_datetime(filing.get("received_timestamp")), + "filed_at": _epoch_millis_to_datetime(filing.get("filed_timestamp")), + } + + +def _epoch_millis_to_datetime(raw: Any) -> datetime | None: + if raw in (None, ""): + return None + try: + return datetime.fromtimestamp(int(raw) / 1000, tz=timezone.utc) + except (TypeError, ValueError, OSError, OverflowError): + return None + + +def _sort_key(entry: dict[str, Any]) -> float: + latest = entry.get("latest_activity") + return latest.timestamp() if latest else 0.0 + + +def cases_for_user( + request, + jurisdiction: str, + *, + start_date: str | None = None, +) -> list[dict[str, Any]]: + """Group this account's filing history into the cases it belongs to. + + A filer thinks in cases, not envelopes: an eviction with six filings is one + thing they are dealing with. Filings that never reached a case (rejected + before the court indexed one) keep their own entry rather than vanishing. + """ + + filings = list_filing_data(request, jurisdiction, start_date=start_date or history_start_date()) + names = court_names(jurisdiction) + archived = archived_case_ids(request.user, jurisdiction) + + cases: dict[str, dict[str, Any]] = {} + for filing in filings: + tracking_id = str(filing.get("case_tracking_id") or "") + key = tracking_id or f"filing:{filing.get('filing_id') or filing.get('envelope_id') or len(cases)}" + described = describe_filing(filing, names) + entry = cases.setdefault( + key, + { + "case_tracking_id": tracking_id, + "case_title": filing.get("case_title", ""), + "docket_number": filing.get("case_number", ""), + "court_code": described["court_code"], + "court_name": described["court_name"], + "filings": [], + "latest_activity": None, + "is_archived": bool(tracking_id) and tracking_id in archived, + }, + ) + # The court fills in a case title and number once it indexes the case, + # so later filings in the same case know more than the first one did. + entry["case_title"] = entry["case_title"] or filing.get("case_title", "") + entry["docket_number"] = entry["docket_number"] or filing.get("case_number", "") + entry["filings"].append(described) + activity = described["received_at"] or described["filed_at"] + if activity and (entry["latest_activity"] is None or activity > entry["latest_activity"]): + entry["latest_activity"] = activity + + for entry in cases.values(): + entry["filings"].sort( + key=lambda filing: filing["received_at"] or datetime.min.replace(tzinfo=timezone.utc), reverse=True + ) + entry["filing_count"] = len(entry["filings"]) + entry["latest_status"] = entry["filings"][0]["status_presentation"] if entry["filings"] else UNKNOWN_STATUS + + return sorted(cases.values(), key=_sort_key, reverse=True) + + +def fetch_filing_detail(request, jurisdiction: str, court_code: str, filing_id: str) -> dict[str, Any] | None: + """Ask the EFSP for everything the court knows about one filing. + + The filing ID is Tyler's FILINGID (a GUID), not the envelope number: the + detail endpoint rejects the envelope number with a 422. + """ + + url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/filingreview/courts/{court_code}/filings/{filing_id}" + headers = get_headers() + tyler_token = get_tyler_token(request, jurisdiction) + if tyler_token: + headers[f"tyler-token-{jurisdiction}"] = tyler_token + else: + logger.info("No Tyler token for jurisdiction %s in filing-detail request", jurisdiction) + return None + + response = requests.get(url, headers=headers, timeout=30) + if not response.ok: + logger.info( + "Filing detail request failed: status=%s court=%s jurisdiction=%s", + response.status_code, + court_code, + jurisdiction, + ) + return None + payload = response.json() + return payload if isinstance(payload, dict) else None + + +# What Tyler calls each copy of a document it hands back, and what that copy is +# to the filer. "Original" is what they uploaded; anything the court transmitted +# or filed is the court's own copy, which carries the file stamp once accepted. +_SUBMITTED_PREFIXES = ("original",) +_COURT_PREFIXES = ("transmitted", "filed", "stamped", "service") + + +def _attachment_kind(description: str) -> str: + label = description.strip().lower() + if label.startswith(_SUBMITTED_PREFIXES): + return "submitted" + if label.startswith(_COURT_PREFIXES): + return "court" + return "other" + + +def _describe_attachment(attachment: Any, *, accepted: bool) -> dict[str, str] | None: + url = _text(attachment, "binaryLocationURI") + description = _text(attachment, "binaryDescriptionText") + if not url: + return None + kind = _attachment_kind(description) + # Tyler writes "Original - petition.pdf"; the half after the dash is the file. + filename = description.split(" - ", 1)[1] if " - " in description else description + if kind == "submitted": + label = "The copy you sent" + elif kind == "court": + label = "The court's file-stamped copy" if accepted else "The copy the court received" + else: + label = description or "Document" + return {"kind": kind, "label": label, "filename": filename, "description": description, "url": url} + + +def _describe_document_comments(document: Any) -> list[dict[str, str]]: + """The clerk's own words about one document, when there are any. + + Tyler puts the comment in ``statusText`` and the *kind* of comment in + ``statusDescriptionText`` ("RejectComments", "AcceptComments"), and sends + the kind with an empty comment far more often than not. + """ + + status = _dig(document, "documentStatus") or {} + text = _text(status, "statusText").strip() + if not text: + return [] + kinds = [_text(entry).strip().lower() for entry in (_dig(status, "statusDescriptionText") or [])] + if any("reject" in kind for kind in kinds): + kind, heading = "rejection", "Why the court rejected this" + elif any("accept" in kind for kind in kinds): + kind, heading = "acceptance", "Note from the court" + else: + kind, heading = "other", "Note from the court" + return [{"kind": kind, "heading": heading, "text": text}] + + +def _describe_fees(payload: dict[str, Any]) -> list[dict[str, str]]: + """The charges on the envelope, keeping only the lines that are not zero. + + Tyler returns a dozen totals for every filing, almost all of them $0.00. A + filer wants to know what they were charged, so a zero-fee filing says that + once rather than twelve times. + """ + + charges: list[dict[str, str]] = [] + for fee_group in payload.get("envelopeFees") or []: + for charge in _dig(fee_group, "allowanceCharge") or []: + reason = _text(charge, "allowanceChargeReason") + amount = _dig(charge, "amount", "value") + if reason and isinstance(amount, int | float) and amount: + charges.append({"reason": reason, "amount": f"{amount:.2f}"}) + return charges + + +def describe_filing_detail( + payload: dict[str, Any] | None, names: dict[str, str] | None = None +) -> dict[str, Any] | None: + """Flatten the EFSP's filing detail into what one screen needs. + + Every field here is optional in the payload, and several of them are absent + on filings the court has not looked at yet. Nothing raises: a missing branch + reads as "the court did not say". + """ + + if not payload: + return None + + names = names or {} + status_code = _text(payload, "filingStatus", "filingStatusCode") + accepted = status_code.strip().lower() == "accepted" + identifiers = { + _text(entry, "identificationCategory", "value").strip().upper(): _text(entry, "identificationID") + for entry in payload.get("documentIdentification") or [] + } + court_code = _text(_dig(payload, "caseCourt", "organizationIdentification", "value"), "identificationID") + case = _dig(payload, "case", "value") or {} + + documents = [] + for document in payload.get("filingLeadDocument") or []: + attachments = [ + described + for rendition in document.get("documentRendition") or [] + for attachment in _dig(rendition, "documentRenditionMetadata", "documentAttachment") or [] + if (described := _describe_attachment(attachment, accepted=accepted)) + ] + documents.append( + { + "description": _text(document, "documentDescriptionText"), + "comments": _describe_document_comments(document), + "attachments": attachments, + } + ) + + submitter = _dig(payload, "documentSubmitter", "entityRepresentation", "value") or {} + submitter_name = " ".join( + part + for part in ( + _text(submitter, "personName", "personGivenName"), + _text(submitter, "personName", "personSurName"), + ) + if part + ) + + return { + "filing_id": identifiers.get("FILINGID", ""), + "envelope_id": identifiers.get("ENVELOPEID", ""), + "status": status_code, + "status_presentation": status_presentation(status_code), + "status_description": _text(next(iter(_dig(payload, "filingStatus", "statusDescriptionText") or []), None)), + "court_code": court_code, + "court_name": names.get(court_code, court_code), + "case_title": _text(case, "caseTitleText"), + "docket_number": _text(case, "caseDocketID"), + "submitted_at": _timestamp(payload.get("filingSubmissionDate")), + "accepted_at": _timestamp(payload.get("filingAcceptDate")), + "submitter_name": submitter_name, + "submitter_firm": _text(submitter, "firmName"), + "fees": _describe_fees(payload), + "fee_waiver": bool(_dig(payload, "payment", "waiverIndicator", "value")), + "documents": documents, + "comments": [comment for document in documents for comment in document["comments"]], + } diff --git a/efile_app/efile/static/config/README.md b/efile_app/efile/static/config/README.md index fa81447..9237157 100644 --- a/efile_app/efile/static/config/README.md +++ b/efile_app/efile/static/config/README.md @@ -309,6 +309,27 @@ court_specific_requirements: - **Bond Court (bond)**: Both sections are hidden, and "Required Parties" header is automatically hidden - **Other courts**: Petitioner shows by default, Name Sought hidden by default (unless configured otherwise) +### Who the filer should call about a filing + +When a clerk rejects a filing, the only person who can explain it works at the +court. The e-filing code lists carry no contact details at all, so a court's +phone number, email, or page lives here, under the same court key: + +```yaml +court_specific_requirements: + "cook:cd1": + contact: + name: "Clerk of the Circuit Court of Cook County - County Division" + phone: "312-555-0100" + email: "countydivision@example.gov" + url: "https://www.cookcountyclerkofcourt.org/" +``` + +Everything in the block is optional. What is configured shows on the filing +details screen ("My cases" → a filing); anything missing falls back to the +jurisdiction-wide `help_url` and `help_number`, and a court with neither shows no +contact rather than a guess. + ## Document checklists A checklist tells the filer which documents a case like theirs usually needs. It diff --git a/efile_app/efile/static/css/components/search-dropdown.css b/efile_app/efile/static/css/components/search-dropdown.css deleted file mode 100644 index d093612..0000000 --- a/efile_app/efile/static/css/components/search-dropdown.css +++ /dev/null @@ -1,175 +0,0 @@ -/* Search Dropdown Component Styles */ - -.search-dropdown-container { - position: relative; -} - -.search-dropdown-input { - width: 100%; - border-radius: 0.375rem; - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} - -.search-dropdown-input:focus { - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(44, 90, 160, 0.25); -} - -.search-dropdown-input:disabled { - background-color: #e9ecef; - opacity: 0.65; - cursor: not-allowed; -} - -.search-dropdown-results { - position: absolute; - top: 100%; - left: 0; - right: 0; - background: #ffffff; - border: 1px solid #ced4da; - border-top: none; - border-radius: 0 0 0.375rem 0.375rem; - max-height: 200px; - overflow-y: auto; - z-index: 999; - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); - margin-top: 0; -} - -.search-dropdown-item { - padding: 0.75rem; - cursor: pointer; - border-bottom: 1px solid var(--light-blue-background); - transition: background-color 0.15s ease-in-out; - background-color: #ffffff; - color: #212529; -} - -.search-dropdown-item:hover, -.search-dropdown-item.highlighted { - background-color: #e9ecef; - color: #212529; -} - -.search-dropdown-item:last-child { - border-bottom: none; - border-radius: 0 0 0.375rem 0.375rem; -} - -.search-dropdown-item strong { - background-color: #fff3cd; - padding: 0.125rem 0.25rem; - border-radius: 0.25rem; - font-weight: 600; - color: #212529; -} - -.search-no-results { - padding: 0.75rem; - color: #6c757d; - font-style: italic; - text-align: center; - background-color: #ffffff; -} - -.search-dropdown-selected { - position: relative; - top: 0; - left: 0; - right: 0; - background: #ffffff; - border: 1px solid #ced4da; - border-radius: 0.375rem; - padding: 0.375rem 2.5rem 0.375rem 0.75rem; - display: none; - align-items: center; - justify-content: space-between; - min-height: calc(1.5em + 0.75rem + 2px); - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - margin-bottom: 0; -} - -.search-dropdown-selected:focus-within { - border-color: #86b7fe; - box-shadow: 0 0 0 0.25rem rgba(44, 90, 160, 0.25); -} - -.search-dropdown-selected .selected-text { - flex: 1; - color: #495057; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.search-dropdown-selected .btn-clear { - position: absolute; - right: 0.75rem; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: #6c757d; - font-size: 1.25rem; - cursor: pointer; - padding: 0; - line-height: 1; - width: 1.5rem; - height: 1.5rem; - display: flex; - align-items: center; - justify-content: center; - transition: color 0.15s ease-in-out; -} - -.search-dropdown-selected .btn-clear:hover { - color: #495057; -} - -.search-dropdown-selected .btn-clear:focus { - outline: 2px solid #86b7fe; - outline-offset: 2px; - border-radius: 0.25rem; -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - .search-dropdown-results { - max-height: 200px; - } - - .search-dropdown-item { - padding: 0.5rem; - } - - .search-no-results { - padding: 0.5rem; - } -} - -/* Accessibility improvements */ -.search-dropdown-input[aria-expanded="true"] { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} - -/* Loading state */ -.search-dropdown-container.loading .search-dropdown-input { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23007bff' viewBox='0 0 16 16'%3E%3Cpath d='M8 3.5a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5H4a.5.5 0 0 1 0-1h3.5V4a.5.5 0 0 1 .5-.5z'/%3E%3Cpath d='M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm0-1A7 7 0 1 1 8 1a7 7 0 0 1 0 14z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 0.75rem center; - background-size: 1rem; - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} \ No newline at end of file diff --git a/efile_app/efile/static/css/expert_form.css b/efile_app/efile/static/css/expert_form.css deleted file mode 100644 index a0b5e14..0000000 --- a/efile_app/efile/static/css/expert_form.css +++ /dev/null @@ -1,191 +0,0 @@ -.form-container { - background: white; - padding: 2rem; - margin: 2rem auto; - max-width: 800px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); -} - -.section-header { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 0.5rem; - - font-size: 2rem; -} - -.section-description { - color: #6c757d; - font-size: 0.95rem; - margin-bottom: 1.5rem; -} - -.optional { - color: #6c757d; - font-size: 0.85rem; - font-style: italic; - margin-left: 0.5rem; -} - -.form-label { - font-weight: 500; - font-size: 1.25rem; - color: #495057; - margin-bottom: 0; -} - -.form-select, -.form-control { - border: 1px solid #ced4da; - border-radius: 4px; - padding: 0.5rem 0.75rem; -} - -.form-select:focus, -.form-control:focus { - border-color: var(--primary-btn-color); - box-shadow: 0 0 0 0.2rem rgba(44, 90, 160, 0.25); -} - -.btn-outline-secondary { - color: #6c757d; - border-color: #6c757d; -} - -.btn-outline-secondary:hover { - background-color: #6c757d; - border-color: #6c757d; -} - -.btn-primary { - background-color: var(--primary-btn-color); - border-color: var(--primary-btn-color); -} - -.btn-primary { - --bs-btn-disabled-color: #ffffff; - --bs-btn-disabled-bg: #333333; - --bs-btn-disabled-border-color: #666666; - --bs-btn-disabled-opacity: -} - -.checkbox-group { - background-color: var(--light-blue-background); - border: 1px solid #e9ecef; - border-radius: 4px; - padding: 1rem; - margin-top: 1rem; -} - -.form-check { - margin-bottom: 0.5rem; -} - -.form-check-input { - margin-top: 0.25rem; -} - -.form-check-label { - font-size: 0.95rem; - color: #495057; -} - -.party-section { - background-color: var(--light-blue-background); - border: 1px solid #e9ecef; - border-radius: 4px; - padding: 1.5rem; - margin: 1rem 0; -} - -.party-title { - font-weight: 600; - color: #495057; - margin-bottom: 1rem; -} - -.loading-spinner { - color: var(--primary-btn-color); - font-size: 0.9rem; - margin-top: 0.5rem; -} - -.dropdown-field:disabled { - background-color: var(--light-blue-background); - opacity: 0.65; -} - -.recommendation-notice { - font-size: 0.875rem !important; - padding: 8px 12px !important; - margin: 1rem 0 2rem 0 !important; - border: 1px solid var(--success); - background-color: #d4edda; - color: #155724; - border-radius: 4px; - animation: fadeInOut 4s ease-in-out; -} - -.recommendation-notice i { - color: #ffd700; - margin-right: 6px; -} - -@keyframes fadeInOut { - 0% { - opacity: 0; - transform: translateY(-10px); - } - - 15% { - opacity: 1; - transform: translateY(0); - } - - 85% { - opacity: 1; - transform: translateY(0); - } - - 100% { - opacity: 0; - transform: translateY(-10px); - } -} - -option.recommended { - font-weight: bold; - background-color: #fff3cd; -} - -.court-container { - position: relative; -} - - -.subsection-header { - margin-top: 3.5rem !important; - margin-bottom: 1rem !important; -} - -.subsection-header:first-of-type { - margin-top: 2rem !important; -} - -/* Increase spacing between form rows */ -.row.mb-3 { - margin-bottom: 2.5rem !important; -} - -/* Add more space before the button section */ -.d-flex.justify-content-between.mt-4 { - margin-top: 4rem !important; - padding-top: 1rem; - border-top: 1px solid #e9ecef; -} - -h4 { - margin-top: 2rem; - margin-bottom: 1rem !important; - color: var(--primary-btn-color); -} \ No newline at end of file diff --git a/efile_app/efile/static/css/header.css b/efile_app/efile/static/css/header.css index 9161b86..b8970ce 100644 --- a/efile_app/efile/static/css/header.css +++ b/efile_app/efile/static/css/header.css @@ -90,4 +90,34 @@ font-size: 1.8rem; font-weight: 600; margin: 0; +} + +.header-menus { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.nav-menu-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 40px; + padding: 0 1rem; + border: 2px solid transparent; + border-radius: 20px; + background-color: white; + color: var(--suffolk-blue); + font-weight: 600; + cursor: pointer; +} + +.nav-menu-toggle:hover { + background-color: #f3f4f6; +} + +.nav-menu-toggle:focus-visible { + border-color: white; + outline: 3px solid var(--suffolk-blue); + outline-offset: 2px; } \ No newline at end of file diff --git a/efile_app/efile/static/css/my_lists.css b/efile_app/efile/static/css/my_lists.css new file mode 100644 index 0000000..96b1ca6 --- /dev/null +++ b/efile_app/efile/static/css/my_lists.css @@ -0,0 +1,165 @@ +/* Shared styling for the "my things" screens: draft e-filings, cases, and one + filing's details. They are all the same shape -- a card per item, a status on + the right, and actions along the bottom. */ + +.list-card__head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.list-card__head h2 { + font-size: 1.25rem; + margin-bottom: 0.25rem; +} + +.list-card__detail { + color: #4b5563; + font-size: 0.95rem; + margin: 0 0 0.25rem; +} + +.list-card__badge { + background-color: #eef2ff; + border-radius: 999px; + color: var(--suffolk-blue); + font-size: 0.8rem; + font-weight: 600; + padding: 0.25rem 0.75rem; + white-space: nowrap; +} + +.list-card__status { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + white-space: nowrap; +} + +.list-card__status--accepted { + color: var(--success); +} + +.list-card__status--rejected { + color: var(--failure); +} + +.list-card__status--pending, +.list-card__status--muted { + color: #4b5563; +} + +.list-card__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + +.list-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + +.case-filings { + list-style: none; + margin: 1rem 0 0; + padding: 0; +} + +.case-filing { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + border-top: 1px solid #e5e7eb; + padding: 0.75rem 0; +} + +.case-filing__status { + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-width: 12rem; + font-size: 0.9rem; + font-weight: 600; +} + +.case-filing__status--accepted { + color: var(--success); +} + +.case-filing__status--rejected { + color: var(--failure); +} + +.case-filing__status--pending, +.case-filing__status--muted { + color: #4b5563; +} + +.case-filing__what { + display: flex; + flex-direction: column; + flex: 1; +} + +.case-filing__what small { + color: #6b7280; +} + +.clerk-comment h2 { + font-size: 1.1rem; +} + +.clerk-comment--rejection { + border-left: 4px solid var(--failure); +} + +.clerk-comment--acceptance, +.clerk-comment--other { + border-left: 4px solid var(--better-blue); +} + +.filing-document+.filing-document { + border-top: 1px solid #e5e7eb; + margin-top: 1rem; + padding-top: 1rem; +} + +.filing-document h3 { + font-size: 1.05rem; +} + +.filing-document__copies { + list-style: none; + margin: 0; + padding: 0; +} + +.filing-document__copies li { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.25rem 0; +} + +.filing-document__copies small { + color: #6b7280; + overflow-wrap: anywhere; +} + +.filing-fees, +.court-contact { + list-style: none; + margin: 0; + padding: 0; +} + +.filing-fees li, +.court-contact li { + padding: 0.2rem 0; +} \ No newline at end of file diff --git a/efile_app/efile/static/css/upload.css b/efile_app/efile/static/css/upload.css deleted file mode 100644 index ba6837e..0000000 --- a/efile_app/efile/static/css/upload.css +++ /dev/null @@ -1,444 +0,0 @@ -/* Upload Page Styles */ - -.upload-container { - max-width: 800px; - margin: 2rem auto; - padding: 2rem; - background: white; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); -} - -.section-header { - margin-bottom: 1rem; - text-align: left; -} - -.section-description { - color: #6c757d; - font-size: 1.1rem; - margin-bottom: 2rem; -} - -.case-summary { - padding: 1rem; - border-top: 1px solid #e9ecef; - border-bottom: 1px solid #e9ecef; - border-left: 4px solid var(--primary-btn-color); - border-right: 4px solid var(--primary-btn-color); - border-radius: 6px; - font-size: 0.95rem; -} - -.case-summary-header { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 1rem; -} - -.document-section { - border: 1px solid #e9ecef; - border-radius: 8px; - padding: 1.5rem; - background: #fff; - margin-bottom: 1.5rem; -} - -.document-label { - font-size: 1.1rem; - margin-bottom: 0.5rem; -} - -.upload-area { - position: relative; - border: 2px dashed #dee2e6; - border-radius: 8px; - padding: 3rem 2rem; - text-align: center; - background: #fafbfc; - transition: all 0.3s ease; - cursor: pointer; -} - -.upload-area:hover { - border-color: var(--primary-btn-color); - background: var(--light-blue-background); -} - -.upload-area.dragover { - border-color: var(--primary-btn-color); - background: #e3f2fd; -} - -.upload-placeholder { - pointer-events: none; -} - -.file-input { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0; - cursor: pointer; - z-index: 1; -} - -.file-preview { - border: 1px solid #e9ecef; - border-radius: 6px; - padding: 1rem; - margin-top: 0.75rem; - background: #fff; - position: relative; -} - -.file-preview:first-child { - margin-top: 0; -} - -.file-preview-lead { - /* border: 1px solid #e9ecef; */ - border-radius: 6px; - padding: 1rem; - margin-top: 0.75rem; - background: #fff; - position: relative; -} - -.file-preview-lead:first-child { - margin-top: 0; -} - -.file-info { - /* overflow: clip; */ - word-break: break-all; - display: flex; - align-items: center; - gap: 0.75rem; - padding-right: 2rem; - /* Make room for the X button */ -} - -.file-info i.fa-file-pdf { - color: #dc3545; - font-size: 1.5rem; - flex-shrink: 0; -} - -.file-remove { - position: absolute; - top: 0.75rem; - right: 0.75rem; - color: #6c757d; - cursor: pointer; - padding: 0.25rem; - border-radius: 4px; - transition: color 0.2s, background-color 0.2s; - font-size: 1.2rem; - line-height: 1; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - background: none; - border: none; - z-index: 20; -} - -.file-remove:hover { - color: #dc3545; - background-color: var(--light-blue-background); -} - -.requirements-section { - background: var(--light-blue-background); - padding: 1.5rem; - border-radius: 6px; - border: 1px solid #e9ecef; -} - -.requirements-section h5 { - color: #495057; - margin-bottom: 1rem; - font-size: 1.1rem; -} - -.requirement-item { - margin-bottom: 0.75rem; - padding-left: 1rem; - position: relative; -} - -.requirement-item:before { - content: "•"; - color: var(--primary-btn-color); - font-weight: bold; - position: absolute; - left: 0; -} - -.document-options { - background: #fff; - border: 1px solid #e9ecef; - border-radius: 8px; - padding: 1.5rem; - margin-top: 1rem; -} - -.document-options row { - align-items: baseline; -} - -.document-options h5 { - color: #495057; - margin-bottom: 1rem; -} - -.document-options h6 { - color: #6c757d; - margin-bottom: 1rem; - font-size: 1rem; - font-weight: 600; -} - -.supporting-document-options { - background: var(--light-blue-background); - border: 1px solid #dee2e6; - border-radius: 6px; - padding: 1rem; - margin-bottom: 1rem; -} - -.supporting-document-options h6 { - color: #495057; - font-size: 0.9rem; - margin-bottom: 0.75rem; - font-weight: 600; - border-bottom: 1px solid #e9ecef; - padding-bottom: 0.5rem; - word-break: break-word; -} - -.upload-progress { - margin: 2rem 0; -} - -.progress { - height: 8px; - background-color: #e9ecef; -} - -.progress-bar { - background-color: var(--primary-btn-color); - transition: width 0.3s ease; -} - -.alert { - border-radius: 6px; - padding: 1rem; - margin-bottom: 1rem; -} - -.alert-warning { - background-color: #fff3cd; - border-color: #ffeaa7; - color: #856404; -} - -.alert-danger { - background-color: #f8d7da; - border-color: #f5c6cb; - color: #721c24; -} - -.alert-success { - background-color: #d4edda; - border-color: #c3e6cb; - color: #155724; -} - -/* Upload Status Indicators */ -.upload-status { - margin-top: 0.5rem; - padding: 0.25rem 0.5rem; - border-radius: 4px; - background: var(--light-blue-background); - font-size: 0.875rem; - display: flex; - align-items: center; - clear: both; - position: relative; - z-index: 10; -} - -.upload-status i.fa-spinner { - color: var(--primary-btn-color); -} - -.upload-status i.fa-check-circle { - color: var(--success); -} - -.upload-status i.fa-exclamation-triangle { - color: #dc3545; -} - -/* Button Styles */ -.btn { - padding: 0.75rem 1.5rem; - border-radius: 6px; - font-weight: 500; - text-decoration: none; - display: inline-flex; - align-items: center; - justify-content: center; - border: none; - cursor: pointer; - transition: all 0.3s ease; - text-align: center; -} - -.btn-primary { - background-color: var(--primary-btn-color); - color: white; -} - -.btn-primary:hover { - background-color: var(--brand-blue-hover); - color: white; -} - -.btn-outline-secondary { - background-color: transparent; - color: #6c757d; - border: 1px solid #6c757d; -} - -.btn-outline-secondary:hover { - background-color: #6c757d; - color: white; -} - -.btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -@media (max-width: 768px) { - .upload-container { - margin: 1rem; - padding: 1rem; - } - - .section-header { - font-size: 1.5rem; - } - - .upload-area { - padding: 2rem 1rem; - } - - .lead-preview-area { - padding: 0rem 0rem; - } - - .case-summary .row { - flex-direction: column; - } - - .case-summary .col-md-4 { - margin-bottom: 0.5rem; - } - - .d-flex.gap-2 .btn { - flex: 1; - min-width: 0; - white-space: nowrap; - } -} - -@media (max-width: 576px) { - .upload-container { - margin: 0.5rem; - padding: 0.75rem; - } - - .btn { - width: 100%; - margin-bottom: 0.5rem; - text-align: center; - justify-content: center; - } - - .d-flex.flex-column .btn { - margin-bottom: 0.5rem; - } - - .d-flex.flex-column .btn:last-child { - margin-bottom: 0; - } - - .d-flex.flex-column.flex-md-row.justify-content-between { - align-items: center; - text-align: center; - } - - .gap-2>*+* { - margin-top: 0.5rem !important; - } - - .gap-3>*+* { - margin-top: 1rem !important; - } -} - -.was-validated .form-control:invalid { - border-color: #dc3545; -} - -.was-validated .form-control:valid { - border-color: var(--success); -} - -.invalid-feedback { - display: block; - color: #dc3545; - font-size: 0.875rem; - margin-top: 0.25rem; -} - -.requirements-section, -.document-options, -.alert { - margin: 0 auto 1.5rem auto; -} - -.alert h2 { - color: var(--bs-heading-color) -} - -.requirements-section h5, -.document-options h5 { - margin-bottom: 1.5rem; - color: #2c3e50; -} - -.d-flex.justify-content-between, -.d-flex.flex-column.flex-md-row.justify-content-between { - margin-top: 2rem; -} - -.d-flex.flex-column.flex-md-row.justify-content-between .btn { - min-width: 200px; -} - -h2 { - font-size: 1.3rem; -} - -h3 { - font-size: 1.1rem; -} \ No newline at end of file diff --git a/efile_app/efile/static/css/view_statuses.css b/efile_app/efile/static/css/view_statuses.css deleted file mode 100644 index 1a2bdf2..0000000 --- a/efile_app/efile/static/css/view_statuses.css +++ /dev/null @@ -1,86 +0,0 @@ -.section-title { - color: #333; - font-size: 2rem; - font-weight: 600; - margin-bottom: 2rem; -} - -.option-card { - background: white; - border: 1px solid #e0e0e0; - border-radius: 12px; - padding: 2rem; - margin-bottom: 1.5rem; - transition: all 0.3s ease; -} - -.option-card:hover { - border-color: var(--better-blue); - box-shadow: 0 4px 12px rgba(44, 90, 160, 0.15); - transform: translateY(-2px); -} - -.button-group { - display: flex; - gap: 1.5rem; - margin-top: 1rem; -} - -.button-group .btn { - flex: 1; - max-width: 150px; - padding: 0.75rem 1.5rem; - border-radius: 25px; - font-weight: 500; - transition: all 0.2s ease; -} - -.button-group .btn:hover { - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -} - -.button-group .btn i { - margin-right: 0.5rem; -} - -.option-icon { - width: 60px; - height: 60px; - color: var(--better-blue); - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - font-size: 2rem; - margin-right: 1.5rem; - flex-shrink: 0; -} - -.option-content h3 { - color: #333; - font-size: 1.5rem; - font-weight: 600; - margin-bottom: 0.5rem; -} - -.option-content p { - color: #666; - font-size: 1.1rem; - margin: 0; - line-height: 1.4; -} - -.brand-title { - font-size: 1.8rem; - font-weight: 600; - margin: 0; -} - -.accepted { - color: var(--success); -} - -.rejected { - color: var(--failure); -} \ No newline at end of file diff --git a/efile_app/efile/static/js/README.md b/efile_app/efile/static/js/README.md index 40e587c..a1c8ce8 100644 --- a/efile_app/efile/static/js/README.md +++ b/efile_app/efile/static/js/README.md @@ -1,264 +1,53 @@ -# Expert form JavaScript architecture - -This document describes the modular JavaScript architecture for the expert form functionality with dynamic court-specific form rendering. - -## File structure - -``` -efile/static/js/ -├── api-utils.js # API communication utilities -├── cascading-dropdowns.js # Smart dropdown functionality with location-based recommendations -├── form-validation.js # Form validation and user feedback -├── dynamic-form-sections.js # Dynamic form rendering with court-specific conditional logic -├── expert-form-main.js # Main coordinator and initialization -└── README.md # This documentation - -efile/static/config/ -├── base-case-types.yaml # Base form configuration templates -└── states/ - └── illinois.yaml # Illinois-specific court requirements and overrides -``` - -## Module overview - -### 1. api-utils.js -**Purpose**: Centralized API communication with error handling and CSRF protection. - -**Key Features**: -- Automatic CSRF token handling -- Request timeout management -- Standardized error handling -- URL building with parameters -- Convenient HTTP verb methods (get, post, put, etc.) - -**Global Access**: `window.apiUtils` - -### 2. cascading-dropdowns.js -**Purpose**: Intelligent form dropdown behavior with location-based recommendations and persistent user notifications. - -**Key Features**: -- User profile integration for location-based defaults -- Court-specific case category filtering -- Progressive form enablement (court → case category → case type, etc.) -- Auto-selection with persistent recommendation notices -- Smart placeholder management -- Court-specific form section triggering - -**Dependencies**: -- `api-utils.js` for API communication -- User profile API endpoint (`/api/auth/profile/`) -- Dynamic form sections integration - -**Global Access**: `window.CascadingDropdowns` - -### 3. form-validation.js -**Purpose**: Real-time form validation with enhanced user experience. - -**Key Features**: -- Real-time field validation with visual feedback -- Draft saving to localStorage -- Enhanced error messaging and notifications -- Form data collection and restoration -- Accessibility-friendly error handling - -**Global Access**: `window.FormValidation` - -### 4. dynamic-form-sections.js -**Purpose**: Dynamic form rendering with court-specific conditional logic and automatic header management. - -**Key Features**: -- YAML-based configuration system for form structures -- Court-specific field visibility and requirements (hidden_for_courts, required_for_courts) -- Dynamic section rendering with conditional logic -- Automatic header hiding when no sections are rendered -- Form data preservation during court changes -- Real-time form updates based on dropdown selections - -**Dependencies**: -- `/api/form-config/` endpoint for court-specific configurations -- YAML configuration files (base-case-types.yaml, states/illinois.yaml) -- Integration with cascading dropdowns for court selection - -**Global Access**: `window.DynamicFormSections` - -### 5. expert-form-main.js -**Purpose**: Main coordinator that initializes and manages all form components. - -**Key Features**: -- Component initialization and coordination -- Draft restoration from previous sessions -- Global instance management -- Error handling and recovery - -**Global Access**: -- `window.ExpertForm` (class) -- `window.getExpertFormInstance()` (active instance) - -## Loading order - -The scripts must be loaded in this specific order due to dependencies: - -1. `api-utils.js` - Provides `apiUtils` global -2. `cascading-dropdowns.js` - Uses `apiUtils` -3. `form-validation.js` - Independent -4. `dynamic-form-sections.js` - Independent, integrates with cascading dropdowns -5. `expert-form-main.js` - Coordinates all modules - -## API endpoints used - -- `/api/auth/profile/` - User profile and location data -- `/api/dropdowns/courts/` - Court listings with location prioritization -- `/api/dropdowns/case-categories/` - Case categories filtered by court -- `/api/dropdowns/case-types/` - Case types based on category -- `/api/dropdowns/filing-types/` - Filing types based on case type -- `/api/dropdowns/document-types/` - Document types for final selection -- `/api/form-config/` - Dynamic form configuration with court-specific conditional requirements - -## Configuration system - -### YAML-based form configuration -The system uses a hierarchical YAML configuration structure: - -**Base Configuration (`base-case-types.yaml`)**: -- Defines common form structures and field templates -- Provides conditional_requirements framework for court-specific modifications -- Sets default field types, validation rules, and column widths - -**State-Specific Configuration (`states/illinois.yaml`)**: -- Extends base configuration with state-specific requirements -- Defines court-specific field modifications using arrays: - - `hidden_for_courts: ["bond"]` - Hide sections for specific courts - - `required_for_courts: ["cook:cd1"]` - Make sections required for specific courts -- Supports inheritance from base templates with custom overrides - -### Court-specific conditional logic -```yaml -# Example: Hide petitioner section for Bond County -court_specific_requirements: - "bond": - case_types: - name_change: - field_modifications: - - field_group: "Petitioner" - modifications: - conditional_requirements: - hidden_for_courts: ["bond"] -``` +# Front-end JavaScript + +There is no front-end framework here, and no client-side router. Each workflow +screen is a Django template that posts to its own view; the script beside it +handles only what a page reload cannot do -- showing and hiding fields, dragging +documents into order, calling a code list as the filer types. A screen with no +JavaScript is a screen that did not need any. + +Every module is an IIFE that starts by looking for the element it belongs to and +returns immediately when it is not on the page, so any script can be loaded +anywhere without checking which template it landed in. + +## Shared modules + +| File | What it is for | +| --- | --- | +| `api-utils.js` | The only place that talks to `/api/…`: CSRF tokens, timeouts, error shaping, and a small response cache for code lists. Exposed as `window.apiUtils`. | +| `filing-payload.js` | Builds the `efile_data` blob the EFSP expects. Review and Payment must send the same one -- fees quoted against a different payload are not the fees the filer pays. | +| `checklist-status.js` | The document checklist's answer buttons, shared by the in-flow step and the filing-plans page. | + +## Screen modules + +`case-lookup.js`, `case-questions.js`, `document-checklist.js`, +`extraction-review.js`, `filing-plans.js`, `organize-documents.js`, +`parties.js`, `party-details.js`, `payment.js`, `review.js`, +`upload-documents.js`, and `your-information.js` each belong to the template of +the same name. + +## API endpoints these call + +- `/api/dropdowns/…` — court code lists (courts, case categories and types, + filing and document types, party types, optional services) +- `/api/filer-roles/`, `/api/form-config/`, `/api/case-type-config/` — partner + configuration for the current jurisdiction and case type +- `/api/suffolk/lookup-case/` — finding an existing court case +- `/api/payment-accounts/`, `/api/payment-account-types/`, + `/api/payment-fees/`, `/api/auth/tyler-token/` — the fees step +- `/api/get-case-data/`, `/api/get-upload-data/`, `/api/save-case-data/` — the + draft's own state, always read from the server rather than from localStorage: + a stale copy could outlive a submit and leak into the next filing +- `/api/submit-final-filing/` — filing, from the review screen ## Configuration -### Location intelligence -The system automatically prioritizes courts based on user location: -- User's zip code → county mapping -- County → court prioritization -- Auto-selection with persistent user notification -- Green recommendation notices positioned above dropdown labels -- Notices persist during automatic cascading operations - -### Court-specific form rendering -- Dynamic form sections based on court selection -- Conditional field visibility (hidden_for_courts, required_for_courts) -- Automatic header management (hides "Parties" header when no sections render) -- Real-time form updates when court selection changes -- Form data preservation during court transitions - -### Draft saving -- Manual save only via "Save Draft" button (auto-save removed) -- User-controlled draft creation for better user experience -- Restoration on page reload (24-hour expiry) -- localStorage backup for reliability -- Visual feedback when saving drafts - -### Error handling -- Network timeouts (30 seconds) -- API error translation to user-friendly messages -- Graceful degradation when APIs are unavailable -- Console logging for debugging -- Court-specific configuration validation - -## Usage examples - -### Accessing the form instance -```javascript -const formInstance = getExpertFormInstance(); -const dropdowns = formInstance.getCascadingDropdowns(); -const validation = formInstance.getFormValidation(); -const dynamicSections = formInstance.getDynamicFormSections(); -``` - -### Manual API calls -```javascript -// Using the global API utility -const response = await apiUtils.get('/api/dropdowns/courts/', { - user_county: 'Cook', - jurisdiction: 'illinois' -}); - -// Get court-specific form configuration -const formConfig = await apiUtils.get('/api/form-config/', { - case_type: 'name_change', - court: 'cook:cd1' -}); -``` - -### Custom validation -```javascript -const validation = getExpertFormInstance().getFormValidation(); -validation.showNotification('Custom message', 'success'); -``` - -### Court-specific configuration examples -```javascript -// Check if a section should show for current court -const dynamicSections = getExpertFormInstance().getDynamicFormSections(); -const shouldShow = dynamicSections.shouldShowSection(sectionConfig, 'bond'); - -// Trigger form re-rendering after court change -dynamicSections.handleCaseTypeChange(); -``` - -## Performance considerations - -- Scripts load asynchronously after DOM ready -- API requests are cached where appropriate -- Loading spinners prevent multiple simultaneous requests -- Manual draft saving prevents excessive storage operations -- Court-specific form configurations are cached to reduce API calls -- Dynamic form sections only re-render when necessary (court or case type changes) - -## Security features - -- CSRF token automatic inclusion -- XSS prevention through proper DOM manipulation -- Input validation on both client and server -- Secure localStorage usage for draft data -- YAML configuration validation prevents injection attacks - -## Debugging - -Enable console logging by setting: -```javascript -window.debugFormModules = true; -``` - -This will provide detailed logging for: -- API requests and responses -- Form state changes -- Validation events -- Draft save operations (manual only) -- Court-specific configuration loading -- Dynamic form section rendering decisions - -## Troubleshooting - -### Debug commands -```javascript -// Check current form configuration -console.log(getExpertFormInstance().getDynamicFormSections().config); +What the forms ask for is configured in YAML, not in JavaScript. See +`../config/README.md`. -// Check court-specific modifications -console.log(window.CascadingDropdowns.selectedValues); +## Tests -// View current form data -console.log(getExpertFormInstance().getFormValidation().collectFormData()); -``` \ No newline at end of file +`js-tests/` holds `node --test` unit tests for the modules worth testing on +their own (`api-utils.js`, `filing-payload.js`). Run them with +`npm run test:unit`. Whole-flow coverage lives in the Playwright specs under +`efile_app/tests/`. diff --git a/efile_app/efile/static/js/api-utils.js b/efile_app/efile/static/js/api-utils.js index f7266e4..00136d4 100644 --- a/efile_app/efile/static/js/api-utils.js +++ b/efile_app/efile/static/js/api-utils.js @@ -363,14 +363,6 @@ class ApiUtils { return this.fetchJSON("/api/get-upload-data", "GET"); } - async saveUploadData(body) { - return this.fetchJSON("/api/save-upload-data/", "POST", {}, body); - } - - async saveFirstUploadData(body) { - return this.fetchJSON("/api/save-upload-data-first/", "POST", {}, body); - } - // Cache management methods clearAllCache() { this.cache = {}; diff --git a/efile_app/efile/static/js/cascading-dropdowns.js b/efile_app/efile/static/js/cascading-dropdowns.js deleted file mode 100644 index f2e0d60..0000000 --- a/efile_app/efile/static/js/cascading-dropdowns.js +++ /dev/null @@ -1,1102 +0,0 @@ -/** - * CascadingDropdowns - Handles smart form dropdown interactions - * Features: Location-based recommendations, court-specific filtering, progressive form enablement - */ -class CascadingDropdowns { - constructor() { - this.dropdownMapping = { - court: { - next: "case_category", - endpoint: "/api/dropdowns/case-categories/", - }, - case_category: { - next: "case_type", - endpoint: "/api/dropdowns/case-types/", - }, - case_type: { - next: "party_type", // Case type is now the final dropdown on expert form - endpoint: "/api/dropdowns/party-types", - }, - party_type: { - next: null, - endpoint: null - } - }; - - this.userProfile = null; - this.selectedValues = { - court: null, - case_category: null, - case_type: null, - party_type: null, - }; - // A new filing has no uploaded document yet, so the upload-data - // response may not contain document classification guesses. - this.guesses = {}; - this.optionalServicesLoaded = false; - this.isAutomaticSelection = false; // Track if selection is automatic - } - - async init() { - // Load user profile first - await this.loadUserProfile(); - await this.loadGuesses(); - - // Load initial data for independent dropdowns with user context - await this.loadCourtsWithUserContext(); - - // Add event listeners - document.addEventListener("change", (e) => { - if (e.target.classList.contains("dropdown-field")) { - this.handleDropdownChange(e.target); - } - }); - } - - async loadCourtsWithUserContext() { - const params = {}; - const currentJurisdiction = apiUtils.getCurrentJurisdiction(); - - // Set the jurisdiction parameter - params.jurisdiction = currentJurisdiction; - params.guessed_court = this.guesses?.court; - - // For Massachusetts, skip location-based filtering and show all courts - if (currentJurisdiction === 'massachusetts') { - await this.loadDropdownData("court", "/api/dropdowns/courts/", params); - return; - } - - // For other jurisdictions (like Illinois), use location-based recommendations - if (this.userProfile) { - // Pass user location info to courts API - if (this.userProfile.preferred_county) { - params.user_county = this.userProfile.preferred_county; - } - if (this.userProfile.zip_code) { - params.user_zip = this.userProfile.zip_code; - } - // Add jurisdiction if available from profile - if (this.userProfile.state) { - const jurisdictionMap = { - IL: "illinois", - Illinois: "illinois", - }; - const profileJurisdiction = jurisdictionMap[this.userProfile.state] || "illinois"; - // Only override if current jurisdiction matches profile - if (currentJurisdiction === "illinois" || !currentJurisdiction) { - params.jurisdiction = profileJurisdiction; - } - } - } else { - console.warn("No user profile available for courts loading"); - } - await this.loadDropdownData("court", "/api/dropdowns/courts/", params); - } - - async loadUserProfile() { - const statusElement = document.getElementById("userProfileStatus"); - - try { - if (statusElement) { - statusElement.style.display = "block"; - statusElement.innerHTML = - ' Loading your information...'; - } - - const response = await this.makeRequest("/api/auth/profile/", { - "jurisdiction": apiUtils.getCurrentJurisdiction() - }); - - if (response.success) { - if (statusElement) { - statusElement.style.display = "none"; - } - this.userProfile = response.data; - } else { - console.warn("❌ Failed to load user profile:", response.error); - if (statusElement) { - statusElement.className = "alert alert-warning"; - statusElement.innerHTML = - ' Could not load profile information. Using default settings.'; - setTimeout(() => { - statusElement.style.display = "none"; - }, 3000); - } - } - } catch (error) { - console.error("❌ Error loading user profile:", error); - if (statusElement) { - statusElement.className = "alert alert-warning"; - statusElement.innerHTML = - ' Could not load profile information. Using default settings.'; - setTimeout(() => { - statusElement.style.display = "none"; - }, 3000); - } - } - } - - async loadGuesses() { - try { - const data = await apiUtils.getUploadData(); - this.guesses = data?.guesses || {}; - } catch (error) { - // Guesses are optional and should never prevent a new filing from - // using the cascading dropdowns. - console.warn("Could not load upload guesses:", error); - this.guesses = {}; - } - } - - async loadDropdownData(fieldId, endpoint, params = {}) { - const dropdown = document.getElementById(fieldId); - - if (!dropdown) { - console.error(`Dropdown with ID '${fieldId}' not found in DOM`); - return; - } - - const loader = document.getElementById(`loading-${dropdown.dataset.level}`); - - try { - this.showLoader(loader); - this.clearDropdown(dropdown); - - const response = await this.makeRequest(endpoint, params); - - if (response.success) { - // Check if we have valid data - if ( - response.data && - Array.isArray(response.data) && - response.data.length > 0 - ) { - this.populateDropdown(dropdown, response.data); - dropdown.parentElement.removeAttribute("hidden"); - dropdown.disabled = false; - if (response.data.length == 1 && dropdown.id == "party_type") { - dropdown.parentElement.hidden = true; - this.selectPartyTypeRadio(dropdown, response.data[0].value || response.data[0].id); - } - } else { - console.warn(`No data returned for ${fieldId}:`, response); - this.showError(dropdown, "No options available for this selection"); - } - } else { - console.error(`API error for ${fieldId}:`, response); - this.showError(dropdown, response.error || "Failed to load options"); - } - } catch (error) { - console.error(`Error loading dropdown data for ${fieldId}:`, error); - this.showError(dropdown, "Network error occurred"); - } finally { - this.hideLoader(loader); - } - } - - async makeRequest(endpoint, params = {}) { - return await apiUtils.get(endpoint, params); - } - - handleDropdownChange(dropdown) { - // Upload guesses are optional. Keep the change handler safe even if a - // stale/partial response or another caller clears the property. - this.guesses = this.guesses || {}; - const fieldId = dropdown.id; - const selectedValue = dropdown.value; - const mapping = this.dropdownMapping[fieldId]; - - // Only clear recommendation notices if the court dropdown is manually changed by user - // Preserve notices for automatic selections and when other dropdowns change - if (fieldId === "court" && !this.isAutomaticSelection) { - this.clearAllRecommendationNotices(); - } - - // Reset the automatic selection flag after handling - this.isAutomaticSelection = false; - - // Store the selected value - this.selectedValues[fieldId] = selectedValue; - - // Reset all dependent dropdowns when this dropdown changes - this.resetDependentDropdowns(fieldId); - - // Reset optional services flag when case type changes - if ( - fieldId === "case_type" || - fieldId === "case_category" || - fieldId === "court" - ) { - this.optionalServicesLoaded = false; - } - - if (mapping && selectedValue) { - // Prepare parameters for the next dropdown - let params = {}; - - // Always add jurisdiction - params.jurisdiction = apiUtils.getCurrentJurisdiction(); - - // Add additional context parameters based on the field - if (fieldId === "court") { - // When court is selected, load case categories for that court - params.court = selectedValue; - } else if (fieldId === "case_category") { - // Case category to case type - params.parent = selectedValue; // Suffolk API expects parent parameter - if (this.selectedValues.court) { - params.court = this.selectedValues.court; - } else { - console.warn("No court selected when trying to load case types"); - return; - } - } else if (fieldId === "case_type") { - params.parent = selectedValue; // Suffolk API expects parent parameter for case_type - params.case_type = selectedValue; - if (this.selectedValues.court) { - params.court = this.selectedValues.court; - } else { - console.warn("No court selected when trying to load filing types"); - return; - } - - // Add both existing_case and initial parameters for filing type endpoint - const existingCase = sessionStorage.getItem('existing_case') || 'no'; - const isInitialFiling = existingCase === 'no'; - params.existing_case = existingCase; // Pass existing_case to Django API - params.initial = isInitialFiling ? 'true' : 'false'; // Pass initial to Suffolk API - } - - // Validate required parameters before making API call - if (this.validateParameters(fieldId, params)) { - // Load data for the next dropdown only if there is a next dropdown - if (mapping.next && mapping.endpoint) { - params.guessed_case_category = this.guesses?.['case category']; - params.guessed_case_type = this.guesses?.['case type']; - params.only_required = true; - this.loadDropdownData(mapping.next, mapping.endpoint, params); - } - } else { - console.warn(`Missing required parameters for ${fieldId}:`, params); - return; - } - - // Special handling for court selection - if (fieldId === "court") { - // Clear the user profile status indicator when court changes - const statusElement = document.getElementById("userProfileStatus"); - if (statusElement) { - statusElement.style.display = "none"; - statusElement.className = "alert alert-info"; // Reset to default class - statusElement.innerHTML = - ' Loading your information...'; - } - - // Only clear recommendation notices and visual indicators if the user manually changed the court - // We'll preserve these for auto-selections - this.clearAllDropdownVisualIndicators(); - - const caseCategoryDropdown = document.getElementById("case_category"); - if (caseCategoryDropdown) { - caseCategoryDropdown.disabled = false; - // Update placeholder text - const placeholder = - caseCategoryDropdown.querySelector('option[value=""]'); - if (placeholder) { - placeholder.textContent = gettext("Select Case Category"); - } - } - } - - // Trigger dynamic form sections when case type changes - if (fieldId === "case_type") { - this.triggerDynamicFormSections(); - } - - // Re-render dynamic form sections when court changes (if they already exist) - // This ensures conditional requirements are re-evaluated - if (fieldId === "court" && this.selectedValues.case_type) { - this.triggerDynamicFormSections(); - } - } else if (mapping) { - // Clear and disable the next dropdown if no value selected - const nextDropdown = document.getElementById(mapping.next); - if (nextDropdown) { - this.clearDropdown(nextDropdown); - nextDropdown.disabled = true; - } - } - - // Clear dependent dropdowns - // Only clear dependent dropdowns when the case_type changes. - // This avoids removing dynamic form fields when other dropdowns (like filing_type) - // are changed as part of cascading operations. - if (fieldId === "case_type") { - this.clearDependentDropdowns(fieldId); - } - } - - triggerDynamicFormSections() { - // Check if dynamic form sections is available - if (window.dynamicFormSections) { - // Add a small delay to ensure the dropdown value is set - setTimeout(() => { - window.dynamicFormSections.handleCaseTypeChange(); - }, 100); - } else { - console.warn( - "dynamicFormSections not available on window object, attempting manual trigger" - ); - - // Fallback: Try to trigger the case type change event manually - setTimeout(() => { - const caseTypeSelect = document.getElementById("case_type"); - if (caseTypeSelect && caseTypeSelect.value) { - const changeEvent = new Event("change", { - bubbles: true - }); - caseTypeSelect.dispatchEvent(changeEvent); - } - - // Also try to find and call the dynamic form sections directly - if (window.DynamicFormSections) { - try { - const dynamicSections = new window.DynamicFormSections(); - window.dynamicFormSections = dynamicSections; - setTimeout(() => { - dynamicSections.handleCaseTypeChange(); - }, 200); - } catch (error) { - console.error("Failed to create DynamicFormSections:", error); - } - } - }, 200); - } - } - - async loadFormConfiguration() { - // Since filing types are now handled on upload page, we can load form configuration - // when we have case type selected - if ( - !this.selectedValues.case_category || - !this.selectedValues.case_type - ) { - return; - } - - try { - // Load optional services from Suffolk API - this is the main feature users expect - await this.loadOptionalServices(); - } catch (error) { - console.error("Error loading form configuration:", error); - } - } - - async loadOptionalServices() { - if (!this.selectedValues.court || !this.selectedValues.case_type) { - console.warn("Missing required values for optional services"); - return; - } - - // Prevent duplicate loading - if (this.optionalServicesLoaded) { - return; - } - - this.optionalServicesLoaded = true; // Set flag immediately to prevent race conditions - - try { - const params = { - court: this.selectedValues.court, - case_type_id: this.selectedValues.case_type, - jurisdiction: "illinois", - }; - - // Use your Django API endpoint instead of direct Suffolk API call - const response = await this.makeRequest( - "/api/dropdowns/optional-services/", - params - ); - - if (response.success && response.data) { - this.updateOptionalServicesFromAPI(response.data); - } else { - console.warn( - "Optional services API failed:", - response.error || "No data" - ); - // Fall back to showing default services - this.showDefaultOptionalServices(); - } - } catch (error) { - console.error("Error loading optional services:", error); - // Fall back to showing default services - this.showDefaultOptionalServices(); - } - } - - updateOptionalServicesFromAPI(services) { - // AGGRESSIVE CLEANUP - Remove ALL possible optional services containers - // This includes containers with different class names and IDs that might exist - const allPossibleSelectors = [ - "#optional-services-container", - ".optional-services-container", - '[data-created-by="cascading-dropdowns"]', - ]; - - allPossibleSelectors.forEach((selector) => { - try { - const containers = document.querySelectorAll(selector); - containers.forEach((container) => { - const isTagged = - container.dataset && - container.dataset.createdBy === "cascading-dropdowns"; - const isNamed = - container.id === "optional-services-container" || - container.classList.contains("optional-services-container"); - if (isTagged || isNamed) { - container.remove(); - } - }); - } catch (e) { - // Ignore selector errors - } - }); - - // Also look for headings but only remove their nearest safe container (one we created) - const headings = document.querySelectorAll("h5, h4, h3"); - headings.forEach((heading) => { - if ( - heading.textContent && - heading.textContent.includes("Optional Services") - ) { - const container = heading.closest( - '#optional-services-container, .optional-services-container, [data-created-by="cascading-dropdowns"]' - ); - if (container) { - container.remove(); - } - } - }); - - // Wait a moment for DOM cleanup - setTimeout(() => { - // Create a fresh container - let servicesContainer = null; - - // Try to find a specific placement location first - const preferredLocations = [ - document.querySelector("#dynamicSections"), // Our main dynamic sections container - document.querySelector('[data-section="filing-options"]'), - document.querySelector("#filing-options"), - document.querySelector(".filing-options"), - document.querySelector(".form-section:last-child"), - document.querySelector(".form-container"), - document.querySelector("#expertForm"), // The main form - document.querySelector("form"), - document.querySelector(".container"), - document.querySelector("main"), - document.body, - ]; - - let formContainer = null; - for (const location of preferredLocations) { - if (location) { - formContainer = location; - break; - } - } - - if (formContainer) { - // Reuse existing container if present to avoid duplicates - const existing = - document.getElementById("optional-services-container") || - document.querySelector('[data-created-by="cascading-dropdowns"]'); - if (existing) { - servicesContainer = existing; - // Clear previous content safely - servicesContainer.innerHTML = ""; - } else { - servicesContainer = document.createElement("div"); - servicesContainer.id = "optional-services-container"; - servicesContainer.className = "optional-services-container mt-4 mb-4"; - servicesContainer.setAttribute( - "data-created-by", - "cascading-dropdowns" - ); - servicesContainer.setAttribute("data-section", "optional-services"); - } - - // Try to insert before buttons if they exist, but do it safely - const buttons = formContainer.querySelector( - '.form-actions, .button-group, [class*="button"], input[type="submit"], button[type="submit"]' - ); - if (!formContainer.contains(servicesContainer)) { - if (buttons && buttons.parentNode === formContainer) { - try { - formContainer.insertBefore(servicesContainer, buttons); - } catch (error) { - console.warn( - "Could not insert before buttons, appending instead:", - error - ); - formContainer.appendChild(servicesContainer); - } - } else { - formContainer.appendChild(servicesContainer); - } - } - } else { - console.error( - "Could not find suitable container for optional services" - ); - return; - } - - if (!services || !Array.isArray(services) || services.length === 0) { - servicesContainer.innerHTML = - '

No optional services available for this filing type.

'; - return; - } - - // Create header - const header = document.createElement("h3"); - header.textContent = gettext("Optional Services"); - header.className = "mb-3"; - servicesContainer.appendChild(header); - - // Create services list - services.forEach((service, index) => { - const serviceDiv = document.createElement("div"); - serviceDiv.className = "form-check mb-2"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.className = "form-check-input"; - checkbox.id = `service_${service.code || service.id || index}`; - checkbox.name = "optional_services"; - checkbox.value = service.code || service.id || index; - - const label = document.createElement("label"); - label.className = "form-check-label"; - label.setAttribute("for", checkbox.id); - - // Build label text with name and fee if available - let labelText = - service.name || service.text || service.label || "Unknown Service"; - if (service.fee && parseFloat(service.fee) > 0) { - labelText += ` ($${parseFloat(service.fee).toFixed(2)})`; - } - label.textContent = labelText; - - serviceDiv.appendChild(checkbox); - serviceDiv.appendChild(label); - - // Add description if available - if (service.description && service.description !== null) { - const description = document.createElement("small"); - description.className = "form-text text-muted d-block ml-4"; - description.textContent = service.description; - serviceDiv.appendChild(description); - } - - servicesContainer.appendChild(serviceDiv); - }); - - // Make sure the container is visible - servicesContainer.style.display = "block"; - }, 100); // Small delay to ensure cleanup completes - } - - showDefaultOptionalServices() { - // Show basic optional services if API fails - - let servicesContainer = - document.querySelector(".optional-services-container") || - document.querySelector("#optional-services-container") || - document.querySelector(".services-container"); - - if (!servicesContainer) { - // Try to create one - const formContainer = - document.querySelector(".form-container") || - document.querySelector("#filing-form") || - document.querySelector("form") || - document.querySelector(".container") || - document.querySelector("main") || - document.body; - - if (formContainer) { - servicesContainer = document.createElement("div"); - servicesContainer.id = "optional-services-container"; - servicesContainer.className = "optional-services-container mt-4"; - formContainer.appendChild(servicesContainer); - } - } - - if (!servicesContainer) { - console.error( - "Could not find or create container for default optional services" - ); - return; - } - - servicesContainer.innerHTML = ` -
Optional services
-
- - -
-
- - -
- `; - - servicesContainer.style.display = "block"; - } - - validateParameters(fieldId, params) { - // Define required parameters for each field - const requiredParams = { - court: ["jurisdiction"], - case_category: ["jurisdiction", "court"], - case_type: ["jurisdiction", "parent"], // parent = category_id - }; - - const required = requiredParams[fieldId] || []; - const missing = required.filter((param) => !params[param]); - - if (missing.length > 0) { - console.error(`Missing required parameters for ${fieldId}:`, missing); - return false; - } - - return true; - } - - clearDependentDropdowns(changedFieldId) { - const dependencies = { - court: ["case_category", "case_type"], - case_category: ["case_type"], - case_type: ["party_type"], // case_type is now the final dropdown on expert form - }; - - const toClear = dependencies[changedFieldId] || []; - toClear.forEach((fieldId) => { - const dropdown = document.getElementById(fieldId); - if (dropdown && fieldId !== this.dropdownMapping[changedFieldId]?.next) { - // Special handling for case_type - notify dynamic forms and preserve temporarily - if (fieldId === "case_type") { - // Store the current value before clearing - const currentCaseTypeValue = dropdown.value; - const currentCaseTypeText = - dropdown.options[dropdown.selectedIndex]?.text || ""; - - // Notify dynamic forms that case_type is being cleared programmatically - if (window.dynamicFormSections) { - window.dynamicFormSections.currentCaseType = null; - // Don't hide sections immediately - let the restoration process handle it - } - - // Clear the dropdown but keep the dynamic forms intact temporarily - this.clearDropdown(dropdown); - dropdown.disabled = true; - - // If this was triggered by filing_type change and we have a case type, - // try to restore it after the dropdown gets repopulated - if (changedFieldId === "filing_type" && currentCaseTypeValue) { - setTimeout(() => { - // Check if dropdown got repopulated - if (dropdown.options.length > 1) { - const matchingOption = dropdown.querySelector( - `option[value="${currentCaseTypeValue}"]` - ); - if (matchingOption) { - dropdown.value = currentCaseTypeValue; - dropdown.disabled = false; - this.selectedValues.case_type = currentCaseTypeValue; - - // Re-trigger dynamic form sections - setTimeout(() => { - if (window.dynamicFormSections) { - window.dynamicFormSections.handleCaseTypeChange(); - } - }, 100); - } - } - }, 1500); // Give more time for dropdown to repopulate - } - } else { - // Regular clearing for other dropdowns - this.clearDropdown(dropdown); - dropdown.disabled = true; - } - } - }); - } - - populateDropdown(dropdown, options) { - // Special handling for filing type search dropdown - if (dropdown.id === 'filing_type' && window.filingTypeSearch) { - window.filingTypeSearch.updateOptions(options); - window.filingTypeSearch.enable(); - return; - } - - // Party type is rendered as a radio group (usually 2-3 options) rather - // than a select, since a dropdown is unnecessary friction for so few choices. - if (dropdown.id === 'party_type') { - this.populatePartyTypeRadios(dropdown, options); - return; - } - - const placeholder = dropdown.querySelector('option[value=""]').textContent; - - // Clear dropdown and remove any visual selection indicators - dropdown.innerHTML = ``; - dropdown.classList.remove("has-selection", "selected", "success"); - dropdown.removeAttribute("data-selected"); - dropdown.value = ""; - - // Check if options is valid - if (!options || !Array.isArray(options)) { - console.warn("Invalid options data:", options); - this.showError(dropdown, "No options available"); - return; - } - - let recommendedOption = null; - - options.forEach((option) => { - const optionElement = document.createElement("option"); - optionElement.value = option.value || option.id; - optionElement.textContent = option.label || option.name || option.text; - - // Check if this is a recommended option - if ( - option.recommended || - option.selected || - option.default - ) { - optionElement.style.fontWeight = "bold"; - recommendedOption = option.value || option.id; - } - - dropdown.appendChild(optionElement); - }); - - // Auto-select recommended court and trigger change event - if ((dropdown.id === "court" || dropdown.id === "case_category" || dropdown.id === "case_type") && recommendedOption) { - this.isAutomaticSelection = true; // Mark as automatic selection - dropdown.value = recommendedOption; - if (dropdown.id === "court") { - this.selectedValues.court = recommendedOption; - } - - // Show a brief notification about the auto-selection - this.showRecommendationNotice(dropdown, dropdown.id); - - // Trigger change event to load dependent dropdowns - setTimeout(() => { - dropdown.dispatchEvent(new Event("change", { - bubbles: true - })); - }, 500); - } - - // Handle user's preferred county auto-selection (fallback) - if ( - dropdown.id === "court" && - !recommendedOption && - this.userProfile && - this.userProfile.preferred_county - ) { - this.isAutomaticSelection = true; // Mark as automatic selection - const preferredValue = this.userProfile.preferred_county; - const preferredOption = dropdown.querySelector( - `option[value="${preferredValue}"]` - ); - if (preferredOption) { - dropdown.value = preferredValue; - this.selectedValues.court = preferredValue; - - // Show notice for this selection too - this.showRecommendationNotice(dropdown, "court"); - - // Trigger change event to load dependent dropdowns - setTimeout(() => { - dropdown.dispatchEvent(new Event("change", { - bubbles: true - })); - }, 500); - } - } - } - - populatePartyTypeRadios(container, options) { - container.innerHTML = ""; - - if (!options || !Array.isArray(options) || options.length === 0) { - container.innerHTML = '

No party types available

'; - return; - } - - options.forEach((option, index) => { - const value = option.value || option.id; - const label = option.label || option.name || option.text; - - const wrapper = document.createElement("div"); - wrapper.className = "form-check"; - - const input = document.createElement("input"); - input.type = "radio"; - input.className = "form-check-input"; - input.name = "party_type"; - input.id = `party_type_${index}`; - input.value = value; - input.required = true; - - const radioLabel = document.createElement("label"); - radioLabel.className = "form-check-label"; - radioLabel.setAttribute("for", input.id); - radioLabel.textContent = label; - - wrapper.appendChild(input); - wrapper.appendChild(radioLabel); - container.appendChild(wrapper); - }); - - // Radios are recreated on every populate call, so attach the listener - // once on the (stable) container rather than per-input. - if (!container.dataset.changeListenerAttached) { - container.addEventListener("change", (e) => { - if (e.target && e.target.name === "party_type") { - this.selectedValues.party_type = e.target.value; - } - }); - container.dataset.changeListenerAttached = "true"; - } - } - - selectPartyTypeRadio(container, value) { - const radio = container.querySelector(`input[value="${value}"]`); - if (radio) { - radio.checked = true; - } - this.selectedValues.party_type = value; - } - - showRecommendationNotice(dropdown, type) { - // Remove any existing recommendation notice for this dropdown first - const existingNotice = dropdown.parentNode.querySelector('.recommendation-notice'); - if (existingNotice) { - existingNotice.remove(); - } - - if (type === "court") { - // Create a persistent notice to show the user why this option was selected - const notice = document.createElement("div"); - notice.className = "alert alert-success recommendation-notice"; - notice.style.cssText = - "position: relative; z-index: 1000; margin-top: 5px; margin-bottom: 10px; padding: 8px 12px; font-size: 0.875rem; border-radius: 4px;"; - notice.innerHTML = ` We've pre-selected some choices based on your uploaded form.`; - // Find the label for this dropdown to insert the notice above it - const dropdownLabel = dropdown.parentNode.querySelector(`label[for="${dropdown.id}"]`); - - if (dropdownLabel) { - // Insert the notice before the label (above the title) - dropdownLabel.parentNode.insertBefore(notice, dropdownLabel); - } else { - // Fallback: insert before the dropdown if no label is found - dropdown.parentNode.insertBefore(notice, dropdown); - } - } - - // The notice will now persist until the court dropdown changes or page reloads - // No automatic removal timeout - } - - clearAllRecommendationNotices() { - // Remove all existing recommendation notices (green success alerts) - const existingNotices = document.querySelectorAll( - ".recommendation-notice, .alert.alert-success.recommendation-notice" - ); - existingNotices.forEach((notice) => { - if (notice.parentNode) { - notice.parentNode.removeChild(notice); - } - }); - } - - clearAllDropdownVisualIndicators() { - // Clear visual indicators from all dropdowns - const allDropdowns = document.querySelectorAll( - ".dropdown-field, select.form-select" - ); - allDropdowns.forEach((dropdown) => { - // Remove success/selection classes - dropdown.classList.remove( - "has-selection", - "selected", - "success", - "is-valid" - ); - dropdown.removeAttribute("data-selected"); - - // Remove any checkmark or success icons that might be added via pseudo-elements - const parent = dropdown.parentElement; - if (parent) { - parent.classList.remove( - "has-success", - "field-success", - "validation-success" - ); - - // Remove any success icons that might have been added - const successIcons = parent.querySelectorAll( - ".fa-check, .fa-check-circle, .success-icon" - ); - successIcons.forEach((icon) => icon.remove()); - } - }); - } - - resetDependentDropdowns(changedFieldId) { - // Define the hierarchy of dependent dropdowns - const hierarchy = [ - "court", - "case_category", - "case_type", - "party_type", - "filing_type", - "document_type", - ]; - - // Find the index of the changed field in the hierarchy - const changedIndex = hierarchy.indexOf(changedFieldId); - - if (changedIndex === -1) return; // Field not in hierarchy - - // Reset all dropdowns that come after the changed field in the hierarchy - for (let i = changedIndex + 1; i < hierarchy.length; i++) { - const fieldToReset = hierarchy[i]; - const dropdown = document.getElementById(fieldToReset); - - if (dropdown) { - // Clear the dropdown - this.clearDropdown(dropdown); - - // Reset the stored value - this.selectedValues[fieldToReset] = null; - } - } - - // Also clear dynamic form sections when case_type is reset - if (changedIndex <= hierarchy.indexOf("case_type")) { - const dynamicSectionsContainer = - document.getElementById("dynamic-sections"); - if (dynamicSectionsContainer) { - dynamicSectionsContainer.innerHTML = ""; - } - } - } - - clearDropdown(dropdown) { - // Special handling for filing type search dropdown - if (dropdown.id === 'filing_type' && window.filingTypeSearch) { - window.filingTypeSearch.reset(); - window.filingTypeSearch.disable(); - return; - } - - // Party type is a radio group, not a select - reset it to its - // placeholder text instead of manipulating `; - - // Remove any visual indicators or classes that might show selection state - dropdown.classList.remove("has-selection", "selected", "success"); - dropdown.removeAttribute("data-selected"); - - // Reset dropdown value explicitly to ensure no selection state - dropdown.value = ""; - } - - showLoader(loader) { - if (loader) loader.style.display = "block"; - } - - hideLoader(loader) { - if (loader) loader.style.display = "none"; - } - - showError(dropdown, message) { - if (dropdown.id === 'party_type') { - dropdown.innerHTML = `

Error: ${message}

`; - return; - } - dropdown.innerHTML = ``; - dropdown.disabled = false; - } - - enableDropdown(fieldId) { - const dropdown = document.getElementById(fieldId); - if (dropdown) { - dropdown.disabled = false; - } - } - - /** - * Clear all dropdowns and reset their state - */ - clearAllDropdowns() { - const dropdownFields = ['court', 'case_category', 'case_type', 'filing_type', 'document_type']; - - dropdownFields.forEach(fieldId => { - const dropdown = document.getElementById(fieldId); - if (dropdown) { - this.clearDropdown(dropdown); - if (fieldId !== 'court') { - dropdown.disabled = true; - } - } - - // Reset selected values - this.selectedValues[fieldId] = null; - }); - - // Hide dynamic sections - const dynamicSections = document.getElementById('dynamicSections'); - if (dynamicSections) { - dynamicSections.style.display = 'none'; - dynamicSections.innerHTML = ''; - } - } -} - -// Export for module use or make globally available -if (typeof module !== "undefined" && module.exports) { - module.exports = CascadingDropdowns; -} else { - window.CascadingDropdowns = CascadingDropdowns; -} \ No newline at end of file diff --git a/efile_app/efile/static/js/components/search-dropdown.js b/efile_app/efile/static/js/components/search-dropdown.js deleted file mode 100644 index dcfc31b..0000000 --- a/efile_app/efile/static/js/components/search-dropdown.js +++ /dev/null @@ -1,339 +0,0 @@ -/** - * SearchDropdown - A reusable search and type-ahead dropdown component - * - * Features: - * - Type-ahead search with highlighting - * - Keyboard navigation (arrow keys, Enter, Escape) - * - Mouse interaction - * - Clear selection functionality - * - Integration with hidden select element for form submission - * - Support for external option updates - * - Accessibility features - */ -class SearchDropdown { - constructor(fieldId, options = {}) { - this.fieldId = fieldId; - this.options = { - placeholder: 'Search...', - noResultsText: 'No matching options found', - ...options - }; - - // Get DOM elements - this.input = document.getElementById(`${fieldId}_search`); - this.select = document.getElementById(fieldId); - this.results = document.getElementById(`${fieldId}-results`); - this.selected = document.getElementById(`${fieldId}-selected`); - this.container = document.getElementById(`${fieldId}-container`); - - // State - this.allOptions = []; - this.filteredOptions = []; - this.highlightedIndex = -1; - this.isInitialized = false; - this.isUpdatingSelect = false; // Flag to prevent infinite loops - - this.init(); - } - - init() { - if (!this.input || !this.select || !this.results || !this.selected) { - console.error(`SearchDropdown: Required elements not found for field ${this.fieldId}`); - return; - } - - this.setupEventListeners(); - this.isInitialized = true; - } - - setupEventListeners() { - // Input events - this.input.addEventListener('input', (e) => this.handleInput(e)); - this.input.addEventListener('focus', (e) => this.handleFocus(e)); - this.input.addEventListener('blur', (e) => this.handleBlur(e)); - this.input.addEventListener('keydown', (e) => this.handleKeydown(e)); - - // Clear button - const clearBtn = this.selected.querySelector('.btn-clear'); - if (clearBtn) { - clearBtn.addEventListener('click', () => this.clearSelection()); - } - - // Hidden select changes (for external updates) - this.select.addEventListener('change', () => this.syncFromSelect()); - - // Click outside to close - document.addEventListener('click', (e) => { - if (this.container && !this.container.contains(e.target)) { - this.hideResults(); - } - }); - } - - handleInput(e) { - const query = e.target.value.toLowerCase(); - this.filterOptions(query); - this.showResults(); - this.highlightedIndex = -1; - } - - handleFocus(e) { - if (this.allOptions.length > 0) { - this.filterOptions(e.target.value.toLowerCase()); - this.showResults(); - } - } - - handleBlur(e) { - // Delay hiding to allow clicks on results - setTimeout(() => { - if (this.container && !this.container.contains(document.activeElement)) { - this.hideResults(); - } - }, 150); - } - - handleKeydown(e) { - if (!this.isResultsVisible()) return; - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - this.highlightNext(); - break; - case 'ArrowUp': - e.preventDefault(); - this.highlightPrevious(); - break; - case 'Enter': - e.preventDefault(); - this.selectHighlighted(); - break; - case 'Escape': - e.preventDefault(); - this.hideResults(); - break; - } - } - - updateOptions(options) { - this.allOptions = options.map(option => ({ - value: option.value, - text: option.text, - searchText: option.text.toLowerCase() - })); - - // Update the hidden select as well - this.select.innerHTML = ``; - options.forEach(option => { - const optionElement = document.createElement('option'); - optionElement.value = option.value; - optionElement.textContent = option.text; - this.select.appendChild(optionElement); - }); - - this.filterOptions(); - } - - filterOptions(query = '') { - if (query === '') { - this.filteredOptions = [...this.allOptions]; - } else { - this.filteredOptions = this.allOptions.filter(option => - option.searchText.includes(query) - ); - } - this.renderResults(); - } - - renderResults() { - if (this.filteredOptions.length === 0) { - this.results.innerHTML = `
${this.options.noResultsText}
`; - } else { - this.results.innerHTML = this.filteredOptions - .map((option, index) => ` -
- ${this.highlightMatch(option.text, this.input.value)} -
- `).join(''); - - // Add event listeners to result items - this.results.querySelectorAll('.search-dropdown-item').forEach((item, index) => { - item.addEventListener('mousedown', (e) => { - e.preventDefault(); // Prevent blur - this.selectOption(this.filteredOptions[index]); - }); - - item.addEventListener('mouseenter', () => { - this.highlightedIndex = index; - this.updateHighlight(); - }); - }); - } - } - - highlightMatch(text, query) { - if (!query) return text; - - const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); - return text.replace(regex, '$1'); - } - - highlightNext() { - this.highlightedIndex = Math.min(this.highlightedIndex + 1, this.filteredOptions.length - 1); - this.updateHighlight(); - } - - highlightPrevious() { - this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0); - this.updateHighlight(); - } - - updateHighlight() { - this.results.querySelectorAll('.search-dropdown-item').forEach((item, index) => { - item.classList.toggle('highlighted', index === this.highlightedIndex); - }); - } - - selectHighlighted() { - if (this.highlightedIndex >= 0 && this.highlightedIndex < this.filteredOptions.length) { - this.selectOption(this.filteredOptions[this.highlightedIndex]); - } - } - - selectOption(option) { - this.isUpdatingSelect = true; - this.input.value = option.text; - this.select.value = option.value; - this.showSelected(option.text); - this.hideResults(); - - // Trigger change event on the hidden select - this.select.dispatchEvent(new Event('change', { - bubbles: true - })); - this.isUpdatingSelect = false; - } - - showSelected(text) { - const selectedText = this.selected.querySelector('.selected-text'); - if (selectedText) { - selectedText.textContent = text; - } - this.selected.style.display = 'flex'; - this.input.style.display = 'none'; - } - - clearSelection() { - if (this.isUpdatingSelect) return; // Prevent infinite loops - - this.isUpdatingSelect = true; - this.input.value = ''; - this.select.value = ''; - this.selected.style.display = 'none'; - this.input.style.display = 'block'; - this.hideResults(); - - // Focus the input - if (!this.input.disabled) { - this.input.focus(); - } - - // Trigger change event on the hidden select - this.select.dispatchEvent(new Event('change', { - bubbles: true - })); - this.isUpdatingSelect = false; - } - - syncFromSelect() { - if (this.isUpdatingSelect) return; // Prevent infinite loops - - // If the select was changed externally, update the search input - const selectedOption = this.select.selectedOptions[0]; - if (selectedOption && selectedOption.value) { - this.input.value = selectedOption.text; - this.showSelected(selectedOption.text); - } else { - this.isUpdatingSelect = true; - this.input.value = ''; - this.selected.style.display = 'none'; - this.input.style.display = 'block'; - this.hideResults(); - this.isUpdatingSelect = false; - } - } - - showResults() { - this.results.style.display = 'block'; - } - - hideResults() { - this.results.style.display = 'none'; - this.highlightedIndex = -1; - } - - isResultsVisible() { - return this.results.style.display !== 'none'; - } - - enable() { - this.input.disabled = false; - this.select.disabled = false; - this.input.placeholder = this.options.placeholder; - } - - disable() { - this.input.disabled = true; - this.select.disabled = true; - this.input.placeholder = 'Select dependencies first'; - this.clearSelection(); - this.hideResults(); - } - - reset() { - this.clearSelection(); - this.allOptions = []; - this.filteredOptions = []; - this.select.innerHTML = ``; - } - - getValue() { - return this.select.value; - } - - setValue(value, triggerChange = true) { - const option = this.allOptions.find(opt => opt.value === value); - if (option) { - this.selectOption(option); - if (!triggerChange) { - // If we don't want to trigger change, we need to prevent it - this.select.value = value; - this.input.value = option.text; - this.showSelected(option.text); - } - } - } - - getFieldLabel() { - const label = this.container?.previousElementSibling?.querySelector('label')?.textContent; - return label ? label.replace('*', '').trim() : 'Option'; - } - - // Static method to create multiple search dropdowns - static createMultiple(fieldIds, options = {}) { - const instances = {}; - fieldIds.forEach(fieldId => { - instances[fieldId] = new SearchDropdown(fieldId, options[fieldId] || {}); - }); - return instances; - } -} - -// Export for use in other modules -if (typeof module !== 'undefined' && module.exports) { - module.exports = SearchDropdown; -} - -// Make available globally -window.SearchDropdown = SearchDropdown; \ No newline at end of file diff --git a/efile_app/efile/static/js/dynamic-form-sections.js b/efile_app/efile/static/js/dynamic-form-sections.js deleted file mode 100644 index e540998..0000000 --- a/efile_app/efile/static/js/dynamic-form-sections.js +++ /dev/null @@ -1,1337 +0,0 @@ -class DynamicFormSections { - constructor() { - // We create parties/services containers dynamically inside #dynamicSections - this.caseInfoContainer = null; // will be created when needed - this.partiesContainer = null; // will be created when needed - this.partiesHeader = null; // will be created when needed - this.servicesContainer = null; // will be created when needed - this.dynamicSections = document.getElementById("dynamicSections"); - this.currentCaseType = null; - this.config = null; - this.preservedFormData = null; - this.preservedCaseType = null; - - this.jurisdiction = apiUtils.getCurrentJurisdiction(); - this.init(); - } - - async init() { - // Load configuration from server - await this.loadConfiguration(); - - // Load any existing case data from the session - await this.loadExistingCaseData(); - - // Listen for case type changes to trigger form section updates - const caseTypeSelect = document.getElementById("case_type"); - if (caseTypeSelect) { - caseTypeSelect.addEventListener("change", () => { - this.handleCaseTypeChange(); - }); - - // Also listen for when the dropdown is cleared/reset - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === "childList" || mutation.type === "attributes") { - // Check if dropdown was cleared - if ( - caseTypeSelect.value === "" && - this.dynamicSections && - this.dynamicSections.style.display === "block" - ) { - this.hideDynamicSections(); - } - } - }); - }); - observer.observe(caseTypeSelect, { - childList: true, - attributes: true, - attributeFilter: ["value"], - }); - } - - // Listen for existing case changes to show/hide case information - const existingCaseSelect = document.getElementById("existing_case"); - if (existingCaseSelect) { - existingCaseSelect.addEventListener("change", () => { - this.handleExistingCaseChange(); - }); - } - - // Listen for court changes to reload configuration with court-specific modifications - const courtSelect = document.getElementById("court"); - if (courtSelect) { - courtSelect.addEventListener("change", () => { - // If we have a case type selected, reload the configuration and re-render - if (caseTypeSelect && caseTypeSelect.value) { - this.handleCaseTypeChange(); - } - }); - } - } - - async loadConfiguration() { - try { - // Get current court selection to include in the request - const courtDropdown = document.getElementById("court"); - const caseTypeDropdown = document.getElementById("case_type"); - const court = courtDropdown ? courtDropdown.value : ""; - const caseTypeValue = caseTypeDropdown ? caseTypeDropdown.value : ""; - const caseTypeText = - caseTypeDropdown && caseTypeDropdown.selectedIndex >= 0 ? - caseTypeDropdown.options[caseTypeDropdown.selectedIndex].text : - ""; - - // Only make API call if we have a case type (required parameter) - if (!caseTypeValue || !caseTypeText) { - this.config = this.getDefaultConfig(); - return; - } - - // Use form-config endpoint which applies court-specific modifications - let url = "/api/form-config/"; - let params = { - "case_type": caseTypeText, - "jurisdiction": this.jurisdiction, - "court": court - }; - - const result = await apiUtils.get(url, params); - - if (result.success && result.data) { - // Transform the response to match the expected config structure - this.config = { - case_types: {}, - base_case_types: {}, - }; - - // If we have sections, create a case type config structure - if ( - result.data.sections && - Object.keys(result.data.sections).length > 0 - ) { - // Determine if this is a base case type by checking common patterns - const isBaseType = - result.data.case_type_name && - (result.data.case_type_name.toLowerCase().includes("eviction") || - result.data.case_type_name - .toLowerCase() - .includes("repossession") || - result.data.case_type_name.toLowerCase().includes("restoration")); - - const caseConfig = { - keywords: this.extractKeywordsFromCaseType( - result.data.case_type_name - ), - sections: result.data.sections, - description: result.data.description || "", - validation_rules: result.data.validation_rules || [], - }; - - // Put eviction/repossession types in base_case_types, others in case_types - if (isBaseType) { - this.config.base_case_types.eviction_repossession = caseConfig; - } else { - this.config.case_types[ - result.data.case_type_name || "name_change" - ] = caseConfig; - } - } - } else { - console.error( - "Failed to load configuration:", - result.error || "Unknown error" - ); - this.config = this.getDefaultConfig(); - } - - let party_params = { - "case_type": caseTypeValue, - "jurisdiction": this.jurisdiction, - "court": court, - "existing_case": "no" - }; - const parties_result = await apiUtils.getPartyTypes(party_params); - if (parties_result.success && parties_result.party_types) { - this.parties = parties_result.party_types; - } - } catch (error) { - console.error("Error loading configuration:", error); - this.config = this.getDefaultConfig(); - } - } - - // Helper method to extract keywords from case type name - extractKeywordsFromCaseType(caseTypeName) { - if (!caseTypeName) return []; - const name = caseTypeName.toLowerCase(); - if (name.includes("eviction") || name.includes("repossession")) { - return ["eviction", "repossession", "restoration"]; - } - if (name.includes("name") && name.includes("change")) { - return ["name change", "name petition", "change of name"]; - } - return [caseTypeName.toLowerCase()]; - } - - async loadExistingCaseData() { - try { - // Check if there's an API endpoint to retrieve saved case data - const result = await apiUtils.getCaseData(); - if ( - result.success && - result.data && - Object.keys(result.data).length > 0 - ) { - this.restorationData = result.data; - - // Also make it available for form validation system - if (window.formValidation) { - window.formValidation.restorationData = result.data; - } - } - } catch (error) { - console.warn("Could not load existing case data:", error); - // This is not a critical error, continue without saved data - } - } - - getDefaultConfig() { - // Fallback configuration if server config fails - return { - case_types: { - name_change: { - keywords: ["name change"], - sections: { - parties: { - title: "Required parties", - fields: [{ - section_title: "Petitioner", - required: true, - fields: [{ - name: "petitioner_first_name", - label: "First Name", - type: "text", - required: true, - column_width: "col-md-6", - }, { - name: "petitioner_last_name", - label: "Last Name", - type: "text", - required: true, - column_width: "col-md-6", - }, ], - }, ], - }, - }, - }, - }, - base_case_types: {}, - }; - } - - async handleCaseTypeChange() { - const caseTypeSelect = document.getElementById("case_type"); - - if (!caseTypeSelect) { - return; - } - - const caseTypeText = - caseTypeSelect.options[caseTypeSelect.selectedIndex]?.text || ""; - const caseTypeValue = caseTypeSelect.value; - - // Don't hide sections immediately if the dropdown is being cleared - give time for restoration - if (!caseTypeValue) { - // Check if this is a temporary clearing (cascading dropdown coordination) - const isBeingCleared = - caseTypeSelect.disabled || - window.cascadingDropdowns?.optionalServicesLoaded === false; - - if (isBeingCleared) { - // Set a timeout to check again later - setTimeout(() => { - if (!caseTypeSelect.value && caseTypeSelect.options.length <= 1) { - this.hideDynamicSections(); - } else if (caseTypeSelect.value) { - this.handleCaseTypeChange(); - } - }, 1000); // Wait 1 second for cascading system to restore values - return; - } else { - this.hideDynamicSections(); - return; - } - } - - // Reload configuration with current court and case type to get court-specific modifications - await this.loadConfiguration(); - - if (!this.config) { - return; - } - - const caseTypeConfig = this.findCaseTypeConfig(caseTypeText); - - if (caseTypeConfig) { - this.currentCaseType = caseTypeValue; // Track the current case type - this.renderCaseTypeForm(caseTypeConfig); - this.showDynamicSections(); - } else { - this.hideDynamicSections(); - } - } - - handleExistingCaseChange() { - const existingCaseSelect = document.getElementById("existing_case"); - if (!existingCaseSelect) { - return; - } - - const existingCaseValue = existingCaseSelect.value; - - // Show/hide case information section based on existing case selection - if (this.caseInfoContainer) { - if (existingCaseValue === "yes") { - // Show case information section - this.caseInfoContainer.style.display = "block"; - // Also show the header if it exists - const caseInfoHeader = this.caseInfoContainer.previousElementSibling; - if (caseInfoHeader && caseInfoHeader.tagName === "H3") { - caseInfoHeader.style.display = "block"; - } - } else { - // Hide case information section - this.caseInfoContainer.style.display = "none"; - // Also hide the header if it exists - const caseInfoHeader = this.caseInfoContainer.previousElementSibling; - if (caseInfoHeader && caseInfoHeader.tagName === "H3") { - caseInfoHeader.style.display = "none"; - } - } - } - } - - findCaseTypeConfig(caseTypeText) { - const lowerCaseText = caseTypeText.toLowerCase(); - - // Also try to match by dropdown value if text matching fails - const caseTypeSelect = document.getElementById("case_type"); - const caseTypeValue = caseTypeSelect ? caseTypeSelect.value : ""; - - // Search both case_types and base_case_types sections - const caseTypeSources = [ - this.config.case_types || {}, - this.config.base_case_types || {}, - ]; - - for (const caseTypes of caseTypeSources) { - for (const [configKey, caseConfig] of Object.entries(caseTypes)) { - const keywords = caseConfig.keywords || []; - - // Method 1: Check if any keyword matches the case type text - const matchingKeyword = keywords.find((keyword) => { - const lowerKeyword = keyword.toLowerCase(); - const matches = lowerCaseText.includes(lowerKeyword); - return matches; - }); - - if (matchingKeyword) { - return caseConfig; - } - - // Method 2: Direct config key matching (fallback) - if ( - configKey === "name_change" && - (lowerCaseText.includes("name") || - lowerCaseText.includes("change") || - caseTypeValue.toLowerCase().includes("name")) - ) { - return caseConfig; - } - - // Method 3: Check for eviction case type - if ( - configKey === "eviction_repossession" && - (lowerCaseText.includes("eviction") || - lowerCaseText.includes("repossession") || - lowerCaseText.includes("restoration")) - ) { - return caseConfig; - } - } - } - - // Method 4: If no match found, try some common patterns - if (lowerCaseText.includes("name") && lowerCaseText.includes("change")) { - if (this.config.case_types && this.config.case_types.name_change) { - return this.config.case_types.name_change; - } - if ( - this.config.base_case_types && - this.config.base_case_types.name_change - ) { - return this.config.base_case_types.name_change; - } - } - - return null; - } - - renderCaseTypeForm(caseTypeConfig) { - this.preserveCurrentState(); - - const sections = caseTypeConfig.sections || {}; - - // Render case information section (should come first) - if (sections.case_information) { - this.renderSection("case_information", sections.case_information || {}); - } - - // Render parties section - if (sections.parties) { - this.renderSection("parties", sections.parties || {}); - } - - // Render services section - if (sections.services) { - this.renderSection("services", sections.services || {}); - } - - // Restore preserved state after rendering - this.restorePreservedState(); - - // Load party type dropdowns after all sections are rendered - this.loadPartyTypeDropdowns(); - - // Update form validation after rendering - this.updateFormValidation(); - - // Check if there's restoration data from form validation that needs to be applied - if (this.restorationData) { - //setTimeout(() => { - this.populateRenderedFields(this.restorationData); - this.restorationData = null; // Clear after use - //}, 50); - } - - // Notify form validation system that dynamic fields have been rendered - setTimeout(() => { - if (window.formValidation && window.formValidation.restorationData) { - window.formValidation.populateDynamicFields( - window.formValidation.restorationData - ); - } - - // Check existing case selection to show/hide case information appropriately - this.handleExistingCaseChange(); - }, 100); - } - - renderSection(sectionType, sectionConfig) { - // Ensure containers exist inside the dynamicSections wrapper - if (!this.dynamicSections) { - return; - } - - if (sectionType === "case_information") { - if (!this.caseInfoContainer) { - // Create header and container for case information - const header = document.createElement("h3"); - header.className = "subsection-header"; - header.textContent = sectionConfig.title || getText("Case information"); - - const containerDiv = document.createElement("div"); - containerDiv.id = "caseInfoContainer"; - - this.dynamicSections.appendChild(header); - this.dynamicSections.appendChild(containerDiv); - this.caseInfoContainer = containerDiv; - - // Initially hide case information section - only show when existing_case = "yes" - header.style.display = "none"; - containerDiv.style.display = "none"; - } - } else if (sectionType === "parties") { - if (!this.partiesContainer) { - // Create header and container for parties - const header = document.createElement("h3"); - header.className = "subsection-header"; - header.textContent = sectionConfig.title || getText("Required parties"); - - const containerDiv = document.createElement("div"); - containerDiv.id = "partiesContainer"; - - this.dynamicSections.appendChild(header); - this.dynamicSections.appendChild(containerDiv); - this.partiesContainer = containerDiv; - this.partiesHeader = header; // Store reference to header for visibility control - } - } else if (sectionType === "services") { - if (!this.servicesContainer) { - const containerDiv = document.createElement("div"); - containerDiv.id = "servicesContainer"; - this.dynamicSections.appendChild(containerDiv); - this.servicesContainer = containerDiv; - } - } - - const container = - sectionType === "case_information" ? - this.caseInfoContainer : - sectionType === "parties" ? - this.partiesContainer : - this.servicesContainer; - - let html = ""; - - if (sectionType === "case_information") { - html = this.renderCaseInformationSection(sectionConfig); - } else if (sectionType === "parties") { - html = this.renderPartiesSection(sectionConfig); - } else if (sectionType === "services") { - html = this.renderServicesSection(sectionConfig); - } - - container.innerHTML = html; - - // Special handling for parties section - hide header if no content - if (sectionType === "parties" && this.partiesHeader) { - if (html.trim() === "") { - // No parties sections were rendered, hide the header - this.partiesHeader.style.display = "none"; - } else { - // There is content, show the header - this.partiesHeader.style.display = "block"; - } - } - - // Note: loadPartyTypeDropdowns() and updateFormValidation() are called - // once after all sections are rendered in renderCaseTypeForm() - } - - renderCaseInformationSection(sectionConfig) { - // Case information has the same nested structure as parties: section_title -> fields - const sectionGroups = sectionConfig.fields || []; - - let html = ""; - - // Iterate through each section group (like "Case Details") - sectionGroups.forEach((sectionGroup) => { - if (sectionGroup.section_title) { - // Check if this section should be required - const isRequired = sectionGroup.required || false; - const requiredIndicator = isRequired ? - '*' : - ""; - const optionalIndicator = !isRequired ? - '(Optional)' : - ""; - - html += `
-
${sectionGroup.section_title} ${requiredIndicator} ${optionalIndicator}
`; - - if (sectionGroup.fields && sectionGroup.fields.length > 0) { - html += '
'; - - // Render each field in this section - sectionGroup.fields.forEach((field) => { - if (field && field.name && field.type) { - // Update field requirement based on section requirement - const updatedField = { - ...field, - required: field.required && isRequired, - }; - html += this.renderField(updatedField); - } else { - console.warn("⚠️ Invalid field structure:", field); - } - }); - - html += "
"; - } - - html += "
"; - } - }); - - return html; - } - - renderPartiesSection(sectionConfig) { - const fields = sectionConfig.fields || []; - let html = ""; - - let party_types = this.parties; - - if (party_types.length <= 1) { - // Skip rendering - return ""; - } - - fields.forEach((partyGroup) => { - if (partyGroup.section_title) { - // Check if this section should be shown based on current court selection - const shouldShow = this.shouldShowSection(partyGroup); - - // Skip rendering this section entirely if it shouldn't be shown - if (!shouldShow) { - return ""; - } - - // Check if this section should be required based on current court selection - const isRequired = this.evaluateConditionalRequirement(partyGroup); - const requiredIndicator = isRequired ? - '*' : - ""; - - // Add optional indicator if not required - const optionalIndicator = !isRequired ? - '(Optional)' : - ""; - - html += `
-
${partyGroup.section_title} ${requiredIndicator} ${optionalIndicator}
`; - - if (partyGroup.fields && partyGroup.fields.length > 0) { - html += '
'; - - partyGroup.fields.forEach((field) => { - // Update field requirement based on section requirement - const updatedField = { - ...field, - required: field.required && isRequired, - }; - html += this.renderField(updatedField); - }); - - html += "
"; - } - - html += "
"; - } - }); - - return html; - } - - shouldShowSection(partyGroup) { - // If no conditional requirements defined, show by default - if (!partyGroup.conditional_requirements) { - return true; - } - - // Get current court selection - const courtDropdown = document.getElementById("court"); - const selectedCourt = courtDropdown ? courtDropdown.value : null; - - if (!selectedCourt) { - // No court selected yet, show by default - return true; - } - - const conditionalReqs = partyGroup.conditional_requirements; - - // Check if current court is in hidden_for_courts list - if ( - conditionalReqs.hidden_for_courts && - conditionalReqs.hidden_for_courts.includes(selectedCourt) - ) { - return false; - } - - // Check if current court is in required_for_courts list (should show) - if ( - conditionalReqs.required_for_courts && - conditionalReqs.required_for_courts.includes(selectedCourt) - ) { - return true; - } - - // Check if current court is in optional_for_courts list (should show but optional) - if ( - conditionalReqs.optional_for_courts && - conditionalReqs.optional_for_courts.includes(selectedCourt) - ) { - return true; - } - - // Check county-based requirements (extract county from court code) - if ( - conditionalReqs.required_for_counties || - conditionalReqs.optional_for_counties - ) { - const county = this.extractCountyFromCourt(selectedCourt); - - if ( - conditionalReqs.required_for_counties && - conditionalReqs.required_for_counties.includes(county) - ) { - return true; - } - - if ( - conditionalReqs.optional_for_counties && - conditionalReqs.optional_for_counties.includes(county) - ) { - return true; - } - } - - // For "Name Sought" section, hide by default for all other courts - if ( - partyGroup.section_title && - partyGroup.section_title.toLowerCase().includes("name sought") - ) { - return false; - } - - // If we have conditional requirements but the current court isn't explicitly listed, - // default to showing for "Petitioner" and hiding for other sections - if ( - partyGroup.section_title && - partyGroup.section_title.toLowerCase().includes("petitioner") - ) { - return true; // Show Petitioner by default unless explicitly hidden - } - - // For all other sections with conditional requirements, hide by default - return false; - } - - evaluateConditionalRequirement(partyGroup) { - // If no conditional requirements defined, use default required value - if (!partyGroup.conditional_requirements) { - return partyGroup.required || false; - } - - // Get current court selection - const courtDropdown = document.getElementById("court"); - const selectedCourt = courtDropdown ? courtDropdown.value : null; - - if (!selectedCourt) { - // No court selected yet, default to base required value - return partyGroup.required || false; - } - - const conditionalReqs = partyGroup.conditional_requirements; - - // Check if current court is in required_for_courts list - if ( - conditionalReqs.required_for_courts && - conditionalReqs.required_for_courts.includes(selectedCourt) - ) { - return true; - } - - // Check if current court is in optional_for_courts list - if ( - conditionalReqs.optional_for_courts && - conditionalReqs.optional_for_courts.includes(selectedCourt) - ) { - return false; - } - - // Check county-based requirements (extract county from court code) - if ( - conditionalReqs.required_for_counties || - conditionalReqs.optional_for_counties - ) { - const county = this.extractCountyFromCourt(selectedCourt); - - if ( - conditionalReqs.required_for_counties && - conditionalReqs.required_for_counties.includes(county) - ) { - return true; - } - - if ( - conditionalReqs.optional_for_counties && - conditionalReqs.optional_for_counties.includes(county) - ) { - return false; - } - } - - // Default to base required value if no specific rules match - return partyGroup.required || false; - } - - extractCountyFromCourt(courtCode) { - // Handle court codes like "cook:cd1" -> "cook" - if (courtCode.includes(":")) { - return courtCode.split(":")[0]; - } - - // Handle direct county codes like "dupage", "kane" - return courtCode.toLowerCase(); - } - - renderServicesSection(sectionConfig) { - const fields = sectionConfig.fields || []; - let html = '
'; - - fields.forEach((field) => { - if (field.type === "checkbox") { - html += ` -
- - -
`; - } - }); - - html += "
"; - return html; - } - - renderField(field) { - if (!field || typeof field !== "object") { - return '

Error: Invalid field configuration

'; - } - - const columnClass = field.column_width || "col-12"; - const requiredAttr = field.required ? "required" : ""; - const placeholderAttr = field.placeholder ? - `placeholder="${field.placeholder}"` : - ""; - const fieldId = field.name || "unknown"; - const fieldName = field.name || "unknown"; - - let inputHtml = ""; - - switch (field.type) { - case "text": - inputHtml = ``; - break; - - case "textarea": - inputHtml = ``; - break; - - case "number": - const minAttr = field.min ? `min="${field.min}"` : ""; - const maxAttr = field.max ? `max="${field.max}"` : ""; - const stepAttr = field.step ? `step="${field.step}"` : ""; - inputHtml = ``; - break; - - case "email": - inputHtml = ``; - break; - - case "tel": - inputHtml = ``; - break; - - case "us_state": - const STATE_CHOICES = [ - ["", "Select a state"], - ["AL", "Alabama"], - ["AK", "Alaska"], - ["AS", "American Samoa"], - ["AZ", "Arizona"], - ["AR", "Arkansas"], - ["CA", "California"], - ["CO", "Colorado"], - ["CT", "Connecticut"], - ["DE", "Delaware"], - ["DC", "District of Columbia"], - ["FL", "Florida"], - ["GA", "Georgia"], - ["GU", "Guam"], - ["HI", "Hawaii"], - ["ID", "Idaho"], - ["IL", "Illinois"], - ["IN", "Indiana"], - ["IA", "Iowa"], - ["KS", "Kansas"], - ["KY", "Kentucky"], - ["LA", "Louisiana"], - ["ME", "Maine"], - ["MD", "Maryland"], - ["MA", "Massachusetts"], - ["MI", "Michigan"], - ["MN", "Minnesota"], - ["MS", "Mississippi"], - ["MO", "Missouri"], - ["MT", "Montana"], - ["NE", "Nebraska"], - ["NV", "Nevada"], - ["NH", "New Hampshire"], - ["NJ", "New Jersey"], - ["NM", "New Mexico"], - ["NY", "New York"], - ["NC", "North Carolina"], - ["ND", "North Dakota"], - ["MP", "Northern Mariana Islands"], - ["OH", "Ohio"], - ["OK", "Oklahoma"], - ["OR", "Oregon"], - ["PA", "Pennsylvania"], - ["PR", "Puerto Rico"], - ["RI", "Rhode Island"], - ["SC", "South Carolina"], - ["SD", "South Dakota"], - ["TN", "Tennessee"], - ["TX", "Texas"], - ["UT", "Utah"], - ["VT", "Vermont"], - ["VA", "Virginia"], - ["VI", "US Virgin Islands"], - ["WA", "Washington"], - ["WV", "West Virginia"], - ["WI", "Wisconsin"], - ["WY", "Wyoming"] - ] - inputHtml = `" - break; - - case "party_type_dropdown": - // Party type is a short, API-provided list (2-3 options in - // practice), so it's rendered as radio buttons rather than a - // dropdown. Populated later by loadPartyTypeDropdowns(). - inputHtml = ` -
-

Select Party Type

-
- `; - break; - - default: - inputHtml = ``; - } - - const helpText = field.help_text ? - `
${field.help_text}
` : - ""; - const fieldLabel = field.label || field.name || "Field"; - - return ` -
- - ${inputHtml} - ${helpText} -
`; - } - - async loadPartyTypeDropdowns() { - // Find all party type dropdowns in the rendered sections - const partyTypeDropdowns = document.querySelectorAll( - ".party-type-dropdown" - ); - - if (partyTypeDropdowns.length === 0) { - return; - } - - partyTypeDropdowns.forEach(async (dropdown) => { - const fieldId = dropdown.id; - const apiEndpoint = - dropdown.dataset.apiEndpoint || "/api/dropdowns/party-types/"; - let defaultValue = dropdown.dataset.defaultValue || ""; - - // Check for saved case data first (highest priority) - if ( - window.formValidation && - window.formValidation.restorationData && - window.formValidation.restorationData[fieldId] - ) { - defaultValue = window.formValidation.restorationData[fieldId]; - } - // Check for restoration data from preserved form state - else if (this.restorationData && this.restorationData[fieldId]) { - defaultValue = this.restorationData[fieldId]; - } - // Check for preserved form data - else if (this.preservedFormData && this.preservedFormData[fieldId]) { - defaultValue = this.preservedFormData[fieldId]; - } - - // Show loading spinner - const loadingSpinner = document.getElementById(`loading-${fieldId}`); - if (loadingSpinner) { - loadingSpinner.style.display = "block"; - } - - try { - // Get current form values for court and case_type - const courtSelect = document.getElementById("court"); - const caseTypeSelect = document.getElementById("case_type"); - - const court = courtSelect ? courtSelect.value : ""; - const caseType = caseTypeSelect ? caseTypeSelect.value : ""; - - if (!court || !caseType) { - if (loadingSpinner) loadingSpinner.style.display = "none"; - return; - } - - // Build API URL with parameters - const result = await apiUtils.get(apiEndpoint, { - court, - "case_type": caseType, - "jurisdiction": this.jurisdiction - }); - - if (result.success && result.data) { - const partyTypes = result.data; - - // If no saved data, try to find intelligent default from API response - if (!defaultValue) { - // Find the parent section title - const partySection = dropdown.closest(".party-section"); - if (partySection) { - const titleElement = partySection.querySelector(".party-title"); - if (titleElement) { - const sectionTitle = titleElement.textContent - .toLowerCase() - .replace(/\s*\*\s*$/, "") - .replace(/\s*\(optional\)\s*$/i, "") - .trim(); - - // Find party type that matches the section title - const matchingPartyType = partyTypes.find((partyType) => { - const partyName = partyType.name.toLowerCase(); - return ( - partyName.includes(sectionTitle) || - sectionTitle.includes(partyName.split(" ")[0]) || - (sectionTitle === "name sought" && - partyName.includes("name")) || - (sectionTitle === "petitioner" && - partyName.includes("petitioner")) || - (sectionTitle === "defendant" && - partyName.includes("defendant")) || - (sectionTitle === "plaintiff" && - partyName.includes("plaintiff")) || - (sectionTitle === "respondent" && - partyName.includes("respondent")) - ); - }); - - if (matchingPartyType) { - defaultValue = matchingPartyType.code; - } - } - } - } - - this.renderPartyTypeRadios(dropdown, partyTypes, defaultValue); - } else { - console.error( - "Failed to load party types:", - result.error || "Unknown error", - result - ); - - dropdown.innerHTML = - '

Error loading party types

'; - } - } catch (error) { - console.error(`Error loading party types for ${fieldId}:`, error); - - dropdown.innerHTML = - '

Error loading party types

'; - } finally { - // Hide loading spinner - if (loadingSpinner) { - loadingSpinner.style.display = "none"; - } - } - }); - } - - renderPartyTypeRadios(container, partyTypes, defaultValue) { - container.innerHTML = ""; - - if (!partyTypes || partyTypes.length === 0) { - container.innerHTML = '

No party types available

'; - return; - } - - const groupName = container.dataset.fieldName || container.id; - const isRequired = container.dataset.required === "true"; - - partyTypes.forEach((partyType, index) => { - const wrapper = document.createElement("div"); - wrapper.className = "form-check"; - - const input = document.createElement("input"); - input.type = "radio"; - input.className = "form-check-input"; - input.name = groupName; - input.id = `${container.id}_${index}`; - input.value = partyType.code; - if (isRequired) { - input.required = true; - } - if (partyType.code === defaultValue) { - input.checked = true; - } - - const label = document.createElement("label"); - label.className = "form-check-label"; - label.setAttribute("for", input.id); - label.textContent = partyType.name; - - wrapper.appendChild(input); - wrapper.appendChild(label); - container.appendChild(wrapper); - }); - } - - showDynamicSections() { - if (this.dynamicSections) { - this.dynamicSections.style.display = "block"; - - // Also verify content was rendered - const partiesContent = this.partiesContainer ? - this.partiesContainer.innerHTML.trim() : - ""; - const servicesContent = this.servicesContainer ? - this.servicesContainer.innerHTML.trim() : - ""; - - if (partiesContent.length === 0 && servicesContent.length === 0) { - console.warn("Dynamic sections shown but no content rendered!"); - } - } else {} - } - - hideDynamicSections() { - if (this.dynamicSections) { - this.dynamicSections.style.display = "none"; - } else {} - - // Add a timeout to prevent immediate clearing race conditions - setTimeout(() => { - this.clearContainers(); - }, 50); - } - - // Add a method to preserve current form state during dropdown changes - preserveCurrentState() { - if (this.currentCaseType) { - const currentConfig = this.findCaseTypeConfig(this.currentCaseType); - if (currentConfig) { - // Store current form values - const formData = {}; - const dynamicFields = this.getAllDynamicFieldNames(); - dynamicFields.forEach((fieldName) => { - const field = document.querySelector(`[name="${fieldName}"]`); - if (field) { - if (field.type === "checkbox") { - formData[fieldName] = field.checked; - } else if (field.type === "radio") { - const checked = document.querySelector(`input[name="${fieldName}"]:checked`); - formData[fieldName] = checked ? checked.value : ""; - } else { - formData[fieldName] = field.value; - } - } - }); - this.preservedFormData = formData; - this.preservedCaseType = this.currentCaseType; - } - } - } - - // Method to restore preserved state - restorePreservedState() { - if ( - this.preservedFormData && - this.preservedCaseType === this.currentCaseType - ) { - setTimeout(() => { - Object.keys(this.preservedFormData).forEach((fieldName) => { - const field = document.querySelector(`[name="${fieldName}"]`); - if (field) { - if (field.type === "checkbox") { - field.checked = this.preservedFormData[fieldName]; - } else if (field.type === "radio") { - const radio = document.querySelector( - `input[name="${fieldName}"][value="${this.preservedFormData[fieldName]}"]` - ); - if (radio) radio.checked = true; - } else { - field.value = this.preservedFormData[fieldName]; - } - } - }); - // Clear preserved data after restoration - this.preservedFormData = null; - this.preservedCaseType = null; - }, 100); - } - } - - clearContainers() { - if (this.caseInfoContainer) { - this.caseInfoContainer.innerHTML = ""; - } - if (this.partiesContainer) { - this.partiesContainer.innerHTML = ""; - } - if (this.partiesHeader) { - this.partiesHeader.style.display = "none"; - } - if (this.servicesContainer) { - this.servicesContainer.innerHTML = ""; - } - } - - updateFormValidation() { - // Re-initialize form validation to include dynamically added fields - if (window.FormValidation && window.formValidation) { - // Find all required fields in the form - const form = document.querySelector("#expertForm"); - if (form) { - window.formValidation.requiredFields = - form.querySelectorAll("[required]"); - - // Populate dynamic fields if restoration data is available - if (window.formValidation.restorationData) { - setTimeout(() => { - this.populateRenderedFields(window.formValidation.restorationData); - }, 100); - } - } - } - } - - populateRenderedFields(data) { - // Get all dynamic fields that were just rendered - const dynamicFields = this.getAllDynamicFieldNames(); - - let fieldsPopulated = 0; - - dynamicFields.forEach((key) => { - if (data[key]) { - const field = document.querySelector(`[name="${key}"]`); - if (field) { - if (field.type === "checkbox") { - field.checked = Array.isArray(data[key]) ? - data[key].includes(field.value) : - data[key] === field.value; - } else if (field.type === "radio") { - // Party type radios are added asynchronously once the - // API responds, so keep retrying until the matching - // option exists. - const setRadioValue = () => { - const radio = document.querySelector( - `input[name="${key}"][value="${data[key]}"]` - ); - if (radio) { - radio.checked = true; - } else if ( - document.querySelectorAll(`input[name="${key}"]`).length === 0 - ) { - setTimeout(setRadioValue, 500); - } - }; - setRadioValue(); - } else { - field.value = data[key]; - } - fieldsPopulated++; - - // Add visual validation feedback (but not for radios until they're populated) - if ( - field.type !== "radio" && - field.value && - field.value.trim() - ) { - field.classList.remove("is-invalid"); - field.classList.add("is-valid"); - } - } - } - }); - - return fieldsPopulated; - } - - getAllDynamicFieldNames() { - // Get all field names from currently rendered dynamic content - const allFields = []; - - if (this.caseInfoContainer) { - const fields = this.caseInfoContainer.querySelectorAll("[name]"); - fields.forEach((field) => allFields.push(field.name)); - } - - if (this.partiesContainer) { - const fields = this.partiesContainer.querySelectorAll("[name]"); - fields.forEach((field) => allFields.push(field.name)); - } - - if (this.servicesContainer) { - const fields = this.servicesContainer.querySelectorAll("[name]"); - fields.forEach((field) => allFields.push(field.name)); - } - - return [...new Set(allFields)]; // Remove duplicates - } - - // Method to be called from form validation when restoration data is available - restoreDynamicFieldData(data) { - if (!data || Object.keys(data).length === 0) { - return; - } - - // Store the data for later restoration - this.restorationData = data; - - // If dynamic sections are already visible, populate immediately - if ( - this.dynamicSections && - this.dynamicSections.style.display === "block" - ) { - this.populateRenderedFields(data); - } - } -} - -// Initialize when DOM is loaded -function initializeDynamicFormSections() { - if (!window.dynamicFormSections) { - window.dynamicFormSections = new DynamicFormSections(); - } -} - -// Try multiple initialization approaches -if (document.readyState === "loading") { - // DOM is still loading - document.addEventListener("DOMContentLoaded", initializeDynamicFormSections); -} else { - // DOM is already loaded - initializeDynamicFormSections(); -} - -// Also make the class available globally for manual instantiation -window.DynamicFormSections = DynamicFormSections; - -// Export for use in other modules -if (typeof module !== "undefined" && module.exports) { - module.exports = DynamicFormSections; -} \ No newline at end of file diff --git a/efile_app/efile/static/js/expert-form-main.js b/efile_app/efile/static/js/expert-form-main.js deleted file mode 100644 index dbe6e2f..0000000 --- a/efile_app/efile/static/js/expert-form-main.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * ExpertForm - Main initialization and coordination - * Coordinates all form functionality and initializes components - */ -class ExpertForm { - constructor() { - this.cascadingDropdowns = null; - this.formValidation = null; - this.initialized = false; - } - - async init() { - if (this.initialized) return; - - try { - // Initialize cascading dropdowns first (they load user profile) - this.cascadingDropdowns = new CascadingDropdowns(); - await this.cascadingDropdowns.init(); - - // Make cascading dropdowns globally accessible for jurisdiction switching - window.cascadingDropdowns = this.cascadingDropdowns; - - // Initialize form validation - this.formValidation = new FormValidation(); - - // Make form validation available globally for dynamic sections - window.formValidation = this.formValidation; - - if (window.caseData) { - this.formValidation.populateForm(window.caseData); - } - - // Note: Auto-save removed - drafts are now only saved when user clicks "Save Draft" button - - this.initialized = true; - - } catch (error) { - console.error('Error initializing ExpertForm:', error); - this.showError(gettext('There was an error loading the form. Please refresh the page.')); - } - } - - // setupAutoSave() method removed - drafts are now only saved when user clicks "Save Draft" button - // This prevents automatic saving and gives users full control over when drafts are saved - - hasFormContent(data) { - // Check if form has meaningful content beyond empty strings - return Object.values(data).some(value => - value && value.toString().trim() !== '' - ); - } - - showError(message) { - const errorDiv = document.createElement('div'); - errorDiv.className = 'alert alert-danger'; - errorDiv.innerHTML = ` ${message}`; - - const container = document.querySelector('.form-container'); - if (container) { - container.insertBefore(errorDiv, container.firstChild); - } - } - - // Public methods for external access - getCascadingDropdowns() { - return this.cascadingDropdowns; - } - - getFormValidation() { - return this.formValidation; - } - - // Method to manually trigger form save - saveForm() { - if (this.formValidation) { - this.formValidation.saveDraft(); - } - } - - // Method to reset form - resetForm() { - const form = document.querySelector('#expertForm'); - if (form) { - form.reset(); - // Clear any validation classes - form.querySelectorAll('.is-valid, .is-invalid').forEach(field => { - field.classList.remove('is-valid', 'is-invalid'); - }); - // Clear localStorage draft - localStorage.removeItem('expertFormDraft'); - } - } -} - -// Global instance -let expertFormInstance = null; - -// Initialize when DOM is loaded -document.addEventListener('DOMContentLoaded', function() { - expertFormInstance = new ExpertForm(); - expertFormInstance.init(); -}); - -// Make available globally for debugging/external access -window.ExpertForm = ExpertForm; -window.getExpertFormInstance = () => expertFormInstance; \ No newline at end of file diff --git a/efile_app/efile/static/js/form-validation.js b/efile_app/efile/static/js/form-validation.js deleted file mode 100644 index 2ef0341..0000000 --- a/efile_app/efile/static/js/form-validation.js +++ /dev/null @@ -1,737 +0,0 @@ -/** - * FormValidation - Handles form validation and user interactions - * Features: Real-time validation, draft saving, submission handling, API caching - */ -class FormValidation { - constructor() { - this.form = document.querySelector("#expertForm"); - - if (!this.form) { - console.error("Expert form not found!"); - return; - } - - this.requiredFields = this.form.querySelectorAll("[required]"); - - this.init(); - } - - init() { - this.setupValidation(); - this.setupFormSubmission(); - this.restoreSessionData(); - } - - setupValidation() { - // Add validation styling for required fields - this.requiredFields.forEach((field) => { - field.addEventListener("invalid", function() { - this.classList.add("is-invalid"); - }); - - field.addEventListener("input", function() { - if (this.validity.valid) { - this.classList.remove("is-invalid"); - this.classList.add("is-valid"); - } - }); - - // Add blur validation for immediate feedback - field.addEventListener("blur", function() { - if (this.value.trim() && this.validity.valid) { - this.classList.remove("is-invalid"); - this.classList.add("is-valid"); - } else if (this.value.trim() && !this.validity.valid) { - this.classList.add("is-invalid"); - } - }); - }); - } - - setupFormSubmission() { - if (!this.form) { - console.error("Cannot setup form submission - form not found"); - return; - } - - // Add event listener to form submit - this.form.addEventListener("submit", (e) => { - this.handleFormSubmission(e); - }); - - // Also add event listener to the submit button directly as backup - const submitButton = this.form.querySelector('button[type="submit"]'); - if (submitButton) { - submitButton.addEventListener("click", (e) => { - e.preventDefault(); - this.handleFormSubmission(e); - }); - } - } - - saveDraft() { - const formData = this.collectFormData(); - - // Check if form has meaningful content - const hasContent = Object.values(formData).some( - (value) => value && value.toString().trim() !== "" - ); - - if (!hasContent) { - this.showNotification( - "Please fill out some form fields before saving a draft.", - "info" - ); - return; - } - - // Save to localStorage as backup - const draftData = { - data: formData, - timestamp: new Date().toISOString(), - savedBy: "user_action", // Indicate this was saved manually by user - }; - - localStorage.setItem("expertFormDraft", JSON.stringify(draftData)); - - // TODO: Send to server when server-side draft functionality is implemented - - this.showNotification( - "Draft saved successfully! You can return to this form later to continue.", - "success" - ); - } - - async handleFormSubmission(e) { - e.preventDefault(); // Prevent default form submission - e.stopPropagation(); // Stop event bubbling - e.stopImmediatePropagation(); // Stop any other handlers - - // Refresh required fields list to include any dynamically added fields - this.requiredFields = this.form.querySelectorAll("[required]"); - - let isValid = true; - const invalidFields = []; - - this.requiredFields.forEach((field) => { - if (!field.validity.valid || !field.value.trim()) { - isValid = false; - field.classList.add("is-invalid"); - invalidFields.push(field.labels[0]?.textContent || field.name); - } else { - field.classList.remove("is-invalid"); - field.classList.add("is-valid"); - } - }); - - if (!isValid) { - this.showValidationErrors(invalidFields); - this.scrollToFirstError(); - return false; - } - - // Collect form data and add friendly names - const formData = this.collectFormData(); - const enhancedFormData = this.addFriendlyNames(formData); - const currentJurisdiction = apiUtils.getCurrentJurisdiction(); - - try { - // Save case data to session via API - await apiUtils.saveCaseData({ - data: enhancedFormData - }); - window.location.replace(`/jurisdiction/${currentJurisdiction}/upload/`); - } catch (error) { - console.error("Network error:", error); - this.showNotification( - "Error saving case data. Please try again.", - "error" - ); - } - - return false; - } - - addFriendlyNames(formData) { - const enhanced = { - ...formData - }; - - // Add friendly names from dropdown text - const dropdownMappings = [{ - field: "court", - friendlyField: "court_name" - }, { - field: "case_category", - friendlyField: "case_category_name" - }, { - field: "case_type", - friendlyField: "case_type_name" - }, { - field: "filing_type", - friendlyField: "filing_type_name" - }, { - field: "document_type", - friendlyField: "document_type_name" - }, ]; - - dropdownMappings.forEach(({ - field, - friendlyField - }) => { - const dropdown = this.form.querySelector(`[name="${field}"]`); - - if (dropdown && dropdown.value) { - const selectedOption = dropdown.selectedOptions[0]; - if ( - selectedOption && - selectedOption.text && - selectedOption.text !== "Please select..." - ) { - // Clean up text by removing "(Recommended)" for court names - let friendlyText = selectedOption.text; - if (field === "court") { - friendlyText = friendlyText - .replace(/\s*\(Recommended\)\s*$/i, "") - .trim(); - } - enhanced[friendlyField] = friendlyText; - } - } - }); - - return enhanced; - } - - collectFormData() { - const formData = new FormData(this.form); - const data = {}; - - for (let [key, value] of formData.entries()) { - if (data[key]) { - // Handle multiple values (like checkboxes) - if (Array.isArray(data[key])) { - data[key].push(value); - } else { - data[key] = [data[key], value]; - } - } else { - data[key] = value; - } - } - - // Also collect disabled fields manually since FormData excludes them - const disabledFields = this.form.querySelectorAll('input[disabled], select[disabled], textarea[disabled]'); - disabledFields.forEach(field => { - if (field.name && field.value) { - data[field.name] = field.value; - } - }); - - return data; - } - - showValidationErrors(invalidFields) { - const message = - invalidFields.length > 1 ? - `Please fill in the following required fields: ${invalidFields.join( - ", " - )}` : - `Please fill in the required field: ${invalidFields[0]}`; - - this.showNotification(message, "error"); - } - - scrollToFirstError() { - const firstError = this.form.querySelector(".is-invalid"); - if (firstError) { - firstError.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - firstError.focus(); - } - } - - showNotification(message, type = "info") { - // Create notification element - const notification = document.createElement("div"); - notification.className = `alert alert-${ - type === "error" ? "danger" : type - } notification-toast`; - notification.style.cssText = ` - position: fixed; - top: 20px; - right: 20px; - z-index: 9999; - min-width: 300px; - animation: slideIn 0.3s ease-out; - `; - - const icon = - type === "success" ? - "check-circle" : - type === "error" ? - "exclamation-circle" : - "info-circle"; - - notification.innerHTML = ` - ${message} - - `; - - document.body.appendChild(notification); - - // Auto-remove after 5 seconds - // setTimeout(() => { - // if (notification.parentNode) { - // notification.style.animation = "slideOut 0.3s ease-in"; - // setTimeout(() => { - // notification.remove(); - // }, 300); - // } - // }, 5000); - - // Add click to dismiss - notification.querySelector(".btn-close")?.addEventListener("click", () => { - notification.remove(); - }); - } - - // Method to restore draft data - restoreDraft() { - const draft = localStorage.getItem("expertFormDraft"); - if (draft) { - try { - const draftObj = JSON.parse(draft); - const { - data, - timestamp, - savedBy - } = draftObj; - const age = Date.now() - new Date(timestamp).getTime(); - - // Only restore if draft is less than 24 hours old - if (age < 24 * 60 * 60 * 1000) { - this.populateForm(data); - const saveMethod = - savedBy === "user_action" ? "manually saved" : "auto-saved"; - this.showNotification( - gettext(`Draft restored from previous session (${saveMethod})`), - "info" - ); - } else { - // Remove old draft - localStorage.removeItem("expertFormDraft"); - } - } catch (error) { - console.warn("Could not restore draft:", error); - localStorage.removeItem("expertFormDraft"); // Remove corrupted draft - } - } - } - - populateForm(data) { - // Store data for later use with dynamic fields - this.restorationData = data; - - Object.keys(data).forEach((key) => { - const fields = this.form.querySelectorAll(`[name="${key}"]`); - if (fields.length == 1) { - let field = fields[0]; - if (field.type === "checkbox" || field.type === "radio") { - field.checked = Array.isArray(data[key]) ? - data[key].includes(field.value) : - data[key] === field.value; - if (field.type === "radio" && field.checked) { - field.dispatchEvent(new Event("change", { - bubbles: true - })); - } - } else { - field.value = Array.isArray(data[key]) ? data[key][0] : data[key]; - } - - // Trigger change event for dropdowns to update dependent fields - if (field.tagName === "SELECT") { - field.dispatchEvent(new Event("change", { - bubbles: true - })); - } - } else if (fields.length > 1) { - for (let field of fields) { - if (field.type === "radio") { - field.checked = Array.isArray(data[key]) ? - data[key].includes(field.value) : - data[key] === field.value; - if (field.checked) { - field.dispatchEvent(new Event("change", { - bubbles: true - })); - } - } - } - } - }); - - // For case type field, trigger dynamic sections immediately, then populate dynamic fields - if (data.case_type && window.dynamicFormSections) { - // First, pass the restoration data to dynamic form sections - window.dynamicFormSections.restoreDynamicFieldData(data); - - // Give time for change event to propagate and trigger dynamic sections - setTimeout(() => { - window.dynamicFormSections.handleCaseTypeChange(); - // Then wait for dynamic fields to actually be rendered in the DOM - this.waitForDynamicFieldsAndPopulate(data); - }, 200); - } - } - - async waitForDynamicFieldsAndPopulate(data) { - // List of dynamic fields that we're waiting for - const dynamicFields = [ - "petitioner_first_name", - "petitioner_last_name", - "new_first_name", - "new_last_name", - ]; - - // Check if any of these fields exist in our data to restore - const fieldsToRestore = dynamicFields.filter((field) => data[field]); - - if (fieldsToRestore.length === 0) { - return; - } - - // Wait up to 8 seconds for the fields to appear in the DOM (increased timeout) - let attempts = 0; - const maxAttempts = 80; // 8 seconds with 100ms intervals - - const checkInterval = setInterval(() => { - attempts++; - - // Check if all required fields are now in the DOM - const foundFields = fieldsToRestore.filter((fieldName) => { - return this.form.querySelector(`[name="${fieldName}"]`) !== null; - }); - - // If all fields are found, or we've reached max attempts, populate what we can - if ( - foundFields.length === fieldsToRestore.length || - attempts >= maxAttempts - ) { - clearInterval(checkInterval); - this.populateDynamicFields(data); - } - }, 100); - } - - populateDynamicFields(data) { - // List of all possible dynamic fields that might be rendered after case type selection - const dynamicFields = [ - "petitioner_first_name", - "petitioner_last_name", - "petitioner_address", - "new_first_name", - "new_last_name", - // Add any other dynamic fields that might exist - "petitioner_phone", - "petitioner_email", - "reason_for_change", - ]; - - let fieldsPopulated = 0; - let fieldsNotFound = []; - - dynamicFields.forEach((key) => { - if (data[key]) { - const field = this.form.querySelector(`[name="${key}"]`); - if (field) { - // Handle different field types - if (field.type === "checkbox" || field.type === "radio") { - field.checked = Array.isArray(data[key]) ? - data[key].includes(field.value) : - data[key] === field.value; - } else { - field.value = Array.isArray(data[key]) ? data[key][0] : data[key]; - } - fieldsPopulated++; - - // Trigger validation styling if the field has content - if (field.value && field.value.trim()) { - field.classList.remove("is-invalid"); - field.classList.add("is-valid"); - } - } else { - fieldsNotFound.push(key); - } - } - }); - - // Show success notification if fields were populated - if (fieldsPopulated > 0) { - // this.showNotification(`Restored ${fieldsPopulated} name field(s) from saved data`, 'success'); - } - } - - restoreSessionData() { - // Check if case data is available from Django template context - if ( - typeof window.caseData !== "undefined" && - window.caseData && - Object.keys(window.caseData).length > 0 - ) { - // Wait for cascading dropdowns to initialize, then populate all dropdowns - setTimeout(() => { - this.populateDropdownsWithApiCalls(window.caseData); - // this.showNotification('Restoring previous case data...', 'info'); - }, 2000); // Wait for initial dropdown system to load - } else { - // Fallback to draft restoration from localStorage - this.restoreDraft(); - } - } - - async populateDropdownsWithApiCalls(data) { - // First populate all the non-dropdown form fields - Object.keys(data).forEach((key) => { - if ( - ![ - "court", - "case_category", - "case_type", - "filing_type", - "document_type", - ].includes(key) - ) { - const fields = this.form.querySelectorAll(`[name="${key}"]`); - if (fields.length === 1 && fields[0].type !== "radio") { - fields[0].value = Array.isArray(data[key]) ? data[key][0] : data[key]; - } else { - fields.forEach((field) => { - if (field.type === "radio" || field.type === "checkbox") { - field.checked = Array.isArray(data[key]) ? - data[key].includes(field.value) : - data[key] === field.value; - } - }); - } - } - }); - - try { - // Step 1: Load courts (should already be loaded, but ensure selection) - if (data.court) { - await this.waitForDropdownOptions("court"); - this.setDropdownValue("court", data.court); - } - - // Step 2: Load case categories based on court - if (data.court && data.case_category) { - try { - await this.loadCascadingDropdown( - "court", - data.court, - "case_category" - ); - this.setDropdownValue("case_category", data.case_category); - } catch (error) { - console.warn( - "Failed to load case categories during restoration:", - error - ); - } - } - - // Step 3: Load case types based on case category - if (data.case_category && data.case_type) { - try { - await this.loadCascadingDropdown( - "case_category", - data.case_category, - "case_type" - ); - this.setDropdownValue("case_type", data.case_type); - } catch (error) { - console.warn("Failed to load case types during restoration:", error); - } - } - - // Step 4: Load filing types based on case type - if (data.case_type && data.filing_type) { - try { - await this.loadCascadingDropdown( - "case_type", - data.case_type, - "filing_type" - ); - this.setDropdownValue("filing_type", data.filing_type); - } catch (error) { - console.warn( - "Failed to load filing types during restoration:", - error - ); - } - } - - // Step 5: Load document types based on filing type - if (data.filing_type && data.document_type) { - try { - await this.loadCascadingDropdown( - "filing_type", - data.filing_type, - "document_type" - ); - this.setDropdownValue("document_type", data.document_type); - } catch (error) { - console.warn( - "Failed to load document types during restoration:", - error - ); - } - } - - // Step 6: After all dropdowns are restored, populate dynamic fields - // Wait a bit for dynamic sections to be rendered, then populate dynamic fields - if (data.case_type) { - setTimeout(async () => { - await this.waitForDynamicFieldsAndPopulate(data); - }, 1000); // Give more time for dynamic sections to render - } - } catch (error) { - console.error("Error during dropdown population:", error); - this.showNotification("Error restoring some dropdown values", "error"); - } - } - - async waitForDropdownOptions(dropdownName, maxWaitMs = 5000) { - const dropdown = this.form.querySelector(`[name="${dropdownName}"]`); - if (!dropdown) { - throw new Error(`Dropdown ${dropdownName} not found`); - } - - const startTime = Date.now(); - - while (dropdown.options.length <= 1 && Date.now() - startTime < maxWaitMs) { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } - - async loadCascadingDropdown( - parentDropdownName, - parentValue, - targetDropdownName - ) { - const parentDropdown = this.form.querySelector( - `[name="${parentDropdownName}"]` - ); - const targetDropdown = this.form.querySelector( - `[name="${targetDropdownName}"]` - ); - - if (!parentDropdown || !targetDropdown) { - throw new Error( - `Dropdown not found: ${parentDropdownName} or ${targetDropdownName}` - ); - } - - // Set parent dropdown value if not already set - if (parentDropdown.value !== parentValue) { - parentDropdown.value = parentValue; - } - - // Trigger change event to populate target dropdown - const changeEvent = new Event("change", { - bubbles: true - }); - parentDropdown.dispatchEvent(changeEvent); - - // Wait for target dropdown to be populated - await this.waitForDropdownOptions(targetDropdownName, 10000); - } - - setDropdownValue(dropdownName, value) { - const dropdown = this.form.querySelector(`[name="${dropdownName}"]`); - if (!dropdown) { - console.warn(`Dropdown ${dropdownName} not found`); - return; - } - - // Special handling for filing_type search dropdown - if (dropdownName === "filing_type" && window.filingTypeSearch) { - const option = dropdown.querySelector(`option[value="${value}"]`); - if (option) { - // Use the search dropdown's setValue method to properly update both the hidden select and the UI - window.filingTypeSearch.setValue(value, false); // Don't trigger change event to avoid cascading - } else { - console.warn(`Filing type option not found: ${value}`); - } - return; - } - - // Check if the option exists for regular dropdowns - const option = dropdown.querySelector(`option[value="${value}"]`); - if (option) { - dropdown.value = value; - - // Special handling for case_type - trigger dynamic form sections - if (dropdownName === "case_type" && value) { - setTimeout(() => { - if (window.dynamicFormSections) { - // Pass restoration data to dynamic sections first - if (this.restorationData) { - window.dynamicFormSections.restoreDynamicFieldData( - this.restorationData - ); - } - - window.dynamicFormSections.handleCaseTypeChange(); - } else { - console.warn( - "dynamicFormSections not available when setting case_type" - ); - } - const changeEvent = new Event("change", { - bubbles: true - }); - dropdown.dispatchEvent(changeEvent); - }, 300); // Give time for dropdown to settle - } - } else { - console.warn( - `Option with value "${value}" not found for ${dropdownName}` - ); - // List available options for debugging - const options = Array.from(dropdown.options).map((opt) => ({ - value: opt.value, - text: opt.text, - })); - } - } -} - -// Add CSS for animations -const style = document.createElement("style"); -style.textContent = ` - @keyframes slideIn { - from { transform: translateX(100%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } - } - @keyframes slideOut { - from { transform: translateX(0); opacity: 1; } - to { transform: translateX(100%); opacity: 0; } - } - .notification-toast { - box-shadow: 0 4px 12px rgba(0,0,0,0.15); - border-radius: 8px; - } -`; -document.head.appendChild(style); - -// Export for module use or make globally available -if (typeof module !== "undefined" && module.exports) { - module.exports = FormValidation; -} else { - window.FormValidation = FormValidation; -} \ No newline at end of file diff --git a/efile_app/efile/static/js/upload-handler-first.js b/efile_app/efile/static/js/upload-handler-first.js deleted file mode 100644 index 23d08fc..0000000 --- a/efile_app/efile/static/js/upload-handler-first.js +++ /dev/null @@ -1,519 +0,0 @@ -/** - * Upload Handler for Document Submission - * Handles file uploads, drag & drop, and Suffolk API integration - */ - -class UploadHandler { - constructor() { - this.form = document.getElementById('uploadForm'); - this.leadDocumentArea = document.getElementById('leadDocumentArea'); - this.leadDocumentInput = document.getElementById('leadDocument'); - this.submitButton = document.getElementById('submitButton'); - this.uploadProgress = document.getElementById('uploadProgress'); - this.errorAlert = document.getElementById('errorAlert'); - this.successAlert = document.getElementById('successAlert'); - - this.uploadedFile = null; - this.uploadPromise = null; - this.leadPersisted = false; - - this.initialized = false; - - this.init(); - } - - async init() { - if (this.initialized) { - console.warn('UploadHandler already initialized, skipping...'); - return; - } - - // First, sync any localStorage data to session - await this.syncFormDataToSession(); - - this.setupEventListeners(); - this.setupDragAndDrop(); - - this.initialized = true; - } - - async syncFormDataToSession() { - // Send stuff from localStorage to server - const caseFormData = localStorage.getItem('caseFormData'); - if (caseFormData) { - try { - await apiUtils.saveCaseData(caseFormdata); - // Clear localStorage since it's now in session - localStorage.removeItem('caseFormData'); - } catch (error) { - console.error('Error syncing form data:', error); - } - } - // Take stuff from server and show on page - const response = await apiUtils.getUploadData(); - const lead = response?.files?.lead; - if (lead) { - this.uploadedFile = lead; - this.leadPersisted = true; - this.updateFilePreview(this.leadDocumentArea, lead); - document.getElementById("leadDocument").removeAttribute("required"); - } - } - - /** - * Build the durable-draft payload for a lead document that S3 has accepted. - * Used both by the save-on-upload path and by the save-on-Continue retry, so - * the two can never drift. - * @param {Object} uploadedLead - one entry from the upload response's `files` - */ - buildLeadPayload(uploadedLead) { - return { - files: { - lead: { - name: this.uploadedFile.name, - size: this.uploadedFile.size, - type: this.uploadedFile.type, - url: uploadedLead.public_url || uploadedLead.url, - s3_key: uploadedLead.key, - }, - }, - options: { - lead: {} - }, - }; - } - - async saveUploadDataToSession(uploadData) { - try { - const payload = { - ...uploadData, - jurisdiction_id: uploadData.jurisdiction_id || apiUtils.getCurrentJurisdiction() - }; - const result = await apiUtils.saveFirstUploadData(payload); - if (!result.success) { - throw new Error(result.error || 'Failed to save upload data to session'); - } - } catch (error) { - console.error('Error saving upload data to session:', error); - throw error; - } - } - - setupEventListeners() { - // Form submission - if (this.form) { - this.form.addEventListener('submit', (e) => { - e.preventDefault(); - this.handleFormSubmission(); - }); - } - - // File input changes - this.leadDocumentInput.addEventListener('change', (e) => { - this.handleFileSelection(e.target.files); - }); - - // Upload area clicks with aggressive throttling to prevent double firing - let lastLeadClick = 0; - const CLICK_THROTTLE_MS = 1000; // 1 second throttle - - this.leadDocumentArea.addEventListener('click', (e) => { - // Check if click is on file preview or remove button - if (e.target.closest('.file-preview') || - e.target.closest('.file-remove') || - e.target.classList.contains('file-remove')) { - return; - } - - const now = Date.now(); - if (now - lastLeadClick < CLICK_THROTTLE_MS) { - return; - } - - lastLeadClick = now; - - // Prevent default and stop propagation to avoid any interference - e.preventDefault(); - e.stopPropagation(); - - // Use setTimeout to ensure this runs after any other event handlers - setTimeout(() => { - this.leadDocumentInput.click(); - }, 10); - }); - } - - setupDragAndDrop() { - // Lead document area - let area = this.leadDocumentArea; - area.addEventListener('dragover', (e) => { - e.preventDefault(); - area.classList.add('dragover'); - }); - - area.addEventListener('dragleave', (e) => { - e.preventDefault(); - area.classList.remove('dragover'); - }); - - area.addEventListener('drop', (e) => { - e.preventDefault(); - area.classList.remove('dragover'); - - const files = e.dataTransfer.files; - this.handleFileSelection(files); - }); - } - - handleFileSelection(files) { - if (files.length === 0) return; - - // Validate files - const validFiles = []; - for (let file of files) { - if (this.validateFile(file)) { - validFiles.push(file); - } - } - - if (validFiles.length === 0) return; - - // Only one lead document allowed - this.uploadedFile = validFiles[0]; - this.updateFilePreview(this.leadDocumentArea, validFiles[0]); - - // Ensure the native file input reflects the selection so browser validation works - try { - const dt = new DataTransfer(); - dt.items.add(validFiles[0]); - if (this.leadDocumentInput) { - this.leadDocumentInput.files = dt.files; - } - } catch (e) { - // Some older browsers may not support DataTransfer constructor in this context - console.warn('Could not set native input.files via DataTransfer:', e); - } - - // Automatically upload lead document - this.uploadPromise = this.uploadFileImmediately(validFiles[0], 0); - - this.updateSubmitButton(); - } - - validateFile(file) { - // Check file type - if (!file.type.includes('pdf') && !file.name.toLowerCase().endsWith('.pdf')) { - this.showError(`Invalid file type: ${file.name}. Only PDF files are allowed.`); - return false; - } - - // Check file size (10MB limit) - const maxSize = 10 * 1024 * 1024; // 10MB - if (file.size > maxSize) { - this.showError(`File too large: ${file.name}. Maximum size is 10MB.`); - return false; - } - - this.hideAlerts(); - - return true; - } - - updateFilePreview(area, file) { - // Clear existing preview - const existingPreviews = area.querySelectorAll('.file-preview'); - existingPreviews.forEach(preview => preview.remove()); - - // Add file previews - if (file) { - const preview = this.createFilePreview(file); - area.appendChild(preview); - } - - // Show/hide document options based on whether files are uploaded - const leadOptions = document.getElementById('leadDocumentOptions'); - if (leadOptions) { - leadOptions.style.display = file ? 'block' : 'none'; - } - - // Hide/show placeholder - const placeholder = area.querySelector('.upload-placeholder'); - if (placeholder) { - placeholder.style.display = file ? 'none' : 'block'; - } - } - - createFilePreview(file) { - const preview = document.createElement('div'); - preview.className = 'file-preview'; - - const fileSize = this.formatFileSize(file.size); - - preview.innerHTML = ` -
- -
-
${file.name}
-
${fileSize}
-
-
- - `; - - // Add event listener to the remove button with strong event prevention - const removeButton = preview.querySelector('.file-remove'); - removeButton.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - this.uploadedFile = null; - this.updateFilePreview(this.leadDocumentArea, null); - // Clear the native file input - if (this.leadDocumentInput) { - this.leadDocumentInput.value = ''; - } - this.updateSubmitButton(); - - // Return false to ensure no further event processing - return false; - }, true); // Use capture phase to intercept before other handlers - - // Also add a mousedown event to completely prevent any interaction issues - removeButton.addEventListener('mousedown', (e) => { - e.stopPropagation(); - }, true); - - return preview; - } - - updateSubmitButton() { - const hasLeadDocument = this.uploadedFile !== null; - this.submitButton.disabled = !hasLeadDocument; - } - - async uploadFileImmediately(file, index) { - try { - // Show upload progress for this specific file - this.showFileUploadProgress(file.name, index); - - // Create FormData with just this file - const formData = new FormData(); - formData.append('documents', file); - - const response = await fetch('/api/simple-s3-upload/', { - method: 'POST', - body: formData, - headers: { - 'X-CSRFToken': apiUtils.getCSRFToken() - } - }); - - const result = await response.json(); - - if (!result.success) { - throw new Error(result.error || 'Upload failed'); - } - - // Update file preview to show successful upload - this.showFileUploadSuccess(file.name, index); - - // Store the upload result for later use during form submission - this.uploadedFile.uploadResult = result; - - // Persist the lead as soon as S3 accepts it. This keeps a refresh - // or navigation from losing the document before the user clicks - // Continue. - const uploadedLead = result.files?.[0]; - if (uploadedLead) { - try { - await this.saveUploadDataToSession(this.buildLeadPayload(uploadedLead)); - this.leadPersisted = true; - } catch (error) { - // Continue still retries the same save, so an interim - // persistence failure should not discard the S3 result. - console.warn('Could not persist lead upload immediately:', error); - } - } - - } catch (error) { - console.error('Error uploading file immediately:', error); - this.showFileUploadError(file.name, index, error.message); - } - } - - showFileUploadProgress(fileName, type, index) { - const selector = '.file-preview'; - const preview = this.leadDocumentArea.querySelector(selector) - - if (preview) { - const statusDiv = preview.querySelector('.upload-status') || document.createElement('div'); - statusDiv.className = 'upload-status'; - statusDiv.innerHTML = 'Uploading...'; - if (!preview.querySelector('.upload-status')) { - preview.appendChild(statusDiv); - } - } - } - - showFileUploadSuccess(fileName, index) { - const selector = '.file-preview'; - const preview = this.leadDocumentArea.querySelector(selector); - - if (preview) { - const statusDiv = preview.querySelector('.upload-status'); - if (statusDiv) { - statusDiv.innerHTML = 'Uploaded'; - } - } - } - - showFileUploadError(fileName, index, error) { - const selector = '.file-preview'; - const preview = this.leadDocumentArea.querySelector(selector) - - if (preview) { - const statusDiv = preview.querySelector('.upload-status'); - if (statusDiv) { - statusDiv.innerHTML = 'Upload failed'; - statusDiv.title = error; - } - } - } - - async handleFormSubmission() { - if (!this.uploadedFile) { - this.showError('Please upload a lead document before continuing.'); - return; - } - - // File selection starts an asynchronous S3 upload. Wait for both the - // upload and its durable-draft save before navigating away. - if (this.uploadPromise) { - await this.uploadPromise; - this.uploadPromise = null; - } - - if (!this.uploadedFile.uploadResult && !(this.uploadedFile.url && this.uploadedFile.s3_key)) { - this.showError('The lead document upload did not finish. Please try again.'); - return; - } - - if (this.uploadedFile.uploadResult && !this.leadPersisted) { - this.showWaiting("Saving your document..."); - try { - const uploadedLead = this.uploadedFile.uploadResult.files?.[0]; - if (!uploadedLead) { - throw new Error('The lead document upload did not return a file.'); - } - await this.saveUploadDataToSession(this.buildLeadPayload(uploadedLead)); - this.leadPersisted = true; - } catch (error) { - console.error('Error persisting lead upload:', error); - this.showError(error.message); - return; - } - } - - if (this.uploadedFile.url && this.uploadedFile.s3_key) { - // We've already uploaded the file previously. Just continue. - const jurisdiction = apiUtils.getCurrentJurisdiction(); - window.location.href = `/jurisdiction/${jurisdiction}/expert_form/`; - return; - } - - this.showWaiting("Processing your form..."); - - try { - // Prepare upload data using already uploaded files (since files are uploaded immediately on selection) - const uploadDataWithUrls = { - files: { - lead: null - }, - options: { - lead: {} - } - }; - - // Use already uploaded file data (files were uploaded immediately when selected) - if (this.uploadedFile && this.uploadedFile.uploadResult) { - const leadResult = this.uploadedFile.uploadResult; - uploadDataWithUrls.files.lead = { - name: this.uploadedFile.name, - size: this.uploadedFile.size, - type: this.uploadedFile.type, - url: leadResult.files[0]?.public_url, - s3_key: leadResult.files[0]?.key - }; - } - - // The lead was already saved after S3 accepted it. Only retry the - // legacy submit-time save if an upload result is unexpectedly absent. - if (!this.leadPersisted) { - await this.saveUploadDataToSession(uploadDataWithUrls); - } - - - // Redirect to next page - const jurisdiction = apiUtils.getCurrentJurisdiction(); - window.location.href = `/jurisdiction/${jurisdiction}/expert_form/`; - - } catch (error) { - console.error('Form submission error:', error); - this.showError(error.message); - } - } - - - - showError(message) { - this.hideAlerts(); - document.getElementById('errorMessage').textContent = message; - this.errorAlert.style.display = 'block'; - - // Scroll to error - this.errorAlert.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); - } - - showWaiting(message) { - this.hideAlerts(); - document.getElementById('successMessage').textContent = message; - document.getElementById('submitButton').disabled = true; - this.successAlert.style.display = 'block'; - - // Scroll to success - this.successAlert.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); - } - - hideAlerts() { - this.errorAlert.style.display = 'none'; - this.successAlert.style.display = 'none'; - } - - formatFileSize(bytes) { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - } -} - -// Initialize when DOM is loaded -document.addEventListener('DOMContentLoaded', function() { - if (!window.uploadHandler) { - window.uploadHandler = new UploadHandler(); - } else { - console.warn('UploadHandler already exists, skipping initialization'); - } -}); \ No newline at end of file diff --git a/efile_app/efile/static/js/upload-handler.js b/efile_app/efile/static/js/upload-handler.js deleted file mode 100644 index ce0c6d5..0000000 --- a/efile_app/efile/static/js/upload-handler.js +++ /dev/null @@ -1,1228 +0,0 @@ -/** - * Upload Handler for Document Submission - * Handles file uploads, drag & drop, and Suffolk API integration - */ - -const FileStatus = Object.freeze({ - UPLOADING: Symbol("uploading"), - SUCCESS: Symbol("success"), - FAILED: Symbol("failed") -}); - -class UploadHandler { - constructor() { - this.form = document.getElementById('uploadForm'); - this.leadDocumentArea = document.getElementById('leadDocumentArea'); - this.supportingDocumentsArea = document.getElementById('supportingDocumentsArea'); - this.supportingDocumentsInput = document.getElementById('supportingDocuments'); - this.submitButton = document.getElementById('submitButton'); - this.uploadProgress = document.getElementById('uploadProgress'); - this.errorAlert = document.getElementById('errorAlert'); - this.successAlert = document.getElementById('successAlert'); - - this.uploadedFiles = []; - this.uploadedFileStatuses = []; - - // Store filing components here once loaded - this.globalFilingComponentLead = {}; - this.globalFilingComponentSupport = {}; - this.globalFilingTypes = []; - - this.jurisdiction = apiUtils.getCurrentJurisdiction(); - - this.initialized = false; - - this.init(); - } - - async init() { - if (this.initialized) { - console.warn('UploadHandler already initialized, skipping...'); - return; - } - - await this.loadFilingComponents(); - - // First, sync any localStorage data to session - await this.syncFormDataToSession(); - - this.setupEventListeners(); - this.setupDragAndDrop(); - this.setupListeners(); - - this.setupCascadingDropdowns(); - - this.initialized = true; - } - - toggleCCEmail(inputElement, checked) { - if (checked) { - inputElement.parentElement.removeAttribute("hidden"); - inputElement.parentElement.setAttribute("required", true); - } else { - inputElement.parentElement.setAttribute("hidden", true); - inputElement.removeAttribute("required"); - } - } - - setupListeners() { - // Listen for changes to lead filing component - const leadDocumentType = document.getElementById('leadDocumentType'); - if (leadDocumentType) { - leadDocumentType.addEventListener('change', () => { - this.updateSubmitButton(); - }); - } - - const leadCertifiedCopies = document.getElementById('leadCertifiedCopies'); - if (leadCertifiedCopies) { - leadCertifiedCopies.addEventListener('change', (e) => { - let inputElement = document.getElementById('leadCertifiedCopyEmail'); - this.toggleCCEmail(inputElement, e.target.checked) - }) - } - - // Use event delegation for supporting filing components since they're added dynamically - document.addEventListener('change', (e) => { - if (e.target && e.target.classList.contains('document-type-select')) { - this.updateSubmitButton(); - } else if (e.target && e.target.classList.contains('supporting-certified-copies')) { - let idToChange = e.target.id.replace("supportingCertifiedCopies", "supportingCertifiedCopyEmail"); - let inputElement = document.getElementById(idToChange); - if (e.target.checked) { - inputElement.parentElement.removeAttribute("hidden"); - inputElement.setAttribute("required", true); - if (!inputElement.value) { - inputElement.value = document.getElementById("leadCertifiedCopyEmail").value; - } - } else { - inputElement.parentElement.setAttribute("hidden", true); - inputElement.removeAttribute("required"); - } - } - }); - } - - async syncFormDataToSession() { - // Check if we have form data in localStorage - const caseFormData = localStorage.getItem('caseFormData'); - if (caseFormData) { - try { - await apiUtils.saveCaseData(caseFormData); - // Clear localStorage since it's now in session - localStorage.removeItem('caseFormData'); - } catch (error) { - console.error('Error syncing form data:', error); - } - } - // Take stuff from server and show on page. Upload metadata is optional - // while a draft is being created, and the page should still initialize - // if the metadata request temporarily fails. - let upload_data = {}; - try { - upload_data = await apiUtils.getUploadData() || {}; - } catch (error) { - console.warn('Could not load saved upload data:', error); - } - await this.prepLeadFileSelection(upload_data); - - await this.prepSupportingFileSelection(upload_data); - } - - async saveUploadDataToSession(uploadData) { - try { - const result = await apiUtils.saveUploadData(uploadData); - if (!result.success) { - throw new Error(result.error || 'Failed to save upload data to session'); - } - } catch (error) { - console.error('Error saving upload data to session:', error); - throw error; - } - } - - async saveFilesToSession() { - try { - // Create file metadata to save to session (we can't store actual File objects) - const fileData = { - supporting: this.uploadedFiles.map(file => ({ - name: file.name, - size: file.size, - type: file.type - })) - }; - - // Collect lead document options - const leadFilingComponent = document.getElementById('leadFilingComponent')?.value || ''; - const leadCertifiedCopies = document.getElementById('leadCertifiedCopies')?.checked || false; - const leadSealedConfidential = document.getElementById('leadSealedConfidential')?.checked || false; - - // Collect supporting document options - const supportingOptions = []; - this.uploadedFiles.forEach((file, index) => { - supportingOptions.push({ - filing_component: this.globalFilingComponentSupport, - certified_copies: document.getElementById(`supportingCertifiedCopies${index}`)?.checked || false, - sealed_confidential: document.getElementById(`supportingSealedConfidential${index}`)?.checked || false - }); - }); - - const uploadData = { - files: fileData, - options: { - lead: { - filing_component: leadFilingComponent, - certified_copies: leadCertifiedCopies, - sealed_confidential: leadSealedConfidential - }, - supporting: supportingOptions - } - }; - - const response = await apiUtils.saveUploadData(uploadData); - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || 'Failed to save upload data to session'); - } - } catch (error) { - console.error('Error saving files to session:', error); - throw error; - } - } - - setupEventListeners() { - // Form submission - if (this.form) { - this.form.addEventListener('submit', (e) => { - e.preventDefault(); - this.handleFormSubmission(); - }); - } - - this.supportingDocumentsInput.addEventListener('change', (e) => { - this.handleFileSelection(e.target.files, 'supporting'); - }); - - // Upload area clicks with aggressive throttling to prevent double firing - let lastLeadClick = 0; - let lastSupportingClick = 0; - const CLICK_THROTTLE_MS = 1000; // 1 second throttle - - this.leadDocumentArea.addEventListener('click', (e) => { - // Check if click is on file preview or remove button - if (e.target.closest('.file-preview') || - e.target.closest('.file-remove') || - e.target.classList.contains('file-remove')) { - return; - } - - const now = Date.now(); - if (now - lastLeadClick < CLICK_THROTTLE_MS) { - return; - } - - lastLeadClick = now; - - // Prevent default and stop propagation to avoid any interference - e.preventDefault(); - e.stopPropagation(); - }); - - this.supportingDocumentsArea.addEventListener('click', (e) => { - // Check if click is on file preview or remove button - if (e.target.closest('.file-preview') || - e.target.closest('.file-remove') || - e.target.classList.contains('file-remove')) { - return; - } - - const now = Date.now(); - if (now - lastSupportingClick < CLICK_THROTTLE_MS) { - return; - } - - lastSupportingClick = now; - - // Prevent default and stop propagation to avoid any interference - e.preventDefault(); - e.stopPropagation(); - - // Use setTimeout to ensure this runs after any other event handlers - setTimeout(() => { - this.supportingDocumentsInput.click(); - }, 10); - }); - } - - async setupDragAndDrop() { - // Supporting documents area - this.setupDragDropForArea(this.supportingDocumentsArea, 'supporting'); - } - - setupDragDropForArea(area, type) { - area.addEventListener('dragover', (e) => { - e.preventDefault(); - area.classList.add('dragover'); - }); - - area.addEventListener('dragleave', (e) => { - e.preventDefault(); - area.classList.remove('dragover'); - }); - - area.addEventListener('drop', (e) => { - e.preventDefault(); - area.classList.remove('dragover'); - - const files = e.dataTransfer.files; - this.handleFileSelection(files, type); - }); - } - - async prepLeadFileSelection(upload_data) { - const lead = upload_data?.files?.lead; - - // A draft may not have a lead document yet (for example after an - // interrupted upload). Leave the options hidden and let the page - // recover without throwing during initialization. - if (!lead) { - return; - } - - // Add file previews - const preview = this.createFilePreviewNoRemove(lead.name, lead.size); - this.leadDocumentArea.appendChild(preview); - - // Show/hide document options based on whether files are uploaded - const leadOptions = document.getElementById('leadDocumentOptions'); - if (leadOptions) { - leadOptions.style.display = 'block'; - } - - if (upload_data.lead_filing_type) { - this.initializeFilingTypeDropdown(document.getElementById("leadFilingType_search")); - window[`leadFilingTypeDropdown`].selectOption({ - "text": upload_data.lead_filing_type_name, - "value": upload_data.lead_filing_type - }); - - await this.populateDocumentTypes(upload_data.lead_filing_type, document.getElementById("leadDocumentType")); - } - if (upload_data.lead_document_type) { - this.setRadioGroupValue(document.getElementById("leadDocumentType"), upload_data.lead_document_type); - } - if (upload_data.lead_cc_email) { - let input_element = document.getElementById("leadCertifiedCopies") - input_element.checked = true; - this.toggleCCEmail(input_element, true); - document.getElementById("leadCertifiedCopyEmail").value = upload_data.lead_cc_email; - } - } - - async prepSupportingFileSelection(upload_data) { - this.uploadedFiles = upload_data?.files?.supporting || []; - if (this.uploadedFiles && this.uploadedFiles.length > 0) { - this.uploadedFileStatuses = this.uploadedFiles.map(f => FileStatus.SUCCESS); - this.updateFilePreview(this.supportingDocumentsArea, this.uploadedFiles, this.uploadedFileStatuses); - const supportingDocuments = upload_data?.supporting_documents || []; - for (let index = 0; index < supportingDocuments.length; index++) { - let d = supportingDocuments[index]; - - if (d.filing_type) { - this.initializeFilingTypeDropdown(document.getElementById(`supportingFilingType${index}_search`)); - window[`supportingFilingType${index}Dropdown`].selectOption({ - "text": d.filing_type_name, - "value": d.filing_type - }); - - await this.populateDocumentTypes(d.filing_type, document.getElementById(`supportingDocumentType${index}`)); - } - if (d.document_type) { - this.setRadioGroupValue(document.getElementById(`supportingDocumentType${index}`), d.document_type); - } - if (d.cc_email) { - let input_element = document.getElementById(`supportingFilingType${index}`); - input_element.checked = true; - this.toggleCCEmail(input_element, true); - document.getElementById(`supportingCertifiedCopyEmail${index}`).value = d.cc_email; - } - }; - } - } - - handleFileSelection(files, type) { - if (files.length === 0) return; - - // Validate files - const validFiles = []; - for (let file of files) { - if (this.validateFile(file)) { - validFiles.push(file); - } - } - - if (validFiles.length === 0) return; - - // Multiple supporting documents allowed - const startIndex = this.uploadedFiles.length; - this.uploadedFiles = [...this.uploadedFiles, ...validFiles]; - this.uploadedFileStatuses = [...this.uploadedFileStatuses, validFiles.map(f => FileStatus.UPLOADING)]; - this.updateFilePreview(this.supportingDocumentsArea, this.uploadedFiles, this.uploadedFileStatuses); - - // Update native supporting input FileList to match uploadedFiles.supporting - try { - const dt = new DataTransfer(); - this.uploadedFiles.forEach(file => dt.items.add(file)); - if (this.supportingDocumentsInput) { - this.supportingDocumentsInput.files = dt.files; - } - } catch (e) { - console.warn('Could not set native supporting input.files via DataTransfer:', e); - } - - // Automatically upload each new supporting document - validFiles.forEach((file, index) => { - this.uploadFileImmediately(file, type, startIndex + index); - }); - - this.updateSubmitButton(); - } - - validateFile(file) { - // Check file type - if (!file.type.includes('pdf') && !file.name.toLowerCase().endsWith('.pdf')) { - this.showError(`Invalid file type: ${file.name}. Only PDF files are allowed.`); - return false; - } - - // Check file size (10MB limit) - const maxSize = 10 * 1024 * 1024; // 10MB - if (file.size > maxSize) { - this.showError(`File too large: ${file.name}. Maximum size is 10MB.`); - return false; - } - - this.hideAlerts(); - - return true; - } - - updateFilePreview(area, files, file_statuses) { - // Clear existing preview - const existingPreviews = area.querySelectorAll('.file-preview'); - existingPreviews.forEach(preview => preview.remove()); - - // Add file previews - files.forEach((file, index) => { - const preview = this.createFilePreview(file, index); - area.appendChild(preview); - }); - - // Update supporting documents options - this.updateSupportingDocumentsOptions(files); - file_statuses.forEach((status, index) => { - if (status === FileStatus.SUCCESS) { - this.showFileUploadSuccess(index); - } else if (status === FileStatus.FAILED) { - this.showFileUploadError(index, "Failed"); - } else if (status === FileStatus.UPLOADING) { - this.showFileUploadProgress(index); - } - }); - - // Hide/show placeholder - const placeholder = area.querySelector('.upload-placeholder'); - if (placeholder) { - placeholder.style.display = files.length > 0 ? 'none' : 'block'; - } - - } - - createFilePreviewNoRemove(file_name, file_size) { - const preview = document.createElement('div'); - preview.className = 'file-preview-lead'; - - const fileSize = this.formatFileSize(file_size); - - preview.innerHTML = ` -
- -
-
${file_name}
-
${fileSize}
-
-
- `; - - return preview; - } - - createFilePreview(file, index) { - const preview = document.createElement('div'); - preview.className = 'file-preview'; - - const fileSize = this.formatFileSize(file.size); - - preview.innerHTML = ` -
- -
-
${file.name}
-
${fileSize}
-
-
- - `; - - // Add event listener to the remove button with strong event prevention - const removeButton = preview.querySelector('.file-remove'); - removeButton.addEventListener('click', (e) => { - this.removeFile(index); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - this.updateSubmitButton(); - - // Return false to ensure no further event processing - return false; - }, true); // Use capture phase to intercept before other handlers - - // Also add a mousedown event to completely prevent any interaction issues - removeButton.addEventListener('mousedown', (e) => { - e.stopPropagation(); - }, true); - - return preview; - } - - updateSupportingDocumentsOptions(files) { - const optionsContainer = document.getElementById('supportingDocumentsOptions'); - if (!optionsContainer) return; - - // Clear existing options - optionsContainer.innerHTML = ''; - - // Add options for each supporting document - files.forEach((file, index) => { - const optionsHTML = createSupportingDocumentOptions(index, file.name); - - const div = document.createElement('div'); - div.innerHTML = optionsHTML; - optionsContainer.appendChild(div.firstElementChild); - }); - - // Initialize search dropdowns for supporting documents - this.initializeSupportingFilingTypeDropdowns(files); - } - - async initializeSupportingFilingTypeDropdowns(files) { - // Use global filing types data if available, otherwise wait for it to load - const checkGlobalData = () => { - if (this.globalFilingTypes && this.globalFilingTypes.length > 0) { - // Initialize each supporting document's filing type dropdown - files.forEach((file, index) => { - const filingTypeDropdown = document.getElementById(`supportingFilingType${index}_search`); - if (filingTypeDropdown && window.SearchDropdown) { - const searchDropdown = new window.SearchDropdown(`supportingFilingType${index}`, { - placeholder: 'Search filing types...' - }); - searchDropdown.updateOptions(this.globalFilingTypes); - - // Store reference for later use - window[`supportingFilingType${index}Dropdown`] = searchDropdown; - } else { - console.warn(`Dropdown element not found for supportingFilingType${index}_search`); - } - - // Setup cascading dropdown logic - this.setupSupportingDocumentCascading(index); - }); - } else { - // Wait a bit and try again - setTimeout(checkGlobalData, 100); - } - }; - - checkGlobalData(); - } - - setupSupportingDocumentCascading(index) { - const filingTypeSelect = document.getElementById(`supportingFilingType${index}`); - const documentTypeSelect = document.getElementById(`supportingDocumentType${index}`); - - if (filingTypeSelect && documentTypeSelect) { - filingTypeSelect.addEventListener('change', async () => { - const selectedFilingTypeId = filingTypeSelect.value; - if (selectedFilingTypeId) { - await this.populateDocumentTypes(selectedFilingTypeId, documentTypeSelect); - } else { - documentTypeSelect.innerHTML = '

Select filing type first

'; - } - }); - } - } - - removeFile(index) { - this.uploadedFiles.splice(index, 1); - this.uploadedFileStatuses.splice(index, 1); - // Regenerate all supporting file previews with correct indices - this.updateFilePreview(this.supportingDocumentsArea, this.uploadedFiles, this.uploadedFileStatuses); - - // Update native supporting input FileList - try { - const dt = new DataTransfer(); - this.uploadedFiles.forEach(file => dt.items.add(file)); - if (this.supportingDocumentsInput) { - this.supportingDocumentsInput.files = dt.files; - } - } catch (e) { - console.warn('Could not update native supporting input.files after removal:', e); - } - - this.updateSubmitButton(); - } - - updateSubmitButton() { - let hasAllFilingComponents = true; - - let uploadsSucceeded = this.uploadedFileStatuses.every(st => st === FileStatus.SUCCESS); - - // Check if lead document has filing component selected - const leadFilingType = document.getElementById('leadFilingType'); - if (leadFilingType && !leadFilingType.value) { - hasAllFilingComponents = false; - } - - // Check if all supporting documents have filing components selected - if (this.uploadedFiles) { - this.uploadedFiles.forEach((file, index) => { - const supportingFilingType = document.getElementById(`supportingFilingType${index}`); - if (supportingFilingType && !supportingFilingType.value) { - hasAllFilingComponents = false; - } - }); - } - this.submitButton.disabled = !uploadsSucceeded || !hasAllFilingComponents; - } - - async uploadFileImmediately(file, type, index) { - try { - this.showFileUploadProgress(index); - - const formData = new FormData(); - formData.append('documents', file); - - const response = await fetch('/api/simple-s3-upload/', { - method: 'POST', - body: formData, - headers: { - 'X-CSRFToken': apiUtils.getCSRFToken() - } - }); - - const result = await response.json(); - - if (!result.success) { - this.uploadedFileStatuses[index] = FileStatus.FAILED; - throw new Error(result.error || 'Upload failed'); - } - - // Update file preview to show successful upload - this.showFileUploadSuccess(index); - - // Store the upload result for later use during form submission - this.uploadedFiles[index].uploadResult = result; - - } catch (error) { - console.error('Error uploading file immediately:', error); - this.showFileUploadError(index, error.message); - } - } - - showFileUploadProgress(index) { - this.uploadedFileStatuses[index] = FileStatus.UPLOADING; - const selector = `.file-preview:nth-child(${index + 3})`; - const preview = this.supportingDocumentsArea.querySelector(selector); - - if (preview) { - const statusDiv = preview.querySelector('.upload-status') || document.createElement('div'); - statusDiv.className = 'upload-status'; - statusDiv.innerHTML = 'Uploading...'; - if (!preview.querySelector('.upload-status')) { - preview.appendChild(statusDiv); - } - } - } - - showFileUploadSuccess(index) { - this.uploadedFileStatuses[index] = FileStatus.SUCCESS; - const selector = `.file-preview:nth-child(${index + 3})`; - const preview = this.supportingDocumentsArea.querySelector(selector); - - if (preview) { - const statusDiv = preview.querySelector('.upload-status') || document.createElement("div"); - statusDiv.className = 'upload-status'; - statusDiv.innerHTML = 'Uploaded'; - if (!preview.querySelector(".upload-status")) { - preview.appendChild(statusDiv); - } - } - } - - showFileUploadError(index, error) { - this.uploadedFileStatuses[index] = FileStatus.FAILED; - const selector = `.file-preview:nth-child(${index + 3})`; - const preview = this.supportingDocumentsArea.querySelector(selector); - - if (preview) { - const statusDiv = preview.querySelector('.upload-status') || document.createElement("div"); - statusDiv.className = "upload-status"; - statusDiv.innerHTML = 'Upload failed'; - statusDiv.title = error; - if (!preview.querySelector('.upload-status')) { - preview.appendChild(statusDiv); - } - } - } - - async handleFormSubmission() { - // Validate that all supporting documents have filing components selected - for (let i = 0; i < this.uploadedFiles.length; i++) { - const supportingFilingType = document.getElementById(`supportingFilingType${i}`)?.value; - if (!supportingFilingType) { - this.showError(`Please select a filing component for supporting document: ${this.uploadedFiles[i].name}`); - return; - } - const supportingDocumentType = this.getRadioGroupValue(document.getElementById(`supportingDocumentType${i}`)); - if (!supportingDocumentType) { - this.showError(`Please select a document type for supporting document: ${this.uploadedFiles[i].name}`); - return; - } - } - - try { - // Collect dropdown values for lead document - const leadFilingTypeSelect = document.getElementById('leadFilingType'); - const leadDocumentTypeContainer = document.getElementById('leadDocumentType'); - - const leadFilingType = leadFilingTypeSelect ? leadFilingTypeSelect.value : ''; - const leadFilingTypeName = leadFilingTypeSelect && leadFilingTypeSelect.selectedOptions[0] ? leadFilingTypeSelect.selectedOptions[0].text : ''; - const leadDocumentType = this.getRadioGroupValue(leadDocumentTypeContainer); - const leadDocumentTypeName = this.getRadioGroupText(leadDocumentTypeContainer); - - if (!leadFilingType || !leadDocumentType) { - this.showError('Please select a filing type and document type for the lead document.'); - return; - } - - const leadFilingComponentValue = this.globalFilingComponentLead.id; - const leadFilingComponentName = this.globalFilingComponentLead.name; - - let leadCCEmail = document.getElementById('leadCertifiedCopyEmail').value; - if (!document.getElementById('leadCertifiedCopies').checked) { - leadCCEmail = null; - } - - // Collect supporting document dropdown values - const supportingDocuments = []; - const supportingDropdowns = document.querySelectorAll('select[id*="supportingFilingType"]:not([id*="_search"])'); - supportingDropdowns.forEach((dropdown, index) => { - const filingType = dropdown.value; - const filingTypeName = dropdown.selectedOptions[0]?.text || ''; - const docTypeContainer = document.getElementById(`supportingDocumentType${index}`); - const docType = this.getRadioGroupValue(docTypeContainer); - const docTypeName = this.getRadioGroupText(docTypeContainer); - const component = this.globalFilingComponentSupport.id; - const componentName = this.globalFilingComponentSupport.name; - - let supportingCCEmail = document.getElementById(`supportingCertifiedCopyEmail${index}`).value; - if (!document.getElementById(`supportingCertifiedCopies${index}`).checked) { - supportingCCEmail = null; - } - - const supportingDoc = { - filing_type: filingType, - filing_type_name: filingTypeName, - document_type: docType, - document_type_name: docTypeName, - filing_component: component, - filing_component_name: componentName, - cc_email: supportingCCEmail - }; - - supportingDocuments.push(supportingDoc); - }); - - // Prepare upload data using already uploaded files (since files are uploaded immediately on selection) - const uploadDataWithUrls = { - files: [], - options: { - lead: { - filing_component: { - id: leadFilingComponentValue, - name: leadFilingComponentName - }, - certified_copies: document.getElementById('leadCertifiedCopies')?.checked || false, - sealed_confidential: document.getElementById('leadSealedConfidential')?.checked || false - }, - supporting: [] - }, - // Add dropdown data for lead document - lead_filing_type: leadFilingType, - lead_filing_type_name: leadFilingTypeName, - lead_document_type: leadDocumentType, - lead_document_type_name: leadDocumentTypeName, - lead_filing_component: leadFilingComponentValue, - lead_filing_component_name: leadFilingComponentName, - lead_cc_email: leadCCEmail, - // Add supporting documents dropdown data - supporting_documents: supportingDocuments - }; - // Process supporting documents that were already uploaded - this.uploadedFiles.forEach((file, index) => { - const supportingFilingComponent = this.globalFilingComponentSupport; - if (file.uploadResult) { - file.url = file.uploadResult.files[0]?.public_url; - file.s3_key = file.uploadResult.files[0]?.key; - } - if (file.s3_key && file.url) { - uploadDataWithUrls.files.push({ - name: file.name, - size: file.size, - type: file.type, - url: file.url, - s3_key: file.s3_key, - filing_component: supportingFilingComponent - }); - - // Also add to options array - uploadDataWithUrls.options.supporting.push({ - filing_component: supportingFilingComponent, - certified_copies: document.getElementById(`supportingCertifiedCopies${index}`)?.checked || false, - sealed_confidential: document.getElementById(`supportingSealedConfidential${index}`)?.checked || false - }); - } - }); - - // Save the complete upload data to session - await this.saveUploadDataToSession(uploadDataWithUrls); - - - // Redirect to payments page - window.location.href = `/jurisdiction/${this.jurisdiction}/payment/`; - - } catch (error) { - console.error('Form submission error:', error); - this.showError(error.message); - } - } - - - - showError(message) { - this.hideAlerts(); - document.getElementById('errorMessage').textContent = message; - this.errorAlert.style.display = 'block'; - - // Scroll to error - this.errorAlert.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); - } - - showSuccess(message) { - this.hideAlerts(); - document.getElementById('successMessage').textContent = message; - this.successAlert.style.display = 'block'; - - // Scroll to success - this.successAlert.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); - } - - hideAlerts() { - this.errorAlert.style.display = 'none'; - this.successAlert.style.display = 'none'; - } - - formatFileSize(bytes) { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - } - - populateForm(data) { - this.restorationData = data; - - Object.keys(data).forEach((key) => { - const field = this.form.querySelector(`[name="${key}"]`); - if (field) { - if (field.type === "checkbox" || field.type === "radio") { - field.checked = Array.isArray(data[key]) ? - data[key].includes(field.value) : - data[key] === field.value; - } else { - field.value = Array.isArray(data[key]) ? data[key][0] : data[key]; - } - - // Trigger change event for dropdowns to update dependent fields - if (field.tagName === "SELECT") { - field.dispatchEvent(new Event("change", { - bubbles: true - })); - } - } - }); - - } - - async loadFilingComponents() { - try { - // Use our backend API endpoint instead of direct Suffolk API call to avoid CORS - const response = await fetch(`/api/get-filing-components/?jurisdiction=${this.jurisdiction}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": apiUtils.getCSRFToken(), - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to load filing components: ${response.status}` - ); - } - - const result = await response.json(); - if (result.success && result.data) { - // TODO(brycew): Make selecting Lead document and attachment more resillient - result.data.map((component) => { - if (component.name === "Lead Document") { - this.globalFilingComponentLead = { - id: component.code, - name: component.name - }; - } - if (component.name === "Attachments") { - this.globalFilingComponentSupport = { - id: component.code, - name: component.name - }; - } - }); - - // Some filing types expose only one filing component. In - // that case the EFSP still expects that component's code for - // supporting documents; never send the UI label "supporting". - if (!this.globalFilingComponentSupport.id && this.globalFilingComponentLead.id) { - this.globalFilingComponentSupport = { - id: this.globalFilingComponentLead.id, - name: this.globalFilingComponentLead.name - }; - } - } else { - console.error("API returned error:", result.error); - } - } catch (error) { - console.error("Error loading filing components:", error); - } - } - - async initializeSearchDropdowns() { - // Get case data from Django context (passed from the view) - const caseClassification = JSON.parse(document.getElementById("case-classification").textContent); - const court = caseClassification.court || sessionStorage.getItem("selected_court"); - const caseType = caseClassification.case_type || sessionStorage.getItem("selected_case_type"); - // TODO(brycew): add escapejs to other templated stuff I've added - // TODO(brycew): check that this works at all fallbacks - const categoryType = caseClassification.case_category || sessionStorage.getItem("selected_category_type"); - const existingCase = sessionStorage.getItem("existing_case") || "no"; - - if (!court || !caseType) { - console.warn( - "Missing court or case_type for filing type dropdown initialization" - ); - return; - } - - let uploadData = {}; - try { - uploadData = await apiUtils.getUploadData() || {}; - } catch (error) { - console.warn('Could not load upload guesses:', error); - } - const guesses = uploadData.guesses || {}; - - // Fetch filing types data only once - if (this.globalFilingTypes.length === 0) { - try { - const apiUrl = `/api/dropdowns/filing-types/?jurisdiction=${this.jurisdiction}&court=${encodeURIComponent( - court - )}&case_category=${encodeURIComponent(categoryType)}&case_type=${encodeURIComponent( - caseType - )}&existing_case=${existingCase}&guessed_filing_type=${guesses["filing type"]}`; - - const response = await fetch(apiUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": apiUtils.getCSRFToken(), - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to load filing types: ${response.status}` - ); - } - - const result = await response.json(); - if (result.success && result.data) { - this.globalFilingTypes = result.data.map((item, index) => { - const processedItem = { - value: item.value || item.code || item.id, - text: item.text || item.name || item.description, - }; - return processedItem; - }); - } else { - console.error("API returned error:", result.error); - return; - } - } catch (error) { - console.error("Error loading filing types:", error); - return; - } - } - - // Initialize all existing filing type search dropdowns - this.initializeFilingTypeDropdowns(); - } - - async populateDocumentTypes(filingTypeId, documentTypeContainer) { - try { - // Get court data from Django context to pass required parameters - const court = JSON.parse(document.getElementById("case-classification").textContent)["court"] || sessionStorage.getItem("selected_court"); - - if (!court) { - console.error("Missing court parameter for document types API"); - documentTypeContainer.innerHTML = - '

Missing court data

'; - return; - } - - let params = { - jurisdiction: this.jurisdiction, - court: court, - parent: filingTypeId, - } - const result = await apiUtils.get('/api/dropdowns/document-types', params, true); - if (result.success && result.data) { - this.renderDocumentTypeRadios(documentTypeContainer, result.data); - } else { - console.error("API returned error:", result.error); - documentTypeContainer.innerHTML = - '

Error loading document types

'; - } - } catch (error) { - console.error("Error loading document types:", error); - documentTypeContainer.innerHTML = - '

Error loading document types

'; - } - } - - // Confidential/non-confidential is a binary choice (occasionally a third - // option), so it's rendered as radio buttons rather than a dropdown. - renderDocumentTypeRadios(container, options) { - container.innerHTML = ""; - const groupName = container.dataset.fieldName || container.id; - - options.forEach((docType, index) => { - const value = docType.value || docType.code || docType.id; - const text = docType.text || docType.name || docType.description; - - const wrapper = document.createElement("div"); - wrapper.className = "form-check"; - - const input = document.createElement("input"); - input.type = "radio"; - input.className = "form-check-input document-type-select"; - input.name = groupName; - input.id = `${container.id}_${index}`; - input.value = value; - input.required = true; - - const label = document.createElement("label"); - label.className = "form-check-label"; - label.setAttribute("for", input.id); - label.textContent = text; - - wrapper.appendChild(input); - wrapper.appendChild(label); - container.appendChild(wrapper); - }); - } - - getRadioGroupValue(container) { - if (!container) return ''; - const checked = container.querySelector('input[type="radio"]:checked'); - return checked ? checked.value : ''; - } - - getRadioGroupText(container) { - if (!container) return ''; - const checked = container.querySelector('input[type="radio"]:checked'); - if (!checked) return ''; - const label = container.querySelector(`label[for="${checked.id}"]`); - return label ? label.textContent.trim() : ''; - } - - setRadioGroupValue(container, value) { - if (!container) return; - const radio = container.querySelector(`input[type="radio"][value="${value}"]`); - if (radio) radio.checked = true; - } - - setupCascadingDropdowns() { - // Lead document cascading dropdowns - const leadFilingTypeSelect = document.getElementById("leadFilingType"); - const leadDocumentTypeSelect = - document.getElementById("leadDocumentType"); - - if (leadFilingTypeSelect) { - leadFilingTypeSelect.addEventListener("change", () => { - const selectedFilingTypeId = leadFilingTypeSelect.value; - - if (selectedFilingTypeId) { - this.populateDocumentTypes( - selectedFilingTypeId, - leadDocumentTypeSelect - ); - // Don't reset filing components - they should remain available - } - }); - } - } - - initializeFilingTypeDropdowns() { - // Initialize filing type search dropdowns - const filingTypeDropdowns = document.querySelectorAll( - '[id*="FilingType_search"]' - ); - - filingTypeDropdowns.forEach((dropdown) => this.initializeFilingTypeDropdown(dropdown)); - } - - initializeFilingTypeDropdown(dropdown) { - // Extract the base field ID from the search input ID - const fieldId = dropdown.id.replace("_search", ""); - - const searchDropdown = new SearchDropdown(fieldId, { - placeholder: gettext("Search filing types..."), - }); - searchDropdown.updateOptions(this.globalFilingTypes); - - // Store reference for later use - window[`${fieldId}Dropdown`] = searchDropdown; - } - -} - -function populateDropdownFallback(dropdown) { - if (!dropdown) return; - - dropdown.innerHTML = ` - - - - - `; -} - -function createSupportingDocumentOptions(index, fileName) { - const html = ` -
-
Options for: ${fileName}
- -
-
- -
- - - - - - - -
-
-
-
- Request Documents to be Sealed / Confidential? * -
-

Select filing type first

-
-
-
-
- -
-
-
- - -
- -
-
-
- `; - - return html; -} - -// Initialize when DOM is loaded -document.addEventListener('DOMContentLoaded', function() { - if (!window.uploadHandler) { - window.uploadHandler = new UploadHandler(); - } else { - console.warn('UploadHandler already exists, skipping initialization'); - } -}); \ No newline at end of file diff --git a/efile_app/efile/templates/efile/components/profile_header.html b/efile_app/efile/templates/efile/components/profile_header.html index 1803fda..23e6a44 100644 --- a/efile_app/efile/templates/efile/components/profile_header.html +++ b/efile_app/efile/templates/efile/components/profile_header.html @@ -10,69 +10,123 @@ {{ config.jurisdiction.name }} - diff --git a/efile_app/efile/templates/efile/components/search_dropdown.html b/efile_app/efile/templates/efile/components/search_dropdown.html deleted file mode 100644 index 966c0c4..0000000 --- a/efile_app/efile/templates/efile/components/search_dropdown.html +++ /dev/null @@ -1,67 +0,0 @@ -{% comment %} - SearchDropdown Component - - A reusable search and type-ahead dropdown component. - - Required parameters: - - field_id: The ID for the field (used to generate unique IDs) - - field_name: The name attribute for the hidden select element - - label_text: The text for the field label - - is_required: Boolean, whether the field is required (default: false) - - placeholder: Placeholder text for the search input (default: "Search...") - - disabled: Boolean, whether the field should be disabled initially (default: false) - - data_level: The data-level attribute for cascading dropdowns (optional) - - Optional parameters: - - help_text: Additional help text shown below the field - - loading_spinner_id: ID for the loading spinner (default: "loading-{data_level}") - - container_classes: Additional CSS classes for the container (default: "col-md-6") - - no_results_text: Text shown when no results are found (default: "No matching options found") - - Usage example: - {% include 'efile/components/search_dropdown.html' with field_id="filing_type" field_name="filing_type" label_text="Filing Type" is_required=True placeholder="Search filing types..." data_level="2" %} -{% endcomment %} -
-
- -
- - - - -
- {% if loading_spinner_id or data_level %} - - {% endif %} - {% if help_text %}
{{ help_text }}
{% endif %} -
-
diff --git a/efile_app/efile/templates/efile/expert_form.html b/efile_app/efile/templates/efile/expert_form.html deleted file mode 100644 index b7dda77..0000000 --- a/efile_app/efile/templates/efile/expert_form.html +++ /dev/null @@ -1,700 +0,0 @@ -{% load static %} -{% load i18n %} - - - - - - {% translate "Case Information" %} - - - - - - - {% include "efile/components/profile_header.html" %} -
-
-
- {% csrf_token %} -

{% translate "Case information" %}

-

- {% translate "Please provide the basic information about your case. Required fields are marked with a red asterisk" %} - (*). -

- -
-

{% translate "Where and what" %}

- -
-
- - {% translate "This is the court you will be filing in" %} - - -
-
-
-
-
- - {% translate "New or existing?" %}* - -
- -
-
- -
-
-
-
-
- - - - - - -
- - -
-
-
-
- {% include "efile/components/footer.html" %} - - - {{ case_data|json_script:"case-data" }} - - - - - - - - - - diff --git a/efile_app/efile/templates/efile/filing_detail.html b/efile_app/efile/templates/efile/filing_detail.html new file mode 100644 index 0000000..0f24242 --- /dev/null +++ b/efile_app/efile/templates/efile/filing_detail.html @@ -0,0 +1,152 @@ +{% load static %} +{% load i18n %} + + + + + + {% translate "Filing details" %} + + + + + + + + {% include "efile/components/profile_header.html" %} +
+
+
+ {% if not filing %} +

{% translate "Filing details" %}

+ + {% else %} +

+ {% if filing.case_title %} + {{ filing.case_title }} + {% elif filing.docket_number %} + {% blocktranslate with docket=filing.docket_number %}Case {{ docket }}{% endblocktranslate %} + {% else %} + {% translate "Filing details" %} + {% endif %} +

+
+
+
+

+ {% if filing.docket_number %} + {% blocktranslate with docket=filing.docket_number %}Case {{ docket }}{% endblocktranslate %} + · + {% endif %} + {{ filing.court_name }} +

+

+ {% if filing.submitted_at %} + {% blocktranslate with when=filing.submitted_at|date:"j F Y" %}Sent {{ when }}{% endblocktranslate %} + {% endif %} + {% if filing.accepted_at %} + · {% blocktranslate with when=filing.accepted_at|date:"j F Y" %}Accepted {{ when }}{% endblocktranslate %} + {% endif %} + {% if filing.envelope_id %} + · {% blocktranslate with envelope=filing.envelope_id %}Envelope {{ envelope }}{% endblocktranslate %} + {% endif %} +

+
+ + + {{ filing.status_presentation.label }} + +
+ {% if filing.submitter_name %} +

+ {% blocktranslate with who=filing.submitter_name %}Filed by {{ who }}{% endblocktranslate %} + {% if filing.submitter_firm %}({{ filing.submitter_firm }}){% endif %} +

+ {% endif %} +
+ {% for comment in filing.comments %} +
+

{{ comment.heading }}

+

{{ comment.text }}

+
+ {% endfor %} +
+

{% translate "Documents in this filing" %}

+ {% for document in filing.documents %} +
+

{% firstof document.description _("Document") %}

+
    + {% for attachment in document.attachments %} +
  • + + + {{ attachment.label }} + + {% if attachment.filename %}{{ attachment.filename }}{% endif %} +
  • + {% empty %} +
  • {% translate "The court is not offering a copy of this document." %}
  • + {% endfor %} +
+
+ {% empty %} +

{% translate "The court has not listed any documents for this filing." %}

+ {% endfor %} +

+ {% blocktranslate with days=document_link_days %}These copies live at the court's e-filing system, which keeps them for {{ days }} days after filing. Save anything you want to keep.{% endblocktranslate %} +

+
+ {% if filing.fees %} +
+

{% translate "What this filing cost" %}

+
    + {% for fee in filing.fees %}
  • {{ fee.reason }}: ${{ fee.amount }}
  • {% endfor %} +
+
+ {% elif filing.fee_waiver %} +
+

{% translate "What this filing cost" %}

+

{% translate "You filed this with a fee waiver, and the court charged you nothing." %}

+
+ {% endif %} + {% if court_contact %} +
+

{% translate "Questions about this filing" %}

+

+ {% translate "Only the court can explain what it did with your filing, or change it. Contact the clerk's office:" %} +

+ +
+ {% endif %} + {% endif %} + {% translate "Back to my cases" %} +
+
+
+ {% include "efile/components/footer.html" %} + + + + diff --git a/efile_app/efile/templates/efile/filing_plans.html b/efile_app/efile/templates/efile/filing_plans.html index 723dd9f..ee177c8 100644 --- a/efile_app/efile/templates/efile/filing_plans.html +++ b/efile_app/efile/templates/efile/filing_plans.html @@ -141,6 +141,19 @@

{{ entry.plan.title }}

+
+ {% translate "Delete this plan" %} +
+ {% csrf_token %} + + +

+ {% translate "This throws away the document list you have been keeping for this matter. It does not touch anything you have already filed." %} +

+ +
+
{% empty %}
diff --git a/efile_app/efile/templates/efile/my_drafts.html b/efile_app/efile/templates/efile/my_drafts.html new file mode 100644 index 0000000..494f073 --- /dev/null +++ b/efile_app/efile/templates/efile/my_drafts.html @@ -0,0 +1,110 @@ +{% load static %} +{% load i18n %} + + + + + + {% translate "My draft e-filings" %} + + + + + + + + {% include "efile/components/profile_header.html" %} +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
+
+

{% translate "My draft e-filings" %}

+
+
+

+ {% translate "These are filings you have started but not sent to the court. Pick one up where you left off, or throw away one you no longer want." %} +

+ {% for entry in drafts %} +
+
+
+

+ {% if entry.title %} + {{ entry.title }} + {% else %} + {% translate "Untitled filing" %} + {% endif %} +

+

+ {% if entry.draft.docket_number %} + {% blocktranslate with docket=entry.draft.docket_number %}Case {{ docket }}{% endblocktranslate %} + · + {% endif %} + {% if entry.draft.court_name %}{{ entry.draft.court_name }} ·{% endif %} + {% if entry.path_label %}{{ entry.path_label }}{% endif %} +

+

+ {% if entry.step_label %} + {% blocktranslate with step=entry.step_label %}Next up: {{ step }}{% endblocktranslate %} + · + {% endif %} + {% blocktranslate count counter=entry.document_count %}1 document{% plural %}{{ counter }} documents{% endblocktranslate %} + · + {% blocktranslate with when=entry.draft.updated_at|date:"j F Y, g:i a" %}Last worked on {{ when }}{% endblocktranslate %} +

+
+ {% if entry.is_current %} + {% translate "The one you are in" %} + {% endif %} +
+
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+
+
+ {% empty %} +
+

{% translate "You have no filings in progress. Everything you started has been sent or thrown away." %}

+
+ {% csrf_token %} + +
+
+ {% endfor %} + {% translate "Go back" %} +
+
+
+ {% include "efile/components/footer.html" %} + + + + diff --git a/efile_app/efile/templates/efile/options.html b/efile_app/efile/templates/efile/options.html index 179805b..fa9753e 100644 --- a/efile_app/efile/templates/efile/options.html +++ b/efile_app/efile/templates/efile/options.html @@ -34,10 +34,6 @@

{% translate "File forms and documents with the court" %}

{{ config.jurisdiction.copyedit.landing_text | md_to_html }}

- {% if not is_logged_in %}
@@ -110,16 +106,38 @@

{% translate "My filing plans" %}

- +
-

{% translate "Start a new filing" %}

-

{% translate "Begin a new case or file a response." %}

+

{% translate "Start a new case" %}

+

{% translate "The court has not given you a case number yet." %}

-
- +
+ {% csrf_token %} + +
+ +
+
+
+
+
+
+ +
+
+

{% translate "File into an existing case" %}

+

{% translate "You have a case number, or the case is already open." %}

+
+
+ {% csrf_token %} + +
+ +
+
@@ -127,8 +145,10 @@

{% translate "Start a new filing" %}

-

{% translate "View past filings" %}

-

{% translate "See if a filing you have made was accepted or rejected." %}

+

{% translate "My cases" %}

+

+ {% translate "See whether the court accepted a filing, read what the clerk said, and get copies of what you filed." %} +

@@ -147,23 +167,25 @@

{% translate "View past filings" %}

{{ resume_url|json_script:"resume-url" }} + {{ drafts_url|json_script:"drafts-url" }} + {{ draft_count|json_script:"draft-count" }} - - - - - - - diff --git a/efile_app/efile/templates/efile/upload_first.html b/efile_app/efile/templates/efile/upload_first.html deleted file mode 100644 index 8848899..0000000 --- a/efile_app/efile/templates/efile/upload_first.html +++ /dev/null @@ -1,159 +0,0 @@ -{% load static %} -{% load i18n %} - - - - - - Upload Your Documents - - - - - - - - {% include "efile/components/profile_header.html" %} -
-
-

{% translate "Upload your lead document" %}

-

- {% translate "Upload your main court form. It must be in PDF format and clearly legible." %} -

-
- {% csrf_token %} - -
-
- {% translate "Document requirements" %} -
-
- {% translate "Format" %}: {% translate "Must be in PDF format with text that can be read clearly." %} -
-
- {% translate "Size Limit" %}: {% translate "Must be under 10MB." %} -
-
- -
- -

{% translate "Your main document - this is the primary filing for your case." %}

-
-
- -

- {% translate "Click to upload or drag and drop" %} -

-

{% translate "PDF files only, maximum 10MB" %}

-
- -
-
- - - - - - - -
-

- {% translate "Before you continue" %} -

-

- {% translate "Review your document" %}: {% translate "Make sure all text is legible and all required information is complete." %} -

-

- {% translate "File names" %}: {% translate "Your document will be renamed according to court standards when filed." %} -

-

- {% translate "Cannot be changed" %}: {% translate "Once submitted, you cannot modify this document without filing additional motions." %} -

-
- -
- - -
-
-
-
- {% include "efile/components/footer.html" %} - {{ upload_data|json_script:"upload-data" }} - - - - - - - - diff --git a/efile_app/efile/templates/efile/view_statuses.html b/efile_app/efile/templates/efile/view_statuses.html index be841f1..377822e 100644 --- a/efile_app/efile/templates/efile/view_statuses.html +++ b/efile_app/efile/templates/efile/view_statuses.html @@ -5,17 +5,17 @@ - {{ config.jurisdiction.name }} + {% translate "My cases" %} - + + {% include "efile/components/profile_header.html" %} -
{% if messages %} {% for message in messages %} @@ -29,122 +29,132 @@ {% endif %}
-

{% translate "Filing statuses" %}

-
-

{{ config.jurisdiction.copyedit.filing_statuses }}

-
-
- -
- {% include "efile/components/footer.html" %} - - - - - +
+ {% include "efile/components/footer.html" %} + + + diff --git a/efile_app/efile/tests/test_current_draft_selection.py b/efile_app/efile/tests/test_current_draft_selection.py index bb58a27..d703c8a 100644 --- a/efile_app/efile/tests/test_current_draft_selection.py +++ b/efile_app/efile/tests/test_current_draft_selection.py @@ -16,7 +16,7 @@ OPTIONS_URL = reverse("efile_options", kwargs={"jurisdiction": "illinois"}) UPLOAD_URL = reverse("upload_documents", kwargs={"jurisdiction": "illinois"}) -CREATE_DRAFT_URL = reverse("create_draft", kwargs={"jurisdiction": "illinois"}) +START_FILING_URL = reverse("start_filing", kwargs={"jurisdiction": "illinois"}) @pytest.fixture @@ -91,8 +91,8 @@ def test_a_late_read_cannot_undo_a_new_filing(signed_in, last_months_filing): """The bug, in the order it happened: start a filing, then a page load that was already in flight comes back and answers "which filing?" as well.""" - started = signed_in.post(CREATE_DRAFT_URL, data="{}", content_type="application/json") - new_draft_id = started.json()["data"]["filing_draft"]["id"] + signed_in.post(START_FILING_URL, {"existing_case": "new"}) + new_draft_id = current_draft_id(signed_in) signed_in.get(reverse("get_case_data_api")) signed_in.get(OPTIONS_URL) @@ -109,7 +109,7 @@ def test_a_late_read_cannot_undo_a_new_filing(signed_in, last_months_filing): @pytest.mark.django_db def test_a_new_filing_starts_with_no_documents(signed_in, last_months_filing): signed_in.get(OPTIONS_URL) - signed_in.post(CREATE_DRAFT_URL, data="{}", content_type="application/json") + signed_in.post(START_FILING_URL, {"existing_case": "new"}) page = signed_in.get(UPLOAD_URL) @@ -155,8 +155,8 @@ def test_resuming_a_filing_by_name_picks_it_back_up(signed_in, last_months_filin def test_resuming_switches_away_from_the_filing_you_were_in(signed_in, last_months_filing): """Naming a filing is the filer saying "this one", whatever they were last in.""" - started = signed_in.post(CREATE_DRAFT_URL, data="{}", content_type="application/json") - new_draft_id = started.json()["data"]["filing_draft"]["id"] + signed_in.post(START_FILING_URL, {"existing_case": "new"}) + new_draft_id = current_draft_id(signed_in) assert current_draft_id(signed_in) == new_draft_id page = signed_in.get(f"{UPLOAD_URL}?draft={last_months_filing.pk}") diff --git a/efile_app/efile/tests/test_durable_drafts.py b/efile_app/efile/tests/test_durable_drafts.py index 152aa22..f208c50 100644 --- a/efile_app/efile/tests/test_durable_drafts.py +++ b/efile_app/efile/tests/test_durable_drafts.py @@ -1,5 +1,4 @@ import json -from unittest.mock import patch import pytest from django.urls import reverse @@ -289,50 +288,6 @@ def test_draft_snapshot_is_json_serializable(django_user_model): json.dumps(snapshot) -@pytest.mark.django_db -def test_create_draft_view_creates_durable_draft(client, django_user_model): - user = django_user_model.objects.create_user( - username="testuser", - password="testpass123", - tyler_jurisdiction="illinois", - ) - client.force_login(user) - _authorize_jurisdiction_session(client) - - response = client.post( - reverse("create_draft", kwargs={"jurisdiction": "illinois"}), - data={}, - content_type="application/json", - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["success"] is True - assert payload["redirect_url"] == reverse("filing_path", kwargs={"jurisdiction": "illinois"}) - - draft = FilingDraft.objects.get(user=user) - assert draft.jurisdiction == "illinois" - assert draft.current_step == WorkflowStepKey.FILING_PATH - assert draft.workflow_version == 2 - assert payload["data"]["filing_draft"]["id"] == draft.pk - - -@pytest.mark.django_db -def test_create_draft_view_requires_jurisdiction_token(client, django_user_model): - user = django_user_model.objects.create_user(username="no-token", tyler_jurisdiction="illinois") - client.force_login(user) - - response = client.post( - reverse("create_draft", kwargs={"jurisdiction": "illinois"}), - data={}, - content_type="application/json", - ) - - assert response.status_code == 403 - assert response.json()["success"] is False - assert not FilingDraft.objects.exists() - - @pytest.mark.django_db def test_options_page_points_resume_to_draft_workflow_step(client, django_user_model): user = django_user_model.objects.create_user( @@ -374,43 +329,6 @@ def test_legacy_documents_url_redirects_into_reorganized_document_flow(client, d assert response.url == reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) + f"?draft={draft.pk}" -@pytest.mark.django_db -def test_first_upload_save_uses_current_draft_jurisdiction(client, django_user_model): - """A first-upload request without a jurisdiction must not fall into ``default``.""" - user = django_user_model.objects.create_user(username="first-upload-owner", tyler_jurisdiction="illinois") - draft = FilingDraft.objects.create(user=user, jurisdiction="illinois") - client.force_login(user) - session = client.session - session[CURRENT_DRAFT_SESSION_KEY] = draft.pk - session["jurisdiction"] = "illinois" - session.save() - - with ( - patch("efile.views.session_api.requests.get") as get_file, - patch("efile.views.session_api.extract_fields_from_file", return_value={}), - ): - get_file.return_value.content = b"%PDF-1.7" - response = client.post( - reverse("save_upload_data_to_session"), - data=json.dumps( - { - "files": { - "lead": { - "name": "petition.pdf", - "url": "http://localstack:4566/forms/petition.pdf", - "s3_key": "efile-documents/lead/petition.pdf", - } - } - } - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).exists() - assert not FilingDraft.objects.filter(user=user, jurisdiction="default").exists() - - @pytest.mark.django_db def test_current_draft_enforces_owner(client, django_user_model): illinois_user = django_user_model.objects.create_user(username="illinois-user", tyler_jurisdiction="illinois") @@ -450,11 +368,7 @@ def test_save_case_endpoint_persists_into_current_draft(client, django_user_mode user = django_user_model.objects.create_user(username="endpoint-user", tyler_jurisdiction="illinois") client.force_login(user) _authorize_jurisdiction_session(client) - client.post( - reverse("create_draft", kwargs={"jurisdiction": "illinois"}), - data={}, - content_type="application/json", - ) + client.post(reverse("start_filing", kwargs={"jurisdiction": "illinois"}), {"existing_case": "new"}) response = client.post( reverse("save_case_data_api"), diff --git a/efile_app/efile/tests/test_filing_history.py b/efile_app/efile/tests/test_filing_history.py new file mode 100644 index 0000000..6e18bf7 --- /dev/null +++ b/efile_app/efile/tests/test_filing_history.py @@ -0,0 +1,404 @@ +"""What a filer can learn about filings they have already sent. + +Two things are under test here. The first is reading Tyler's filing-detail +payload, which is deeply nested ECF 4 XML rendered as JSON: the fixtures below +keep that shape exactly (only the names and identifiers are invented), because +every simplification of it would test a payload the court never sends. + +The second is "My cases" itself -- grouping a flat filing history into cases, +and archiving the ones the filer is done watching. +""" + +from unittest.mock import patch + +import pytest +from django.urls import reverse + +from efile.models import ArchivedCase +from efile.services.filings import ( + archive_case, + cases_for_user, + court_contact, + describe_filing_detail, + status_presentation, +) + +CASES_URL = reverse("filing_statuses", kwargs={"jurisdiction": "illinois"}) + + +def _identification(category, value): + return { + "identificationID": {"value": value}, + "identificationCategory": { + "name": "{http://niem.gov/niem/niem-core/2.0}IdentificationCategoryText", + "value": {"value": category}, + }, + } + + +def _attachment(description, url): + return { + "binaryDescriptionText": {"value": description}, + "binaryLocationURI": {"value": url}, + "attachmentSequenceID": {"value": "0"}, + } + + +def rejected_detail(): + """A rejected filing, as the EFSP returns it: comment on the document.""" + + return { + "caseCourt": { + "organizationIdentification": { + "name": "{http://niem.gov/niem/niem-core/2.0}OrganizationIdentification", + "value": {"identificationID": {"value": "kane"}}, + } + }, + "filingSubmissionDate": { + "dateRepresentation": { + "name": "{http://niem.gov/niem/niem-core/2.0}DateTime", + "value": {"value": 1677189792000}, + } + }, + "documentIdentification": [ + _identification("ENVELOPEID", "275057"), + _identification("FILINGID", "94e86d5d-de80-454d-a47c-ecd017d22e3a"), + ], + "filingStatus": { + "statusDescriptionText": [{"value": "filing has been rejected"}], + "filingStatusCode": "rejected", + }, + "filingLeadDocument": [ + { + "documentDescriptionText": {"value": "Appearance (No Fee)"}, + "documentStatus": { + "statusText": {"value": "Please refile with the case number on page 1."}, + "statusDescriptionText": [{"value": "RejectComments"}], + }, + "documentRendition": [ + { + "documentRenditionMetadata": { + "documentAttachment": [ + _attachment("Original - appearance.pdf", "https://example.tylertech.cloud/one"), + ] + } + } + ], + } + ], + "case": { + "name": "{urn:oasis:names:tc:legalxml-courtfiling:schema:xsd:CivilCase-4.0}CivilCase", + "value": { + "caseTitleText": {"value": "Ada Torres v. Blue Harbor LLC"}, + "caseDocketID": {"value": "2017-L-000278"}, + }, + }, + "payment": {"accountName": "Global Account", "waiverIndicator": {"value": True}}, + "envelopeFees": [ + { + "allowanceCharge": [ + { + "allowanceChargeReason": {"value": "Convenience Fee"}, + "amount": {"value": 0.0, "currencyID": "USD"}, + }, + { + "allowanceChargeReason": {"value": "Total Court Filing Fees"}, + "amount": {"value": 89.5, "currencyID": "USD"}, + }, + ] + } + ], + } + + +def accepted_detail(): + """An accepted filing carries the court's own copy alongside the filer's.""" + + detail = rejected_detail() + detail["filingStatus"] = { + "statusDescriptionText": [{"value": "filing has been accepted by the court"}], + "filingStatusCode": "accepted", + } + detail["filingAcceptDate"] = { + "dateRepresentation": { + "name": "{http://niem.gov/niem/niem-core/2.0}DateTime", + "value": {"value": 1677276192000}, + } + } + document = detail["filingLeadDocument"][0] + document["documentStatus"] = {"statusDescriptionText": [{"value": "AcceptComments"}]} + document["documentRendition"][0]["documentRenditionMetadata"]["documentAttachment"] = [ + _attachment("Original - appearance.pdf", "https://example.tylertech.cloud/one"), + _attachment("Transmitted - appearance.pdf", "https://example.tylertech.cloud/two"), + ] + return detail + + +def filing_row(**overrides): + """One entry as ``list_filing_data`` normalizes it.""" + + row = { + "filing_status": "accepted", + "filing_status_text": "filing has been accepted by the court", + "filing_id": "filing-1", + "envelope_id": "1001", + "case_tracking_id": "case-a", + "case_title": "Ada Torres v. Blue Harbor LLC", + "case_number": "2024-EV-000123", + "court_code": "kane", + "filing_code": "Appearance", + "received_timestamp": 1677189792000, + "filed_timestamp": 1677189792000, + } + row.update(overrides) + return row + + +def described(payload, names=None): + """``describe_filing_detail`` for a payload that is really there.""" + + filing = describe_filing_detail(payload, names) + assert filing is not None + return filing + + +@pytest.fixture +def user(django_user_model): + return django_user_model.objects.create_user(username="history-user", tyler_jurisdiction="illinois") + + +def sign_in(client, user): + client.force_login(user) + session = client.session + session["jurisdiction"] = "illinois" + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "token"} + session.save() + + +# ---------------------------------------------------------------- reading Tyler + + +def test_rejection_comment_is_pulled_off_the_document(): + filing = described(rejected_detail()) + + assert filing["status_presentation"]["label"] == "Rejected" + assert filing["comments"] == [ + { + "kind": "rejection", + "heading": "Why the court rejected this", + "text": "Please refile with the case number on page 1.", + } + ] + + +def test_accepted_filing_offers_both_the_sent_copy_and_the_courts_copy(): + filing = described(accepted_detail()) + + attachments = filing["documents"][0]["attachments"] + assert [attachment["label"] for attachment in attachments] == [ + "The copy you sent", + "The court's file-stamped copy", + ] + assert [attachment["filename"] for attachment in attachments] == ["appearance.pdf", "appearance.pdf"] + assert attachments[1]["url"] == "https://example.tylertech.cloud/two" + + +def test_a_pending_filing_does_not_call_the_courts_copy_file_stamped(): + pending = rejected_detail() + pending["filingStatus"] = {"filingStatusCode": "under-review", "statusDescriptionText": []} + pending["filingLeadDocument"][0]["documentRendition"][0]["documentRenditionMetadata"]["documentAttachment"] = [ + _attachment("Transmitted - appearance.pdf", "https://example.tylertech.cloud/two"), + ] + + filing = described(pending) + + assert filing["documents"][0]["attachments"][0]["label"] == "The copy the court received" + + +def test_identifiers_case_and_fees_come_through(): + filing = described(rejected_detail(), {"kane": "Kane County"}) + + assert filing["filing_id"] == "94e86d5d-de80-454d-a47c-ecd017d22e3a" + assert filing["envelope_id"] == "275057" + assert filing["court_name"] == "Kane County" + assert filing["docket_number"] == "2017-L-000278" + assert filing["submitted_at"].year == 2023 + # Only the charges that cost something are worth showing. + assert filing["fees"] == [{"reason": "Total Court Filing Fees", "amount": "89.50"}] + + +def test_a_payload_the_court_never_filled_in_still_describes_something(): + filing = described({"filingStatus": {"filingStatusCode": "submitted"}}) + + assert filing["documents"] == [] + assert filing["comments"] == [] + assert filing["status_presentation"]["label"] == "Waiting on the court" + assert describe_filing_detail(None) is None + + +def test_an_unfamiliar_status_code_is_described_rather_than_dropped(): + assert status_presentation("something-tyler-added-yesterday")["label"] == "Sent to the court" + + +def test_court_contact_falls_back_to_the_jurisdictions_help_line(): + # Massachusetts is the shipped config with jurisdiction-level help details. + contact = court_contact("massachusetts", "no-such-court") + + assert contact["phone"] == "711" + assert contact["url"].startswith("https://") + + +# ------------------------------------------------------------------- My cases + + +@pytest.mark.django_db +def test_filings_are_grouped_into_the_cases_they_belong_to(rf, user): + request = rf.get(CASES_URL) + request.user = user + rows = [ + filing_row(filing_id="filing-1", received_timestamp=1677189792000), + filing_row(filing_id="filing-2", filing_status="rejected", received_timestamp=1677276192000), + filing_row(filing_id="filing-3", case_tracking_id="case-b", case_number="2024-EV-000999"), + ] + + with ( + patch("efile.services.filings.list_filing_data", return_value=rows), + patch("efile.services.filings.court_names", return_value={"kane": "Kane County"}), + ): + cases = cases_for_user(request, "illinois") + + assert [case["docket_number"] for case in cases] == ["2024-EV-000123", "2024-EV-000999"] + first = cases[0] + assert first["filing_count"] == 2 + assert first["court_name"] == "Kane County" + # Newest filing first, and it is the one the case's status reflects. + assert first["filings"][0]["filing_id"] == "filing-2" + assert first["latest_status"]["label"] == "Rejected" + + +@pytest.mark.django_db +def test_a_filing_with_no_case_yet_keeps_its_own_entry(rf, user): + request = rf.get(CASES_URL) + request.user = user + rows = [filing_row(case_tracking_id="", case_number="", case_title="", filing_status="rejected")] + + with ( + patch("efile.services.filings.list_filing_data", return_value=rows), + patch("efile.services.filings.court_names", return_value={}), + ): + cases = cases_for_user(request, "illinois") + + assert len(cases) == 1 + assert cases[0]["case_tracking_id"] == "" + + +@pytest.mark.django_db +def test_archiving_a_case_takes_it_out_of_the_list_without_losing_it(client, user): + sign_in(client, user) + rows = [filing_row(), filing_row(filing_id="filing-3", case_tracking_id="case-b", case_number="2024-EV-000999")] + + with ( + patch("efile.services.filings.list_filing_data", return_value=rows), + patch("efile.services.filings.court_names", return_value={}), + ): + response = client.post( + CASES_URL, + {"action": "archive", "case_tracking_id": "case-a", "docket_number": "2024-EV-000123"}, + follow=True, + ) + assert response.status_code == 200 + assert ArchivedCase.objects.filter(user=user, case_tracking_id="case-a").exists() + + listed = client.get(CASES_URL) + assert [case["docket_number"] for case in listed.context["cases"]] == ["2024-EV-000999"] + assert listed.context["archived_count"] == 1 + + archived = client.get(f"{CASES_URL}?archived=1") + assert [case["docket_number"] for case in archived.context["cases"]] == ["2024-EV-000123"] + + +@pytest.mark.django_db +def test_a_case_can_come_back_out_of_the_archive(client, user): + sign_in(client, user) + archive_case(user, "illinois", "case-a", docket_number="2024-EV-000123") + + with ( + patch("efile.services.filings.list_filing_data", return_value=[filing_row()]), + patch("efile.services.filings.court_names", return_value={}), + ): + client.post(CASES_URL, {"action": "unarchive", "case_tracking_id": "case-a"}, follow=True) + listed = client.get(CASES_URL) + + assert not ArchivedCase.objects.filter(user=user).exists() + assert [case["docket_number"] for case in listed.context["cases"]] == ["2024-EV-000123"] + + +@pytest.mark.django_db +def test_one_filers_archive_does_not_touch_anothers(client, user, django_user_model): + other = django_user_model.objects.create_user(username="other-filer", tyler_jurisdiction="illinois") + archive_case(other, "illinois", "case-a", docket_number="2024-EV-000123") + sign_in(client, user) + + with ( + patch("efile.services.filings.list_filing_data", return_value=[filing_row()]), + patch("efile.services.filings.court_names", return_value={}), + ): + listed = client.get(CASES_URL) + + assert [case["docket_number"] for case in listed.context["cases"]] == ["2024-EV-000123"] + + +@pytest.mark.django_db +def test_the_court_being_unreachable_says_so_instead_of_erroring(client, user): + sign_in(client, user) + + with patch("efile.services.filings.list_filing_data", side_effect=ValueError("bad JSON")): + response = client.get(CASES_URL) + + assert response.status_code == 200 + assert response.context["lookup_failed"] is True + + +@pytest.mark.django_db +def test_filing_detail_shows_the_clerks_comment_and_the_documents(client, user): + sign_in(client, user) + url = reverse( + "filing_detail", + kwargs={"jurisdiction": "illinois", "court_code": "kane", "filing_id": "filing-1"}, + ) + + with ( + patch("efile.views.my_cases.fetch_filing_detail", return_value=accepted_detail()), + patch("efile.views.my_cases.court_names", return_value={"kane": "Kane County"}), + ): + response = client.get(url) + + content = response.content.decode() + assert response.status_code == 200 + assert "Please refile with the case number on page 1." not in content # accepted filings have no reject comment + assert "The court's file-stamped copy" in content + assert "https://example.tylertech.cloud/two" in content + assert "Kane County" in content + + +@pytest.mark.django_db +def test_filing_detail_says_so_when_the_court_will_not_answer(client, user): + sign_in(client, user) + url = reverse( + "filing_detail", + kwargs={"jurisdiction": "illinois", "court_code": "kane", "filing_id": "filing-1"}, + ) + + with patch("efile.views.my_cases.fetch_filing_detail", return_value=None): + response = client.get(url) + + assert response.status_code == 200 + assert "could not get this filing" in response.content.decode() + + +@pytest.mark.django_db +def test_my_cases_needs_a_signed_in_filer(client): + response = client.get(CASES_URL) + + assert response.status_code == 302 + assert "/login/" in response.url diff --git a/efile_app/efile/tests/test_filing_plan_actions.py b/efile_app/efile/tests/test_filing_plan_actions.py index 6d69e3d..4226524 100644 --- a/efile_app/efile/tests/test_filing_plan_actions.py +++ b/efile_app/efile/tests/test_filing_plan_actions.py @@ -467,6 +467,27 @@ def test_i_can_rename_a_plan_to_something_i_recognize(client, signed_in): assert plan.title == "My child's name change" +@pytest.mark.django_db +def test_i_can_delete_a_plan_i_am_done_with(client, signed_in): + """The list is the filer's own, so clearing something off it is theirs to do.""" + + plan = configured_plan(signed_in) + draft = signed_in + draft.plan = plan + draft.save(update_fields=["plan"]) + + page = client.get(PLANS_URL).content.decode() + assert "Delete this plan" in page + + client.post(PLANS_URL, {"action": "delete", "plan_id": plan.pk}) + + assert not FilingPlan.objects.filter(pk=plan.pk).exists() + # The filing made under it is not collateral damage. + draft.refresh_from_db() + assert draft.plan_id is None + assert FilingDraft.objects.filter(pk=draft.pk).exists() + + @pytest.mark.django_db def test_i_cannot_touch_someone_elses_plan(client, signed_in, django_user_model): plan = configured_plan(signed_in) diff --git a/efile_app/efile/tests/test_integration.py b/efile_app/efile/tests/test_integration.py index eb8e1d9..f7a8343 100644 --- a/efile_app/efile/tests/test_integration.py +++ b/efile_app/efile/tests/test_integration.py @@ -112,19 +112,6 @@ def test_case_categories_api_basic_functionality(self): assert "data" in data assert isinstance(data["data"], list) - def test_form_page_loads(self): - """Test that the expert form page loads without errors.""" - client = Client() - - # Test the form page (might need authentication) - try: - response = client.get("/illinois/expert-form/") - # Page should load (200) or redirect to login (302) - assert response.status_code in [200, 302, 404] # 404 if route doesn't exist - except Exception: - # If the route doesn't exist, that's okay for this test - assert True - def test_login_page_functionality(self): """Test that login functionality works.""" client = Client() @@ -160,51 +147,3 @@ def test_dropdown_api_views_can_be_imported(self): assert hasattr(DropdownAPIViews, "_prioritize_courts_by_location") assert hasattr(DropdownAPIViews, "get_case_categories") assert hasattr(DropdownAPIViews, "get_courts") - - -class TestJavaScriptFileStructure: - """Test that our refactored JavaScript files exist.""" - - def test_javascript_files_exist(self): - """Test that all required JavaScript files exist.""" - import os - - from django.conf import settings - - # Get the static files directory - static_root = os.path.join(settings.BASE_DIR, "efile", "static", "js") - - required_files = [ - "api-utils.js", - "cascading-dropdowns.js", - "form-validation.js", - "expert-form-main.js", - "README.md", - ] - - for filename in required_files: - file_path = os.path.join(static_root, filename) - assert os.path.exists(file_path), f"Required file {filename} not found at {file_path}" - - def test_javascript_files_have_content(self): - """Test that JavaScript files contain expected content.""" - import os - - from django.conf import settings - - static_root = os.path.join(settings.BASE_DIR, "efile", "static", "js") - - # Test that files contain expected classes/functions - tests = [ - ("api-utils.js", "class ApiUtils"), - ("cascading-dropdowns.js", "class CascadingDropdowns"), - ("form-validation.js", "class FormValidation"), - ("expert-form-main.js", "class ExpertForm"), - ] - - for filename, expected_content in tests: - file_path = os.path.join(static_root, filename) - if os.path.exists(file_path): - with open(file_path) as f: - content = f.read() - assert expected_content in content, f"{filename} should contain '{expected_content}'" diff --git a/efile_app/efile/tests/test_my_drafts.py b/efile_app/efile/tests/test_my_drafts.py new file mode 100644 index 0000000..922ff2a --- /dev/null +++ b/efile_app/efile/tests/test_my_drafts.py @@ -0,0 +1,137 @@ +"""The filer's own list of unsent filings. + +A draft nobody can see is a draft nobody can finish or throw away. These tests +describe the page that lists them: what it says about each one, which filing +resuming actually opens, and what "throw this away" does to the rest. +""" + +import pytest +from django.urls import reverse + +from efile.models import FilingDocument, FilingDraft, FilingPlan +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY +from efile.workflow import ExistingCase, WorkflowStepKey + +DRAFTS_URL = reverse("my_drafts", kwargs={"jurisdiction": "illinois"}) + + +@pytest.fixture +def user(django_user_model): + return django_user_model.objects.create_user(username="drafts-user", tyler_jurisdiction="illinois") + + +def sign_in(client, user): + client.force_login(user) + session = client.session + session["jurisdiction"] = "illinois" + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "token"} + session.save() + + +def make_draft(user, **overrides): + fields = { + "user": user, + "jurisdiction": "illinois", + "existing_case": ExistingCase.NEW, + "current_step": WorkflowStepKey.UPLOAD_DOCUMENTS, + "workflow_version": 2, + } + fields.update(overrides) + return FilingDraft.objects.create(**fields) + + +@pytest.mark.django_db +def test_the_list_says_enough_to_tell_two_drafts_apart(client, user): + sign_in(client, user) + plan = FilingPlan.objects.create(user=user, jurisdiction="illinois", title="My name change") + make_draft(user, plan=plan, court_name="Cook County Circuit Court") + make_draft( + user, + existing_case=ExistingCase.EXISTING, + docket_number="2024-EV-000123", + case_title="Blue Harbor LLC v. Ada Torres", + current_step=WorkflowStepKey.REVIEW, + ) + + content = client.get(DRAFTS_URL).content.decode() + + assert "My name change" in content + assert "Blue Harbor LLC v. Ada Torres" in content + assert "2024-EV-000123" in content + assert "Filing into an existing case" in content + assert "Cook County Circuit Court" in content + assert "Review" in content # where each one left off + + +@pytest.mark.django_db +def test_resuming_from_the_list_opens_that_draft_and_not_the_newest(client, user): + sign_in(client, user) + older = make_draft(user, case_title="The one I want") + make_draft(user, case_title="The one I touched last") + + response = client.post(DRAFTS_URL, {"action": "resume", "draft_id": older.pk}) + + assert response.status_code == 302 + assert f"draft={older.pk}" in response.url + assert client.session[CURRENT_DRAFT_SESSION_KEY] == older.pk + + +@pytest.mark.django_db +def test_throwing_a_draft_away_takes_it_out_of_every_list(client, user): + sign_in(client, user) + draft = make_draft(user, case_title="Started by mistake") + FilingDocument.objects.create(draft=draft, role=FilingDocument.Role.LEAD, sort_order=0, name="petition.pdf") + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session.save() + + client.post(DRAFTS_URL, {"action": "delete", "draft_id": draft.pk}, follow=True) + + draft.refresh_from_db() + assert draft.status == FilingDraft.Status.ABANDONED + # The browser is no longer pointed at a filing that no longer exists for it. + assert CURRENT_DRAFT_SESSION_KEY not in client.session + listed = client.get(DRAFTS_URL) + assert listed.context["drafts"] == [] + assert "no filings in progress" in listed.content.decode() + + +@pytest.mark.django_db +def test_a_submitted_filing_is_not_offered_as_a_draft(client, user): + sign_in(client, user) + make_draft(user, status=FilingDraft.Status.SUBMITTED, case_title="Already sent") + + assert client.get(DRAFTS_URL).context["drafts"] == [] + + +@pytest.mark.django_db +def test_a_filer_cannot_touch_someone_elses_draft(client, user, django_user_model): + other = django_user_model.objects.create_user(username="other-drafter", tyler_jurisdiction="illinois") + theirs = make_draft(other, case_title="Not yours") + sign_in(client, user) + + response = client.post(DRAFTS_URL, {"action": "delete", "draft_id": theirs.pk}, follow=True) + + theirs.refresh_from_db() + assert theirs.status == FilingDraft.Status.DRAFT + assert "no longer here" in response.content.decode() + + +@pytest.mark.django_db +def test_the_options_page_sends_a_filer_with_several_drafts_to_the_list(client, user): + sign_in(client, user) + make_draft(user) + make_draft(user) + + response = client.get(reverse("efile_options", kwargs={"jurisdiction": "illinois"})) + + assert response.context["draft_count"] == 2 + assert DRAFTS_URL in response.content.decode() + + +@pytest.mark.django_db +def test_my_drafts_needs_a_signed_in_filer(client): + response = client.get(DRAFTS_URL) + + assert response.status_code == 302 + assert "/login/" in response.url diff --git a/efile_app/efile/tests/test_navigation_menu.py b/efile_app/efile/tests/test_navigation_menu.py new file mode 100644 index 0000000..f4455d4 --- /dev/null +++ b/efile_app/efile/tests/test_navigation_menu.py @@ -0,0 +1,155 @@ +"""Getting somewhere else from wherever the filer is. + +The complaint behind this was simple: part way through a filing there was no way +back to a home screen, to another filing, or to a fresh start without hunting for +a URL. Every screen now carries the same menu, and the two things people came to +do -- start a case, file into one they already have -- are one click from +anywhere. +""" + +import pytest +from django.urls import reverse + +from efile.models import FilingDraft +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY +from efile.workflow import ExistingCase, WorkflowStepKey + +OPTIONS_URL = reverse("efile_options", kwargs={"jurisdiction": "illinois"}) +START_URL = reverse("start_filing", kwargs={"jurisdiction": "illinois"}) +UPLOAD_URL = reverse("upload_documents", kwargs={"jurisdiction": "illinois"}) +FILING_PATH_URL = reverse("filing_path", kwargs={"jurisdiction": "illinois"}) + + +@pytest.fixture +def user(django_user_model): + return django_user_model.objects.create_user(username="nav-user", tyler_jurisdiction="illinois") + + +def sign_in(client, user): + client.force_login(user) + session = client.session + session["jurisdiction"] = "illinois" + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "token"} + session.save() + + +@pytest.mark.django_db +def test_every_signed_in_screen_carries_the_filing_menu(client, user): + sign_in(client, user) + + content = client.get(OPTIONS_URL).content.decode() + + assert "Start a new case" in content + assert "File into an existing case" in content + assert "My draft e-filings" in content + assert "My cases" in content + assert "My filing plans" in content + assert reverse("my_drafts", kwargs={"jurisdiction": "illinois"}) in content + assert reverse("filing_statuses", kwargs={"jurisdiction": "illinois"}) in content + + +@pytest.mark.django_db +def test_the_menu_is_there_part_way_through_a_filing_too(client, user): + """The whole complaint: mid-filing there was no way anywhere else.""" + + sign_in(client, user) + + content = client.get(FILING_PATH_URL).content.decode() + + assert "Start a new case" in content + assert "My draft e-filings" in content + assert START_URL in content + + +@pytest.mark.django_db +def test_the_menu_is_not_offered_to_someone_who_is_not_signed_in(client): + content = client.get(OPTIONS_URL).content.decode() + + assert "My draft e-filings" not in content + assert "Sign in" in content + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("chosen", "expected"), + [("new", ExistingCase.NEW), ("existing", ExistingCase.EXISTING)], +) +def test_starting_a_filing_from_the_menu_knows_which_kind_it_is(client, user, chosen, expected): + sign_in(client, user) + + response = client.post(START_URL, {"existing_case": chosen}) + + draft = FilingDraft.objects.get(user=user) + assert response.status_code == 302 + assert response.url == UPLOAD_URL + assert draft.existing_case == expected + assert draft.current_step == WorkflowStepKey.UPLOAD_DOCUMENTS + # The filing the filer just asked for is the one they are now in. + assert client.session[CURRENT_DRAFT_SESSION_KEY] == draft.pk + + +@pytest.mark.django_db +def test_starting_a_filing_without_saying_which_kind_asks(client, user): + sign_in(client, user) + + response = client.post(START_URL, {}) + + assert response.url == FILING_PATH_URL + assert FilingDraft.objects.get(user=user).existing_case == "" + + +@pytest.mark.django_db +def test_starting_a_filing_never_reopens_the_last_one(client, user): + """The way out of a draft is to start another one, so it must be another one.""" + + sign_in(client, user) + old = FilingDraft.objects.create( + user=user, + jurisdiction="illinois", + existing_case=ExistingCase.EXISTING, + docket_number="2024-EV-000123", + current_step=WorkflowStepKey.REVIEW, + ) + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = old.pk + session.save() + + client.post(START_URL, {"existing_case": "new"}) + + current_id = client.session[CURRENT_DRAFT_SESSION_KEY] + assert current_id != old.pk + assert FilingDraft.objects.get(pk=current_id).docket_number == "" + old.refresh_from_db() + assert old.status == FilingDraft.Status.DRAFT # the old one is left alone, not thrown away + + +@pytest.mark.django_db +def test_starting_a_filing_needs_a_signed_in_filer(client): + response = client.post(START_URL, {"existing_case": "new"}) + + assert response.status_code == 302 + assert "/login/" in response.url + assert not FilingDraft.objects.exists() + + +@pytest.mark.django_db +def test_starting_a_filing_needs_the_jurisdiction_signed_in_too(client, user): + """Django login is not Tyler login: without the court's token there is nothing to file with.""" + + client.force_login(user) + + response = client.post(START_URL, {"existing_case": "new"}) + + assert response.status_code == 302 + assert "/login/" in response.url + assert not FilingDraft.objects.exists() + + +@pytest.mark.django_db +def test_the_menu_does_not_start_filings_on_a_get(client, user): + sign_in(client, user) + + response = client.get(START_URL) + + assert response.status_code == 405 + assert not FilingDraft.objects.exists() diff --git a/efile_app/efile/tests/tests.py b/efile_app/efile/tests/tests.py index b1b2194..2d0e37a 100644 --- a/efile_app/efile/tests/tests.py +++ b/efile_app/efile/tests/tests.py @@ -457,26 +457,6 @@ def mock_api_response(*args, **kwargs): ) assert response.status_code == 200 - def test_form_submission_validation(self, authenticated_client): - """Test form submission with validation.""" - response = authenticated_client.post( - "/jurisdiction/illinois/expert_form/", - { - "court": "cook:law1", - "case_category": "civil", - "case_type": "contract", - "filing_type": "complaint", - "document_type": "motion", - "petitioner_first_name": "John", - "petitioner_last_name": "Doe", - "new_first_name": "Jane", - "new_last_name": "Smith", - }, - ) - - # Should redirect or show success (depending on implementation) - assert response.status_code in [200, 302] - # ============================================================================ # ERROR HANDLING TESTS diff --git a/efile_app/efile/urls.py b/efile_app/efile/urls.py index 66e51c9..c3c8e37 100644 --- a/efile_app/efile/urls.py +++ b/efile_app/efile/urls.py @@ -12,13 +12,14 @@ from .views.choose_jurisdiction import change_jurisdiction, choose_jurisdiction from .views.confirmation import filing_confirmation from .views.document_checklist import document_checklist -from .views.draft_views import create_draft_view, get_current_draft_view, start_filing_from_plan +from .views.draft_views import get_current_draft_view, start_filing, start_filing_from_plan from .views.extraction_review import extraction_review from .views.filing_path import filing_path from .views.filing_plans import filing_plans -from .views.filing_statuses import filing_statuses from .views.legacy_workflow import legacy_workflow_redirect from .views.login import efile_login, efile_logout, efile_password_reset +from .views.my_cases import filing_detail, filing_statuses +from .views.my_drafts import my_drafts from .views.options import efile_options from .views.organize_documents import organize_documents from .views.parties import parties @@ -33,8 +34,6 @@ fetch_and_save_party_type, get_upload_data_from_session, save_party_type_to_session, - save_upload_data_to_session, - save_upload_first_data, ) from .views.submission import submit_final_filing from .views.upload_documents import upload_documents @@ -79,7 +78,6 @@ def jurisdiction_homepage(request, jurisdiction): path("jurisdiction//parties/", parties, name="parties"), path("jurisdiction//party-details/", party_details, name="party_details"), path("jurisdiction//case-questions/", case_questions, name="case_questions"), - path("jurisdiction//drafts/", create_draft_view, name="create_draft"), path("jurisdiction//plans/", filing_plans, name="filing_plans"), path( "jurisdiction//plans//filings/", @@ -87,6 +85,13 @@ def jurisdiction_homepage(request, jurisdiction): name="start_filing_from_plan", ), path("jurisdiction//filing_statuses/", filing_statuses, name="filing_statuses"), + path( + "jurisdiction//filings///", + filing_detail, + name="filing_detail", + ), + path("jurisdiction//my-drafts/", my_drafts, name="my_drafts"), + path("jurisdiction//start-filing/", start_filing, name="start_filing"), path( "jurisdiction//expert_form/", legacy_workflow_redirect, @@ -113,15 +118,12 @@ def jurisdiction_homepage(request, jurisdiction): path("api/get-filing-components/", get_filing_components, name="get_filing_components"), path("api/draft/", get_current_draft_view, name="get_current_draft"), path("api/save-case-data/", api_save_case_data, name="save_case_data_api"), - path("api/save-upload-data/", save_upload_data_to_session, name="save_upload_data_to_session"), - path("api/save-upload-data-first/", save_upload_first_data, name="save_upload_data_to_session"), path("api/get-upload-data/", get_upload_data_from_session, name="get_upload_data_from_session"), path("api/fetch-party-type/", fetch_and_save_party_type, name="fetch_party_type"), path("api/save-party-type/", save_party_type_to_session, name="save_party_type"), path("api/submit-final-filing/", submit_final_filing, name="submit_final_filing"), path("api/clear-session/", clear_session_data, name="clear_session_data"), path("api/debug-session/", debug_session_data, name="debug_session_data"), - path("api/debug-session-data/", debug_session_data, name="debug_session_data"), # API endpoints for dropdowns path("api/", include("efile.api.urls")), # Legacy endpoints for backward compatibility (can be removed later) diff --git a/efile_app/efile/views/draft_views.py b/efile_app/efile/views/draft_views.py index 60768af..95543e2 100644 --- a/efile_app/efile/views/draft_views.py +++ b/efile_app/efile/views/draft_views.py @@ -1,4 +1,3 @@ -import json import logging from django.contrib import messages @@ -9,30 +8,32 @@ from efile.api.suffolk_api_views import get_tyler_token from efile.models import FilingPlan from efile.services.current_drafts import attach_current_draft, create_current_draft, get_current_draft -from efile.services.drafts import draft_snapshot +from efile.services.drafts import draft_snapshot, write_case_data from efile.services.filing_plans import create_draft_from_plan from efile.utils.django_helpers import flush_cache_stay_logged_in -from efile.workflow import WorkflowStepKey, get_step_url +from efile.workflow import ExistingCase, WorkflowStepKey, get_step_url, normalize_existing_case logger = logging.getLogger(__name__) @require_http_methods(["POST"]) -def create_draft_view(request, jurisdiction): - """Start a durable draft for the current user/session.""" +def start_filing(request, jurisdiction): + """Start a filing from anywhere, already knowing which kind it is. - if not request.user.is_authenticated: - return JsonResponse({"success": False, "error": "Authentication required"}, status=401) - if not get_tyler_token(request, jurisdiction): - return JsonResponse({"success": False, "error": "Jurisdiction authorization required"}, status=403) + The navigation menu offers "Start a new case" and "File into an existing + case" as two destinations, because that is how filers describe what they + are about to do. Both land on the same workflow; naming the path here just + saves answering the first question again. + + Starting a filing always makes a new one. Nothing is carried over from the + filing the browser was last pointed at, which is the whole point of the + menu item: it is how a filer gets out of a draft they no longer want. + """ - try: - payload = json.loads(request.body or "{}") - except json.JSONDecodeError: - return JsonResponse({"success": False, "error": "Invalid JSON data"}, status=400) - if not isinstance(payload, dict): - return JsonResponse({"success": False, "error": "JSON body must be an object"}, status=400) + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + path = normalize_existing_case(request.POST.get("existing_case")) flush_cache_stay_logged_in(request.session) draft = create_current_draft( request, @@ -40,15 +41,13 @@ def create_draft_view(request, jurisdiction): current_step=WorkflowStepKey.FILING_PATH, workflow_version=2, ) - logger.info("Created durable draft id=%s jurisdiction=%s", draft.pk, jurisdiction) - - return JsonResponse( - { - "success": True, - "data": {"filing_draft": draft_snapshot(draft)}, - "redirect_url": get_step_url(WorkflowStepKey.FILING_PATH, jurisdiction), - } - ) + logger.info("Started draft id=%s from the navigation menu (path=%s)", draft.pk, path or "unset") + + if path in {ExistingCase.NEW, ExistingCase.EXISTING}: + write_case_data(draft, {"existing_case": path}, current_step=WorkflowStepKey.UPLOAD_DOCUMENTS) + return redirect(get_step_url(WorkflowStepKey.UPLOAD_DOCUMENTS, jurisdiction)) + # No path named (or one we do not recognize): ask, rather than guess. + return redirect(get_step_url(WorkflowStepKey.FILING_PATH, jurisdiction)) @require_http_methods(["POST"]) diff --git a/efile_app/efile/views/expert_form.py b/efile_app/efile/views/expert_form.py deleted file mode 100644 index 87772f1..0000000 --- a/efile_app/efile/views/expert_form.py +++ /dev/null @@ -1,70 +0,0 @@ -import logging - -from django.shortcuts import redirect, render - -from efile.api.suffolk_api_views import get_tyler_token -from efile.services.current_drafts import ensure_current_draft -from efile.services.drafts import draft_snapshot - -from ..utils.case_data_utils import get_upload_data -from ..workflow import WorkflowStepKey, get_workflow_context - -logger = logging.getLogger(__name__) - - -def efile_expert_form(request, jurisdiction): - """Expert form view for creating filings with cascading dropdowns.""" - # Log all GET parameters for debugging - logger.debug(f"Expert form accessed with GET parameters: {dict(request.GET)}") - - if not request.user.is_authenticated: - return redirect("efile_login", jurisdiction=jurisdiction) - - if not get_tyler_token(request, jurisdiction): - return redirect("efile_login", jurisdiction=jurisdiction) - - filing_draft = ensure_current_draft(request, jurisdiction, current_step=WorkflowStepKey.CASE_INFORMATION) - - # Get auth tokens from session if available - auth_tokens = request.session.get("auth_tokens", None) - # Log only presence/keys, not token values - if auth_tokens: - logger.debug("Auth tokens present with keys=%s", list(auth_tokens.keys())) - else: - logger.debug("No auth tokens in session") - - # Get existing case data from session to populate form - case_data = request.session.get("case_data", {}) - - logger.debug(f"Case data from session: {case_data}") - logger.debug(f"All session data keys: {list(request.session.keys())}") - logger.debug(f"Clear session parameter received: {request.GET.get('clear_session', 'not present')}") - - upload_data = get_upload_data(request, jurisdiction) - - # Check if we have all required data for upload - required_fields = ["court", "case_category", "case_type", "filing_type", "document_type"] - has_all_required = all(case_data.get(field) for field in required_fields) - - # For name change cases, also check for party information - has_party_info = True # Default for non-name change cases - if has_all_required and "name change" in case_data.get("case_type", "").lower(): - party_fields = ["petitioner_first_name", "petitioner_last_name", "new_first_name", "new_last_name"] - has_party_info = all(case_data.get(field) for field in party_fields) - - # Display the form for data collection with existing data populated - context = { - "is_logged_in": True, - "case_data": case_data, - "filing_draft": draft_snapshot(filing_draft), - "guessed_court": upload_data.get("guesses", {}).get("court"), - "guessed_case_category": upload_data.get("guesses", {}).get("case type"), - "guessed_case_type": upload_data.get("guesses", {}).get("case category"), - "auth_tokens": auth_tokens, - "can_proceed_to_upload": has_all_required and has_party_info, - "missing_required_fields": not has_all_required, - "missing_party_info": has_all_required and not has_party_info, - } - context.update(get_workflow_context(WorkflowStepKey.CASE_INFORMATION, jurisdiction, filing_draft)) - - return render(request, "efile/expert_form.html", context) diff --git a/efile_app/efile/views/filing_plans.py b/efile_app/efile/views/filing_plans.py index 80c6e39..2684c29 100644 --- a/efile_app/efile/views/filing_plans.py +++ b/efile_app/efile/views/filing_plans.py @@ -6,8 +6,6 @@ workflow, so nothing here has to be finished before something else can start. """ -from datetime import date - import requests from django.contrib import messages from django.shortcuts import get_object_or_404, redirect, render @@ -24,20 +22,13 @@ set_checklist_answers, status_choices, ) +from efile.services.filings import history_start_date def _plan_for(request, jurisdiction, plan_id): return get_object_or_404(FilingPlan, pk=plan_id, user=request.user, jurisdiction=jurisdiction) -def _five_years_ago(): - today = date.today() - try: - return today.replace(year=today.year - 5).isoformat() - except ValueError: # February 29 - return today.replace(year=today.year - 5, day=28).isoformat() - - @require_http_methods(["GET", "POST"]) def filing_plans(request, jurisdiction): if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): @@ -68,7 +59,7 @@ def filing_plans(request, jurisdiction): request, jurisdiction, case_tracking_id, - start_date=_five_years_ago(), + start_date=history_start_date(), ) except (requests.RequestException, TypeError, ValueError, KeyError): messages.error(request, "We could not verify your court cases. Try again shortly.") @@ -94,6 +85,13 @@ def filing_plans(request, jurisdiction): elif action == "unlink_case": link_case_to_plan(plan, case_tracking_id="", docket_number="", case_title="") messages.success(request, f"{plan.title} is no longer linked to a court case.") + elif action == "delete": + # A plan is the filer's own list, so throwing it away is theirs to + # decide. Filings made under it keep their own record: the draft's + # link to the plan is nulled, not cascaded. + title = plan.title + plan.delete() + messages.success(request, f"We deleted {title}. Filings you already made are still in My cases.") return redirect("filing_plans", jurisdiction=jurisdiction) diff --git a/efile_app/efile/views/filing_statuses.py b/efile_app/efile/views/filing_statuses.py deleted file mode 100644 index 945ff2b..0000000 --- a/efile_app/efile/views/filing_statuses.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.shortcuts import redirect, render - -from efile.api.suffolk_api_views import get_tyler_token - -from ..utils.case_data_utils import get_case_data - - -def filing_statuses(request, jurisdiction): - """Options view that displays saved case data and provides next steps.""" - if not request.user.is_authenticated: - return redirect("efile_login", jurisdiction=jurisdiction) - - # Get case data from session - case_data = get_case_data(request, jurisdiction) - - is_logged_in = request.user.is_authenticated - if not get_tyler_token(request, jurisdiction): - is_logged_in = False - # Pass case data to template for display - context = { - "is_logged_in": is_logged_in, - "case_data": case_data, - "has_case_data": bool(case_data), - } - - return render(request, "efile/view_statuses.html", context) diff --git a/efile_app/efile/views/my_cases.py b/efile_app/efile/views/my_cases.py new file mode 100644 index 0000000..f1e0353 --- /dev/null +++ b/efile_app/efile/views/my_cases.py @@ -0,0 +1,126 @@ +"""My cases: the court's record of what this account has filed. + +The list is the court's, not ours -- we hold nothing here except which cases the +filer has archived. Archiving matters because the list only grows: an attorney +with three hundred filings should be able to say "not this one, not any more" +without losing the ability to go back and look. +""" + +import logging + +import requests +from django.contrib import messages +from django.shortcuts import redirect, render +from django.urls import reverse +from django.views.decorators.http import require_http_methods + +from efile.api.suffolk_api_views import get_tyler_token +from efile.services.filings import ( + DOCUMENT_LINK_DAYS, + archive_case, + cases_for_user, + court_contact, + court_names, + describe_filing_detail, + fetch_filing_detail, + unarchive_case, +) + +logger = logging.getLogger(__name__) + +SHOW_ARCHIVED_PARAM = "archived" + + +@require_http_methods(["GET", "POST"]) +def filing_statuses(request, jurisdiction): + """List the filer's court cases, newest activity first.""" + + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + showing_archived = request.GET.get(SHOW_ARCHIVED_PARAM) == "1" + + if request.method == "POST": + showing_archived = request.POST.get(SHOW_ARCHIVED_PARAM) == "1" + action = request.POST.get("action") + case_tracking_id = (request.POST.get("case_tracking_id") or "").strip() + docket_number = request.POST.get("docket_number") or "" + case_name = docket_number or request.POST.get("case_title") or "This case" + if not case_tracking_id: + messages.error(request, "We could not tell which case you meant.") + elif action == "archive": + archive_case( + request.user, + jurisdiction, + case_tracking_id, + docket_number=docket_number, + case_title=request.POST.get("case_title") or "", + ) + messages.success(request, f"{case_name} is archived. It is still here under archived cases.") + elif action == "unarchive": + unarchive_case(request.user, jurisdiction, case_tracking_id) + messages.success(request, f"{case_name} is back in your list of cases.") + else: + messages.error(request, "We did not recognize that action.") + destination = reverse("filing_statuses", kwargs={"jurisdiction": jurisdiction}) + if showing_archived: + destination = f"{destination}?{SHOW_ARCHIVED_PARAM}=1" + return redirect(destination) + + cases = [] + lookup_failed = False + try: + cases = cases_for_user(request, jurisdiction) + except (requests.RequestException, TypeError, ValueError, KeyError, AttributeError): + logger.exception("Could not load filing history for jurisdiction %s", jurisdiction) + lookup_failed = True + + active = [case for case in cases if not case["is_archived"]] + archived = [case for case in cases if case["is_archived"]] + + return render( + request, + "efile/view_statuses.html", + { + "is_logged_in": True, + "cases": archived if showing_archived else active, + "showing_archived": showing_archived, + "active_count": len(active), + "archived_count": len(archived), + "lookup_failed": lookup_failed, + }, + ) + + +def filing_detail(request, jurisdiction, court_code, filing_id): + """Everything the court will tell us about one filing. + + This is where a rejection comment and the filed documents themselves live. + The documents are Tyler's own links, so they are handed to the filer as + links rather than copied through this server. + """ + + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + detail = None + try: + detail = describe_filing_detail( + fetch_filing_detail(request, jurisdiction, court_code, filing_id), + court_names(jurisdiction), + ) + except (requests.RequestException, TypeError, ValueError, KeyError, AttributeError): + logger.exception("Could not load filing detail for %s in %s", filing_id, jurisdiction) + + return render( + request, + "efile/filing_detail.html", + { + "is_logged_in": True, + "filing": detail, + "court_code": court_code, + "filing_id": filing_id, + "court_contact": court_contact(jurisdiction, court_code), + "document_link_days": DOCUMENT_LINK_DAYS, + }, + ) diff --git a/efile_app/efile/views/my_drafts.py b/efile_app/efile/views/my_drafts.py new file mode 100644 index 0000000..7759f19 --- /dev/null +++ b/efile_app/efile/views/my_drafts.py @@ -0,0 +1,90 @@ +"""My draft e-filings: every filing this filer has started and not sent. + +The durable-draft layer has always allowed several drafts at once; until now +there was nowhere to see them. Without this screen a second draft is invisible +-- the filer is offered "continue where you left off" and has no way to know +which filing that is, or to throw away the one they started by mistake. +""" + +import logging + +from django.contrib import messages +from django.shortcuts import redirect, render +from django.urls import reverse +from django.views.decorators.http import require_http_methods + +from efile.api.suffolk_api_views import get_tyler_token +from efile.models import FilingDocument, FilingDraft +from efile.services.current_drafts import ( + CURRENT_DRAFT_SESSION_KEY, + adopt_draft, + clear_current_draft, + pointed_at_draft, +) +from efile.services.drafts import active_drafts_for +from efile.workflow import ExistingCase, get_resume_step_url, get_step + +logger = logging.getLogger(__name__) + +PATH_LABELS = { + ExistingCase.NEW: "Starting a new case", + ExistingCase.EXISTING: "Filing into an existing case", + ExistingCase.UNSURE: "Still deciding which kind of filing this is", +} + + +def _describe(draft: FilingDraft, current_draft_id: int | None) -> dict: + try: + step_label = get_step(draft.current_step).label + except KeyError: + step_label = "" + return { + "draft": draft, + "title": draft.case_title or (draft.plan.title if draft.plan else ""), + "path_label": PATH_LABELS.get(draft.existing_case, ""), + "step_label": step_label, + "resume_url": get_resume_step_url(draft.current_step, draft.jurisdiction, draft_id=draft.pk), + "document_count": FilingDocument.objects.filter(draft=draft).count(), + "is_current": draft.pk == current_draft_id, + } + + +@require_http_methods(["GET", "POST"]) +def my_drafts(request, jurisdiction): + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + if request.method == "POST": + action = request.POST.get("action") + draft_id = request.POST.get("draft_id") + # Ownership is enforced by the queryset, not by trusting the form. + draft = active_drafts_for(request.user, jurisdiction=jurisdiction).filter(pk=draft_id).first() + if draft is None: + messages.error(request, "That draft is no longer here.") + elif action == "resume": + adopt_draft(request, draft.pk, jurisdiction=jurisdiction) + resume_url = get_resume_step_url(draft.current_step, jurisdiction, draft_id=draft.pk) + return redirect(resume_url or reverse("efile_options", kwargs={"jurisdiction": jurisdiction})) + elif action == "delete": + # Abandoned, not deleted: it leaves every list the filer sees, and + # a filer who deleted the wrong thing can still be helped. + draft.status = FilingDraft.Status.ABANDONED + draft.save(update_fields=["status", "updated_at"]) + if str(request.session.get(CURRENT_DRAFT_SESSION_KEY)) == str(draft.pk): + clear_current_draft(request) + logger.info("Filer discarded draft id=%s", draft.pk) + messages.success(request, "We threw that draft away.") + else: + messages.error(request, "We did not recognize that action.") + return redirect("my_drafts", jurisdiction=jurisdiction) + + current = pointed_at_draft(request, jurisdiction=jurisdiction) + drafts = active_drafts_for(request.user, jurisdiction=jurisdiction).select_related("plan") + return render( + request, + "efile/my_drafts.html", + { + "is_logged_in": True, + "drafts": [_describe(draft, current.pk if current else None) for draft in drafts], + }, + ) diff --git a/efile_app/efile/views/options.py b/efile_app/efile/views/options.py index e4e4459..417edd2 100644 --- a/efile_app/efile/views/options.py +++ b/efile_app/efile/views/options.py @@ -1,9 +1,10 @@ from django.middleware.csrf import get_token from django.shortcuts import render +from django.urls import reverse from efile.api.suffolk_api_views import get_tyler_token from efile.services.current_drafts import get_current_draft -from efile.services.drafts import draft_snapshot +from efile.services.drafts import active_drafts_for, draft_snapshot from efile.services.filing_plans import plans_for from ..utils.case_data_utils import get_case_data @@ -19,10 +20,12 @@ def efile_options(request, jurisdiction): case_data = get_case_data(request, jurisdiction) active_draft = get_current_draft(request, jurisdiction=jurisdiction) plans = plans_for(request.user, jurisdiction) + draft_count = active_drafts_for(request.user, jurisdiction=jurisdiction).count() else: case_data = {} active_draft = None plans = [] + draft_count = 0 is_logged_in = request.user.is_authenticated if not get_tyler_token(request, jurisdiction): @@ -39,6 +42,10 @@ def efile_options(request, jurisdiction): draft_id=active_draft.pk if active_draft else None, ), "has_case_data": bool(case_data or active_draft), + # With more than one filing in progress, "continue where you left off" + # cannot mean anything on its own: the filer picks which one. + "draft_count": draft_count, + "drafts_url": reverse("my_drafts", kwargs={"jurisdiction": jurisdiction}), # The three most recently worked on matters. The rest are one click # further on, rather than turning this page into a list of everything. "plans": plans[:3], diff --git a/efile_app/efile/views/session_api.py b/efile_app/efile/views/session_api.py index 32fa11d..c9abf69 100644 --- a/efile_app/efile/views/session_api.py +++ b/efile_app/efile/views/session_api.py @@ -1,19 +1,15 @@ import json import logging -from tempfile import NamedTemporaryFile -import requests from django.conf import settings from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -from ..services.current_drafts import get_current_draft from ..services.efsp_errors import describe_efsp_error from ..services.efsp_payload import PayloadValidationError, prepare_efile_payload from ..services.submission_errors import SubmissionErrorCode -from ..utils.case_data_utils import get_case_data, get_upload_data, update_case_data, update_upload_data -from ..utils.llms import LlmError, extract_fields_from_file +from ..utils.case_data_utils import get_case_data, get_upload_data, update_case_data from ..utils.proxy_connection import get_party_type_code_from_api logger = logging.getLogger(__name__) @@ -137,99 +133,6 @@ def determine_party_type_for_existing_case(case_data): return party_code -@csrf_exempt -@require_http_methods(["POST"]) -def save_upload_first_data(request): - """Save upload data and file information to Django session for review.""" - logger.debug("Received POST request to save upload data") - logger.debug(f"Request body: {request.body.decode('utf-8')}") - - if not request.user.is_authenticated: - return JsonResponse({"success": False, "error": "Authentication required"}, status=401) - - data = json.loads(request.body) - current_draft = get_current_draft(request, resume_latest=False) - jurisdiction_id = ( - data.get("jurisdiction_id") - or (current_draft.jurisdiction if current_draft is not None else None) - or request.session.get("jurisdiction") - or "default" - ) - if data.get("jurisdiction_id"): - request.session["jurisdiction"] = jurisdiction_id - upload_data = {"files": data.get("files", {})} - - try: - url = upload_data["files"]["lead"]["url"] - file_resp = requests.get(url) - with NamedTemporaryFile(delete_on_close=False, suffix=".pdf") as f: - f.write(file_resp.content) - f.close() - - # noqa: E501 - llm_hint = llm_hints.get(jurisdiction_id) - fields: dict[str, str] = llm_fields.get(jurisdiction_id, {}) - found_fields = extract_fields_from_file( - f.name, - fields, - llm_hint=llm_hint, - ) - logger.debug("Found fields: %s", found_fields) - - except LlmError as e: - logger.exception("Error processing upload data to session: %s", e) - found_fields = {} - except Exception as e: - logger.exception("Error saving upload data to session: %s", e) - found_fields = {} - - upload_data["guesses"] = {} - upload_data["guesses"]["court"] = found_fields.get("court name") - upload_data["guesses"]["filing type"] = found_fields.get("filing type") - upload_data["guesses"]["case category"] = found_fields.get("case category") - upload_data["guesses"]["case type"] = found_fields.get("case type") - upload_data["guesses"]["docket number"] = found_fields.get("docket number") - - update_upload_data(request, upload_data, jurisdiction_id) - - logger.info("Persisted lead upload to the current draft") - return JsonResponse({"success": True, "message": "Upload data saved"}) - - -@csrf_exempt -@require_http_methods(["POST"]) -def save_upload_data_to_session(request): - """Persist supporting documents and lead filing config onto the current draft.""" - try: - if not request.user.is_authenticated: - return JsonResponse({"success": False, "error": "Authentication required"}, status=401) - - data = json.loads(request.body) - - upload_data = { - "files": {"supporting": data.get("files", [])}, - # Lead document filing information (updates the already-persisted lead doc) - "lead_filing_type": data.get("lead_filing_type", ""), - "lead_filing_type_name": data.get("lead_filing_type_name", ""), - "lead_document_type": data.get("lead_document_type", ""), - "lead_document_type_name": data.get("lead_document_type_name", ""), - "lead_filing_component": data.get("lead_filing_component", ""), - "lead_filing_component_name": data.get("lead_filing_component_name", ""), - "lead_cc_email": data.get("lead_cc_email", ""), - # Supporting documents filing information - "supporting_documents": data.get("supporting_documents", []), - } - - update_upload_data(request, upload_data) - - logger.info("Persisted supporting documents to the current draft") - return JsonResponse({"success": True, "message": "Upload data saved"}) - - except Exception as e: - logger.exception("Error saving upload data") - return JsonResponse({"success": False, "error": str(e)}, status=500) - - @require_http_methods(["GET"]) def get_upload_data_from_session(request): """Return the current draft's documents as the upload_data blob.""" diff --git a/efile_app/efile/views/upload.py b/efile_app/efile/views/upload.py deleted file mode 100644 index dce8dfd..0000000 --- a/efile_app/efile/views/upload.py +++ /dev/null @@ -1,71 +0,0 @@ -import logging - -from django.contrib import messages -from django.shortcuts import redirect, render -from django.utils.translation import gettext - -from efile.api.suffolk_api_views import get_tyler_token -from efile.services.current_drafts import ensure_current_draft -from efile.services.drafts import draft_snapshot - -from ..utils.case_data_utils import ( - get_case_classification, - get_case_data, - get_name_sought_info, - get_petitioner_info, - get_upload_data, -) -from ..workflow import WorkflowStepKey, get_workflow_context - -logger = logging.getLogger(__name__) - - -def efile_upload(request, jurisdiction): - """Upload view for document submission and filing creation.""" - - if not request.user.is_authenticated: - return redirect("efile_login", jurisdiction=jurisdiction) - - if not get_tyler_token(request, jurisdiction): - return redirect("efile_login", jurisdiction=jurisdiction) - - case_data = get_case_data(request, jurisdiction) - - if not case_data: - messages.error(request, gettext("Please complete the case details first.")) - return redirect("efile_options", jurisdiction=jurisdiction) - - upload_data = get_upload_data(request, jurisdiction) - if not upload_data.get("files", {}).get("lead"): - messages.error(request, gettext("Please upload a lead document before continuing.")) - return redirect("upload_first", jurisdiction=jurisdiction) - - filing_draft = ensure_current_draft(request, jurisdiction, current_step=WorkflowStepKey.DOCUMENTS) - - petitioner_info = get_petitioner_info(request, jurisdiction) - name_sought_info = get_name_sought_info(request, jurisdiction) - case_classification = get_case_classification(request, jurisdiction) - - friendly_case_type = case_data.get("case_type_name", case_classification["case_type"]) - friendly_filing_type = case_data.get("filing_type_name", case_classification["filing_type"]) - friendly_court = case_data.get("court_name", case_classification["court"]) - - context = { - "is_logged_in": True, - "case_data": case_data, - "upload_data": upload_data, - "filing_draft": draft_snapshot(filing_draft), - "petitioner_info": petitioner_info, - "name_sought_info": name_sought_info, - "case_classification": case_classification, - "case_type_name": friendly_case_type, - "filing_type": friendly_filing_type, - "court": friendly_court, - "case_type_raw": case_classification["case_type"], - "category_type_raw": case_classification["case_category"], - "filing_type_raw": case_classification["filing_type"], - "court_raw": case_classification["court"], - } - context.update(get_workflow_context(WorkflowStepKey.DOCUMENTS, jurisdiction, filing_draft)) - - return render(request, "efile/upload.html", context) diff --git a/efile_app/efile/views/upload_first.py b/efile_app/efile/views/upload_first.py deleted file mode 100644 index 950b55c..0000000 --- a/efile_app/efile/views/upload_first.py +++ /dev/null @@ -1,66 +0,0 @@ -import logging -import uuid - -from django.shortcuts import redirect, render - -from efile.api.suffolk_api_views import get_tyler_token -from efile.services.current_drafts import ensure_current_draft -from efile.services.drafts import draft_snapshot - -from ..utils.case_data_utils import ( - get_case_classification, - get_name_sought_info, - get_petitioner_info, - get_upload_data, -) -from ..utils.django_helpers import flush_cache_stay_logged_in -from ..workflow import WorkflowStepKey, get_workflow_context - -logger = logging.getLogger(__name__) - - -def efile_upload_first(request, jurisdiction): - """Upload view for document submission and filing creation.""" - - # Check if user is authenticated first - if not request.user.is_authenticated: - return redirect("efile_login", jurisdiction=jurisdiction) - - if not get_tyler_token(request, jurisdiction): - return redirect("efile_login", jurisdiction=jurisdiction) - - # Check if we need to clear cache (only when explicitly coming from options page button) - clear_session = request.GET.get("clear_session", "false").lower() == "true" - from_options = request.GET.get("from_options", "false").lower() == "true" - - logger.debug(f"Cache clear conditions - clear_session: {clear_session}, from_options: {from_options}") - - if clear_session and from_options: - flush_cache_stay_logged_in(request.session) - - # They are actively starting a new session, so make the base info for that. - request.session["session_id"] = str(uuid.uuid4()) - request.session["jurisdiction"] = jurisdiction - request.session.modified = True - - filing_draft = ensure_current_draft(request, jurisdiction, current_step=WorkflowStepKey.UPLOAD_FIRST) - - # Could visit here from a back button press, so use upload data if any - upload_data = get_upload_data(request, jurisdiction) - - # Get organized case information - petitioner_info = get_petitioner_info(request, jurisdiction) - name_sought_info = get_name_sought_info(request, jurisdiction) - case_classification = get_case_classification(request, jurisdiction) - - context = { - "is_logged_in": True, - "upload_data": upload_data, - "filing_draft": draft_snapshot(filing_draft), - "petitioner_info": petitioner_info, - "name_sought_info": name_sought_info, - "case_classification": case_classification, - } - context.update(get_workflow_context(WorkflowStepKey.UPLOAD_FIRST, jurisdiction, filing_draft)) - - return render(request, "efile/upload_first.html", context) diff --git a/efile_app/js-tests/cascading-dropdowns.test.js b/efile_app/js-tests/cascading-dropdowns.test.js deleted file mode 100644 index dfe84fb..0000000 --- a/efile_app/js-tests/cascading-dropdowns.test.js +++ /dev/null @@ -1,50 +0,0 @@ -const test = require("node:test"); -const assert = require("node:assert/strict"); - -const CascadingDropdowns = require("../efile/static/js/cascading-dropdowns.js"); - -test("missing upload guesses do not break a new filing", async () => { - globalThis.apiUtils = { - getUploadData: async () => ({}), - getCurrentJurisdiction: () => "illinois" - }; - - const dropdowns = new CascadingDropdowns(); - await dropdowns.loadGuesses(); - - assert.deepEqual(dropdowns.guesses, {}); - - globalThis.document = { - getElementById: () => null - }; - - const requested = []; - dropdowns.resetDependentDropdowns = () => {}; - dropdowns.clearAllRecommendationNotices = () => {}; - dropdowns.clearAllDropdownVisualIndicators = () => {}; - dropdowns.validateParameters = () => true; - dropdowns.loadDropdownData = async (...args) => requested.push(args); - - assert.doesNotThrow(() => dropdowns.handleDropdownChange({ - id: "court", - value: "adams" - })); - - assert.equal(requested.length, 1); - assert.equal(requested[0][0], "case_category"); - assert.equal(requested[0][2].guessed_case_category, undefined); - assert.equal(requested[0][2].guessed_case_type, undefined); -}); - -test("failed upload guess requests fall back to an empty guess set", async () => { - globalThis.apiUtils = { - getUploadData: async () => { - throw new Error("network unavailable"); - } - }; - - const dropdowns = new CascadingDropdowns(); - await dropdowns.loadGuesses(); - - assert.deepEqual(dropdowns.guesses, {}); -}); \ No newline at end of file diff --git a/efile_app/tests/expert-form-forfeiture-of-seized-property.spec.js b/efile_app/tests/expert-form-forfeiture-of-seized-property.spec.js deleted file mode 100644 index 03e47ac..0000000 --- a/efile_app/tests/expert-form-forfeiture-of-seized-property.spec.js +++ /dev/null @@ -1,108 +0,0 @@ -const { - test, - expect -} = require('@playwright/test'); -const { - loginViaLogout -} = require('./test-utils'); - -test('expert-form-forfeiture-of-seized-property', async ({ - page -}) => { - // Use the common login utility - await loginViaLogout(page); - - // Ensure the "Respond" section is visible - await page.getByRole('heading', { - level: 3, - name: /Respond/i - }).waitFor(); - - // Click the Expert Form button under Respond (matches onclick) - await page.locator("button.btn.btn-primary[onclick=\"goToExpertForm('response')\"]").click(); - - // Wait for the form to be visible - await page.waitForSelector('form'); - - // Select Court - const courtSelect = page.locator('select#court'); - await courtSelect.waitFor({ - state: 'visible' - }); - await courtSelect.selectOption({ - value: 'winnebago' - }); - - await page.waitForTimeout(500); - - // Find and fill the Case Number input field - const caseNumberInput = page.locator('input#case_number'); - await caseNumberInput.waitFor({ - state: 'visible' - }); - await caseNumberInput.fill('2024-MX-50'); - - // Wait for "Case Information Found" text to appear - await page.locator('text=Case Information Found').waitFor({ - state: 'visible' - }); - - // Validate case information fields are displayed - await expect(page.locator('text=Case Title:')).toBeVisible(); - await expect(page.locator('text=People of the State of Illinois vs. One Thousand Nine Hundred Dollars US Currency')).toBeVisible(); - await expect(page.locator('text=Docket Number:')).toBeVisible(); - await expect(page.locator('text=2024-MX-50')).toBeVisible(); - await expect(page.locator('text=Case Category:')).toBeVisible(); - await expect(page.locator('text=190925')).toBeVisible(); - await expect(page.locator('text=Case Type:')).toBeVisible(); - await expect(page.locator('text=324882')).toBeVisible(); - - // Click Continue (this should trigger continueToExpertForm() which sets session storage) - await page.getByRole('button', { - name: 'Continue' - }).click(); - await page.locator('text=Loading your information').waitFor({ - state: 'hidden' - }); - - // Verify that the form automatically populated the dropdowns correctly - const courtDropdown = page.locator('select#court'); - const caseCategoryDropdown = page.locator('select#case_category'); - const caseTypeDropdown = page.locator('select#case_type'); - - // Verify court is pre-selected and disabled - await expect(courtDropdown).toHaveValue('winnebago'); - await expect(courtDropdown.locator('option:checked')).toContainText('Winnebago County'); - - // Verify case category is pre-selected and disabled - await expect(caseCategoryDropdown.locator('option:checked')).toContainText('Miscellaneous Criminal (190925)'); - - // Verify case type is pre-selected and disabled - await expect(caseTypeDropdown.locator('option:checked')).toContainText('Forfeiture of Seized Property (324882)'); - - // Wait briefly for client-side updates, then click Continue - await page.waitForTimeout(500); // Small delay for safety - await page.getByRole('button', { - name: 'Continue to Documents' - }).click(); - - // Check that we're on the Upload Your Documents Page. - // We'll stop here to avoid sending to S3 and filing the case. - - // Check for "Upload Your Documents" heading - await expect(page.getByRole('heading', { - name: /Upload Your Documents/i - })).toBeVisible(); - - // Verify case details are displayed correctly - await expect(page.locator('text=Case Type: Forfeiture of Seized Property')).toBeVisible(); - await expect(page.locator('text=County: Winnebago')).toBeVisible(); - - // Take a screenshot - await page.screenshot({ - path: 'screenshots/expert-form-forfeiture-of-seized-property.png', - fullPage: true - }); - - console.log('Screenshot saved as expert-form-forfeiture-of-seized-property.png'); -}); \ No newline at end of file diff --git a/efile_app/tests/expert-form-name-change.spec.js b/efile_app/tests/expert-form-name-change.spec.js deleted file mode 100644 index ebee2f7..0000000 --- a/efile_app/tests/expert-form-name-change.spec.js +++ /dev/null @@ -1,168 +0,0 @@ -const { - test, - expect -} = require('@playwright/test'); -const { - loginViaLoginPage -} = require('./test-utils'); - -test('expert-form-name-change', async ({ - page -}) => { - // Use the common login utility - await loginViaLoginPage(page); - - // Ensure the "File a New Case" section is visible - await page.getByRole('heading', { - level: 3, - name: /File a New Case/i - }).waitFor(); - - // Click the Expert Form button under File a New Case (matches onclick) - await page.locator("button.btn.btn-primary[onclick=\"goToExpertForm('new')\"]").click(); - - // Wait for the form to be visible - await page.waitForSelector('form'); - - // Select Court - const courtSelect = page.locator('select#court'); - await courtSelect.waitFor({ - state: 'visible' - }); - await courtSelect.selectOption({ - value: 'cook:cd1' - }); - - await page.waitForTimeout(500); - - // Wait for Case Category to be enabled and select option - const categorySelect = page.locator('select#case_category'); - await categorySelect.waitFor({ - state: 'visible' - }); - - // Move focus to a different element first to ensure form state is stable - await page.locator('body').click(); - await page.waitForTimeout(500); - - // Focus on the category select before making selection - await categorySelect.focus(); - await page.waitForTimeout(500); - - const optionToSelect = await categorySelect.locator('option', { - hasText: /^Miscellaneous \(/ - }); - const optionValue = await optionToSelect.getAttribute('value'); - await categorySelect.selectOption({ - value: optionValue - }); - - // Wait for any JavaScript events to complete after category selection - await page.waitForTimeout(200); - - // Verify category selection is still active - await expect(categorySelect).toHaveValue(optionValue); - - // Wait for Case Type to be enabled and select option - const caseTypeSelect = page.locator('select#case_type'); - await caseTypeSelect.waitFor({ - state: 'visible' - }); - const caseTypeOptionToSelect = await caseTypeSelect.locator('option', { - hasText: /Name Change/ - }); - const caseTypeOptionValue = await caseTypeOptionToSelect.getAttribute('value'); - await caseTypeSelect.selectOption({ - value: caseTypeOptionValue - }); - - // Wait for any JavaScript events to complete after case type selection - await page.waitForTimeout(1000); - - // Verify both selections are still active - await expect(categorySelect).toHaveValue(optionValue); - await expect(caseTypeSelect).toHaveValue(caseTypeOptionValue); - - // Wait for the Required Parties section to appear, then fill the fields - await page.locator('h3:has-text("Required parties")').waitFor(); - await page.locator('#petitioner_first_name').fill('John'); - await page.locator('#petitioner_last_name').fill('Doe'); - await page.locator('#new_first_name').fill('Jane'); - await page.locator('#new_last_name').fill('Doe'); - - // Wait briefly for client-side updates, then click Continue - await page.waitForTimeout(500); // Small delay for safety - await page.getByRole('button', { - name: 'Continue to Documents' - }).click(); - - // Wait for 10 seconds before taking screenshot - await page.waitForTimeout(10000); - - // Check that we're on the Upload Your Documents Page. - // We'll stop here to avoid sending to S3 and filing the case. - // TODO: you can un-comment to have the tests run all the way through to filing the case into Tyler's systems. - - /* - // Check for "Upload Your Documents" heading - await expect(page.getByRole('heading', { name: /Upload Your Documents/i })).toBeVisible(); - - // Verify case details are displayed correctly - await expect(page.locator('text=Case Type: Name Change')).toBeVisible(); - await expect(page.locator('text=County: Cook County - County Division - District 1 - Chicago')).toBeVisible(); - - // Upload the PDF file to the Lead Document upload area - const fileInput = page.locator('input[type="file"]').first(); // Target the first file input (Lead Document) - // NOTE: relies on the test file being in the tmp directory, which isn't checked into git - await fileInput.setInputFiles('tmp/test-name-change1.pdf'); - - // Wait for the file to be processed/uploaded - await page.waitForTimeout(10000); - - // Fill in Filing Type with type-ahead search - const filingTypeInput = page.locator('#leadFilingType_search'); - await filingTypeInput.fill('Petition for Name Change ('); - - // Wait for type-ahead results and click on the first match containing "Petition for Name Change" - await page.waitForTimeout(1000); - await page.locator('.search-dropdown-item').filter({ hasText: 'Petition for Name Change (' }).first().click(); - - // Fill in Document Type - select first Non-Confidential option - const documentTypeSelect = page.locator('#leadDocumentType'); - const nonConfidentialOption = await documentTypeSelect.locator('option').filter({ hasText: /Non-Confidential \(/ }).first(); - const documentTypeValue = await nonConfidentialOption.getAttribute('value'); - await documentTypeSelect.selectOption({ value: documentTypeValue }); - - // Fill in Filing Component - select "Lead Document" - const filingComponentSelect = page.locator('#leadFilingComponent'); - await filingComponentSelect.selectOption({ label: 'Lead Document' }); - - // Wait for all selections to be processed - await page.waitForTimeout(1000); - - // Click "Continue to Review & Pay" button - await page.getByRole('button', { name: 'Continue to Review & Pay' }).click(); - - await page.waitForTimeout(5000); - - // Wait for the review page to load - await page.waitForSelector('text=Review Case Details', { timeout: 30000 }); - - // Click the Continue button - await page.getByRole('button', { name: 'Continue' }).click(); - - await page.waitForTimeout(8000); - - // Wait for the e-filing success page to load - await page.waitForSelector('text=You will receive email confirmation shortly', { timeout: 30000 }); - - */ - - // Take a screenshot - await page.screenshot({ - path: 'screenshots/expert-form-name-change.png', - fullPage: true - }); - - console.log('Screenshot saved as expert-form-name-change.png'); -}); \ No newline at end of file diff --git a/efile_app/tests/expert-form-order-of-protection.spec.js b/efile_app/tests/expert-form-order-of-protection.spec.js deleted file mode 100644 index 38f13f3..0000000 --- a/efile_app/tests/expert-form-order-of-protection.spec.js +++ /dev/null @@ -1,122 +0,0 @@ -const { - test, - expect -} = require('@playwright/test'); -const { - loginViaLoginPage -} = require('./test-utils'); - -test('expert-form-order-of-protection', async ({ - page -}) => { - // Use the common login utility - await loginViaLoginPage(page); - - // Ensure the "File a New Case" section is visible - await page.getByRole('heading', { - level: 3, - name: /File a New Case/i - }).waitFor(); - - // Click the Expert Form button under File a New Case (matches onclick) - await page.locator("button.btn.btn-primary[onclick=\"goToExpertForm('new')\"]").click(); - - // Wait for the form to be visible - await page.waitForSelector('form'); - - // Select Court - const courtSelect = page.locator('select#court'); - await courtSelect.waitFor({ - state: 'visible' - }); - await courtSelect.selectOption({ - value: 'winnebago' - }); - - await page.waitForTimeout(500); - - // Wait for Case Category to be enabled and select option - const categorySelect = page.locator('select#case_category'); - await categorySelect.waitFor({ - state: 'visible' - }); - - // Move focus to a different element first to ensure form state is stable - await page.locator('body').click(); - await page.waitForTimeout(500); - - // Focus on the category select before making selection - await categorySelect.focus(); - await page.waitForTimeout(500); - - // Select Domestic Relations category for Order of Protection - const optionToSelect = await categorySelect.locator('option', { - hasText: /^Order of Protection \(/ - }); - const optionValue = await optionToSelect.getAttribute('value'); - await categorySelect.selectOption({ - value: optionValue - }); - - // Wait for any JavaScript events to complete after category selection - await page.waitForTimeout(200); - - // Verify category selection is still active - await expect(categorySelect).toHaveValue(optionValue); - - // Wait for Case Type to be enabled and select Order of Protection - const caseTypeSelect = page.locator('select#case_type'); - await caseTypeSelect.waitFor({ - state: 'visible' - }); - const caseTypeOptionToSelect = await caseTypeSelect.locator('option', { - hasText: /^Order of Protection \(/ - }); - const caseTypeOptionValue = await caseTypeOptionToSelect.getAttribute('value'); - await caseTypeSelect.selectOption({ - value: caseTypeOptionValue - }); - - // Wait for any JavaScript events to complete after case type selection - await page.waitForTimeout(1000); - - // Verify both selections are still active - await expect(categorySelect).toHaveValue(optionValue); - await expect(caseTypeSelect).toHaveValue(caseTypeOptionValue); - - // Wait for the Required Parties section to appear, then fill the fields - await page.locator('h3:has-text("Required parties")').waitFor(); - - // For Order of Protection, we need to fill respondent information - await page.locator('#respondent_first_name').fill('John'); - await page.locator('#respondent_last_name').fill('Smith'); - - // Wait briefly for client-side updates, then click Continue - await page.waitForTimeout(500); // Small delay for safety - await page.getByRole('button', { - name: 'Continue to Documents' - }).click(); - - // Wait for 10 seconds before taking screenshot - await page.waitForTimeout(10000); - - // Check that we're on the Upload Your Documents Page. - // We'll stop here to avoid sending to S3 and filing the case. - - // Check for "Upload Your Documents" heading - await expect(page.getByRole('heading', { - name: /Upload Your Documents/i - })).toBeVisible(); - - // Verify case details are displayed correctly - await expect(page.locator('text=Case Type: Order of Protection')).toBeVisible(); - await expect(page.locator('text=County: Winnebago')).toBeVisible(); - - // Take a screenshot - await page.screenshot({ - path: 'screenshots/expert-form-order-of-protection.png', - fullPage: true - }); - - console.log('Screenshot saved as expert-form-order-of-protection.png'); -}); \ No newline at end of file diff --git a/efile_app/tests/reorganized-filing-matrix.spec.js b/efile_app/tests/reorganized-filing-matrix.spec.js index 75e93a4..59c5380 100644 --- a/efile_app/tests/reorganized-filing-matrix.spec.js +++ b/efile_app/tests/reorganized-filing-matrix.spec.js @@ -337,23 +337,22 @@ async function finishFiling(page, scenario, ordinal) { })).toBeVisible(); } -async function runNewCase(page, scenario, ordinal) { - console.log(`${scenario.label}: opening options`); +async function startFiling(page, path) { + // The options screen (and the header menu) start a filing that already + // knows which kind it is, so there is no filing-path screen to answer. await page.goto('/jurisdiction/illinois/options/'); - console.log(`${scenario.label}: starting draft`); - const begin = page.locator('button[onclick="goToExpertForm(\'new\')"]'); - await Promise.all([ - page.waitForURL(/\/filing-path\//), - begin.evaluate(element => element.click()), - ]); - console.log(`${scenario.label}: choosing new case`); - await page.locator('input[name="existing_case"][value="new"]').check(); + const form = page.locator(`form[action$="/start-filing/"]:has(input[name="existing_case"][value="${path}"])`); await Promise.all([ page.waitForURL(/\/upload-documents\//), - page.getByRole('button', { - name: /^Continue/ + form.getByRole('button', { + name: /^Begin/ }).click(), ]); +} + +async function runNewCase(page, scenario, ordinal) { + console.log(`${scenario.label}: starting a new-case draft`); + await startFiling(page, 'new'); console.log(`${scenario.label}: uploading document`); await page.locator('#documents-input').setInputFiles(SAMPLE_PDF); @@ -388,19 +387,7 @@ async function runNewCase(page, scenario, ordinal) { async function runExistingCase(page, scenario, ordinal) { console.log(`${scenario.label}: starting existing-case draft`); - await page.goto('/jurisdiction/illinois/options/'); - const begin = page.locator('button[onclick="goToExpertForm(\'new\')"]'); - await Promise.all([ - page.waitForURL(/\/filing-path\//), - begin.evaluate(element => element.click()), - ]); - await page.locator('input[name="existing_case"][value="existing"]').check(); - await Promise.all([ - page.waitForURL(/\/upload-documents\//), - page.getByRole('button', { - name: /^Continue/ - }).click(), - ]); + await startFiling(page, 'existing'); await page.locator('#documents-input').setInputFiles(SAMPLE_PDF); await page.getByRole('button', { name: 'Upload selected files' diff --git a/session_cookies.txt b/session_cookies.txt deleted file mode 100644 index ea623ea..0000000 --- a/session_cookies.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_127.0.0.1 FALSE / FALSE 1756921221 sessionid pykrwfts8aemuef5yyt9h5q2hpgwnurw