Merge main: resolve grid layout conflicts, add missing privacy-sensitive coverage

Resolve merge conflicts in investment summary/performance views where main's
grid layout refactoring conflicted with privacy-sensitive class additions.
Also add privacy-sensitive to transaction list amounts, transaction detail
header, and sankey cashflow chart containers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
sokiee
2026-03-22 10:46:52 +01:00
398 changed files with 18005 additions and 1353 deletions

View File

@@ -0,0 +1,22 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="admin-invitation-delete"
// Handles individual invitation deletion and alt-click to delete all family invitations
export default class extends Controller {
static targets = [ "button", "destroyAllForm" ]
static values = { deleteAllLabel: String }
handleClick(event) {
if (event.altKey) {
event.preventDefault()
this.buttonTargets.forEach(btn => {
btn.textContent = this.deleteAllLabelValue
})
if (this.hasDestroyAllFormTarget) {
this.destroyAllFormTarget.requestSubmit()
}
}
}
}

View File

@@ -0,0 +1,63 @@
import { Controller } from "@hotwired/stimulus"
export default class AttachmentUploadController extends Controller {
static targets = ["fileInput", "submitButton", "fileName", "uploadText"]
static values = {
maxFiles: Number,
maxSize: Number
}
connect() {
this.updateSubmitButton()
}
triggerFileInput() {
this.fileInputTarget.click()
}
updateSubmitButton() {
const files = Array.from(this.fileInputTarget.files)
const hasFiles = files.length > 0
// Basic validation hints (server validates definitively)
let isValid = hasFiles
let errorMessage = ""
if (hasFiles) {
if (this.hasUploadTextTarget) this.uploadTextTarget.classList.add("hidden")
if (this.hasFileNameTarget) {
const filenames = files.map(f => f.name).join(", ")
const textElement = this.fileNameTarget.querySelector("p")
if (textElement) textElement.textContent = filenames
this.fileNameTarget.classList.remove("hidden")
}
// Check file count
if (files.length > this.maxFilesValue) {
isValid = false
errorMessage = `Too many files (max ${this.maxFilesValue})`
}
// Check file sizes
const oversizedFiles = files.filter(file => file.size > this.maxSizeValue)
if (oversizedFiles.length > 0) {
isValid = false
errorMessage = `File too large (max ${Math.round(this.maxSizeValue / 1024 / 1024)}MB)`
}
} else {
if (this.hasUploadTextTarget) this.uploadTextTarget.classList.remove("hidden")
if (this.hasFileNameTarget) this.fileNameTarget.classList.add("hidden")
}
this.submitButtonTarget.disabled = !isValid
if (hasFiles && isValid) {
const count = files.length
this.submitButtonTarget.textContent = count === 1 ? "Upload 1 file" : `Upload ${count} files`
} else if (errorMessage) {
this.submitButtonTarget.textContent = errorMessage
} else {
this.submitButtonTarget.textContent = "Upload"
}
}
}

View File

