mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
A failed Turbo response used to be rendered: the error page drawn as the page (or merged under it — a picked PDF avatar left plMail's 500 hanging off the settings screen), or "Content missing" in a frame. - request_errors.js catches every Turbo visit, form and frame. A failed response is not rendered; the page stays as it was and an error toast says what happened. 422 (a form with its errors), 401/403 (the session guard) and elements marked data-turbo-errors="self" (the compose dock) are left alone. A network failure gets its own words; a frame response of the wrong shape is visited as a page. - requestFailed() gives controllers' own fetch() calls the same toast. An audit of the 60 calls fixed the 22 files whose user-initiated requests failed silently (star, archive, labels, drag and drop, bulk actions, reorders, appearance save, undo/cancel send, and more), and left background polling and preferences quiet on purpose. - Every request gets an 8-hex id: X-Request-Id on the response, `id` on its log entries, "Ref. …" in the toast with a copy button, and a Reference filter in Admin → Logs. A proxy's own id is kept. - toast.js clones the server's toast partial, so a script's toast is the same pane and theme as a streamed one. The sync button uses it, and its hardcoded English is now translated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
4.7 KiB
JavaScript
123 lines
4.7 KiB
JavaScript
import { Controller } from "@hotwired/stimulus"
|
|
import Sortable from "sortablejs"
|
|
import { requestFailed } from "../../request_errors.js"
|
|
|
|
/**
|
|
* Execution order for mail rules: drag on a pointer, buttons everywhere else.
|
|
*
|
|
* Drag-and-drop alone would put ordering out of reach for keyboard users and
|
|
* make it fiddly on a phone, and order is not decoration here — combined with
|
|
* "stop after this filter" it decides which rule wins. So the move buttons are
|
|
* the real control and the drag handle is the shortcut, rather than the other
|
|
* way round.
|
|
*
|
|
* Uses sortablejs directly rather than @stimulus-components/sortable (which the
|
|
* accounts list uses): that wrapper builds its own request body and offers no
|
|
* way to attach a CSRF token, which is why the account reorder endpoint has
|
|
* none. Sending the whole order as JSON also makes the write idempotent — a
|
|
* duplicated request lands on the same arrangement instead of shifting twice.
|
|
*/
|
|
export default class extends Controller {
|
|
static targets = ["list"]
|
|
static values = { url: String, csrf: String }
|
|
|
|
connect() {
|
|
this.sortable = Sortable.create(this.listTarget, {
|
|
handle: ".rule-drag-handle",
|
|
animation: 150,
|
|
// Without this a touch-drag scrolls the page instead of moving the
|
|
// row; the handle also carries touch-none for the same reason.
|
|
forceFallback: false,
|
|
onEnd: () => {
|
|
this.syncEdges()
|
|
this.persist()
|
|
},
|
|
})
|
|
|
|
this.syncEdges()
|
|
}
|
|
|
|
disconnect() {
|
|
this.sortable?.destroy()
|
|
}
|
|
|
|
/** Keyboard- and touch-reachable equivalent of a drag. */
|
|
move(event) {
|
|
const row = event.currentTarget.closest("[data-rule-id]")
|
|
const direction = event.currentTarget.dataset.direction === "up" ? -1 : 1
|
|
const rows = [...this.listTarget.querySelectorAll("[data-rule-id]")]
|
|
const index = rows.indexOf(row)
|
|
const target = index + direction
|
|
|
|
if (target < 0 || target >= rows.length) {
|
|
return
|
|
}
|
|
|
|
// Move in the DOM first so the list is already correct if the request
|
|
// is slow; the server is told what the user can already see.
|
|
direction === -1
|
|
? rows[target].before(row)
|
|
: rows[target].after(row)
|
|
|
|
this.syncEdges()
|
|
|
|
// Keep focus on the control that was pressed, or a keyboard user loses
|
|
// their place after every step. Re-queried because syncEdges() may have
|
|
// just disabled this very button — if it did, move focus to the other
|
|
// direction rather than leaving it on a dead control.
|
|
const direction_ = event.currentTarget.dataset.direction
|
|
const same = row.querySelector(`[data-direction="${direction_}"]`)
|
|
const other = row.querySelector(`[data-direction="${direction_ === "up" ? "down" : "up"}"]`)
|
|
|
|
;(same?.disabled ? other : same)?.focus()
|
|
|
|
this.persist()
|
|
}
|
|
|
|
/**
|
|
* Re-evaluate which move buttons are dead ends.
|
|
*
|
|
* The server renders these disabled states from loop.first/loop.last, but a
|
|
* move rearranges the DOM without re-rendering — so without this the top
|
|
* row keeps an enabled "up" and the row that used to be first has a
|
|
* permanently disabled one.
|
|
*/
|
|
syncEdges() {
|
|
const rows = [...this.listTarget.querySelectorAll("[data-rule-id]")]
|
|
|
|
rows.forEach((row, index) => {
|
|
const up = row.querySelector('[data-direction="up"]')
|
|
const down = row.querySelector('[data-direction="down"]')
|
|
|
|
if (up) up.disabled = index === 0
|
|
if (down) down.disabled = index === rows.length - 1
|
|
|
|
// The position number is the visible statement of execution order,
|
|
// so it has to follow the move too.
|
|
const position = row.querySelector("[data-rule-position]")
|
|
|
|
if (position) position.textContent = String(index + 1)
|
|
})
|
|
}
|
|
|
|
persist() {
|
|
const ids = [...this.listTarget.querySelectorAll("[data-rule-id]")].map(
|
|
(row) => Number(row.dataset.ruleId),
|
|
)
|
|
|
|
fetch(this.urlValue, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfValue },
|
|
body: JSON.stringify({ ids }),
|
|
keepalive: true,
|
|
}).then((response) => {
|
|
requestFailed(response)
|
|
}).catch(() => {
|
|
requestFailed(null)
|
|
})
|
|
// The order on screen is left as the user chose it, but a failed write
|
|
// is said out loud: order decides which rule wins, and finding the old
|
|
// one back on the next load, unannounced, is how a rule misfires.
|
|
}
|
|
}
|