Merge branch 'main' into feature/retirement-planning

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Juan José Mata
2026-05-24 12:14:14 +02:00
committed by GitHub
1630 changed files with 98596 additions and 7676 deletions

View File

@@ -18,7 +18,8 @@ export default class extends Controller {
// Hide all subtype selects
const subtypeSelects = container.querySelectorAll('.subtype-select')
subtypeSelects.forEach(select => {
select.style.display = 'none'
select.classList.add('hidden')
select.style.removeProperty('display')
// Clear the name attribute so it doesn't get submitted
const selectElement = select.querySelector('select')
if (selectElement) {
@@ -34,7 +35,8 @@ export default class extends Controller {
// Show the relevant subtype select
const relevantSubtype = container.querySelector(`[data-type="${selectedType}"]`)
if (relevantSubtype) {
relevantSubtype.style.display = 'block'
relevantSubtype.classList.remove('hidden')
relevantSubtype.style.removeProperty('display')
// Re-add the name attribute so it gets submitted
const selectElement = relevantSubtype.querySelector('select')
if (selectElement) {
@@ -65,4 +67,4 @@ export default class extends Controller {
}
}
}
}
}

View File

@@ -111,10 +111,21 @@ export default class extends Controller {
if (response.ok) {
const data = await response.json()
if (data.issuer) {
// Valid OIDC discovery endpoint
issuerInput.classList.remove('border-yellow-300', 'border-red-300')
issuerInput.classList.add('border-green-300')
this.showValidationMessage(issuerInput, 'Valid OIDC issuer', 'success')
if (data.issuer === issuer) {
issuerInput.classList.remove('border-yellow-300', 'border-red-300', 'border-amber-300')
issuerInput.classList.add('border-green-300')
this.showValidationMessage(issuerInput, 'Valid OIDC issuer', 'success')
} else {
issuerInput.classList.remove('border-yellow-300', 'border-green-300')
issuerInput.classList.add('border-amber-300')
const trailingSlashOnly = data.issuer.replace(/\/$/, '') === issuer.replace(/\/$/, '')
const message = trailingSlashOnly
? `Issuer mismatch: discovery returned ${data.issuer}. This is usually a trailing slash mismatch, so copy the issuer exactly as returned.`
: `Issuer mismatch: discovery returned ${data.issuer}. Copy the issuer exactly as returned by the provider.`
this.showValidationMessage(issuerInput, message, 'warning')
}
} else {
throw new Error('Invalid discovery response')
}

View File

@@ -21,22 +21,27 @@ export default class extends Controller {
toggleLeftSidebar() {
const isOpen = this.leftSidebarTarget.classList.contains("w-full");
this.#updateUserPreference("show_sidebar", !isOpen);
this.#toggleSidebarWidth(this.leftSidebarTarget, isOpen);
this.#toggleSidebarWidth(this.leftSidebarTarget, isOpen, "left");
}
toggleRightSidebar() {
const isOpen = this.rightSidebarTarget.classList.contains("w-full");
this.#updateUserPreference("show_ai_sidebar", !isOpen);
this.#toggleSidebarWidth(this.rightSidebarTarget, isOpen);
this.#toggleSidebarWidth(this.rightSidebarTarget, isOpen, "right");
}
#toggleSidebarWidth(el, isCurrentlyOpen) {
#toggleSidebarWidth(el, isCurrentlyOpen, side) {
const expandedClasses = side === "left" ? [...this.expandedSidebarClasses, "border-r"] : [...this.expandedSidebarClasses, "border-l"];
const collapsedClasses = side === "left" ? [...this.collapsedSidebarClasses, "border-r-0"] : [...this.collapsedSidebarClasses, "border-l-0"];
if (isCurrentlyOpen) {
el.classList.remove(...this.expandedSidebarClasses);
el.classList.add(...this.collapsedSidebarClasses);
el.classList.remove(...expandedClasses);
el.classList.add(...collapsedClasses);
el.inert = true;
} else {
el.classList.add(...this.expandedSidebarClasses);
el.classList.remove(...this.collapsedSidebarClasses);
el.classList.add(...expandedClasses);
el.classList.remove(...collapsedClasses);
el.inert = false;
}
}

View File

@@ -0,0 +1,19 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["input", "item", "emptyState"];
filter() {
const query = this.inputTarget.value.toLocaleLowerCase().trim();
let visibleCount = 0;
this.itemTargets.forEach(item => {
const name = item.dataset.bankName?.toLocaleLowerCase() ?? "";
const match = name.includes(query);
item.style.display = match ? "" : "none";
if (match) visibleCount++;
});
this.emptyStateTarget.classList.toggle("hidden", visibleCount > 0);
}
}

View File

@@ -0,0 +1,58 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["onTrack", "overBudget", "tab"];
static values = { filter: { type: String, default: "all" } };
connect() {
const filterParam = new URLSearchParams(window.location.search).get("filter");
if (this.#isValidFilter(filterParam) && filterParam !== this.filterValue) {
this.filterValue = filterParam;
} else if (filterParam && !this.#isValidFilter(filterParam)) {
this.#syncFilterParam();
}
}
setFilter(event) {
this.filterValue = event.params.filter;
this.#syncFilterParam();
}
filterValueChanged() {
const filter = this.filterValue;
if (this.hasOnTrackTarget) {
this.onTrackTarget.hidden = filter === "over_budget";
}
if (this.hasOverBudgetTarget) {
this.overBudgetTarget.hidden = filter === "on_track";
}
this.tabTargets.forEach((tab) => {
const isActive = tab.dataset.budgetFilterFilterParam === filter;
tab.classList.toggle("bg-white", isActive);
tab.classList.toggle("theme-dark:bg-gray-700", isActive);
tab.classList.toggle("text-primary", isActive);
tab.classList.toggle("shadow-sm", isActive);
tab.classList.toggle("text-secondary", !isActive);
});
}
#isValidFilter(filter) {
return ["all", "over_budget", "on_track"].includes(filter);
}
#syncFilterParam() {
const url = new URL(window.location.href);
if (this.filterValue === "all") {
url.searchParams.delete("filter");
} else {
url.searchParams.set("filter", this.filterValue);
}
window.history.replaceState({}, "", url);
}
}

View File

@@ -13,6 +13,8 @@ export default class extends Controller {
static values = {
singularLabel: String,
pluralLabel: String,
selectedLabel: { type: String, default: "selected" },
editLabel: { type: String, default: "Edit" },
selectedIds: { type: Array, default: [] },
};
@@ -28,7 +30,7 @@ export default class extends Controller {
bulkEditDrawerHeaderTargetConnected(element) {
const headingTextEl = element.querySelector("h2");
headingTextEl.innerText = `Edit ${
headingTextEl.innerText = `${this.editLabelValue} ${
this.selectedIdsValue.length
} ${this._pluralizedResourceName()}`;
}
@@ -132,14 +134,19 @@ export default class extends Controller {
_updateSelectionBar() {
const count = this.selectedIdsValue.length;
this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} selected`;
this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} ${this.selectedLabelValue}`;
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 selectedRow = this._selectedRow();
const canDuplicate =
count === 1 && selectedRow?.dataset.entryType === "Transaction";
this.duplicateLinkTarget.classList.toggle("hidden", !canDuplicate);
if (canDuplicate) {
const url = new URL(
this.duplicateLinkTarget.href,
window.location.origin,
@@ -158,6 +165,14 @@ export default class extends Controller {
return this.pluralLabelValue;
}
_selectedRow() {
if (this.selectedIdsValue.length !== 1) return null;
return this.rowTargets.find(
(row) => row.dataset.id === this.selectedIdsValue[0],
);
}
_updateGroups() {
this.groupTargets.forEach((group) => {
const rows = this.rowTargets.filter(

View File

@@ -0,0 +1,57 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["dialog", "checkbox", "selectedCount"];
static values = {
baseCurrency: String,
locale: String,
selectedCountTranslations: Object,
};
connect() {
this.updateSelectedCount();
}
open() {
this.updateSelectedCount();
this.dialogTarget.showModal();
}
selectAll() {
this.checkboxTargets.forEach((checkbox) => {
checkbox.checked = true;
});
this.updateSelectedCount();
}
selectBaseOnly() {
this.checkboxTargets.forEach((checkbox) => {
checkbox.checked = checkbox.value === this.baseCurrencyValue;
});
this.updateSelectedCount();
}
updateSelectedCount() {
if (!this.hasSelectedCountTarget) return;
const selectedCount = this.checkboxTargets.filter((checkbox) => checkbox.checked).length;
const pluralRules = new Intl.PluralRules(this.localeValue || undefined);
const pluralCategory = pluralRules.select(selectedCount);
const labelTemplate =
this.selectedCountTranslationsValue[pluralCategory] ||
this.selectedCountTranslationsValue.other ||
"%{count}";
const label = labelTemplate.replace("%{count}", selectedCount);
this.selectedCountTarget.textContent = label;
}
handleSubmitEnd(event) {
if (!event.detail.success) return;
if (!this.dialogTarget.open) return;
this.dialogTarget.close();
}
}

View File

@@ -26,12 +26,14 @@ export default class extends Controller {
this.#draw();
document.addEventListener("turbo:load", this.#redraw);
this.element.addEventListener("mouseleave", this.#clearSegmentHover);
this.contentContainerTarget.addEventListener("mouseleave", this.#clearSegmentHover);
}
disconnect() {
this.#teardown();
document.removeEventListener("turbo:load", this.#redraw);
this.element.removeEventListener("mouseleave", this.#clearSegmentHover);
this.contentContainerTarget.removeEventListener("mouseleave", this.#clearSegmentHover);
}
get #data() {
@@ -151,8 +153,12 @@ export default class extends Controller {
this.#handleSegmentHover(event);
}, 10);
})
.on("mouseleave", () => {
.on("mouseleave", (event, d) => {
clearTimeout(hoverTimeout);
const leavingUnused = d.data.id === this.unusedSegmentIdValue;
if (leavingUnused || !this.contentContainerTarget.contains(event.relatedTarget)) {
this.#clearSegmentHover();
}
})
.on("click", (event, d) => {
if (this.enableClickValue) {

View File

@@ -0,0 +1,298 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [
"amount",
"destinationAmount",
"date",
"exchangeRateContainer",
"exchangeRateField",
"convertDestinationDisplay",
"calculateRateDisplay"
];
static values = {
exchangeRateUrl: String,
accountCurrencies: Object
};
connect() {
this.sourceCurrency = null;
this.destinationCurrency = null;
this.activeTab = "convert";
if (!this.hasRequiredExchangeRateTargets()) {
return;
}
this.checkCurrencyDifference();
}
hasRequiredExchangeRateTargets() {
return this.hasDateTarget;
}
checkCurrencyDifference() {
const context = this.getExchangeRateContext();
if (!context) {
this.hideExchangeRateField();
return;
}
const { fromCurrency, toCurrency, date } = context;
if (!fromCurrency || !toCurrency) {
this.hideExchangeRateField();
return;
}
this.sourceCurrency = fromCurrency;
this.destinationCurrency = toCurrency;
if (fromCurrency === toCurrency) {
this.hideExchangeRateField();
return;
}
this.fetchExchangeRate(fromCurrency, toCurrency, date);
}
onExchangeRateTabClick(event) {
const btn = event.target.closest("button[data-id]");
if (!btn) {
return;
}
const nextTab = btn.dataset.id;
if (nextTab === this.activeTab) {
return;
}
this.activeTab = nextTab;
if (this.activeTab === "convert") {
this.clearCalculateRateFields();
} else if (this.activeTab === "calculateRate") {
this.clearConvertFields();
}
}
onAmountChange() {
this.onAmountInputChange();
}
onSourceAmountChange() {
this.onAmountInputChange();
}
onAmountInputChange() {
if (!this.hasAmountTarget) {
return;
}
if (this.activeTab === "convert") {
this.calculateConvertDestination();
} else {
this.calculateRateFromAmounts();
}
}
onConvertSourceAmountChange() {
this.calculateConvertDestination();
}
onConvertExchangeRateChange() {
this.calculateConvertDestination();
}
calculateConvertDestination() {
if (!this.hasAmountTarget || !this.hasExchangeRateFieldTarget || !this.hasConvertDestinationDisplayTarget) {
return;
}
const amount = Number.parseFloat(this.amountTarget.value);
const rate = Number.parseFloat(this.exchangeRateFieldTarget.value);
if (amount && rate && rate !== 0) {
const destAmount = (amount * rate).toFixed(2);
this.convertDestinationDisplayTarget.textContent = this.destinationCurrency ? `${destAmount} ${this.destinationCurrency}` : destAmount;
} else {
this.convertDestinationDisplayTarget.textContent = "-";
}
}
onCalculateRateSourceAmountChange() {
this.calculateRateFromAmounts();
}
onCalculateRateDestinationAmountChange() {
this.calculateRateFromAmounts();
}
calculateRateFromAmounts() {
if (!this.hasAmountTarget || !this.hasDestinationAmountTarget || !this.hasCalculateRateDisplayTarget || !this.hasExchangeRateFieldTarget) {
return;
}
const amount = Number.parseFloat(this.amountTarget.value);
const destAmount = Number.parseFloat(this.destinationAmountTarget.value);
if (amount && destAmount && amount !== 0) {
const rate = destAmount / amount;
const formattedRate = this.formatExchangeRate(rate);
this.calculateRateDisplayTarget.textContent = formattedRate;
this.exchangeRateFieldTarget.value = rate.toFixed(14);
} else {
this.calculateRateDisplayTarget.textContent = "-";
this.exchangeRateFieldTarget.value = "";
}
}
formatExchangeRate(rate) {
let formattedRate = rate.toFixed(14);
formattedRate = formattedRate.replace(/(\.\d{2}\d*?)0+$/, "$1");
if (!formattedRate.includes(".")) {
formattedRate += ".00";
} else if (formattedRate.match(/\.\d$/)) {
formattedRate += "0";
}
return formattedRate;
}
clearConvertFields() {
if (this.hasExchangeRateFieldTarget) {
this.exchangeRateFieldTarget.value = "";
}
if (this.hasConvertDestinationDisplayTarget) {
this.convertDestinationDisplayTarget.textContent = "-";
}
}
clearCalculateRateFields() {
if (this.hasDestinationAmountTarget) {
this.destinationAmountTarget.value = "";
}
if (this.hasCalculateRateDisplayTarget) {
this.calculateRateDisplayTarget.textContent = "-";
}
if (this.hasExchangeRateFieldTarget) {
this.exchangeRateFieldTarget.value = "";
}
}
async fetchExchangeRate(fromCurrency, toCurrency, date) {
if (this.exchangeRateAbortController) {
this.exchangeRateAbortController.abort();
}
this.exchangeRateAbortController = new AbortController();
const signal = this.exchangeRateAbortController.signal;
try {
const url = new URL(this.exchangeRateUrlValue, window.location.origin);
url.searchParams.set("from", fromCurrency);
url.searchParams.set("to", toCurrency);
if (date) {
url.searchParams.set("date", date);
}
const response = await fetch(url, { signal });
const data = await response.json();
if (!this.isCurrentExchangeRateState(fromCurrency, toCurrency, date)) {
return;
}
if (!response.ok) {
if (this.shouldShowManualExchangeRate(data)) {
this.showManualExchangeRateField();
} else {
this.hideExchangeRateField();
}
return;
}
if (data.same_currency) {
this.hideExchangeRateField();
} else {
this.sourceCurrency = fromCurrency;
this.destinationCurrency = toCurrency;
this.showExchangeRateField(data.rate);
}
} catch (error) {
if (error.name === "AbortError") {
return;
}
console.error("Error fetching exchange rate:", error);
this.hideExchangeRateField();
}
}
showExchangeRateField(rate) {
if (this.hasExchangeRateFieldTarget) {
this.exchangeRateFieldTarget.value = this.formatExchangeRate(rate);
}
if (this.hasExchangeRateContainerTarget) {
this.exchangeRateContainerTarget.classList.remove("hidden");
}
this.calculateConvertDestination();
}
showManualExchangeRateField() {
const context = this.getExchangeRateContext();
this.sourceCurrency = context?.fromCurrency || null;
this.destinationCurrency = context?.toCurrency || null;
if (this.hasExchangeRateFieldTarget) {
this.exchangeRateFieldTarget.value = "";
}
if (this.hasExchangeRateContainerTarget) {
this.exchangeRateContainerTarget.classList.remove("hidden");
}
this.calculateConvertDestination();
}
shouldShowManualExchangeRate(data) {
if (!data || typeof data.error !== "string") {
return false;
}
return data.error === "Exchange rate not found" || data.error === "Exchange rate unavailable";
}
hideExchangeRateField() {
if (this.hasExchangeRateContainerTarget) {
this.exchangeRateContainerTarget.classList.add("hidden");
}
if (this.hasExchangeRateFieldTarget) {
this.exchangeRateFieldTarget.value = "";
}
if (this.hasConvertDestinationDisplayTarget) {
this.convertDestinationDisplayTarget.textContent = "-";
}
if (this.hasCalculateRateDisplayTarget) {
this.calculateRateDisplayTarget.textContent = "-";
}
if (this.hasDestinationAmountTarget) {
this.destinationAmountTarget.value = "";
}
this.sourceCurrency = null;
this.destinationCurrency = null;
}
getExchangeRateContext() {
throw new Error("Subclasses must implement getExchangeRateContext()");
}
isCurrentExchangeRateState(_fromCurrency, _toCurrency, _date) {
throw new Error("Subclasses must implement isCurrentExchangeRateState()");
}
}

View File

@@ -9,6 +9,9 @@ export default class extends Controller {
const inputEvent = new Event("input", { bubbles: true })
this.inputTarget.dispatchEvent(inputEvent)
const changeEvent = new Event("change", { bubbles: true })
this.inputTarget.dispatchEvent(changeEvent)
const form = this.element.closest("form")
const controllers = (form?.dataset.controller || "").split(/\s+/)
if (form && controllers.includes("auto-submit-form")) {

View File

@@ -52,11 +52,11 @@ export default class extends Controller {
// Update block lines sequentially based on total requirements met
this.blockLineTargets.forEach((line, index) => {
if (index < requirementsMet) {
line.classList.remove("bg-gray-200");
line.classList.remove("bg-surface-inset");
line.classList.add("bg-green-600");
} else {
line.classList.remove("bg-green-600");
line.classList.add("bg-gray-200");
line.classList.add("bg-surface-inset");
}
});
}

View File

@@ -6,6 +6,7 @@ export default class extends Controller {
static values = {
url: String,
interval: { type: Number, default: 3000 },
frameId: String,
};
connect() {
@@ -33,10 +34,16 @@ export default class extends Controller {
async refresh() {
try {
const frame = this.frameElement();
if (!frame) {
this.stopPolling();
return;
}
const response = await fetch(this.urlValue, {
headers: {
Accept: "text/html",
"Turbo-Frame": this.element.id,
"Turbo-Frame": frame.id,
},
});
@@ -46,13 +53,19 @@ export default class extends Controller {
template.innerHTML = html;
const newFrame = template.content.querySelector(
`turbo-frame#${this.element.id}`,
`turbo-frame#${this.cssEscape(frame.id)}`,
);
if (newFrame) {
this.element.innerHTML = newFrame.innerHTML;
if (frame === this.element) {
this.syncPollingAttributes(newFrame);
}
frame.innerHTML = newFrame.innerHTML;
// Check if we should stop polling (no more pending/processing exports)
if (!newFrame.hasAttribute("data-polling-url-value")) {
if (
frame === this.element &&
!newFrame.hasAttribute("data-polling-url-value")
) {
this.stopPolling();
}
}
@@ -61,4 +74,41 @@ export default class extends Controller {
console.error("Polling error:", error);
}
}
frameElement() {
if (this.hasFrameIdValue) {
return document.getElementById(this.frameIdValue);
}
if (this.element.tagName.toLowerCase() === "turbo-frame") {
return this.element;
}
return this.element.closest("turbo-frame");
}
cssEscape(value) {
if (window.CSS?.escape) return CSS.escape(value);
return value.replaceAll('"', '\\"');
}
syncPollingAttributes(newFrame) {
const pollingUrl = newFrame.getAttribute("data-polling-url-value");
const pollingInterval = newFrame.getAttribute(
"data-polling-interval-value",
);
if (pollingUrl) {
this.element.setAttribute("data-polling-url-value", pollingUrl);
} else {
this.element.removeAttribute("data-polling-url-value");
}
if (pollingInterval) {
this.element.setAttribute("data-polling-interval-value", pollingInterval);
} else {
this.element.removeAttribute("data-polling-interval-value");
}
}
}

View File

@@ -0,0 +1,68 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="providers-filter"
// Filters provider cards by free-text query and a chip-selected kind.
// Updates the visible-count target on the section heading and toggles
// an empty-state target when no card matches.
export default class extends Controller {
static targets = ["input", "chip", "card", "empty", "count"];
static values = { kind: { type: String, default: "all" } };
connect() {
this.syncChipState();
}
filter() {
const query = this.hasInputTarget
? this.inputTarget.value.toLocaleLowerCase().trim()
: "";
const activeKind = this.kindValue;
let visibleCount = 0;
this.cardTargets.forEach((card) => {
const name = card.dataset.providerName ?? "";
const region = card.dataset.providerRegion ?? "";
const kind = card.dataset.providerKind ?? "";
const haystack = `${name} ${region} ${kind}`;
const matchesQuery = !query || haystack.includes(query);
const matchesKind = activeKind === "all" || kind === activeKind;
const visible = matchesQuery && matchesKind;
card.classList.toggle("hidden", !visible);
if (visible) visibleCount++;
});
if (this.hasCountTarget) {
this.countTarget.textContent = visibleCount;
}
if (this.hasEmptyTarget) {
this.emptyTarget.classList.toggle("hidden", visibleCount > 0);
}
}
selectChip(event) {
this.kindValue = event.currentTarget.dataset.kind ?? "all";
this.syncChipState();
this.filter();
}
clear() {
if (this.hasInputTarget) this.inputTarget.value = "";
this.kindValue = "all";
this.syncChipState();
this.filter();
if (this.hasInputTarget) this.inputTarget.focus();
}
syncChipState() {
if (!this.hasChipTarget) return;
this.chipTargets.forEach((chip) => {
const active = chip.dataset.kind === this.kindValue;
chip.classList.toggle("bg-container", active);
chip.classList.toggle("shadow-border-xs", active);
chip.classList.toggle("text-primary", active);
chip.classList.toggle("text-secondary", !active);
chip.setAttribute("aria-pressed", active ? "true" : "false");
});
}
}

View File

@@ -1,14 +1,17 @@
import { Controller } from "@hotwired/stimulus";
import * as d3 from "d3";
import { sankey } from "d3-sankey";
import { sankeyNodeHasChildren, zoomSankeyData } from "utils/sankey_zoom";
// Connects to data-controller="sankey-chart"
export default class extends Controller {
static targets = ["chart", "zoomOutButton"];
static values = {
data: Object,
nodeWidth: { type: Number, default: 15 },
nodePadding: { type: Number, default: 20 },
currencySymbol: { type: String, default: "$" }
currencySymbol: { type: String, default: "$" },
};
// Visual constants
@@ -18,64 +21,158 @@ export default class extends Controller {
static MIN_NODE_PADDING = 4;
static MAX_PADDING_RATIO = 0.4;
static CORNER_RADIUS = 8;
static ZOOM_TRANSITION_MS = 220;
static DEFAULT_COLOR = "var(--color-gray-400)";
static CSS_VAR_MAP = {
"var(--color-success)": "#10A861",
"var(--color-destructive)": "#EC2222",
"var(--color-gray-400)": "#9E9E9E",
"var(--color-gray-500)": "#737373"
"var(--color-gray-500)": "#737373",
};
static MIN_LABEL_SPACING = 28; // Minimum vertical space needed for labels (2 lines)
connect() {
this.connected = true;
this.zoomRootId = null;
this.resizeObserver = new ResizeObserver(() => this.#draw());
this.resizeObserver.observe(this.element);
this.resizeObserver.observe(this.#chartElement());
this.tooltip = null;
this.#createTooltip();
this.#syncZoomControls();
this.#draw();
}
dataValueChanged() {
if (!this.connected) return;
this.zoomRootId = null;
this.#syncZoomControls();
this.#draw();
}
disconnect() {
this.connected = false;
this.resizeObserver?.disconnect();
clearTimeout(this.drawTimeout);
this.tooltip?.remove();
this.tooltip = null;
}
#draw() {
const { nodes = [], links = [] } = this.dataValue || {};
zoomOut() {
if (!this.zoomRootId) return;
this.zoomRootId = null;
this.#syncZoomControls();
this.#draw({ animate: true });
}
#draw({ animate = false } = {}) {
const { nodes = [], links = [] } = this.#visibleData();
if (!nodes.length || !links.length) return;
// Hide tooltip and reset any hover states before redrawing
this.#hideTooltip();
d3.select(this.element).selectAll("svg").remove();
const chartElement = this.#chartElement();
const chart = d3.select(chartElement);
const width = this.element.clientWidth || 600;
const height = this.element.clientHeight || 400;
clearTimeout(this.drawTimeout);
chart.selectAll("svg").interrupt();
const svg = d3.select(this.element)
if (animate) {
chart
.selectAll("svg")
.transition()
.duration(this.constructor.ZOOM_TRANSITION_MS / 2)
.style("opacity", 0)
.remove();
this.drawTimeout = setTimeout(() => {
this.#render(nodes, links, true);
}, this.constructor.ZOOM_TRANSITION_MS / 2);
} else {
chart.selectAll("svg").remove();
this.#render(nodes, links, false);
}
}
#render(nodes, links, animate) {
const chartElement = this.#chartElement();
const chart = d3.select(chartElement);
const width = chartElement.clientWidth || 600;
const height = chartElement.clientHeight || 400;
const svg = chart
.append("svg")
.attr("width", width)
.attr("height", height);
.attr("height", height)
.style("opacity", animate ? 0 : 1);
const effectivePadding = this.#calculateNodePadding(nodes.length, height);
const sankeyData = this.#generateSankeyData(nodes, links, width, height, effectivePadding);
const sankeyData = this.#generateSankeyData(
nodes,
links,
width,
height,
effectivePadding,
);
this.#createGradients(svg, sankeyData.links);
const linkPaths = this.#drawLinks(svg, sankeyData.links);
const { nodeGroups, hiddenLabels } = this.#drawNodes(svg, sankeyData.nodes, width);
const { nodeGroups, hiddenLabels } = this.#drawNodes(
svg,
sankeyData.nodes,
width,
);
this.#attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels);
if (animate) {
svg
.transition()
.duration(this.constructor.ZOOM_TRANSITION_MS / 2)
.style("opacity", 1);
}
}
#chartElement() {
return this.hasChartTarget ? this.chartTarget : this.element;
}
#visibleData() {
if (!this.zoomRootId) return this.dataValue || {};
return zoomSankeyData(this.dataValue, this.zoomRootId);
}
#syncZoomControls() {
this.zoomOutButtonTargets.forEach((button) => {
button.hidden = !this.zoomRootId;
});
}
#zoomIn(node) {
if (!node.id || !sankeyNodeHasChildren(this.#visibleData(), node.id))
return;
this.zoomRootId = node.id;
this.#syncZoomControls();
this.#draw({ animate: true });
}
// Dynamic padding prevents padding from dominating when there are many nodes
#calculateNodePadding(nodeCount, height) {
const margin = this.constructor.EXTENT_MARGIN;
const availableHeight = height - (margin * 2);
const maxPaddingTotal = availableHeight * this.constructor.MAX_PADDING_RATIO;
const availableHeight = height - margin * 2;
const maxPaddingTotal =
availableHeight * this.constructor.MAX_PADDING_RATIO;
const gaps = Math.max(nodeCount - 1, 1);
const dynamicPadding = Math.min(this.nodePaddingValue, Math.floor(maxPaddingTotal / gaps));
const dynamicPadding = Math.min(
this.nodePaddingValue,
Math.floor(maxPaddingTotal / gaps),
);
return Math.max(this.constructor.MIN_NODE_PADDING, dynamicPadding);
}
@@ -84,11 +181,14 @@ export default class extends Controller {
const sankeyGenerator = sankey()
.nodeWidth(this.nodeWidthValue)
.nodePadding(nodePadding)
.extent([[margin, margin], [width - margin, height - margin]]);
.extent([
[margin, margin],
[width - margin, height - margin],
]);
return sankeyGenerator({
nodes: nodes.map(d => ({ ...d })),
links: links.map(d => ({ ...d })),
nodes: nodes.map((d) => ({ ...d })),
links: links.map((d) => ({ ...d })),
});
}
@@ -97,17 +197,20 @@ export default class extends Controller {
links.forEach((link, i) => {
const gradientId = this.#gradientId(link, i);
const gradient = defs.append("linearGradient")
const gradient = defs
.append("linearGradient")
.attr("id", gradientId)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", link.source.x1)
.attr("x2", link.target.x0);
gradient.append("stop")
gradient
.append("stop")
.attr("offset", "0%")
.attr("stop-color", this.#colorWithOpacity(link.source.color));
gradient.append("stop")
gradient
.append("stop")
.attr("offset", "100%")
.attr("stop-color", this.#colorWithOpacity(link.target.color));
});
@@ -132,32 +235,37 @@ export default class extends Controller {
}
#drawLinks(svg, links) {
return svg.append("g")
return svg
.append("g")
.attr("fill", "none")
.selectAll("path")
.data(links)
.join("path")
.attr("class", "sankey-link")
.attr("d", d => d3.linkHorizontal()({
source: [d.source.x1, d.y0],
target: [d.target.x0, d.y1]
}))
.attr("d", (d) =>
d3.linkHorizontal()({
source: [d.source.x1, d.y0],
target: [d.target.x0, d.y1],
}),
)
.attr("stroke", (d, i) => `url(#${this.#gradientId(d, i)})`)
.attr("stroke-width", d => Math.max(1, d.width))
.attr("stroke-width", (d) => Math.max(1, d.width))
.style("transition", "opacity 0.3s ease");
}
#drawNodes(svg, nodes, width) {
const nodeGroups = svg.append("g")
const nodeGroups = svg
.append("g")
.selectAll("g")
.data(nodes)
.join("g")
.style("transition", "opacity 0.3s ease");
nodeGroups.append("path")
.attr("d", d => this.#nodePath(d))
.attr("fill", d => d.color || this.constructor.DEFAULT_COLOR)
.attr("stroke", d => d.color ? "none" : "var(--color-gray-500)");
nodeGroups
.append("path")
.attr("d", (d) => this.#nodePath(d))
.attr("fill", (d) => d.color || this.constructor.DEFAULT_COLOR)
.attr("stroke", (d) => (d.color ? "none" : "var(--color-gray-500)"));
const hiddenLabels = this.#addNodeLabels(nodeGroups, width, nodes);
@@ -167,10 +275,15 @@ export default class extends Controller {
#nodePath(node) {
const { x0, y0, x1, y1 } = node;
const height = y1 - y0;
const radius = Math.max(0, Math.min(this.constructor.CORNER_RADIUS, height / 2));
const radius = Math.max(
0,
Math.min(this.constructor.CORNER_RADIUS, height / 2),
);
const isSourceNode = node.sourceLinks?.length > 0 && !node.targetLinks?.length;
const isTargetNode = node.targetLinks?.length > 0 && !node.sourceLinks?.length;
const isSourceNode =
node.sourceLinks?.length > 0 && !node.targetLinks?.length;
const isTargetNode =
node.targetLinks?.length > 0 && !node.sourceLinks?.length;
// Too small for rounded corners
if (height < radius * 2) {
@@ -215,14 +328,18 @@ export default class extends Controller {
const controller = this;
const hiddenLabels = this.#calculateHiddenLabels(nodes);
nodeGroups.append("text")
.attr("x", d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)
.attr("y", d => (d.y1 + d.y0) / 2)
nodeGroups
.append("text")
.attr("x", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6))
.attr("y", (d) => (d.y1 + d.y0) / 2)
.attr("dy", "-0.2em")
.attr("text-anchor", d => d.x0 < width / 2 ? "start" : "end")
.attr("class", "text-xs font-medium text-primary fill-current select-none")
.attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end"))
.attr(
"class",
"text-xs font-medium text-primary fill-current select-none",
)
.style("cursor", "default")
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1)
.style("opacity", (d) => (hiddenLabels.has(d.index) ? 0 : 1))
.style("transition", "opacity 0.2s ease")
.each(function (d) {
const textEl = d3.select(this);
@@ -230,7 +347,8 @@ export default class extends Controller {
textEl.append("tspan").text(d.name);
textEl.append("tspan")
textEl
.append("tspan")
.attr("x", textEl.attr("x"))
.attr("dy", "1.2em")
.attr("class", "font-mono text-secondary")
@@ -244,26 +362,28 @@ export default class extends Controller {
// Calculate which labels should be hidden to prevent overlap
#calculateHiddenLabels(nodes) {
const hiddenLabels = new Set();
const height = this.element.clientHeight || 400;
const height = this.#chartElement().clientHeight || 400;
const isLargeGraph = height > 600;
const minSpacing = isLargeGraph ? this.constructor.MIN_LABEL_SPACING * 0.7 : this.constructor.MIN_LABEL_SPACING;
const minSpacing = isLargeGraph
? this.constructor.MIN_LABEL_SPACING * 0.7
: this.constructor.MIN_LABEL_SPACING;
// Group nodes by column (using depth which d3-sankey assigns)
const columns = new Map();
nodes.forEach(node => {
nodes.forEach((node) => {
const depth = node.depth;
if (!columns.has(depth)) columns.set(depth, []);
columns.get(depth).push(node);
});
// For each column, check for overlapping labels
columns.forEach(columnNodes => {
columns.forEach((columnNodes) => {
// Sort by vertical position
columnNodes.sort((a, b) => ((a.y0 + a.y1) / 2) - ((b.y0 + b.y1) / 2));
columnNodes.sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2);
let lastVisibleY = Number.NEGATIVE_INFINITY;
columnNodes.forEach(node => {
columnNodes.forEach((node) => {
const nodeY = (node.y0 + node.y1) / 2;
const nodeHeight = node.y1 - node.y0;
@@ -284,25 +404,41 @@ export default class extends Controller {
#attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels) {
const applyHover = (targetLinks) => {
const targetSet = new Set(targetLinks);
const connectedNodes = new Set(targetLinks.flatMap(l => [l.source, l.target]));
const connectedNodes = new Set(
targetLinks.flatMap((l) => [l.source, l.target]),
);
linkPaths
.style("opacity", d => targetSet.has(d) ? 1 : this.constructor.HOVER_OPACITY)
.style("filter", d => targetSet.has(d) ? this.constructor.HOVER_FILTER : "none");
.style("opacity", (d) =>
targetSet.has(d) ? 1 : this.constructor.HOVER_OPACITY,
)
.style("filter", (d) =>
targetSet.has(d) ? this.constructor.HOVER_FILTER : "none",
);
nodeGroups.style("opacity", d => connectedNodes.has(d) ? 1 : this.constructor.HOVER_OPACITY);
nodeGroups.style("opacity", (d) =>
connectedNodes.has(d) ? 1 : this.constructor.HOVER_OPACITY,
);
// Show labels for connected nodes (even if normally hidden)
nodeGroups.selectAll("text")
.style("opacity", d => connectedNodes.has(d) ? 1 : (hiddenLabels.has(d.index) ? 0 : this.constructor.HOVER_OPACITY));
nodeGroups
.selectAll("text")
.style("opacity", (d) =>
connectedNodes.has(d)
? 1
: hiddenLabels.has(d.index)
? 0
: this.constructor.HOVER_OPACITY,
);
};
const resetHover = () => {
linkPaths.style("opacity", 1).style("filter", "none");
nodeGroups.style("opacity", 1);
// Restore hidden labels to hidden state
nodeGroups.selectAll("text")
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1);
nodeGroups
.selectAll("text")
.style("opacity", (d) => (hiddenLabels.has(d.index) ? 0 : 1));
};
linkPaths
@@ -310,33 +446,56 @@ export default class extends Controller {
applyHover([d]);
this.#showTooltip(event, d.value, d.percentage);
})
.on("mousemove", event => this.#updateTooltipPosition(event))
.on("mousemove", (event) => this.#updateTooltipPosition(event))
.on("mouseleave", () => {
resetHover();
this.#hideTooltip();
});
// Hover on node rectangles (not just text)
nodeGroups.selectAll("path")
.style("cursor", "default")
nodeGroups
.selectAll("path")
.style("cursor", (d) =>
sankeyNodeHasChildren(this.#visibleData(), d.id)
? "pointer"
: "default",
)
.on("mouseenter", (event, d) => {
const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d);
const connectedLinks = sankeyData.links.filter(
(l) => l.source === d || l.target === d,
);
applyHover(connectedLinks);
this.#showTooltip(event, d.value, d.percentage, d.name);
})
.on("mousemove", event => this.#updateTooltipPosition(event))
.on("mousemove", (event) => this.#updateTooltipPosition(event))
.on("click", (event, d) => {
event.stopPropagation();
this.#zoomIn(d);
})
.on("mouseleave", () => {
resetHover();
this.#hideTooltip();
});
nodeGroups.selectAll("text")
nodeGroups
.selectAll("text")
.style("cursor", (d) =>
sankeyNodeHasChildren(this.#visibleData(), d.id)
? "pointer"
: "default",
)
.on("mouseenter", (event, d) => {
const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d);
const connectedLinks = sankeyData.links.filter(
(l) => l.source === d || l.target === d,
);
applyHover(connectedLinks);
this.#showTooltip(event, d.value, d.percentage, d.name);
})
.on("mousemove", event => this.#updateTooltipPosition(event))
.on("mousemove", (event) => this.#updateTooltipPosition(event))
.on("click", (event, d) => {
event.stopPropagation();
this.#zoomIn(d);
})
.on("mouseleave", () => {
resetHover();
this.#hideTooltip();
@@ -347,9 +506,13 @@ export default class extends Controller {
#createTooltip() {
const dialog = this.element.closest("dialog");
this.tooltip = d3.select(dialog || document.body)
this.tooltip = d3
.select(dialog || document.body)
.append("div")
.attr("class", "bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50 top-0")
.attr(
"class",
"bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50 top-0",
)
.style("opacity", 0)
.style("pointer-events", "none");
}
@@ -381,9 +544,7 @@ export default class extends Controller {
const x = isInDialog ? event.clientX : event.pageX;
const y = isInDialog ? event.clientY : event.pageY;
this.tooltip
?.style("left", `${x + 10}px`)
.style("top", `${y - 10}px`);
this.tooltip?.style("left", `${x + 10}px`).style("top", `${y - 10}px`);
}
}
@@ -400,7 +561,7 @@ export default class extends Controller {
#formatCurrency(value) {
const formatted = Number.parseFloat(value).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
maximumFractionDigits: 2,
});
return this.currencySymbolValue + formatted;
}

View File

@@ -2,9 +2,9 @@ import { Controller } from "@hotwired/stimulus"
import { autoUpdate } from "@floating-ui/dom"
export default class extends Controller {
static targets = ["button", "menu", "input"]
static targets = ["button", "menu", "input", "content", "option"]
static values = {
placement: { type: String, default: "bottom-start" },
menuPlacement: { type: String, default: "auto" },
offset: { type: Number, default: 6 }
}
@@ -70,12 +70,14 @@ export default class extends Controller {
const previousSelected = this.menuTarget.querySelector("[aria-selected='true']")
if (previousSelected) {
previousSelected.setAttribute("aria-selected", "false")
previousSelected.setAttribute("tabindex", "-1")
previousSelected.classList.remove("bg-container-inset")
const prevIcon = previousSelected.querySelector(".check-icon")
if (prevIcon) prevIcon.classList.add("hidden")
}
selectedElement.setAttribute("aria-selected", "true")
selectedElement.setAttribute("tabindex", "0")
selectedElement.classList.add("bg-container-inset")
const selectedIcon = selectedElement.querySelector(".check-icon")
if (selectedIcon) selectedIcon.classList.remove("hidden")
@@ -103,7 +105,25 @@ export default class extends Controller {
scrollToSelected() {
const selected = this.menuTarget.querySelector(".bg-container-inset")
if (selected) selected.scrollIntoView({ block: "center" })
if (!selected) return
const container = this.hasContentTarget ? this.contentTarget : this.menuTarget
const containerRect = container.getBoundingClientRect()
const selectedRect = selected.getBoundingClientRect()
const delta = selectedRect.top - containerRect.top - (container.clientHeight - selectedRect.height) / 2
const nextScrollTop = container.scrollTop + delta
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
container.scrollTop = this.clamp(nextScrollTop, 0, maxScrollTop)
}
clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
placementMode() {
const mode = (this.menuPlacementValue || "auto").toLowerCase()
return ["auto", "down", "up"].includes(mode) ? mode : "auto"
}
handleOutsideClick(event) {
@@ -112,8 +132,66 @@ export default class extends Controller {
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() }
if (event.key === "Escape") { this.close(); this.buttonTarget.focus(); return }
if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click(); return }
// WAI-ARIA APG listbox keyboard pattern: ArrowUp/Down moves focus
// between options (roving tabindex), Home/End jump to first/last.
// From the search input, ArrowDown/Up bridge into the visible
// options so users can reach the filtered matches; other keys
// (typing, caret movement) stay with the input.
const fromSearch = event.target.matches('input[type="search"]')
const visibleOptions = this.visibleOptions()
if (fromSearch) {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
if (visibleOptions.length === 0) return
event.preventDefault()
const targetIndex = event.key === "ArrowDown" ? 0 : visibleOptions.length - 1
this.rovingFocus(visibleOptions, targetIndex)
return
}
if (visibleOptions.length === 0) return
const currentIndex = visibleOptions.indexOf(event.target)
let nextIndex = null
switch (event.key) {
case "ArrowDown": nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % visibleOptions.length; break
case "ArrowUp": nextIndex = currentIndex < 0 ? visibleOptions.length - 1 : (currentIndex - 1 + visibleOptions.length) % visibleOptions.length; break
case "Home": nextIndex = 0; break
case "End": nextIndex = visibleOptions.length - 1; break
default: return
}
event.preventDefault()
this.rovingFocus(visibleOptions, nextIndex)
}
// Roving tabindex helper: makes the target option tabbable (and
// focuses it), clears tabindex on every other option in the listbox.
rovingFocus(visibleOptions, index) {
const all = this.hasOptionTarget ? this.optionTargets : []
const target = visibleOptions[index]
all.forEach(opt => opt.setAttribute("tabindex", opt === target ? "0" : "-1"))
target.focus()
}
// Options the user can currently see — list-filter hides non-matches
// by setting `style.display = "none"`. Inline check keeps it cheap.
visibleOptions() {
const options = this.hasOptionTarget ? this.optionTargets : []
return options.filter(opt => opt.style.display !== "none")
}
// After list-filter#filter runs, the option holding tabindex="0" may
// be hidden. Promote the first visible option so Tab from the search
// input still lands somewhere reachable; if none match, no-op.
syncTabindex() {
const visible = this.visibleOptions()
if (visible.length === 0) return
const tabbable = visible.find(opt => opt.getAttribute("tabindex") === "0")
if (tabbable) return
const all = this.hasOptionTarget ? this.optionTargets : []
all.forEach(opt => opt.setAttribute("tabindex", "-1"))
visible[0].setAttribute("tabindex", "0")
}
handleTurboLoad() { if (this.isOpen) this.close() }
@@ -163,7 +241,8 @@ export default class extends Controller {
const spaceBelow = containerRect.bottom - buttonRect.bottom
const spaceAbove = buttonRect.top - containerRect.top
const shouldOpenUp = spaceBelow < menuHeight && spaceAbove > spaceBelow
const placement = this.placementMode()
const shouldOpenUp = placement === "up" || (placement === "auto" && spaceBelow < menuHeight && spaceAbove > spaceBelow)
this.menuTarget.style.left = "0"
this.menuTarget.style.width = "100%"

View File

@@ -39,15 +39,15 @@ export default class extends Controller {
}
}
// Sets or removes the data-theme attribute
// Sets the data-theme attribute and broadcasts a `theme:change` event so
// imperative consumers (D3/SVG/canvas) can repaint without polling.
setTheme(isDark) {
if (isDark) {
localStorage.theme = "dark";
document.documentElement.setAttribute("data-theme", "dark");
} else {
localStorage.theme = "light";
document.documentElement.setAttribute("data-theme", "light");
}
const theme = isDark ? "dark" : "light";
localStorage.theme = theme;
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.dispatchEvent(
new CustomEvent("theme:change", { detail: { theme } }),
);
}
systemPrefersDark() {

View File

@@ -110,7 +110,7 @@ export default class extends Controller {
.attr("cx", this._d3InitialContainerWidth / 2)
.attr("cy", this._d3InitialContainerHeight / 2)
.attr("r", 4)
.attr("class", "fg-subdued")
.attr("class", "text-subdued")
.style("fill", "currentColor");
}
@@ -220,7 +220,7 @@ export default class extends Controller {
// Style ticks
this._d3Group
.selectAll(".tick text")
.attr("class", "fg-gray")
.attr("class", "text-secondary")
.style("font-size", "12px")
.style("font-weight", "500")
.attr("text-anchor", "middle")
@@ -289,7 +289,7 @@ export default class extends Controller {
.append("div")
.attr(
"class",
"bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0 top-0",
"bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0 top-0 privacy-sensitive",
);
}
@@ -334,7 +334,7 @@ export default class extends Controller {
// Guideline
this._d3Group
.append("line")
.attr("class", "guideline fg-subdued")
.attr("class", "guideline text-subdued")
.attr("x1", this._d3XScale(d.date))
.attr("y1", 0)
.attr("x2", this._d3XScale(d.date))

View File

@@ -0,0 +1,60 @@
import ExchangeRateFormController from "controllers/exchange_rate_form_controller";
// Connects to data-controller="transaction-form"
export default class extends ExchangeRateFormController {
static targets = [
...ExchangeRateFormController.targets,
"account",
"currency"
];
hasRequiredExchangeRateTargets() {
if (!this.hasAccountTarget || !this.hasCurrencyTarget || !this.hasDateTarget) {
return false;
}
return true;
}
getExchangeRateContext() {
if (!this.hasRequiredExchangeRateTargets()) {
return null;
}
const accountId = this.accountTarget.value;
const currency = this.currencyTarget.value;
const date = this.dateTarget.value;
if (!accountId || !currency) {
return null;
}
const accountCurrency = this.accountCurrenciesValue[accountId];
if (!accountCurrency) {
return null;
}
return {
fromCurrency: currency,
toCurrency: accountCurrency,
date
};
}
isCurrentExchangeRateState(fromCurrency, toCurrency, date) {
if (!this.hasRequiredExchangeRateTargets()) {
return false;
}
const currentAccountId = this.accountTarget.value;
const currentCurrency = this.currencyTarget.value;
const currentDate = this.dateTarget.value;
const currentAccountCurrency = this.accountCurrenciesValue[currentAccountId];
return fromCurrency === currentCurrency && toCurrency === currentAccountCurrency && date === currentDate;
}
onCurrencyChange() {
this.checkCurrencyDifference();
}
}

View File

@@ -0,0 +1,21 @@
import { Controller } from "@hotwired/stimulus"
const ACTIVE_CLASSES = ["bg-container", "text-primary", "shadow-sm"]
const INACTIVE_CLASSES = ["hover:bg-container", "text-subdued", "hover:text-primary", "hover:shadow-sm"]
export default class extends Controller {
static targets = ["tab", "natureField"]
selectTab(event) {
event.preventDefault()
const selectedTab = event.currentTarget
this.natureFieldTarget.value = selectedTab.dataset.nature
this.tabTargets.forEach(tab => {
const isActive = tab === selectedTab
tab.classList.remove(...(isActive ? INACTIVE_CLASSES : ACTIVE_CLASSES))
tab.classList.add(...(isActive ? ACTIVE_CLASSES : INACTIVE_CLASSES))
})
}
}

View File

@@ -0,0 +1,59 @@
import ExchangeRateFormController from "controllers/exchange_rate_form_controller";
// Connects to data-controller="transfer-form"
export default class extends ExchangeRateFormController {
static targets = [
...ExchangeRateFormController.targets,
"fromAccount",
"toAccount"
];
hasRequiredExchangeRateTargets() {
if (!this.hasFromAccountTarget || !this.hasToAccountTarget || !this.hasDateTarget) {
return false;
}
return true;
}
getExchangeRateContext() {
if (!this.hasRequiredExchangeRateTargets()) {
return null;
}
const fromAccountId = this.fromAccountTarget.value;
const toAccountId = this.toAccountTarget.value;
const date = this.dateTarget.value;
if (!fromAccountId || !toAccountId) {
return null;
}
const fromCurrency = this.accountCurrenciesValue[fromAccountId];
const toCurrency = this.accountCurrenciesValue[toAccountId];
if (!fromCurrency || !toCurrency) {
return null;
}
return {
fromCurrency,
toCurrency,
date
};
}
isCurrentExchangeRateState(fromCurrency, toCurrency, date) {
if (!this.hasRequiredExchangeRateTargets()) {
return false;
}
const currentFromAccountId = this.fromAccountTarget.value;
const currentToAccountId = this.toAccountTarget.value;
const currentFromCurrency = this.accountCurrenciesValue[currentFromAccountId];
const currentToCurrency = this.accountCurrenciesValue[currentToAccountId];
const currentDate = this.dateTarget.value;
return fromCurrency === currentFromCurrency && toCurrency === currentToCurrency && date === currentDate;
}
}

View File

@@ -0,0 +1,62 @@
import WebauthnController from "controllers/webauthn_controller";
import {
prepareCredentialRequestOptions,
serializePublicKeyCredential,
} from "utils/webauthn";
export default class extends WebauthnController {
static targets = ["error"];
static values = {
optionsUrl: String,
verifyUrl: String,
unsupportedMessage: String,
errorFallback: String,
};
async authenticate(event) {
event.preventDefault();
this.clearError();
if (!window.PublicKeyCredential) {
this.showError(this.unsupportedMessageValue);
return;
}
try {
const options = await this.fetchOptions();
const credential = await navigator.credentials.get({
publicKey: prepareCredentialRequestOptions(options),
});
await this.verifyCredential(serializePublicKeyCredential(credential));
} catch (error) {
this.showError(error.message);
}
}
async fetchOptions() {
const response = await fetch(this.optionsUrlValue, {
method: "POST",
headers: this.headers,
credentials: "same-origin",
});
if (!response.ok) throw new Error(await this.errorMessage(response));
return response.json();
}
async verifyCredential(credential) {
const response = await fetch(this.verifyUrlValue, {
method: "POST",
headers: this.headers,
credentials: "same-origin",
body: JSON.stringify({ credential }),
});
if (!response.ok) throw new Error(await this.errorMessage(response));
const result = await response.json();
window.location.href = result.redirect_url;
}
}

View File

@@ -0,0 +1,39 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
get headers() {
return {
Accept: "application/json",
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']")
?.content,
};
}
async errorMessage(response) {
try {
const result = await response.clone().json();
if (result.error) return result.error;
} catch (_error) {
return this.errorFallbackValue;
}
return this.errorFallbackValue;
}
showError(message) {
if (this.hasErrorTarget) {
this.errorTarget.textContent = message;
this.errorTarget.hidden = false;
this.errorTarget.setAttribute("aria-hidden", "false");
}
}
clearError() {
if (this.hasErrorTarget) {
this.errorTarget.textContent = "";
this.errorTarget.hidden = true;
this.errorTarget.setAttribute("aria-hidden", "true");
}
}
}

View File

@@ -0,0 +1,67 @@
import WebauthnController from "controllers/webauthn_controller";
import {
prepareCredentialCreationOptions,
serializePublicKeyCredential,
} from "utils/webauthn";
export default class extends WebauthnController {
static targets = ["error", "nickname"];
static values = {
optionsUrl: String,
createUrl: String,
unsupportedMessage: String,
errorFallback: String,
};
async register(event) {
event.preventDefault();
this.clearError();
if (!window.PublicKeyCredential) {
this.showError(this.unsupportedMessageValue);
return;
}
try {
const options = await this.fetchOptions();
const credential = await navigator.credentials.create({
publicKey: prepareCredentialCreationOptions(options),
});
await this.createCredential(serializePublicKeyCredential(credential));
} catch (error) {
this.showError(error.message);
}
}
async fetchOptions() {
const response = await fetch(this.optionsUrlValue, {
method: "POST",
headers: this.headers,
credentials: "same-origin",
});
if (!response.ok) throw new Error(await this.errorMessage(response));
return response.json();
}
async createCredential(credential) {
const response = await fetch(this.createUrlValue, {
method: "POST",
headers: this.headers,
credentials: "same-origin",
body: JSON.stringify({
credential,
webauthn_credential: {
nickname: this.hasNicknameTarget ? this.nicknameTarget.value : "",
},
}),
});
if (!response.ok) throw new Error(await this.errorMessage(response));
const result = await response.json();
window.location.href = result.redirect_url;
}
}