@@ -8,6 +8,7 @@ export default class extends Controller {
"selectionBar",
"selectionBarText",
"bulkEditDrawerHeader",
"duplicateLink",
];
static values = {
singularLabel: String,
@@ -135,6 +136,18 @@ export default class extends Controller {
this.selectionBarTarget.classList.toggle("hidden", count === 0);
this.selectionBarTarget.querySelector("input[type='checkbox']").checked =
count > 0;
if (this.hasDuplicateLinkTarget) {
this.duplicateLinkTarget.classList.toggle("hidden", count !== 1);
if (count === 1) {
const url = new URL(
this.duplicateLinkTarget.href,
window.location.origin,
);
url.searchParams.set("duplicate_entry_id", this.selectedIdsValue[0]);
this.duplicateLinkTarget.href = url.toString();
}
}
}
_pluralizedResourceName() {

View File

@@ -0,0 +1,18 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input"]
onSelect(event) {
this.inputTarget.value = event.detail.value
const inputEvent = new Event("input", { bubbles: true })
this.inputTarget.dispatchEvent(inputEvent)
const form = this.element.closest("form")
const controllers = (form?.dataset.controller || "").split(/\s+/)
if (form && controllers.includes("auto-submit-form")) {
form.requestSubmit()
}
}
}

View File

@@ -10,11 +10,75 @@ export default class extends Controller {
};
connect() {
this.open();
this._connectionToken = (this._connectionToken ?? 0) + 1;
const connectionToken = this._connectionToken;
this.open(connectionToken).catch((error) => {
console.error("Failed to initialize Plaid Link", error);
});
}
open() {
const handler = Plaid.create({
disconnect() {
this._handler?.destroy();
this._handler = null;
this._connectionToken = (this._connectionToken ?? 0) + 1;
}
waitForPlaid() {
if (typeof Plaid !== "undefined") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
let plaidScript = document.querySelector(
'script[src*="link-initialize.js"]'
);
// Reject if the CDN request stalls without firing load or error
const timeoutId = window.setTimeout(() => {
if (plaidScript) plaidScript.dataset.plaidState = "error";
reject(new Error("Timed out loading Plaid script"));
}, 10_000);
// Remove previously failed script so we can retry with a fresh element
if (plaidScript?.dataset.plaidState === "error") {
plaidScript.remove();
plaidScript = null;
}
if (!plaidScript) {
plaidScript = document.createElement("script");
plaidScript.src = "https://cdn.plaid.com/link/v2/stable/link-initialize.js";
plaidScript.async = true;
plaidScript.dataset.plaidState = "loading";
document.head.appendChild(plaidScript);
}
plaidScript.addEventListener("load", () => {
window.clearTimeout(timeoutId);
plaidScript.dataset.plaidState = "loaded";
resolve();
}, { once: true });
plaidScript.addEventListener("error", () => {
window.clearTimeout(timeoutId);
plaidScript.dataset.plaidState = "error";
reject(new Error("Failed to load Plaid script"));
}, { once: true });
// Re-check after attaching listeners in case the script loaded between
// the initial typeof check and listener attachment (avoids a permanently
// pending promise on retry flows).
if (typeof Plaid !== "undefined") {
window.clearTimeout(timeoutId);
resolve();
}
});
}
async open(connectionToken = this._connectionToken) {
await this.waitForPlaid();
if (connectionToken !== this._connectionToken) return;
this._handler = Plaid.create({
token: this.linkTokenValue,
onSuccess: this.handleSuccess,
onLoad: this.handleLoad,
@@ -22,7 +86,7 @@ export default class extends Controller {
onEvent: this.handleEvent,
});
handler.open();
this._handler.open();
}
handleSuccess = (public_token, metadata) => {

View File

@@ -35,7 +35,7 @@ export default class extends Controller {
try {
const response = await fetch(this.urlValue, {
headers: {
Accept: "text/vnd.turbo-stream.html",
Accept: "text/html",
"Turbo-Frame": this.element.id,
},
});

View File

@@ -0,0 +1,182 @@
import { Controller } from "@hotwired/stimulus"
import { autoUpdate } from "@floating-ui/dom"
export default class extends Controller {
static targets = ["button", "menu", "input"]
static values = {
placement: { type: String, default: "bottom-start" },
offset: { type: Number, default: 6 }
}
connect() {
this.isOpen = false
this.boundOutsideClick = this.handleOutsideClick.bind(this)
this.boundKeydown = this.handleKeydown.bind(this)
this.boundTurboLoad = this.handleTurboLoad.bind(this)
document.addEventListener("click", this.boundOutsideClick)
document.addEventListener("turbo:load", this.boundTurboLoad)
this.element.addEventListener("keydown", this.boundKeydown)
this.observeMenuResize()
}
disconnect() {
document.removeEventListener("click", this.boundOutsideClick)
document.removeEventListener("turbo:load", this.boundTurboLoad)
this.element.removeEventListener("keydown", this.boundKeydown)
this.stopAutoUpdate()
if (this.resizeObserver) this.resizeObserver.disconnect()
}
toggle = () => {
this.isOpen ? this.close() : this.openMenu()
}
openMenu() {
this.isOpen = true
this.menuTarget.classList.remove("hidden")
this.buttonTarget.setAttribute("aria-expanded", "true")
this.startAutoUpdate()
this.clearSearch()
requestAnimationFrame(() => {
this.menuTarget.classList.remove("opacity-0", "-translate-y-1", "pointer-events-none")
this.menuTarget.classList.add("opacity-100", "translate-y-0")
this.updatePosition()
this.scrollToSelected()
})
}
close() {
this.isOpen = false
this.stopAutoUpdate()
this.menuTarget.classList.remove("opacity-100", "translate-y-0")
this.menuTarget.classList.add("opacity-0", "-translate-y-1", "pointer-events-none")
this.buttonTarget.setAttribute("aria-expanded", "false")
setTimeout(() => { if (!this.isOpen && this.hasMenuTarget) this.menuTarget.classList.add("hidden") }, 150)
}
select(event) {
const selectedElement = event.currentTarget
const value = selectedElement.dataset.value
const label = selectedElement.dataset.filterName || selectedElement.textContent.trim()
this.buttonTarget.textContent = label
if (this.hasInputTarget) {
this.inputTarget.value = value
this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
}
const previousSelected = this.menuTarget.querySelector("[aria-selected='true']")
if (previousSelected) {
previousSelected.setAttribute("aria-selected", "false")
previousSelected.classList.remove("bg-container-inset")
const prevIcon = previousSelected.querySelector(".check-icon")
if (prevIcon) prevIcon.classList.add("hidden")
}
selectedElement.setAttribute("aria-selected", "true")
selectedElement.classList.add("bg-container-inset")
const selectedIcon = selectedElement.querySelector(".check-icon")
if (selectedIcon) selectedIcon.classList.remove("hidden")
this.element.dispatchEvent(new CustomEvent("dropdown:select", {
detail: { value, label },
bubbles: true
}))
this.close()
this.buttonTarget.focus()
}
focusSearch() {
const input = this.menuTarget.querySelector('input[type="search"]')
if (input) { input.focus({ preventScroll: true }); return true }
return false
}
focusFirstElement() {
const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
const el = this.menuTarget.querySelector(selector)
if (el) el.focus({ preventScroll: true })
}
scrollToSelected() {
const selected = this.menuTarget.querySelector(".bg-container-inset")
if (selected) selected.scrollIntoView({ block: "center" })
}
handleOutsideClick(event) {
if (this.isOpen && !this.element.contains(event.target)) this.close()
}
handleKeydown(event) {
if (!this.isOpen) return
if (event.key === "Escape") { this.close(); this.buttonTarget.focus() }
if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click() }
}
handleTurboLoad() { if (this.isOpen) this.close() }
clearSearch() {
const input = this.menuTarget.querySelector('input[type="search"]')
if (!input) return
input.value = ""
input.dispatchEvent(new Event("input", { bubbles: true }))
}
startAutoUpdate() {
if (!this._cleanup && this.buttonTarget && this.menuTarget) {
this._cleanup = autoUpdate(this.buttonTarget, this.menuTarget, () => this.updatePosition())
}
}
stopAutoUpdate() {
if (this._cleanup) { this._cleanup(); this._cleanup = null }
}
observeMenuResize() {
this.resizeObserver = new ResizeObserver(() => {
if (this.isOpen) requestAnimationFrame(() => this.updatePosition())
})
this.resizeObserver.observe(this.menuTarget)
}
getScrollParent(element) {
let parent = element.parentElement
while (parent) {
const style = getComputedStyle(parent)
const overflowY = style.overflowY
if (overflowY === "auto" || overflowY === "scroll") return parent
parent = parent.parentElement
}
return document.documentElement
}
updatePosition() {
if (!this.buttonTarget || !this.menuTarget || !this.isOpen) return
const container = this.getScrollParent(this.element)
const containerRect = container.getBoundingClientRect()
const buttonRect = this.buttonTarget.getBoundingClientRect()
const menuHeight = this.menuTarget.scrollHeight
const spaceBelow = containerRect.bottom - buttonRect.bottom
const spaceAbove = buttonRect.top - containerRect.top
const shouldOpenUp = spaceBelow < menuHeight && spaceAbove > spaceBelow
this.menuTarget.style.left = "0"
this.menuTarget.style.width = "100%"
this.menuTarget.style.top = ""
this.menuTarget.style.bottom = ""
this.menuTarget.style.overflowY = "auto"
if (shouldOpenUp) {
this.menuTarget.style.bottom = "100%"
this.menuTarget.style.maxHeight = `${Math.max(0, spaceAbove - this.offsetValue)}px`
} else {
this.menuTarget.style.top = "100%"
this.menuTarget.style.maxHeight = `${Math.max(0, spaceBelow - this.offsetValue)}px`
}
}
}