Skip to content

feat(attachments): robust photo uploads — retry, per-photo progress, real XHR progress#1525

Draft
brokenalarms wants to merge 5 commits into
sysadminsmedia:mainfrom
brokenalarms:up/attachment-upload-robustness
Draft

feat(attachments): robust photo uploads — retry, per-photo progress, real XHR progress#1525
brokenalarms wants to merge 5 commits into
sysadminsmedia:mainfrom
brokenalarms:up/attachment-upload-robustness

Conversation

@brokenalarms

Copy link
Copy Markdown

What type of PR is this?

  • feature

What this PR does / why we need it:

Makes attachment/photo uploads resilient and gives users real feedback during upload. Bundled as one cohesive feature because the parts share a common composable and error path.

  • frontend/lib/requests/extract-error.ts (+ test): reusable extractErrorMessage() that normalizes API/thrown errors into a human-readable string.
  • frontend/lib/requests/retry.ts (+ test): generic retry helper for upload requests, using extractErrorMessage() to report the failure reason.
  • frontend/composables/use-attachment-upload.ts: shared upload composable with retry, applied to the item edit page (pages/item/[id]/index/edit.vue).
  • frontend/components/Item/CreateModal.vue: retry + per-photo upload progress on item creation.
  • frontend/lib/requests/xhr-upload.ts (+ test) and lib/api/classes/items.ts / lib/requests/requests.ts: real upload progress via XHR (replacing simulated progress) so the progress bar reflects actual bytes sent.

Which issue(s) this PR fixes:

N/A

Special notes for your reviewer:

This combines four internally-staged changes (error extractor → upload composable+retry → create-item retry/progress → real XHR progress) into a single feature PR, since they build on each other. Happy to split into a stack if you'd prefer to review them separately.

Testing

  • Unit tests added: extract-error.test.ts, retry.test.ts, xhr-upload.test.ts.
  • Manually: uploaded photos on both create and edit flows; observed per-photo progress and retry-on-failure behavior.
  • eslint passes clean on the changed files.

brokenalarms and others added 4 commits June 1, 2026 19:29
The frontend was showing generic toast messages ("Couldn't create item",
"Failed to upload attachment") on API failures, hiding the real reason
the backend already provides in its `ErrorResponse { error, fields? }`
JSON shape.

Add `lib/requests/extract-error.ts` — a pure function that:

- Reads the backend's standard error/fields shape and prefers
  per-field messages over the generic top-level error
- Strips Go validator boilerplate, turning
  "Field validation for 'Name' failed on the 'required' tag" into
  "Name is required"
- Handles legacy `[{ field, error }]` array responses, raw thrown
  exceptions (e.g. network failure), and falls back through statusText
  and HTTP status as last resorts
- Is fully covered by `extract-error.test.ts` (12 cases)

Apply it to the Create Item modal's item-create error path as the
first concrete consumer: validation failures now show the real reason
("Couldn't create item: Name is required") instead of just "Couldn't
create item". Other call sites will switch to the extractor in
follow-up PRs.

