diff --git a/docs/developer-notes/miro-vision-alignment-2026-10-17.md b/docs/developer-notes/miro-vision-alignment-2026-10-17.md new file mode 100644 index 00000000..eeb80aa7 --- /dev/null +++ b/docs/developer-notes/miro-vision-alignment-2026-10-17.md @@ -0,0 +1,112 @@ +# Miro wireframe alignment review + +Reviewed 2026-10-17 against the 18 device-screen containers on [E-filing Wireframe](https://miro.com/app/board/uXjVEabHGug=/), using the signed-in browser's accessible outline and a visual check of Confirm case / Organize documents. The connector still denied access. Baseline: clean local `main`; branch: `codex/miro-vision-alignment`. + +## Scope and design intent + +Use short instructions, “documents” and “e-file” consistently, pop-ups for short optional definitions, and disclosures for longer optional help. Preserve the existing eight stages. The board uses Easy File branding; confirm before replacing LITEfile or jurisdiction branding. + +This is not a complete flow specification. It contains alternative home/start layouts and questions about removing the new/existing distinction, clickable progress, and fee-waiver timing. Sample errors and duplicate party names are examples, not behavior to reproduce. Illinois-specific material must remain jurisdiction-specific. + +## Screen-by-screen comparison + +Implementation paths below are relative to `efile_app/efile/`. “Proposed” items are not implemented. + +| Board frame | Implementation | Required changes / current alignment | +| --- | --- | --- | +| General design/information sharing | Shared header, footer, workflow base | Proposed: consistent information hierarchy and optional help; confirm Easy File branding. Get more help/Call already exist where configured, including Illinois Court Help. The simplified footer proposal differs from the fuller footers in other frames: resolve before removing GitHub, Call or credit. | +| Landing / Sign in | `templates/efile/login.html` | Implemented “Sign in” heading and existing-account instruction with state parameter. Email/password, Forgot password, Sign in and Create an account already match. Proposed: Learn more about e-filing link/destination. Keep password visibility. | +| Create an account | `templates/efile/register.html`, `forms.py` | Implemented “Create an account”, “Name”, “Address”. Proposed: “Create an account to e-file in [state]”, contact-reuse note, explicit optional middle-name label. Preserve address line 2 and validation despite omissions in drawing. | +| Activation | `views/register.py`, login messages | Already redirects to sign-in with an activation message. No new route needed. Verify provider email subject/link before adopting exact quoted wording from the board across states. | +| Home options: new case; existing case/draft | `templates/efile/options.html`, `views/draft_views.py` | Implemented My filings / View filings and status description. Proposed: one Start filing card, visible View drafts/count instead of collapsed resume controls, concise state-specific welcome. Keep plans accessible through Menu and preserve fresh-draft isolation. | +| Start: What are you trying to do? | `templates/efile/filing_path.html` | New/existing/unsure options already exist. Small remaining rename: File in → File into. Board asks whether this question can go; recommend deferring to Confirm information, not silently inferring the answer. Awaiting decision. | +| Start: What do you want to do? New account; existing account/filing | Options and workflow progress | Treat as alternative home/start designs, not two new sequential screens. Choose one canonical home with conditional draft card and Find documents/help. Do not repeat a new-sign-in banner on every return. | +| Upload: no documents; rules/AI/documents uploaded | `templates/efile/upload_documents.html` | Implemented Upload your documents, Choose PDF or Word files or drag them here, Learn more about how we use AI. Proposed: concise branch instruction/Change, less routine size/readability/preparation copy, remove redundant document-role sublabels. Keep validation/errors and conversion/extraction states. AI explainer content already matches. Court-notice title must describe actual state notices. | +| Upload navigation | Upload → Confirm information | Board conflicts: empty state says Preview your PDFs, populated state Confirm information. Recommend current Confirm information plus inline PDF preview. A separate Preview screen would change workflow; the legacy route currently maps to Confirm. | +| Confirm case | `templates/efile/extraction_review.html`, corresponding view/script | Proposed: simplified summary; move people editing to People; retain PDF/Change files; section heading Confirm the details used for this filing; code search after selectors; case-number help where lookup occurs. Drawing combines case number and new-case classifications, unlike the app's separate existing-case lookup. Do not copy that mixed state literally. Preserve partial-read/failure/AI-off states. | +| Organize documents | `templates/efile/organize_documents.html` | Mostly matches: main document, name, filing type, confidentiality, component, optional services/courtesy email, PDF. Trim routine guidance; preserve conditional warnings. Single-document example does not imply removing main-document selection or reorder controls for multiple documents. | +| People: your information | `templates/efile/your_information.html`, UI text catalog | Proposed concise Check your name and contact information plus Why do you need this? explainer. Fields and Continue to parties largely match. Keep jurisdiction-specific required fields/state restrictions. | +| People: who is involved | `templates/efile/parties.html`, `party_details.html`, people services | Role, Save role, Add another person, list/Edit/Continue exist. Proposed destination for extracted-name/side/identity editing; removal inside Edit. Preserve deduplication, organizations, representation, notice recipient, required parties and court-owned records. Do not use You already told us when no people were supplied. | +| Fees: fee waiver/add; choose credit card | `templates/efile/payment.html`, payment/waiver scripts | Most controls match. Keep detailed state-specific eligibility behind short trigger; do not reduce benefits/hardship criteria to income only. Preserve exemptions/zero-fee states, waiver PDF review, account removal, bank retry and fee confirmation gates. | +| Review | `templates/efile/review.html` | Implemented short review/Edit instruction. Existing sections, markers, PDFs, court requirements and consent mostly match. Retain missing Documents Edit, conditional questions/correction/plan warnings and Submit and pay when payable. Sample provider errors are not normal copy. | + +## Controls absent from the wireframes and required destinations + +Absence is not proof of intentional removal. The following must remain reachable or be explicitly redesigned. + +| Current control | Recommended destination / dependency | +| --- | --- | +| Home/Menu separate new/existing Begin buttons | Generic Start filing must still create a fresh draft. Ask the branch on Confirm before code selection. Unspecified path currently redirects to the separate question: changing the label alone is insufficient. | +| Upload Add or replace a document select | Per-document Replace or secondary Upload disclosure. Preserve replacement ID, saved filing choices, extraction reset and new PDF review. Remove+upload is not equivalent. | +| Remember this AI choice and saved account preference | Secondary preference disclosure or account settings. No other screen currently manages the account-wide preference. Preserve scope, reversibility and visibility; never enable AI implicitly. | +| Upload Find forms and instructions | Home/Menu or compact Upload help link; preserve draft/return context. Alternative Start frames include Find documents. | +| Confirm names/sides/add/remove/This is me | Move review and persistence to People, including identity matching. Change Confirm POST handling together with UI; omitted rows must not erase saved people. | +| Confirm I checked these details checkbox | Keep beside PDF, or explicitly redesign Continue and server acknowledgement. `needs_acknowledgement` rejects missing `reviewed_extraction=yes`; deleting HTML alone blocks progression. Prepared-PDF fingerprint approval is a separate safeguard that must also remain. | +| Confirm new/existing Change/radios | Keep concise correction and undecided-state choice. Branch changes invalidate incompatible codes, case identity and fees. | +| Confirm Which side of this case are you on? | Before Check documents or in that screen's existing fallback. Side-specific document plans need the answer before People. | +| Existing-case suggested filing type | Keep editable proposal on Confirm or move to scoped Organize filing-type search; it is not yet a court-approved code. | +| Registration street address 2 | Keep optional apartment/unit field or equivalent address component. | +| People This is me / Replace with me / Add me as a party | Preserve conditional identity/name-choice controls before matching. Prevent duplicate account/extracted parties. | +| People Remove party | Inside Edit or overflow control, preserving court-owned-party restrictions. | +| Who are you filing for? / Email for court notices | Conditional People panel for representation and existing cases. The board only draws the not-a-party radio, not its necessary follow-up. | +| Multiple-document main selection / move up/down | Retain for multiple documents; add missing state to design. Single-document omission needs no relocation. | +| Payment Remove account | Compact Manage payment methods or per-account action. No alternate management screen exists. Preserve confirmation and selection clearing. | +| Waiver This copy looks right / Remove and upload a different file | Post-upload preview/check state next to waiver upload. Preserve prepared-file review fingerprints. | +| Review Documents Edit | Keep Organize link with `return_to=review` and access to change files. Otherwise name/type/confidentiality cannot be corrected from this summary. | +| Review Submit and pay | Keep when confirmed fees are payable; board's universal Submit filing obscures payment consequence. Preserve fresh fee and consent gates. | +| Profile sign out/change state | Retain account menu actions; only icon/Menu are drawn. | + +## Missing designs versus missing app screens + +- Check documents is stage 4 but has no frame. Existing implementation includes plans, side-specific lists, attach/detach, progress and PDF checks. Do not remove the stage. +- Existing-case lookup, no-match/failure, confirmation and imported parties need designs; routes already exist. +- Add/edit person, organizations, required parties, duplicate-name claim and representation need designs; components/routes already exist. +- Case questions/amount in controversy, plans/document finding, drafts, filing status/detail, corrections and successful submission/tracking are not drawn. Their omission does not authorize removal. +- State designs needed: registration/reset errors, extraction failure/partial/AI off, rejection/conversion, multiple documents, payment account creation/removal, unavailable banks, waiver preview and blocked submission. +- New UI work likely needed: contact-purpose explainer, case-number help, consolidated home and approved secondary controls. Activation does not require a new route. +- Progress is currently noninteractive (`components/workflow_progress.html` renders spans). Board asks for clickable stages: recommend completed-stage links with save/dirty-form handling and existing branch prerequisites, not arbitrary jumps past validation. + +## Copy to reduce versus conditional guidance to retain + +Reduce routine preparation/requirements text, document-role sublabels, repeated introductions and always-visible contact-purpose explanation. Use optional disclosures and short contextual errors. Do not delete partial extraction, branch/court change, confidentiality, court-owned record, eligibility, unavailable-filing or error messages because the happy-path frame omits them. Court acknowledgements and final consent are functional controls. The board retains notices at Upload and Review. + +## Decisions requested + +1. One Start filing with branch selection on Confirm (recommended), separate question before Upload, or existing split buttons. +2. Move people controls to People and retain explicit review acknowledgement (recommended), replace acknowledgement with Continue confirmation, or retain current layout. +3. Preserve omitted actions in compact secondary controls (recommended), or retain current placement. + +Also unresolved: Easy File branding, canonical home variant, scope of clickable progress, and whether a standalone PDF preview is intended. These initial questions were answered; see the approved implementation below. + +## Initial copy-only changes and verification + +Five template-only files changed: login, register, upload_documents, options, review. No routes, input names, persistence, validation, payment behavior or migrations changed. + +- WSL server running at http://127.0.0.1:8000. Updated login/registration observed in browser; fields and navigation intact. +- 64 tests passed: interface consistency, reorganized start, review submission flow, AI opt-out. +- Used `uv sync --group dev`, then `uv run --group dev python -m pytest`; existing pytest executable could not spawn. +- Server started without extraction/index workers or LocalStack startup, and without migrations. Existing `EFSP_TEST_DOCUMENT_URL` substitutes a sample PDF on submission. No live filing/payment; authenticated end-to-end upload/fee flow not browser-verified. +- Copy remains in translation tags; catalogs need normal localization follow-up. English verified. + + +## Approved implementation after review + +The user approved one Start filing action with the branch decision at Confirm information, and compact secondary controls. They clarified that detected roles must be confirmed immediately next to the detection; only undetected roles may be deferred. + +- Home and Menu now offer one Start filing action. Generic starts create a fresh, blank-branch draft and go directly to Upload. Back returns to Home. Explicit-path legacy starts and plan starts remain compatible. +- Confirm information asks new/existing before exposing new-case classifications. Detected names, sides and This is me remain beside the extraction, honoring the clarification rather than moving all identity matching to People. +- A side-specific role suggestion must still be confirmed immediately. Without a suggestion or an already confirmed role, that question is deferred; Check documents asks if its checklist needs the side. Normal party details continue on People. +- Document-review acknowledgement remains and now sits beside the PDF previews. +- Upload replacement is in Replace a document. Saved AI preference is in AI preferences for future filings; opening it does not save a preference. Changing the checkbox saves through the existing endpoint. +- Party removal is in the party Edit screen under More options for this person or organization, posting to the same protected removal endpoint with return context. +- Review's document Edit link is under Document options and retains return-to-review behavior. +- Branding, broader layout changes, payment-method management, clickable progress and a standalone preview screen remain outside this approved implementation. + +## Verification of the approved implementation + +- Broad regression run: 267 passed, 2 skipped, and 2 stale UI assertions failed. Updated those assertions for the renamed path label and native AI-preference disclosure; both passed on rerun. +- Final focused run: 75 passed, including both corrected assertions, navigation, role confirmation/deferral, reorganized start, document previews and interface consistency. +- Ruff, JavaScript syntax checks for both changed scripts, and git diff whitespace checks passed. +- In-app browser inspection of Django-rendered synthetic previews confirmed one Start filing button and working native disclosures for replacement and saved AI preference. These were layout checks with scripts removed, not authenticated end-to-end tests. Temporary synthetic account/session data was removed. +- A separate headless browser check was rejected by automatic approval review with only “blocked by policy”; it was not retried. Authenticated upload, extraction, payment and submission still require an end-to-end browser pass. No live filing or payment was performed. +- Implementation branch: codex/miro-vision-alignment. The WSL development app was checked at http://127.0.0.1:8000 with the limitations described above. diff --git a/docs/docs/partners-courts/customization.md b/docs/docs/partners-courts/customization.md index d10eae4a..5fa46774 100644 --- a/docs/docs/partners-courts/customization.md +++ b/docs/docs/partners-courts/customization.md @@ -66,7 +66,6 @@ putting `text` under a court does not create a court-specific override. | `terms.main_document` | main document | No | | `organize_documents.main_document_question` | Which PDF is the `{main_document}`? | No | | `organize_documents.main_document_help` | Choose the document that starts this filing, like a `{starting_document_example}`. | Yes | -| `organize_documents.single_document_note` | This is your `{main_document}`. | No | | `organize_documents.filing_component_label` | Court filing component | No | | `organize_documents.filing_component_help` | The court may call more than one PDF a “Lead Document”. This is separate from your `{main_document}`. | Yes | | `organize_documents.filing_component_fixed_note` | Required by the court. | No | diff --git a/efile_app/efile/services/document_preparation.py b/efile_app/efile/services/document_preparation.py index 6ae1cc1c..756d09fa 100644 --- a/efile_app/efile/services/document_preparation.py +++ b/efile_app/efile/services/document_preparation.py @@ -13,7 +13,7 @@ from django.conf import settings from django.core.files.uploadedfile import SimpleUploadedFile from pypdf import PdfReader, PdfWriter -from pypdf.generic import ArrayObject, DictionaryObject +from pypdf.generic import ArrayObject, DictionaryObject, NameObject from efile.utils.config_loader import config_loader @@ -163,6 +163,51 @@ def _repair_text_appearances(content, source, widgets): ) from error +def _blank_and_undrawn(widget): + """True for a widget with no appearance and no answer, such as an unsigned signature line. + + A missing appearance alone is not enough: a checked box or chosen option + can lose its appearance and still hold an answer, and that must not be + dropped quietly. + """ + appearance = widget.get("/AP") + if appearance and appearance.get_object().get("/N"): + return False + field = widget.get("/Parent", widget).get_object() + value = widget.get("/V", field.get("/V")) + return value is None or str(value) in {"", "/Off"} + + +def _drop_undrawn_widgets(content, reader, widgets): + """Remove leftover widgets that are blank and have no appearance. + + The flatten engine skips widgets with nothing to draw, so they survive as + form fields. They print as nothing, so removing them changes no page. + """ + if not widgets or not all(_blank_and_undrawn(widget) for widget in widgets): + return content + writer = PdfWriter(clone_from=reader) + for page in writer.pages: + if "/Annots" not in page: + continue + annotations = page["/Annots"].get_object() + if not isinstance(annotations, ArrayObject): + continue + kept = [ + ref + for ref in annotations + if not (ref.get_object().get("/Subtype") == "/Widget" and _blank_and_undrawn(ref.get_object())) + ] + if kept: + page[NameObject("/Annots")] = ArrayObject(kept) + else: + del page["/Annots"] + writer._root_object.pop("/AcroForm", None) + output = io.BytesIO() + writer.write(output) + return output.getvalue() + + def _flatten(content): source, widgets = inspect_pdf(content) if not widgets: @@ -172,6 +217,7 @@ def _flatten(content): raise PreparationError("We cannot prepare this signed PDF. Print it to PDF and upload that copy.") content = _repair_text_appearances(content, source, widgets) result = _gotenberg(content, ".pdf", "/forms/pdfengines/flatten") + result = _drop_undrawn_widgets(result, *inspect_pdf(result)) output, remaining = inspect_pdf(result) if remaining or output.get_fields() or len(output.pages) != len(source.pages): raise PreparationError("We could not prepare this form. Print it to PDF and upload that copy.") diff --git a/efile_app/efile/services/filing_path.py b/efile_app/efile/services/filing_path.py index 2d329510..b97b82d7 100644 --- a/efile_app/efile/services/filing_path.py +++ b/efile_app/efile/services/filing_path.py @@ -1,13 +1,11 @@ """The new-or-existing-case answer: where it came from, and changing it. -A filer can say which kind of filing this is in three places -- the start -menu's "Start a new case" / "File into an existing case", a filing plan, or the -"What are you trying to do?" question -- and can correct it on Confirm case -once their documents have been read. Two things follow from that: - -* Back and the progress steps should follow the way the filer actually came - in. Someone who chose from the start menu never saw the question, and Back - from Upload should not introduce it as if they had. +Ordinary starts upload first and choose new/existing on Confirm information. +Older explicit-path starts, filing plans and the standalone correction question +remain supported. + +* Back follows the way the filer came in. Generic starts and explicit menu + starts return from Upload to Home, without introducing an unseen question. * Changing the answer is more than one field. The same draft and the same PDFs carry on, but what was chosen for the other kind of filing -- the court's filing types differ between opening a case and filing into one, an diff --git a/efile_app/efile/services/filing_plans.py b/efile_app/efile/services/filing_plans.py index e164a9ae..9cfe16be 100644 --- a/efile_app/efile/services/filing_plans.py +++ b/efile_app/efile/services/filing_plans.py @@ -164,6 +164,12 @@ def filer_roles_for_draft(draft: FilingDraft) -> list[dict[str, Any]]: ) +def needs_filer_role(draft: FilingDraft) -> bool: + """Whether this case has sides and the filer has not said which is theirs.""" + + return not draft.filer_role and bool(filer_roles_for_draft(draft)) + + def set_filer_role(draft: FilingDraft, filer_role: str) -> bool: """Record which side of the case the filer is on, if it is one on offer.""" diff --git a/efile_app/efile/static/css/components/filing-code-search.css b/efile_app/efile/static/css/components/filing-code-search.css index 8ad5b480..511fcdcc 100644 --- a/efile_app/efile/static/css/components/filing-code-search.css +++ b/efile_app/efile/static/css/components/filing-code-search.css @@ -1,10 +1,26 @@ .code-search-invitation { - margin-block: 1.5rem; - padding: 1.25rem; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1.5rem; + margin-top: 1rem; + padding: 1rem 1.25rem; background: #edf5ff; + border: 1px solid #cbdcf1; border-radius: 0.75rem; } +.code-search-invitation__text { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.code-search-invitation__text [hidden] { + display: none; +} + .code-search { width: min(48rem, calc(100% - 2rem)); height: min(60rem, calc(100dvh - 2rem)); diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index 6de0f947..002b84a1 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -209,6 +209,7 @@ justify-content: center; min-height: 190px; padding: 2rem; + position: relative; text-align: center; } @@ -432,6 +433,11 @@ color: var(--better-blue); } +.status-pill--additional { + background: var(--border-subtle); + color: var(--text-muted); +} + .document-list__empty { border: 1px solid var(--border-default); border-radius: 10px; @@ -1097,14 +1103,21 @@ padding: 1rem; } +/* Floated so the legend sits inside the box instead of on its border. */ .main-document-choice legend, .primary-question legend { color: var(--text-heading); - float: none; + float: left; font-size: 1.05rem; font-weight: 750; margin: 0; - width: auto; + padding: 0; + width: 100%; +} + +.main-document-choice legend+*, +.primary-question legend+* { + clear: both; } .main-document-choice>p, @@ -1139,17 +1152,21 @@ font-size: 1.4rem; } -.organize-card__header strong, -.organize-card__position { +.organize-card__header strong { display: block; } +/* The document's place in the filing, with any reorder buttons beside it. */ +.organize-card__meta { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + .organize-card__position { - color: var(--text-muted); - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; + white-space: nowrap; } .organize-card__fields { @@ -1207,6 +1224,12 @@ width: auto; } +/* Each choice explains itself between its question and its options, so the + explanations line up across the two columns. */ +.compact-choice-field .form-help { + margin: 0 0 0.6rem; +} + .form-help { color: var(--text-muted); display: block; @@ -1576,6 +1599,15 @@ grid-template-columns: 1fr; } + .organize-card__header { + grid-template-columns: auto 1fr; + } + + .organize-card__meta { + grid-column: 1 / -1; + justify-content: space-between; + } + .people-grid, .people-grid--name, .your-role-card { @@ -1996,6 +2028,49 @@ margin: 0; } +/* The court, case and filing type, set apart as one task with two routes: + the guided helper, or choosing each field directly. */ +.new-case-details { + background: var(--surface-subtle); + border: 1px solid var(--border-default); + border-radius: 12px; + margin-top: 1.5rem; + padding: 1.25rem; +} + +.new-case-details__or { + align-items: center; + color: var(--text-muted); + display: flex; + font-size: 0.92rem; + gap: 0.75rem; + margin: 1rem 0; +} + +.new-case-details__or::before, +.new-case-details__or::after { + border-top: 1px solid var(--border-default); + content: ""; + flex: 1; +} + +/* Floated so the legend sits inside the card instead of on its border. */ +.new-case-details__heading { + color: var(--text-heading); + float: left; + font-size: 1.05rem; + font-weight: 750; + margin-bottom: 0.4rem; + padding: 0; + width: 100%; +} + +.new-case-details__lede { + clear: both; + color: var(--text-muted); + margin-bottom: 0; +} + /* New or existing case, first among the filing details. */ .path-section { margin-bottom: 1.5rem; @@ -2030,4 +2105,12 @@ .drop-zone label { cursor: pointer; width: 100%; +} + +/* Stretch the label over the whole drop zone so a click anywhere in it opens + the file picker, while the label text stays the input's accessible name. */ +.drop-zone label::after { + content: ""; + inset: 0; + position: absolute; } \ No newline at end of file diff --git a/efile_app/efile/static/js/extraction-review.js b/efile_app/efile/static/js/extraction-review.js index 22312ccc..31ea8f7c 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -6,8 +6,13 @@ const context = JSON.parse(contextEl.textContent); const guesses = context.guesses || {}; + // "new", "existing", or "" before the filer has answered. + function chosenPath() { + return form.querySelector('input[name="existing_case"]:checked')?.value || ""; + } + function isExisting() { - return form.querySelector('input[name="existing_case"]:checked')?.value === "existing"; + return chosenPath() === "existing"; } const errorBox = document.getElementById("extraction-review-error"); const acknowledgement = document.getElementById("reviewed_extraction"); @@ -128,6 +133,16 @@ field.editButton?.addEventListener("click", () => beginEdit(key)); }); + // Once the page has filled in a choice, the helper's prompt asks the filer + // to check it rather than offering to start from nothing. + function showPrefilledPrompt() { + const prompt = document.getElementById("code-search-prompt"); + const prefilled = document.getElementById("code-search-prompt-prefilled"); + if (!prompt || !prefilled) return; + prompt.hidden = true; + prefilled.hidden = false; + } + function setMode(key, mode) { const field = fields[key]; field.mode = mode; @@ -245,8 +260,7 @@ } function existingCaseWire() { - const checked = form.querySelector('input[name="existing_case"]:checked'); - return checked && checked.value === "existing" ? "yes" : "no"; + return isExisting() ? "yes" : "no"; } const LISTS = { @@ -352,7 +366,10 @@ field.hint.textContent = field.defaultHint; // Only the page's first look folds a match into its summary. After // that a field stays the way the filer left it. - if (firstLoad) setMode(key, "found"); + if (firstLoad) { + setMode(key, "found"); + showPrefilledPrompt(); + } } else if (wanted && !firstLoad) { // The filer's choice is not on the new list. Said beside the field, // naming what went, rather than left to look like it never existed. @@ -391,7 +408,10 @@ const roleField = document.getElementById("filer-role-field"); const roleOptions = document.getElementById("filer-role-options"); - let savedRole = context.filer_role || ""; + // The side saved on the draft. The server asks for it again whenever the + // chosen case type still offers it, so the screen must show it then too. + const draftRole = context.filer_role || ""; + let savedRole = draftRole; let roleGeneration = 0; function chosenRole() { @@ -441,6 +461,7 @@ return false; } courtSelector = selector; + if (field.select.value) showPrefilledPrompt(); // The selector shows the court it settled on, and folds and reopens // its own questions with their own Update and Cancel, so the field's // found/edit panels have nothing left to say. @@ -478,14 +499,14 @@ // -- an eviction is two different filings depending on who is making it -- // and which ones depends on the case type chosen above, so the question // appears and disappears with it. - function roleOptionHtml(role) { - const hint = role.suggested && !savedRole ? + function roleOptionHtml(role, chosen) { + const hint = role.suggested && !chosen ? ` ${gettext("probably you, from the document you uploaded")}` : ""; const description = role.description ? `${escapeHtml(role.description)}` : ""; return ` `; } @@ -517,8 +538,12 @@ console.warn("Could not load the sides of this case:", error); } if (token !== roleGeneration) return; - roleOptions.innerHTML = roles.map(roleOptionHtml).join(""); - roleField.hidden = roles.length === 0; + // Same rule as the server: ask when a side is suggested or the saved + // side is on offer. An answer given here is kept while it is offered. + const chosen = roles.some((role) => role.id === savedRole) ? savedRole : ""; + const needsConfirmation = Boolean(chosen) || roles.some((role) => role.suggested || role.id === draftRole); + roleOptions.innerHTML = needsConfirmation ? roles.map((role) => roleOptionHtml(role, chosen)).join("") : ""; + roleField.hidden = !needsConfirmation; } const ADVANCE = { @@ -665,8 +690,9 @@ proposal.disabled = !existing; } const details = document.getElementById("new-case-details"); - details.hidden = existing; - details.disabled = existing; + const isNew = chosenPath() === "new"; + details.hidden = !isNew; + details.disabled = !isNew; const parties = document.getElementById("review-parties"); if (parties) { parties.hidden = existing; @@ -676,8 +702,14 @@ availability.check(); } + const PATH_ERROR = gettext("Choose whether this is a new or existing court case to continue."); + form.querySelectorAll('input[name="existing_case"]').forEach((radio) => { - radio.addEventListener("change", updateCasePathSections); + radio.addEventListener("change", () => { + // Answering the question clears the error that asked for it. + if (errorBox.textContent === PATH_ERROR) errorBox.hidden = true; + updateCasePathSections(); + }); }); updateCasePathSections(); @@ -710,9 +742,15 @@ form.addEventListener("submit", (event) => { if (isExisting()) return; + if (!chosenPath()) { + event.preventDefault(); + errorBox.textContent = PATH_ERROR; + errorBox.hidden = false; + openPathQuestion(""); + return; + } if (blockUnfinished(event)) return; - const isNew = form.querySelector('input[name="existing_case"]:checked')?.value === "new"; - const missingCase = isNew && (!fields.court.select.value || !fields.case_category.select.value || !fields.case_type.select.value); + const missingCase = !fields.court.select.value || !fields.case_category.select.value || !fields.case_type.select.value; const missingRole = !roleField.hidden && !chosenRole(); if (!missingCase && !missingRole) return; diff --git a/efile_app/efile/static/js/filing-code-search.js b/efile_app/efile/static/js/filing-code-search.js index 83e83b4f..f477ab92 100644 --- a/efile_app/efile/static/js/filing-code-search.js +++ b/efile_app/efile/static/js/filing-code-search.js @@ -176,18 +176,18 @@ } }).catch(error => { if (error.name === "AbortError") throw error; - const unavailable = new Error(gettext("Code search could not be loaded. Try again shortly.")); + const unavailable = new Error(gettext("This helper could not be loaded. Try again shortly.")); unavailable.retryable = true; throw unavailable; }); if (!response.headers.get("content-type")?.includes("application/json")) { - const error = new Error(gettext("Code search could not be loaded. Refresh the page and try again.")); + const error = new Error(gettext("This helper could not be loaded. Refresh the page and try again.")); error.retryable = response.status >= 500; throw error; } const data = await response.json(); if (!response.ok) { - const error = new Error(data.error || gettext("Code search could not be loaded. Try again.")); + const error = new Error(data.error || gettext("This helper could not be loaded. Try again.")); error.retryable = response.status === 503; throw error; } diff --git a/efile_app/efile/static/js/upload-documents.js b/efile_app/efile/static/js/upload-documents.js index 3ee7d1b8..9efeac65 100644 --- a/efile_app/efile/static/js/upload-documents.js +++ b/efile_app/efile/static/js/upload-documents.js @@ -90,10 +90,9 @@ waitTimerDone = true; }, Number(form.dataset.waitSeconds) * 1000); } - // The "remember this" row is offered only after the filer changes the - // setting, and only for the rest of this page load. Until then the account - // preference is not this request's business, so it is left out of the post. - let rememberOffered = false; + // Merely opening preferences never changes the account default. Include + // it only after the filer changes an AI setting during this page load. + let aiChoiceTouched = false; function aiIsOff() { return Boolean(aiOptOut && aiOptOut.checked); @@ -119,21 +118,13 @@ }); } - function offerToRemember() { - if (!aiRemember || rememberOffered) return; - rememberOffered = true; - if (window.bootstrap) new window.bootstrap.Collapse(aiRemember, { - toggle: false - }).show(); - else aiRemember.classList.add("show"); - } async function saveAiPreference() { const optedOut = aiOptOut.checked; const body = new FormData(); body.append("action", "ai_preference"); body.append("ai_opt_out", optedOut ? "yes" : ""); - if (rememberOffered && aiRememberChoice) { + if (aiChoiceTouched && aiRememberChoice) { body.append("remember_ai_choice", aiRememberChoice.checked ? "yes" : "no"); } body.append("csrfmiddlewaretoken", apiUtils.getCSRFToken()); @@ -159,12 +150,24 @@ const optedOut = aiOptOut.checked; if (aiNoteOn) aiNoteOn.hidden = optedOut; if (aiNoteOff) aiNoteOff.hidden = !optedOut; - offerToRemember(); + aiChoiceTouched = true; + if (aiRemember) aiRemember.open = true; saveAiPreference(); }); } - if (aiRememberChoice) aiRememberChoice.addEventListener("change", saveAiPreference); + // Closing "Replace a document" goes back to adding: a Replace choice the + // filer can no longer see must not overwrite one of their documents. + const replaceDocument = document.getElementById("replace-document"); + const replaceDisclosure = document.getElementById("replace-document-disclosure"); + if (replaceDocument && replaceDisclosure) replaceDisclosure.addEventListener("toggle", () => { + if (!replaceDisclosure.open) replaceDocument.value = ""; + }); + + if (aiRememberChoice) aiRememberChoice.addEventListener("change", () => { + aiChoiceTouched = true; + saveAiPreference(); + }); function syncFiles() { const transfer = new DataTransfer(); diff --git a/efile_app/efile/templates/efile/components/filing_code_search.html b/efile_app/efile/templates/efile/components/filing_code_search.html index 8b08be6b..af779552 100644 --- a/efile_app/efile/templates/efile/components/filing_code_search.html +++ b/efile_app/efile/templates/efile/components/filing_code_search.html @@ -1,10 +1,18 @@ {% load i18n %}
- {% translate "Not sure which code to use?" %} + {% comment %} + The court calls these four choices a filing code, but filers never see + that word: the prompt names what they get, which is the fields filled in. + {% endcomment %} +
+ {% translate "Not sure what to choose?" %} + + {% translate "Answer a few questions and we'll fill these in for you." %} +
+ disabled>{% translate "Help me choose" %}
{% comment %} Three steps, each answering one question: what is being filed (search, ZIP, @@ -22,7 +30,7 @@

{% translate "What are you filing?" %}< + aria-label="{% translate 'Close' %}">
{% translate "Common filing types" %}

- + diff --git a/efile_app/efile/templates/efile/components/profile_header.html b/efile_app/efile/templates/efile/components/profile_header.html index ada140cb..39f471f4 100644 --- a/efile_app/efile/templates/efile/components/profile_header.html +++ b/efile_app/efile/templates/efile/components/profile_header.html @@ -38,30 +38,13 @@
{% if unsupported_state %}{% endif %} {% for code, label in state_choices %}{% endfor %} {% endif %} - - {% if state_source == "efsp" %} - {% translate "Only locations accepted by the filing service are listed. If your address is not listed, contact the court for filing instructions. Do not choose a different state." %} - {% elif state_source == "usps" %} - {% translate "The filing service's location list is unavailable. USPS locations, including territories and military addresses, are shown. The filing service will check whether it accepts your address." %} - {% else %} - {% translate "The filing service's list of states or provinces is unavailable. Enter your address as written; the filing service will check whether it accepts it." %} - {% endif %} - +{% comment %} +The filing service's own list needs no note: only accepted locations can be +chosen, and an address saved outside it gets an error that says what to do. +The fallbacks explain why the list looks different. +{% endcomment %} +{% if state_source != "efsp" %} + + {% if state_source == "usps" %} + {% translate "The filing service's location list is unavailable. USPS locations, including territories and military addresses, are shown. The filing service will check whether it accepts your address." %} + {% else %} + {% translate "The filing service's list of states or provinces is unavailable. Enter your address as written; the filing service will check whether it accepts it." %} + {% endif %} + +{% endif %} diff --git a/efile_app/efile/templates/efile/parties.html b/efile_app/efile/templates/efile/parties.html index 37df7750..d37a5526 100644 --- a/efile_app/efile/templates/efile/parties.html +++ b/efile_app/efile/templates/efile/parties.html @@ -275,17 +275,6 @@

{% translate "Party list" %}

{% if item.party.source != "court" %} {% translate "Edit" %} - - {% csrf_token %} - - - - - {% endif %} {% endif %} diff --git a/efile_app/efile/templates/efile/party_details.html b/efile_app/efile/templates/efile/party_details.html index 831e2224..7a730e64 100644 --- a/efile_app/efile/templates/efile/party_details.html +++ b/efile_app/efile/templates/efile/party_details.html @@ -258,6 +258,16 @@

+
+ {% translate "More options for this person or organization" %} +
+ {% csrf_token %} + + + + +
+
{% if not filer_is_party and claimable %} {% comment %} Offered for a party the document named as much as for a blank row: diff --git a/efile_app/efile/templates/efile/register.html b/efile_app/efile/templates/efile/register.html index 4fe7ee3e..298d673b 100644 --- a/efile_app/efile/templates/efile/register.html +++ b/efile_app/efile/templates/efile/register.html @@ -16,7 +16,7 @@ {% endblock extra_css %} {% block auth_content %}
-

{% translate "Create an eFile account" %}

+

{% translate "Create an account" %}

@@ -43,7 +43,7 @@

{% translate "Create an eFile account
{% csrf_token %} -
{% translate "Your legal name" %}
+
{% translate "Name" %}
{{ form.first_name.label_tag }} {{ form.first_name }} @@ -59,7 +59,7 @@

{% translate "Create an eFile account {% if form.last_name.errors %}
{{ form.last_name.errors }}
{% endif %}

-
{% translate "Physical address" %}
+
{% translate "Address" %}
{{ form.street_address.label_tag }} {{ form.street_address }} {% if form.street_address.errors %}
{{ form.street_address.errors }}
{% endif %} diff --git a/efile_app/efile/templates/efile/review.html b/efile_app/efile/templates/efile/review.html index 9aadbcd6..6a9e616f 100644 --- a/efile_app/efile/templates/efile/review.html +++ b/efile_app/efile/templates/efile/review.html @@ -23,6 +23,7 @@

Before you can submit

{% endif %} {% endif %} +

{% translate "Check each section carefully. If you need to make changes, use Edit to go back to the screen where you entered it." %}

{% include "efile/partials/filing_guidance.html" %} {% if extracted_markers.has_any %}
@@ -89,8 +90,11 @@

{% translate "Case" %}

{% translate "Documents" %}

- {% translate "Edit" %} +
+ {% translate "Document options" %} + {% translate "Edit" %} +
{% include "efile/partials/correction_section_issues.html" with section="documents" %}
diff --git a/efile_app/efile/templates/efile/upload_documents.html b/efile_app/efile/templates/efile/upload_documents.html index 87e1fd5a..40c86f17 100644 --- a/efile_app/efile/templates/efile/upload_documents.html +++ b/efile_app/efile/templates/efile/upload_documents.html @@ -7,7 +7,7 @@ {% endblock title %} {% block workflow_content %}
-

{% translate "Upload your court documents" %}

+

{% translate "Upload your documents" %}

{% include "efile/partials/filing_guidance.html" %} {% if saved_path %}

@@ -48,15 +48,18 @@

{% translate "Upload your court documents" %}

data-wait-seconds="{{ extraction_wait_seconds }}"> {% csrf_token %} {% if documents %} - - -

To replace a document, select it above and choose one new file. Its filing choices stay saved. Review the new PDF before submitting.

+
+ {% translate "Replace a document" %} + + +

To replace a document, select it above and choose one new file. Its filing choices stay saved. Review the new PDF before submitting.

+
{% endif %}
{% translate "Upload your court documents" %}

aria-describedby="upload-requirements upload-preparation" multiple /> - + {% translate "PDF or Word (DOC or DOCX)" %} {% translate "10 MB per file" %} @@ -130,16 +133,16 @@

{% translate "Selected files" %}

type="button" id="ai-explainer" aria-label="{% translate "How do we use AI? Read our explanation." %}"> - {% translate "How do we use AI?" %} + {% translate "Learn more about how we use AI." %} {% comment %} - Offered only once the filer has actually changed the choice above, - and only for the rest of that page load: someone who never touches - the setting is not asked to make a decision about it. + Account preferences are optional secondary controls. Merely opening + this disclosure does not change the saved preference. {% endcomment %} -
+
+ {% translate "AI preferences for future filings" %}
{% translate "Selected files" %} {% translate "Remember this choice and use it for all my future filings" %}
-
+
{% comment %} Both states of the choice are written here so each one is a translated string rather than something JavaScript assembles. The diff --git a/efile_app/efile/tests/test_ai_opt_out.py b/efile_app/efile/tests/test_ai_opt_out.py index f47f7dcc..c863af15 100644 --- a/efile_app/efile/tests/test_ai_opt_out.py +++ b/efile_app/efile/tests/test_ai_opt_out.py @@ -241,13 +241,13 @@ def test_review_screen_credits_the_keyword_search_rather_than_a_reading(client, @pytest.mark.django_db -def test_the_remember_row_is_not_offered_until_the_filer_changes_the_setting(client, opted_out_draft): +def test_saved_ai_preference_is_available_in_a_closed_disclosure(client, opted_out_draft): page = client.get(upload_url()).content.decode() - # It is in the markup, closed: the script opens it on the first change, and - # a filer who never touches the setting is never asked about their account. + # The optional preference is discoverable without changing the filing choice. assert 'id="ai-remember"' in page - assert 'class="collapse ai-remember"' in page + assert '
' in page + assert "AI preferences for future filings" in page assert "Remember this choice" in page assert 'id="ai-remember-choice" />' in page diff --git a/efile_app/efile/tests/test_document_prep.py b/efile_app/efile/tests/test_document_prep.py index 098a998c..4a584981 100644 --- a/efile_app/efile/tests/test_document_prep.py +++ b/efile_app/efile/tests/test_document_prep.py @@ -223,7 +223,7 @@ def test_organize_shows_no_radio_choice_for_a_single_document(client, document_d assert response.status_code == 200 content = response.content.decode() - assert "This is your main document." in content + assert "main-document-choice" not in content assert 'type="radio"' not in content assert f'name="main_document" value="{lead.pk}"' in content diff --git a/efile_app/efile/tests/test_document_preparation.py b/efile_app/efile/tests/test_document_preparation.py index 1a9b4738..fbfe5557 100644 --- a/efile_app/efile/tests/test_document_preparation.py +++ b/efile_app/efile/tests/test_document_preparation.py @@ -194,3 +194,49 @@ def test_special_field_appearances_do_not_require_literal_value_text(kind): "efile.services.document_preparation.requests.post", return_value=service_response(pdf_bytes("12/34/56")) ): assert prepare_document(upload(output.getvalue()), "vermont").operation == "flattened" + + +def flattened_with_leftover_widget(field_type, value=None): + """Service output that still carries one widget with no appearance.""" + from pypdf import PdfWriter + from pypdf.generic import ArrayObject, DictionaryObject, FloatObject, NameObject, NumberObject, TextStringObject + + writer = PdfWriter(clone_from=PdfReader(io.BytesIO(pdf_bytes("Filled answer")))) + widget = DictionaryObject( + { + NameObject("/Type"): NameObject("/Annot"), + NameObject("/Subtype"): NameObject("/Widget"), + NameObject("/FT"): NameObject(field_type), + NameObject("/T"): TextStringObject("leftover"), + NameObject("/F"): NumberObject(4), + NameObject("/Rect"): ArrayObject([FloatObject(n) for n in [50, 100, 300, 130]]), + } + ) + if value is not None: + widget[NameObject("/V")] = NameObject(value) + writer.pages[0][NameObject("/Annots")] = ArrayObject([writer._add_object(widget)]) + output = io.BytesIO() + writer.write(output) + return output.getvalue() + + +def test_blank_signature_lines_left_by_the_flattener_are_removed(): + # The flatten engine skips widgets with no appearance, so an unsigned + # signature line comes back as a leftover form field that draws nothing. + output = flattened_with_leftover_widget("/Sig") + with patch("efile.services.document_preparation.requests.post", return_value=service_response(output)): + prepared = prepare_document(upload(pdf_bytes(form_value="Filled answer")), "vermont") + assert prepared.operation == "flattened" + reader = PdfReader(io.BytesIO(prepared.content)) + assert "/Annots" not in reader.pages[0] + assert not reader.get_fields() + assert "Filled answer" in reader.pages[0].extract_text() + + +def test_a_leftover_widget_that_still_holds_an_answer_is_rejected(): + # A checked box that lost its appearance still has an answer; dropping it + # would file the form without that answer. + output = flattened_with_leftover_widget("/Btn", "/Yes") + with patch("efile.services.document_preparation.requests.post", return_value=service_response(output)): + with pytest.raises(PreparationError, match="could not prepare this form"): + prepare_document(upload(pdf_bytes(form_value="Filled answer")), "vermont") diff --git a/efile_app/efile/tests/test_filer_role.py b/efile_app/efile/tests/test_filer_role.py index 322ca2dc..5137c171 100644 --- a/efile_app/efile/tests/test_filer_role.py +++ b/efile_app/efile/tests/test_filer_role.py @@ -304,3 +304,61 @@ def test_without_a_side_the_party_type_is_still_guessed_from_posture(signed_in): ] assert guessed_party_type(signed_in, party_types)["code"] == "PL" + + +@pytest.mark.django_db +def test_no_detected_side_defers_to_the_document_checklist(client, signed_in): + response = confirm_filing(client, filing_type_name="Other filing") + + signed_in.refresh_from_db() + assert response.status_code == 302 + assert response.url.partition("?")[0] == CHECKLIST_URL + assert signed_in.filer_role == "" + page = client.get(response.url) + assert page.context["choosing_filer_role"] is True + + +@pytest.mark.django_db +def test_deferred_side_cannot_accept_an_unoffered_role(client, signed_in): + response = confirm_filing(client, filing_type_name="Other filing", filer_role="invalid") + assert response.status_code == 200 + signed_in.refresh_from_db() + assert signed_in.filer_role == "" + + +@pytest.mark.django_db +def test_a_saved_side_still_on_offer_must_be_confirmed(client, signed_in): + signed_in.filer_role = "landlord" + signed_in.save() + + response = confirm_filing(client, filing_type_name="Other filing") + + assert response.status_code == 200 + assert "Choose which side of this case you are on" in response.content.decode() + signed_in.refresh_from_db() + assert signed_in.filer_role == "landlord" + + +@pytest.mark.django_db +def test_a_side_the_new_case_type_does_not_offer_is_cleared(client, signed_in): + signed_in.filer_role = "petitioner" + signed_in.save() + + response = confirm_filing(client, filing_type_name="Other filing") + + assert response.status_code == 302 + signed_in.refresh_from_db() + assert signed_in.filer_role == "" + assert client.get(response.url).context["choosing_filer_role"] is True + + +@pytest.mark.django_db +def test_a_review_detour_with_an_unanswered_side_goes_through_the_checklist(client, signed_in): + response = confirm_filing(client, filing_type_name="Other filing", return_to="review") + + assert response.status_code == 302 + path, _, query = response.url.partition("?") + assert path == CHECKLIST_URL + assert "return_to=review" in query + signed_in.refresh_from_db() + assert signed_in.current_step == WorkflowStepKey.DOCUMENT_CHECKLIST diff --git a/efile_app/efile/tests/test_filing_path_navigation.py b/efile_app/efile/tests/test_filing_path_navigation.py index 60e5afe4..07f694f1 100644 --- a/efile_app/efile/tests/test_filing_path_navigation.py +++ b/efile_app/efile/tests/test_filing_path_navigation.py @@ -220,7 +220,7 @@ def test_upload_shows_the_saved_answer_with_a_change_link(signed_in): content = signed_in.get(UPLOAD_URL).content.decode() - assert "Start a new case" in content + assert "Starting a new case" in content assert f'href="{FILING_PATH_URL}' in content assert "Change whether this is a new or existing case" in content @@ -435,3 +435,17 @@ def test_new_or_existing_comes_before_the_case_fields_with_the_case_number_besid fields = content.index('class="review-grid"') category = content.index('id="case_category_code"') assert section < question < case_number < fields < category + + +@pytest.mark.django_db +def test_generic_start_keeps_back_at_home_and_branch_unset(signed_in): + response = signed_in.post(START_URL, {}) + draft = FilingDraft.objects.latest("pk") + assert response.url.partition("?")[0] == UPLOAD_URL + assert draft.existing_case == "" + assert back_link(signed_in.get(response.url).content.decode()) == OPTIONS_URL + assert WorkflowStepKey.FILING_PATH not in [step.key for step in get_visible_workflow(draft)] + reviewed_document(draft=draft, role=FilingDocument.Role.LEAD, name="document.pdf") + confirm_page = signed_in.get(CONFIRM_URL) + assert confirm_page.context["path_question_open"] + assert confirm_page.context["chosen_existing_case"] == "" diff --git a/efile_app/efile/tests/test_navigation_menu.py b/efile_app/efile/tests/test_navigation_menu.py index 0ea83775..b03ad62a 100644 --- a/efile_app/efile/tests/test_navigation_menu.py +++ b/efile_app/efile/tests/test_navigation_menu.py @@ -39,10 +39,10 @@ def test_every_signed_in_screen_carries_the_filing_menu(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 "Start filing" in content + assert "File into an existing case" not in content assert "My draft e-filings" in content - assert "My cases" in content + assert "My filings" 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 @@ -56,7 +56,7 @@ def test_the_menu_is_there_part_way_through_a_filing_too(client, user): content = client.get(FILING_PATH_URL).content.decode() - assert "Start a new case" in content + assert "Start filing" in content assert "My draft e-filings" in content assert START_URL in content @@ -89,12 +89,12 @@ def test_starting_a_filing_from_the_menu_knows_which_kind_it_is(client, user, ch @pytest.mark.django_db -def test_starting_a_filing_without_saying_which_kind_asks(client, user): +def test_generic_start_defers_the_branch_until_confirm(client, user): sign_in(client, user) response = client.post(START_URL, {}) - assert response.url == f"{FILING_PATH_URL}?draft={FilingDraft.objects.get(user=user).pk}" + assert response.url == f"{UPLOAD_URL}?draft={FilingDraft.objects.get(user=user).pk}" assert FilingDraft.objects.get(user=user).existing_case == "" @@ -114,7 +114,7 @@ def test_starting_a_filing_never_reopens_the_last_one(client, user): session[CURRENT_DRAFT_SESSION_KEY] = old.pk session.save() - client.post(START_URL, {"existing_case": "new"}) + client.post(START_URL, {}) current_id = client.session[CURRENT_DRAFT_SESSION_KEY] assert current_id != old.pk diff --git a/efile_app/efile/tests/test_people_flow.py b/efile_app/efile/tests/test_people_flow.py index 257e834e..faefe257 100644 --- a/efile_app/efile/tests/test_people_flow.py +++ b/efile_app/efile/tests/test_people_flow.py @@ -880,3 +880,39 @@ def test_a_party_already_saved_with_the_filers_role_starts_confirmed(client, peo confirm = re.search(r'
]*>', content) assert confirm is not None and "hidden" not in confirm.group() assert re.search(r'id="same_role_confirmed"[^>]*checked', content, re.S) + + +@pytest.mark.django_db +def test_party_details_removes_the_party_and_keeps_the_review_detour(client, people_draft): + FilingParty.objects.create( + draft=people_draft, + role="filer", + sort_order=0, + party_type="plaintiff", + first_name="Jamie", + last_name="Rivera", + address_line_1="100 State Street", + city="Chicago", + state="IL", + zip_code="60601", + ) + party = FilingParty.objects.create(draft=people_draft, role="other", sort_order=0, first_name="Duplicate") + url = reverse("party_details", kwargs={"jurisdiction": "illinois"}) + parties_url = reverse("parties", kwargs={"jurisdiction": "illinois"}) + + with patch("efile.views.party_details.get_party_types", return_value=PARTY_TYPES): + page = client.get(f"{url}?party={party.pk}&return_to=review").content.decode() + remove_form = re.search(rf'(.*?)', page, re.S) + assert remove_form is not None + assert 'name="action" value="remove"' in remove_form.group(1) + assert f'name="party_id" value="{party.pk}"' in remove_form.group(1) + assert 'name="return_to" value="review"' in remove_form.group(1) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + response = client.post(parties_url, {"action": "remove", "party_id": party.pk, "return_to": "review"}) + + assert response.status_code == 302 + path, _, query = response.url.partition("?") + assert path == parties_url + assert "return_to=review" in query + assert not FilingParty.objects.filter(pk=party.pk).exists() diff --git a/efile_app/efile/tests/test_public_pages.py b/efile_app/efile/tests/test_public_pages.py index 9886fd1a..ddfcd78a 100644 --- a/efile_app/efile/tests/test_public_pages.py +++ b/efile_app/efile/tests/test_public_pages.py @@ -152,7 +152,7 @@ def test_about_page_carries_filing_menu_when_signed_in(client, django_user_model assert response.status_code == 200 content = response.content.decode() - assert "Start a new case" in content + assert "Start filing" in content assert "My draft e-filings" in content assert "Illinois e-Filing" in content diff --git a/efile_app/efile/utils/ui_text.py b/efile_app/efile/utils/ui_text.py index 45665cf6..fdbe8bad 100644 --- a/efile_app/efile/utils/ui_text.py +++ b/efile_app/efile/utils/ui_text.py @@ -128,9 +128,6 @@ class UIString: description="Names the kind of document that opens a case, which differs by state.", links=True, ), - "organize_documents.single_document_note": UIString( - default="This is your {main_document}.", - ), "organize_documents.filing_component_label": UIString( default="Court filing component", description=( diff --git a/efile_app/efile/views/document_checklist.py b/efile_app/efile/views/document_checklist.py index 580774b1..c3f71f74 100644 --- a/efile_app/efile/views/document_checklist.py +++ b/efile_app/efile/views/document_checklist.py @@ -25,6 +25,7 @@ filing_type_for_item, grouped_checklist, mark_item_have, + needs_filer_role, set_checklist_answers, set_filer_role, status_choices, @@ -217,7 +218,7 @@ def document_checklist(request, jurisdiction): # Normally answered on the confirm-filing step. It can still be # unanswered here on the existing-case path, where the case type is not # known until after the case lookup, so the question has a home here too. - "choosing_filer_role": bool(filer_roles) and not draft.filer_role, + "choosing_filer_role": needs_filer_role(draft), "checklist_groups": grouped_checklist(plan, draft), "status_choices": status_choices(), "guidance": plan.guidance if plan else {}, diff --git a/efile_app/efile/views/draft_views.py b/efile_app/efile/views/draft_views.py index 4a1027a3..324599ed 100644 --- a/efile_app/efile/views/draft_views.py +++ b/efile_app/efile/views/draft_views.py @@ -19,16 +19,10 @@ @require_http_methods(["POST"]) def start_filing(request, jurisdiction): - """Start a filing from anywhere, already knowing which kind it is. + """Start a fresh draft; ordinary starts choose new/existing after upload. - 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. + Explicit paths from older links remain supported. Neither kind of start + carries answers over from the draft the user was previously working on. """ if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): @@ -39,17 +33,17 @@ def start_filing(request, jurisdiction): draft = create_current_draft( request, jurisdiction, - current_step=WorkflowStepKey.FILING_PATH, + current_step=WorkflowStepKey.UPLOAD_DOCUMENTS, workflow_version=2, ) logger.info("Started draft id=%s from the navigation menu (path=%s)", draft.pk, path or "unset") + # Without an answer, leave it unset until the filer confirms it beside + # their document. if path in {ExistingCase.NEW, ExistingCase.EXISTING}: - write_case_data(draft, {"existing_case": path}, current_step=WorkflowStepKey.UPLOAD_DOCUMENTS) - record_filing_path_source(draft, FilingPathSource.START_MENU) - 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)) + write_case_data(draft, {"existing_case": path}) + record_filing_path_source(draft, FilingPathSource.START_MENU) + return redirect(get_step_url(WorkflowStepKey.UPLOAD_DOCUMENTS, jurisdiction)) @require_http_methods(["POST"]) diff --git a/efile_app/efile/views/extraction_review.py b/efile_app/efile/views/extraction_review.py index 7969b5be..ebf5b451 100644 --- a/efile_app/efile/views/extraction_review.py +++ b/efile_app/efile/views/extraction_review.py @@ -31,6 +31,7 @@ describe_path_change, filing_path_conflict, ) +from efile.services.filing_plans import needs_filer_role from efile.services.filing_type_proposals import current_proposal, save_proposal from efile.utils.s3_upload_handler import S3UploadHandler from efile.workflow import ( @@ -237,11 +238,11 @@ def extraction_review(request, jurisdiction): ) ) - offered_roles = ( - {role["id"] for role in _offered_filer_roles(request, jurisdiction)} - if existing_case == ExistingCase.NEW - else set() - ) + roles = _offered_filer_roles(request, jurisdiction) if existing_case == ExistingCase.NEW else [] + offered_roles = {role["id"] for role in roles} + # Confirm detected roles beside the suggestion. Without a suggestion, + # Check documents can ask later if its side-specific plan needs one. + role_confirmation_required = any(role["suggested"] for role in roles) or draft.filer_role in offered_roles filer_role = request.POST.get("filer_role", "") if availability_message: @@ -257,17 +258,19 @@ def extraction_review(request, jurisdiction): # a new case can't proceed on free-text guesses -- unlike an existing # case, which resolves these from the case lookup step instead. messages.error(request, "Choose a court, case category, and case type from the lists to continue.") - elif offered_roles and filer_role not in offered_roles: - # This case type means two different jobs, and the documents follow - # the side rather than the case, so there is nothing to show until - # the filer says which side is theirs. + elif (role_confirmation_required or filer_role) and filer_role not in offered_roles: + # A detected or previously confirmed role must be checked here. + # With no suggestion, the checklist can ask for the side later. messages.error(request, "Choose which side of this case you are on to continue.") elif approval_error := _document_approval_error(draft, request): # Last, so the files are only marked checked when the rest of the # form is accepted along with them. preview_error, status = approval_error else: - if offered_roles and draft.filer_role != filer_role: + # Validated above, so this is an offered side or, when nothing + # needed confirming, a blank that drops a side the new case type + # no longer has. + if existing_case == ExistingCase.NEW and draft.filer_role != filer_role: draft.filer_role = filer_role draft.save(update_fields=["filer_role", "updated_at"]) # Before the rest is saved: switching kinds of filing clears the @@ -325,6 +328,13 @@ def extraction_review(request, jurisdiction): if existing_case == ExistingCase.EXISTING and not draft.previous_case_id: write_case_data(draft, {}, current_step=WorkflowStepKey.CASE_LOOKUP) return redirect(with_return_to(get_step_url(WorkflowStepKey.CASE_LOOKUP, jurisdiction), return_to)) + if needs_filer_role(draft): + # The side was left for the checklist to ask, so the + # detour goes there before returning. + write_case_data(draft, {}, current_step=WorkflowStepKey.DOCUMENT_CHECKLIST) + return redirect( + with_return_to(get_step_url(WorkflowStepKey.DOCUMENT_CHECKLIST, jurisdiction), return_to) + ) linear = get_next_step(WorkflowStepKey.EXTRACTION_REVIEW, draft) default_step = linear.key if linear else WorkflowStepKey.REVIEW write_case_data(draft, {}, current_step=continue_step(draft, return_to, default_step)) @@ -360,8 +370,8 @@ def classified(level, key): "case_type_name": draft.case_type_name or classified("case type", "name"), "filing_type_code": (lead.filing_type_code if lead else "") or classified("filing type", "route_key"), "filing_type_name": (lead.filing_type_name if lead else "") or classified("filing type", "name"), - # Only some case types have sides. The screen asks for one as soon as - # the chosen case type turns out to be one of them. + # Keep a confirmed side visible; otherwise ask here only when the + # filing type suggests one. The checklist handles undecided sides. "filer_role": draft.filer_role, "zip_shortcuts": _zip_shortcuts(request, draft, jurisdiction), } diff --git a/efile_app/tests/confirm-case-editing.spec.js b/efile_app/tests/confirm-case-editing.spec.js index a0dcd937..93ae92c7 100644 --- a/efile_app/tests/confirm-case-editing.spec.js +++ b/efile_app/tests/confirm-case-editing.spec.js @@ -356,11 +356,11 @@ test.describe('filing code search', () => { }); const toCourts = page => button(page, 'Next: court and case type').click(); const toCheck = page => button(page, 'Next: check and use').click(); - const useCode = page => button(page, 'Use this filing code').click(); + const useCode = page => button(page, 'Fill in these answers').click(); async function openSearch(page) { await page.getByRole('button', { - name: 'Find the right filing code' + name: 'Help me choose' }).click(); return page.getByRole('dialog'); } @@ -474,7 +474,7 @@ test.describe('filing code search', () => { await openSavedDraft(page); await openSearch(page); await button(page, 'Complaint').click(); - await expect(page.locator('#code-search-status')).toContainText('Code search could not be loaded'); + await expect(page.locator('#code-search-status')).toContainText('This helper could not be loaded'); await searchbox(page).press('Enter'); await expect(page.getByRole('dialog').getByRole('radio')).toBeVisible(); }); @@ -513,7 +513,7 @@ test.describe('filing code search', () => { const queries = await mockSearch(page); await openSavedDraft(page); const open = page.getByRole('button', { - name: 'Find the right filing code' + name: 'Help me choose' }); const dialog = await openSearch(page); await expect(searchbox(page)).toBeFocused(); @@ -1360,7 +1360,7 @@ test.describe('filing code search', () => { await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).not.toBeVisible(); await expect(page.getByRole('button', { - name: 'Find the right filing code' + name: 'Help me choose' })).toBeFocused(); });