mirror of
https://github.com/we-promise/sure.git
synced 2026-04-09 23:34:50 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/javascript/controllers/attachment_upload_controller.js
Normal file
63
app/javascript/controllers/attachment_upload_controller.js
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
18
app/javascript/controllers/form_dropdown_controller.js
Normal file
18
app/javascript/controllers/form_dropdown_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
182
app/javascript/controllers/select_controller.js
Normal file
182
app/javascript/controllers/select_controller.js
Normal 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`
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user