https://claude.ai/code/session_012aZhnQmF8RGDXAMdpKdoka

Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 919fc0f)
…ge (#3)

Mobile attachment uploads were silently failing on the item edit page:
thrown `fetch` exceptions (network drop, server read timeout) never
reached any error handler, so the dropzone gave no feedback and the
user had to refresh to find out whether the upload landed. Server-side
errors only ever surfaced as a generic "Failed to upload attachment"
toast.

Split the fix into two reusable layers:

- `lib/requests/retry.ts` — pure `withRetries(call, options)` helper
  that wraps any `TResponse`-returning call in try/catch, retries
  transient failures (status 0 from thrown exceptions, 408, 429, 5xx)
  with configurable backoff, and gives up immediately on 4xx (won't
  get better). Returns a tagged-union `RetryResult` so callers handle
  one shape regardless of failure mode. Fully covered by 11 vitest
  cases (success/retry/give-up paths, backoff schedule, 4xx-no-retry,
  thrown-error surfacing).

- `composables/use-attachment-upload.ts` — thin Vue wrapper exposing
  `uploadFile`, `uploadExternalLink`, and a reactive `uploading`
  counter for in-flight UI state. Errors are extracted via the
  `extract-error` helper added in the parent PR.

Apply both to the item edit page's file dropzone and URL drop. The
dropzone area now visibly switches to a spinning loader plus
"Uploading attachment…" while a request is in flight, and failure
toasts include the real reason ("Failed to upload attachment: failed
to parse multipart form" instead of just "Failed to upload
attachment"). The Create Item modal moves over in the next PR.

https://claude.ai/code/session_012aZhnQmF8RGDXAMdpKdoka

Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 6e40280)
The Create Item modal had three mobile-upload UX failures: a thrown
fetch exception left the submit button stuck disabled forever (loading
flag never reset); when a photo upload failed cleanly the item was
created with no picture and no way to retry without re-creating it;
and during the upload itself the UI gave only a fleeting toast — the
disabled button with a static package icon looked dead.

Switch the create flow to use the attachment-upload composable and
restructure the handler:

- The whole body is wrapped in try/catch/finally so loading is always
  reset and unexpected exceptions surface as a toast with the real
  message instead of vanishing.
- Each photo upload now goes through the composable (retry on
  network/5xx, real error reason via extractErrorMessage). Per-photo
  progress is tracked in `uploadProgress` and rendered on the submit
  button as "Uploading 2 / 5…" — with a spinning MdiLoading icon
  replacing the static package icon while in flight.
- On partial success — item created but one or more photos failed —
  the failed photos stay in `form.photos` and `pendingItemId` is
  stashed. The submit button label flips to "Retry photo upload" and
  the "Create and Add Another" button is hidden in that state. The
  retry path skips item creation entirely and only re-uploads the
  remaining photos to the existing item, so there's no risk of
  duplicate items.
- Opening the modal fresh clears `pendingItemId` so stale retry state
  doesn't carry over between sessions.

https://claude.ai/code/session_012aZhnQmF8RGDXAMdpKdoka

Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 123b22e)
The previous two PRs added a spinner and a per-photo counter ("Uploading
2 / 5…") to the Create Item modal and the edit-page dropzone, but
neither showed a percentage — because `fetch()` doesn't expose a usable
upload-progress event. On a slow mobile connection the spinner can still
sit for 10+ seconds per photo with no signal of forward motion.

Add an XHR-based transport so call sites can subscribe to real bytes-
uploaded events:

- `lib/requests/xhr-upload.ts` — standalone helper that wraps
  `XMLHttpRequest.upload.onprogress`, parses the response the same way
  `Requests.do` does, and resolves to a `TResponse<T>`. Rejects only on
  transport failure (network / abort / timeout) so callers can read
  4xx/5xx via `result.error`. Covered by 8 vitest cases that drive a
  fake XHR through every code path.
- `Requests.postWithProgress` — public method that wires the existing
  auth token, base URL, and response interceptors into the XHR helper.
  Mirrors `post`'s `TResponse<U>` shape so the rest of the stack is
  unchanged.
- `AttachmentsAPI.addWithProgress` — same payload as `add`, just routes
  through `postWithProgress` and exposes the progress callback. The
  multipart-form construction is now extracted into a shared
  `buildAttachmentForm` helper used by both `add` and `addWithProgress`.

Wire into the call sites:

- `useAttachmentUpload.uploadFile` takes an optional `onProgress`
  callback. If passed, it routes through `addWithProgress` (XHR);
  otherwise it stays on `add` (fetch) so consumers that don't care
  pay no cost.
- CreateModal extends its `uploadProgress` shape with `percent` and
  passes the callback to `uploadFile`. The submit button label becomes
  "Uploading 2 / 5 (45%)" with the percentage updating live as bytes
  leave the browser.
- The edit-page dropzone tracks a separate `attachmentProgress` ref
  and renders "Uploading attachment… (45%)" while the file streams.

The percentage measures bytes leaving the browser, not bytes received
by the server. If the server crashes or times out mid-stream the bar
will reach 100% locally and then the failure toast fires — which the
retry layer can recover from.

https://claude.ai/code/session_012aZhnQmF8RGDXAMdpKdoka

Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 2075565)
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: eae4e4bd-affc-4716-b55b-7c30b67eb446

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The frontend CI runs `pnpm lint:ci` which is `eslint ... --max-warnings 1`,
and main has accumulated 7 errors plus 2 warnings that block every PR
regardless of what it touches. Clear them so the lint job goes green
for this PR (and is a no-op once upstream merges any of these in any
order).

- components/Location/CreateModal.vue: drop unused EntityTypeSummary
  import.
- lib/api/classes/items.ts: drop the `error: any` cast on
  getLocations' return; TypeScript infers a tighter type from the
  spread.
- pages/collection/index/entity-types.vue: drop the unused `t` from
  useI18n and the now-unused useI18n import itself.
- pages/item/[id]/index/edit.vue,
  pages/location/[id]/index/edit.vue: drop the dead `locations`
  computed and the locationStore wiring it dragged in. Nothing on the
  template reads either.
- pages/location/[id]/index/index.vue: drop the dead `refresh` from
  useAsyncData and the dead locationStore. Replace
  `class="aspect-square ..."` with `aspect-[1/1]` so the
  tailwindcss/no-custom-classname rule stops flagging it (the rule's
  allowlist doesn't include `aspect-square`).
- pages/reset-password.vue: collapse a multi-line Button attribute set
  to one line per the prettier suggestion.

No runtime behavior changes — every removal is a value/import that
was never read. Verified with `pnpm lint:ci` exit 0.

https://claude.ai/code/session_012aZhnQmF8RGDXAMdpKdoka
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants