Change evidence report

CRM numeric editing states: reviewer evidence

PR 1900 keeps CRM numeric controls as editable strings until their form boundary, so deletion no longer paints zero back into the field. Required, optional, integer, decimal, minimum and maximum contracts remain field-specific. The final candidate also rejects a blank visit-pack credit count, fractional guest passes, fractional stock, blank required sort values and newly entered non-HTTP(S) product image URLs while preserving unchanged legacy image values.

Range
9d8fdc955b1bf705f129ec5ac981fca02f88d911…956789388190002261d0bdb40c90f03fd85e65d6
Source state
fix/1880-numeric-input-editing at code head 956789388190002261d0bdb40c90f03fd85e65d6; this evidence source is the following report update
Generated

Reviewer brief

Outcome and scope

Blank editing, replacement, decimal input and explicit validation are observable

The original browser captures reproduce the duration snapback and candidate blank/error states. The rebased candidate preserves those fixes and adds submit-time validation for visit-pack credits, guest passes, product stock, required sort order and product image URLs. Focused component suites total 38 passing tests and CRM typecheck passes.

Evidence boundary

The duration and stock screenshots were captured from exact pre-rebase revisions 6049e37fc and 707767a4f. The final rebase did not change those pictured controls, but the final validation corrections were added afterward and are evidenced by current-head component tests and source excerpts rather than recaptured screenshots. No production data was used.

Claim map

Evidence coverage

Each material changed behavior or state maps to direct evidence or an explicit gap.

Behavior or claimEvidenceStatus
Clearing a required numeric field preserves an empty editing valueCurrent-head ClassFormDialog interaction test asserts input.value is empty; matched screenshots show the old zero value versus candidate validation, whose visible 60 is placeholder textCovered
Required blanks and invalid optional values use field-specific React validationDuration and stock captures plus current-head requestSubmit testsCovered
Visit-pack credits remain positive decimals and guest passes remain whole numbers from 0 through 100MembershipPlanFormDialog current-head tests cover blank credits and fractional guest passes; source excerpt shows submit guardsCovered
Product stock and sort order reject fractional or blank input without coercionProductFormDialog current-head interaction tests distinguish blank, zero, fraction and required blankCovered
New product image URLs accept only HTTP(S), while unchanged legacy values remain editableProductFormDialog current-head tests cover a malformed value, javascript: and an unchanged legacy path; the shared isHttpUrl guard rejects other non-HTTP(S) schemes; issue #1915 tracks server-side enforcementCovered
Class, hook and membership numeric boundaries preserve typed editing until submitCurrent-head Class 11, Membership 14, Product 6 and Hook 7 test suitesCovered

Observed change

Review evidence

Evidence is grouped by the reviewer question each item addresses.

Sessions, booking fields

Screenshot

Deleting Duration no longer snaps to zero

The same local Serenity session form at 1280 by 900 CSS pixels. The merge-base eagerly coerces the cleared string to zero. In the candidate the DOM value remains empty, the browser displays the field placeholder 60, and submit renders the form-owned required error.

Merge-base after selecting and deleting Duration: React immediately paints 0 back into the number control.Before
Session booking form at merge-base with Duration showing zero after deletion
Candidate after deleting Duration and submitting: the DOM value is blank; the visible 60 is placeholder text, and Duration is required appears below.After
Session booking form at candidate showing the Duration placeholder 60 in an empty input and a Duration is required error

Products

Screenshot

Invalid optional stock reaches the form-owned error

The Product form carries noValidate, so submitting negative stock reaches React validation and keeps the dialog open.

Stock remains -1, is marked invalid and displays Stock cannot be negative instead of being blocked silently by the browser.After
Edit product dialog showing Stock minus one with Stock cannot be negative validation

Validation contract

Table

Observed and automated form-boundary cases

Each value class is retained as editable text, then validated and converted once at submit.

CaseExpected contractEvidence
Required blank numericKeep blank and refuse submit with a field messageDuration capture; Class, Membership and Product tests
Optional blank numericSubmit null rather than zeroProduct interaction test
Explicit zeroRemain legitimate where the field allows zeroStock capture and Product tests
Positive decimal creditsPreserve up to two decimals and reject blank or non-positiveMembership tests and source guard
Whole-number fieldsReject fractions instead of truncatingClass, Membership and Product tests
Guest passesAccept only whole numbers from 0 through 100Membership tests and source guard
Product image URLAccept HTTP(S), refuse unsafe new schemes, preserve an unchanged legacy valueProduct URL validation tests

Membership plan validation

Code

Credits and guest passes keep distinct numeric contracts

Visit packs require a positive decimal credit count with at most two decimal places. Recurring-plan guest passes remain optional but must be a whole number from 0 through 100.

Submit-time membership numeric guardsAfter
packages/crm/src/components/memberships/MembershipPlanFormDialog.tsx · 956789388190 · lines 202-221
  const visitCountValue = visitCount.trim();
  const visitCountError = !isCreditPack
    ? null
    : visitCountValue === ""
      ? "Enter a credit count."
      : !CREDIT_COUNT_PATTERN.test(visitCountValue) ||
          Number(visitCountValue) <= 0
        ? "Credit count must be a positive number with up to two decimal places."
        : null;
  const guestPassesValue = guestPassesPerMonth.trim();
  const parsedGuestPasses =
    guestPassesValue === "" ? null : Number(guestPassesValue);
  const guestPassesError =
    !isCreditPack &&
    parsedGuestPasses !== null &&
    (!Number.isInteger(parsedGuestPasses) ||
      parsedGuestPasses < 0 ||
      parsedGuestPasses > 100)
      ? "Guest passes must be a whole number between 0 and 100."
      : null;

Product validation

Code

Image URLs, stock and sort order fail before mutation

New image values require HTTP(S). Numeric stock and sort values remain text until the form validates whole-number and blank constraints.

Product form validation boundaryAfter
packages/crm/src/components/products/ProductFormDialog.tsx · 956789388190 · lines 142-168
  const nameError = name.trim() ? null : "Enter a product name.";
  const priceError = validatePriceInput(price);
  const categoryError = categoryId ? null : "Select a category.";
  const trimmedImageUrl = imageUrl.trim();
  const keepsLegacyImageUrl = !!product && trimmedImageUrl === product.imageUrl;
  const imageUrlError =
    trimmedImageUrl === "" || keepsLegacyImageUrl || isHttpUrl(trimmedImageUrl)
      ? null
      : "Enter a valid http(s) URL.";
  const parsedStockQuantity =
    stockQuantity.trim() === "" ? null : Number(stockQuantity.trim());
  const stockQuantityError =
    parsedStockQuantity === null
      ? null
      : !Number.isFinite(parsedStockQuantity) ||
          !Number.isInteger(parsedStockQuantity)
        ? "Stock must be a whole number."
        : parsedStockQuantity < 0
          ? "Stock cannot be negative."
          : null;
  const parsedSortOrder = Number(sortOrder.trim());
  const sortOrderError =
    sortOrder.trim() === ""
      ? "Enter a sort order."
      : !Number.isFinite(parsedSortOrder) || !Number.isInteger(parsedSortOrder)
        ? "Sort order must be a whole number."
        : null;

Confidence

Validation

Pass

Class form

11 passed, 0 failed, 64 assertions

Pass

Membership plan form

14 passed, 0 failed, 51 assertions, including blank credit count and fractional guest passes

Pass

Product form

6 passed, 0 failed, 29 assertions, including stock/sort coercion and image URL safety

Pass

Hook form

7 passed, 0 failed, 26 assertions

Pass

CRM typecheck

bun run --filter @sauna-crm/crm typecheck exited successfully on the rebased candidate

Warn

Final-head screenshot matrix

Existing captures cover unchanged duration and stock states at the cited pre-rebase revisions; final validation additions are covered by current DOM interaction tests, not new screenshots

Limits

Known issues, risks, and gaps

Current-head tests cover all changed form contracts; image URL enforcement remains client-side until issue #1915 is resolved

Product image URL enforcement is not yet an API invariant The CRM refuses unsafe new values and rendering uses the shared safe-URL helper, but another client can still submit one. Issue #1915 tracks server-side enforcement.

Some visual evidence predates the final rebase The pictured duration and stock behavior is unchanged, and current-head interaction tests defend the final DOM contract. No screenshot is presented as proof of the later image URL, credit-count or guest-pass corrections.