From e357c0485f2a227ec1a35266ef9cfffa569c653d Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 11 Oct 2024 14:40:13 -0400 Subject: [PATCH 001/736] Temp fix for Stimulus charts --- .../controllers/bulk_select_controller.js | 46 +-- .../time_series_chart_controller.js | 344 +++++++++--------- 2 files changed, 195 insertions(+), 195 deletions(-) diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js index f7050b1a..c8ae14f2 100644 --- a/app/javascript/controllers/bulk_select_controller.js +++ b/app/javascript/controllers/bulk_select_controller.js @@ -19,19 +19,19 @@ export default class extends Controller { } bulkEditDrawerTitleTargetConnected(element) { - element.innerText = `Edit ${this.selectedIdsValue.length} ${this.#pluralizedResourceName()}` + element.innerText = `Edit ${this.selectedIdsValue.length} ${this._pluralizedResourceName()}` } submitBulkRequest(e) { const form = e.target.closest("form"); const scope = e.params.scope - this.#addHiddenFormInputsForSelectedIds(form, `${scope}[entry_ids][]`, this.selectedIdsValue) + this._addHiddenFormInputsForSelectedIds(form, `${scope}[entry_ids][]`, this.selectedIdsValue) form.requestSubmit() } togglePageSelection(e) { if (e.target.checked) { - this.#selectAll() + this._selectAll() } else { this.deselectAll() } @@ -40,20 +40,20 @@ export default class extends Controller { toggleGroupSelection(e) { const group = this.groupTargets.find(group => group.contains(e.target)) - this.#rowsForGroup(group).forEach(row => { + this._rowsForGroup(group).forEach(row => { if (e.target.checked) { - this.#addToSelection(row.dataset.id) + this._addToSelection(row.dataset.id) } else { - this.#removeFromSelection(row.dataset.id) + this._removeFromSelection(row.dataset.id) } }) } toggleRowSelection(e) { if (e.target.checked) { - this.#addToSelection(e.target.dataset.id) + this._addToSelection(e.target.dataset.id) } else { - this.#removeFromSelection(e.target.dataset.id) + this._removeFromSelection(e.target.dataset.id) } } @@ -66,8 +66,8 @@ export default class extends Controller { this._updateView() } - #addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) { - this.#resetFormInputs(form, paramName); + _addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) { + this._resetFormInputs(form, paramName); transactionIds.forEach(id => { const input = document.createElement("input"); @@ -78,47 +78,47 @@ export default class extends Controller { }) } - #resetFormInputs(form, paramName) { + _resetFormInputs(form, paramName) { const existingInputs = form.querySelectorAll(`input[name='${paramName}']`); existingInputs.forEach((input) => input.remove()); } - #rowsForGroup(group) { + _rowsForGroup(group) { return this.rowTargets.filter(row => group.contains(row)) } - #addToSelection(idToAdd) { + _addToSelection(idToAdd) { this.selectedIdsValue = Array.from( new Set([...this.selectedIdsValue, idToAdd]) ) } - #removeFromSelection(idToRemove) { + _removeFromSelection(idToRemove) { this.selectedIdsValue = this.selectedIdsValue.filter(id => id !== idToRemove) } - #selectAll() { + _selectAll() { this.selectedIdsValue = this.rowTargets.map(t => t.dataset.id) } _updateView = () => { - this.#updateSelectionBar() - this.#updateGroups() - this.#updateRows() + this._updateSelectionBar() + this._updateGroups() + this._updateRows() } - #updateSelectionBar() { + _updateSelectionBar() { const count = this.selectedIdsValue.length - this.selectionBarTextTarget.innerText = `${count} ${this.#pluralizedResourceName()} selected` + this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} selected` this.selectionBarTarget.hidden = count === 0 this.selectionBarTarget.querySelector("input[type='checkbox']").checked = count > 0 } - #pluralizedResourceName() { + _pluralizedResourceName() { return `${this.resourceValue}${this.selectedIdsValue.length === 1 ? "" : "s"}` } - #updateGroups() { + _updateGroups() { this.groupTargets.forEach(group => { const rows = this.rowTargets.filter(row => group.contains(row)) const groupSelected = rows.length > 0 && rows.every(row => this.selectedIdsValue.includes(row.dataset.id)) @@ -126,7 +126,7 @@ export default class extends Controller { }) } - #updateRows() { + _updateRows() { this.rowTargets.forEach(row => { row.checked = this.selectedIdsValue.includes(row.dataset.id) }) diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index b094f41c..e9d3b6c2 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -11,47 +11,47 @@ export default class extends Controller { usePercentSign: Boolean } - #d3SvgMemo = null; - #d3GroupMemo = null; - #d3Tooltip = null; - #d3InitialContainerWidth = 0; - #d3InitialContainerHeight = 0; - #normalDataPoints = []; + _d3SvgMemo = null; + _d3GroupMemo = null; + _d3Tooltip = null; + _d3InitialContainerWidth = 0; + _d3InitialContainerHeight = 0; + _normalDataPoints = []; connect() { - this.#install() - document.addEventListener("turbo:load", this.#reinstall) + this._install() + document.addEventListener("turbo:load", this._reinstall) } disconnect() { - this.#teardown() - document.removeEventListener("turbo:load", this.#reinstall) + this._teardown() + document.removeEventListener("turbo:load", this._reinstall) } - #reinstall = () => { - this.#teardown() - this.#install() + _reinstall = () => { + this._teardown() + this._install() } - #teardown() { - this.#d3SvgMemo = null - this.#d3GroupMemo = null - this.#d3Tooltip = null - this.#normalDataPoints = [] + _teardown() { + this._d3SvgMemo = null + this._d3GroupMemo = null + this._d3Tooltip = null + this._normalDataPoints = [] - this.#d3Container.selectAll("*").remove() + this._d3Container.selectAll("*").remove() } - #install() { - this.#normalizeDataPoints() - this.#rememberInitialContainerSize() - this.#draw() + _install() { + this._normalizeDataPoints() + this._rememberInitialContainerSize() + this._draw() } - #normalizeDataPoints() { - this.#normalDataPoints = (this.dataValue.values || []).map((d) => ({ + _normalizeDataPoints() { + this._normalDataPoints = (this.dataValue.values || []).map((d) => ({ ...d, date: new Date(d.date), value: d.value.amount ? +d.value.amount : +d.value, @@ -60,96 +60,96 @@ export default class extends Controller { } - #rememberInitialContainerSize() { - this.#d3InitialContainerWidth = this.#d3Container.node().clientWidth - this.#d3InitialContainerHeight = this.#d3Container.node().clientHeight + _rememberInitialContainerSize() { + this._d3InitialContainerWidth = this._d3Container.node().clientWidth + this._d3InitialContainerHeight = this._d3Container.node().clientHeight } - #draw() { - if (this.#normalDataPoints.length < 2) { - this.#drawEmpty() + _draw() { + if (this._normalDataPoints.length < 2) { + this._drawEmpty() } else { - this.#drawChart() + this._drawChart() } } - #drawEmpty() { - this.#d3Svg.selectAll(".tick").remove() - this.#d3Svg.selectAll(".domain").remove() + _drawEmpty() { + this._d3Svg.selectAll(".tick").remove() + this._d3Svg.selectAll(".domain").remove() - this.#drawDashedLineEmptyState() - this.#drawCenteredCircleEmptyState() + this._drawDashedLineEmptyState() + this._drawCenteredCircleEmptyState() } - #drawDashedLineEmptyState() { - this.#d3Svg + _drawDashedLineEmptyState() { + this._d3Svg .append("line") - .attr("x1", this.#d3InitialContainerWidth / 2) + .attr("x1", this._d3InitialContainerWidth / 2) .attr("y1", 0) - .attr("x2", this.#d3InitialContainerWidth / 2) - .attr("y2", this.#d3InitialContainerHeight) + .attr("x2", this._d3InitialContainerWidth / 2) + .attr("y2", this._d3InitialContainerHeight) .attr("stroke", tailwindColors.gray[300]) .attr("stroke-dasharray", "4, 4") } - #drawCenteredCircleEmptyState() { - this.#d3Svg + _drawCenteredCircleEmptyState() { + this._d3Svg .append("circle") - .attr("cx", this.#d3InitialContainerWidth / 2) - .attr("cy", this.#d3InitialContainerHeight / 2) + .attr("cx", this._d3InitialContainerWidth / 2) + .attr("cy", this._d3InitialContainerHeight / 2) .attr("r", 4) .style("fill", tailwindColors.gray[400]) } - #drawChart() { - this.#drawTrendline() + _drawChart() { + this._drawTrendline() if (this.useLabelsValue) { - this.#drawXAxisLabels() - this.#drawGradientBelowTrendline() + this._drawXAxisLabels() + this._drawGradientBelowTrendline() } if (this.useTooltipValue) { - this.#drawTooltip() - this.#trackMouseForShowingTooltip() + this._drawTooltip() + this._trackMouseForShowingTooltip() } } - #drawTrendline() { - this.#installTrendlineSplit() + _drawTrendline() { + this._installTrendlineSplit() - this.#d3Group + this._d3Group .append("path") - .datum(this.#normalDataPoints) + .datum(this._normalDataPoints) .attr("fill", "none") .attr("stroke", `url(#${this.element.id}-split-gradient)`) - .attr("d", this.#d3Line) + .attr("d", this._d3Line) .attr("stroke-linejoin", "round") .attr("stroke-linecap", "round") .attr("stroke-width", this.strokeWidthValue) } - #installTrendlineSplit() { - const gradient = this.#d3Svg + _installTrendlineSplit() { + const gradient = this._d3Svg .append("defs") .append("linearGradient") .attr("id", `${this.element.id}-split-gradient`) .attr("gradientUnits", "userSpaceOnUse") - .attr("x1", this.#d3XScale.range()[0]) - .attr("x2", this.#d3XScale.range()[1]) + .attr("x1", this._d3XScale.range()[0]) + .attr("x2", this._d3XScale.range()[1]) gradient.append("stop") .attr("class", "start-color") .attr("offset", "0%") - .attr("stop-color", this.#trendColor) + .attr("stop-color", this._trendColor) gradient.append("stop") .attr("class", "middle-color") .attr("offset", "100%") - .attr("stop-color", this.#trendColor) + .attr("stop-color", this._trendColor) gradient.append("stop") .attr("class", "end-color") @@ -157,31 +157,31 @@ export default class extends Controller { .attr("stop-color", tailwindColors.gray[300]) } - #setTrendlineSplitAt(percent) { - this.#d3Svg + _setTrendlineSplitAt(percent) { + this._d3Svg .select(`#${this.element.id}-split-gradient`) .select(".middle-color") .attr("offset", `${percent * 100}%`) - this.#d3Svg + this._d3Svg .select(`#${this.element.id}-split-gradient`) .select(".end-color") .attr("offset", `${percent * 100}%`) - this.#d3Svg + this._d3Svg .select(`#${this.element.id}-trendline-gradient-rect`) - .attr("width", this.#d3ContainerWidth * percent) + .attr("width", this._d3ContainerWidth * percent) } - #drawXAxisLabels() { + _drawXAxisLabels() { // Add ticks - this.#d3Group + this._d3Group .append("g") - .attr("transform", `translate(0,${this.#d3ContainerHeight})`) + .attr("transform", `translate(0,${this._d3ContainerHeight})`) .call( d3 - .axisBottom(this.#d3XScale) - .tickValues([this.#normalDataPoints[0].date, this.#normalDataPoints[this.#normalDataPoints.length - 1].date]) + .axisBottom(this._d3XScale) + .tickValues([this._normalDataPoints[0].date, this._normalDataPoints[this._normalDataPoints.length - 1].date]) .tickSize(0) .tickFormat(d3.timeFormat("%d %b %Y")) ) @@ -189,7 +189,7 @@ export default class extends Controller { .remove() // Style ticks - this.#d3Group.selectAll(".tick text") + this._d3Group.selectAll(".tick text") .style("fill", tailwindColors.gray[500]) .style("font-size", "12px") .style("font-weight", "500") @@ -201,54 +201,54 @@ export default class extends Controller { .attr("dy", "0em") } - #drawGradientBelowTrendline() { + _drawGradientBelowTrendline() { // Define gradient - const gradient = this.#d3Group + const gradient = this._d3Group .append("defs") .append("linearGradient") .attr("id", `${this.element.id}-trendline-gradient`) .attr("gradientUnits", "userSpaceOnUse") .attr("x1", 0) .attr("x2", 0) - .attr("y1", this.#d3YScale(d3.max(this.#normalDataPoints, d => d.value))) - .attr("y2", this.#d3ContainerHeight) + .attr("y1", this._d3YScale(d3.max(this._normalDataPoints, d => d.value))) + .attr("y2", this._d3ContainerHeight) gradient .append("stop") .attr("offset", 0) - .attr("stop-color", this.#trendColor) + .attr("stop-color", this._trendColor) .attr("stop-opacity", 0.06) gradient .append("stop") .attr("offset", 0.5) - .attr("stop-color", this.#trendColor) + .attr("stop-color", this._trendColor) .attr("stop-opacity", 0) // Clip path makes gradient start at the trendline - this.#d3Group + this._d3Group .append("clipPath") .attr("id", `${this.element.id}-clip-below-trendline`) .append("path") - .datum(this.#normalDataPoints) + .datum(this._normalDataPoints) .attr("d", d3.area() - .x(d => this.#d3XScale(d.date)) - .y0(this.#d3ContainerHeight) - .y1(d => this.#d3YScale(d.value)) + .x(d => this._d3XScale(d.date)) + .y0(this._d3ContainerHeight) + .y1(d => this._d3YScale(d.value)) ) // Apply the gradient + clip path - this.#d3Group + this._d3Group .append("rect") .attr("id", `${this.element.id}-trendline-gradient-rect`) - .attr("width", this.#d3ContainerWidth) - .attr("height", this.#d3ContainerHeight) + .attr("width", this._d3ContainerWidth) + .attr("height", this._d3ContainerHeight) .attr("clip-path", `url(#${this.element.id}-clip-below-trendline)`) .style("fill", `url(#${this.element.id}-trendline-gradient)`) } - #drawTooltip() { - this.#d3Tooltip = d3 + _drawTooltip() { + this._d3Tooltip = d3 .select(`#${this.element.id}`) .append("div") .style("position", "absolute") @@ -261,13 +261,13 @@ export default class extends Controller { .style("opacity", 0) // Starts as hidden } - #trackMouseForShowingTooltip() { + _trackMouseForShowingTooltip() { const bisectDate = d3.bisector(d => d.date).left - this.#d3Group + this._d3Group .append("rect") - .attr("width", this.#d3ContainerWidth) - .attr("height", this.#d3ContainerHeight) + .attr("width", this._d3ContainerWidth) + .attr("height", this._d3ContainerHeight) .attr("fill", "none") .attr("pointer-events", "all") .on("mousemove", (event) => { @@ -278,53 +278,53 @@ export default class extends Controller { const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX const [xPos] = d3.pointer(event) - const x0 = bisectDate(this.#normalDataPoints, this.#d3XScale.invert(xPos), 1) - const d0 = this.#normalDataPoints[x0 - 1] - const d1 = this.#normalDataPoints[x0] - const d = xPos - this.#d3XScale(d0.date) > this.#d3XScale(d1.date) - xPos ? d1 : d0 - const xPercent = this.#d3XScale(d.date) / this.#d3ContainerWidth + const x0 = bisectDate(this._normalDataPoints, this._d3XScale.invert(xPos), 1) + const d0 = this._normalDataPoints[x0 - 1] + const d1 = this._normalDataPoints[x0] + const d = xPos - this._d3XScale(d0.date) > this._d3XScale(d1.date) - xPos ? d1 : d0 + const xPercent = this._d3XScale(d.date) / this._d3ContainerWidth - this.#setTrendlineSplitAt(xPercent) + this._setTrendlineSplitAt(xPercent) // Reset - this.#d3Group.selectAll(".data-point-circle").remove() - this.#d3Group.selectAll(".guideline").remove() + this._d3Group.selectAll(".data-point-circle").remove() + this._d3Group.selectAll(".guideline").remove() // Guideline - this.#d3Group + this._d3Group .append("line") .attr("class", "guideline") - .attr("x1", this.#d3XScale(d.date)) + .attr("x1", this._d3XScale(d.date)) .attr("y1", 0) - .attr("x2", this.#d3XScale(d.date)) - .attr("y2", this.#d3ContainerHeight) + .attr("x2", this._d3XScale(d.date)) + .attr("y2", this._d3ContainerHeight) .attr("stroke", tailwindColors.gray[300]) .attr("stroke-dasharray", "4, 4") // Big circle - this.#d3Group + this._d3Group .append("circle") .attr("class", "data-point-circle") - .attr("cx", this.#d3XScale(d.date)) - .attr("cy", this.#d3YScale(d.value)) + .attr("cx", this._d3XScale(d.date)) + .attr("cy", this._d3YScale(d.value)) .attr("r", 8) - .attr("fill", this.#trendColor) + .attr("fill", this._trendColor) .attr("fill-opacity", "0.1") .attr("pointer-events", "none") // Small circle - this.#d3Group + this._d3Group .append("circle") .attr("class", "data-point-circle") - .attr("cx", this.#d3XScale(d.date)) - .attr("cy", this.#d3YScale(d.value)) + .attr("cx", this._d3XScale(d.date)) + .attr("cy", this._d3YScale(d.value)) .attr("r", 3) - .attr("fill", this.#trendColor) + .attr("fill", this._trendColor) .attr("pointer-events", "none") // Render tooltip - this.#d3Tooltip - .html(this.#tooltipTemplate(d)) + this._d3Tooltip + .html(this._tooltipTemplate(d)) .style("opacity", 1) .style("z-index", 999) .style("left", adjustedX + "px") @@ -334,16 +334,16 @@ export default class extends Controller { const hoveringOnGuideline = event.toElement?.classList.contains("guideline") if (!hoveringOnGuideline) { - this.#d3Group.selectAll(".guideline").remove() - this.#d3Group.selectAll(".data-point-circle").remove() - this.#d3Tooltip.style("opacity", 0) + this._d3Group.selectAll(".guideline").remove() + this._d3Group.selectAll(".data-point-circle").remove() + this._d3Tooltip.style("opacity", 0) - this.#setTrendlineSplitAt(1) + this._setTrendlineSplitAt(1) } }) } - #tooltipTemplate(datum) { + _tooltipTemplate(datum) { return (`
${d3.timeFormat("%b %d, %Y")(datum.date)} @@ -356,26 +356,26 @@ export default class extends Controller { cx="5" cy="5" r="4" - stroke="${this.#tooltipTrendColor(datum)}" + stroke="${this._tooltipTrendColor(datum)}" fill="transparent" stroke-width="1"> - ${this.#tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""} + ${this._tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
${this.usePercentSignValue || datum.trend.value === 0 || datum.trend.value.amount === 0 ? ` ` : ` - - ${this.#tooltipChange(datum)} (${datum.trend.percent}%) + + ${this._tooltipChange(datum)} (${datum.trend.percent}%) `} `) } - #tooltipTrendColor(datum) { + _tooltipTrendColor(datum) { return { up: tailwindColors.success, down: tailwindColors.error, @@ -383,30 +383,30 @@ export default class extends Controller { }[datum.trend.direction] } - #tooltipValue(datum) { + _tooltipValue(datum) { if (datum.currency) { - return this.#currencyValue(datum) + return this._currencyValue(datum) } else { return datum.value } } - #tooltipChange(datum) { + _tooltipChange(datum) { if (datum.currency) { - return this.#currencyChange(datum) + return this._currencyChange(datum) } else { - return this.#decimalChange(datum) + return this._decimalChange(datum) } } - #currencyValue(datum) { + _currencyValue(datum) { return Intl.NumberFormat(undefined, { style: "currency", currency: datum.currency, }).format(datum.value) } - #currencyChange(datum) { + _currencyChange(datum) { return Intl.NumberFormat(undefined, { style: "currency", currency: datum.currency, @@ -414,7 +414,7 @@ export default class extends Controller { }).format(datum.trend.value.amount) } - #decimalChange(datum) { + _decimalChange(datum) { return Intl.NumberFormat(undefined, { style: "decimal", signDisplay: "always", @@ -422,38 +422,38 @@ export default class extends Controller { } - #createMainSvg() { - return this.#d3Container + _createMainSvg() { + return this._d3Container .append("svg") - .attr("width", this.#d3InitialContainerWidth) - .attr("height", this.#d3InitialContainerHeight) - .attr("viewBox", [0, 0, this.#d3InitialContainerWidth, this.#d3InitialContainerHeight]) + .attr("width", this._d3InitialContainerWidth) + .attr("height", this._d3InitialContainerHeight) + .attr("viewBox", [0, 0, this._d3InitialContainerWidth, this._d3InitialContainerHeight]) } - #createMainGroup() { - return this.#d3Svg + _createMainGroup() { + return this._d3Svg .append("g") - .attr("transform", `translate(${this.#margin.left},${this.#margin.top})`) + .attr("transform", `translate(${this._margin.left},${this._margin.top})`) } - get #d3Svg() { - if (this.#d3SvgMemo) { - return this.#d3SvgMemo + get _d3Svg() { + if (this._d3SvgMemo) { + return this._d3SvgMemo } else { - return this.#d3SvgMemo = this.#createMainSvg() + return this._d3SvgMemo = this._createMainSvg() } } - get #d3Group() { - if (this.#d3GroupMemo) { - return this.#d3GroupMemo + get _d3Group() { + if (this._d3GroupMemo) { + return this._d3GroupMemo } else { - return this.#d3GroupMemo = this.#createMainGroup() + return this._d3GroupMemo = this._createMainGroup() } } - get #margin() { + get _margin() { if (this.useLabelsValue) { return { top: 20, right: 0, bottom: 30, left: 0 } } else { @@ -461,59 +461,59 @@ export default class extends Controller { } } - get #d3ContainerWidth() { - return this.#d3InitialContainerWidth - this.#margin.left - this.#margin.right + get _d3ContainerWidth() { + return this._d3InitialContainerWidth - this._margin.left - this._margin.right } - get #d3ContainerHeight() { - return this.#d3InitialContainerHeight - this.#margin.top - this.#margin.bottom + get _d3ContainerHeight() { + return this._d3InitialContainerHeight - this._margin.top - this._margin.bottom } - get #d3Container() { + get _d3Container() { return d3.select(this.element) } - get #trendColor() { - if (this.#trendDirection === "flat") { + get _trendColor() { + if (this._trendDirection === "flat") { return tailwindColors.gray[500] - } else if (this.#trendDirection === this.#favorableDirection) { + } else if (this._trendDirection === this._favorableDirection) { return tailwindColors.green[500] } else { return tailwindColors.error } } - get #trendDirection() { + get _trendDirection() { return this.dataValue.trend.direction } - get #favorableDirection() { + get _favorableDirection() { return this.dataValue.trend.favorable_direction } - get #d3Line() { + get _d3Line() { return d3 .line() - .x(d => this.#d3XScale(d.date)) - .y(d => this.#d3YScale(d.value)) + .x(d => this._d3XScale(d.date)) + .y(d => this._d3YScale(d.value)) } - get #d3XScale() { + get _d3XScale() { return d3 .scaleTime() - .rangeRound([0, this.#d3ContainerWidth]) - .domain(d3.extent(this.#normalDataPoints, d => d.date)) + .rangeRound([0, this._d3ContainerWidth]) + .domain(d3.extent(this._normalDataPoints, d => d.date)) } - get #d3YScale() { + get _d3YScale() { const reductionPercent = this.useLabelsValue ? 0.15 : 0.05 - const dataMin = d3.min(this.#normalDataPoints, d => d.value) - const dataMax = d3.max(this.#normalDataPoints, d => d.value) + const dataMin = d3.min(this._normalDataPoints, d => d.value) + const dataMax = d3.max(this._normalDataPoints, d => d.value) const padding = (dataMax - dataMin) * reductionPercent return d3 .scaleLinear() - .rangeRound([this.#d3ContainerHeight, 0]) + .rangeRound([this._d3ContainerHeight, 0]) .domain([dataMin - padding, dataMax + padding]) } -} +} \ No newline at end of file From 57a81e44ef9dea37a7457e4cb3bcc7fa7007b9a1 Mon Sep 17 00:00:00 2001 From: Alex Hatzenbuhler Date: Mon, 14 Oct 2024 09:19:33 -0500 Subject: [PATCH 002/736] Add period to value delete modal (#1297) --- config/locales/views/account/valuations/en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/views/account/valuations/en.yml b/config/locales/views/account/valuations/en.yml index 9b8dad56..4f8ed28b 100644 --- a/config/locales/views/account/valuations/en.yml +++ b/config/locales/views/account/valuations/en.yml @@ -18,7 +18,7 @@ en: confirm_body_html: "

Deleting this entry will remove it from the account’s history which will impact different parts of your account. This includes the net worth and account graphs.


The only way you’ll be able - to add this entry back is by re-entering it manually via a new entry

" + to add this entry back is by re-entering it manually via a new entry.

" confirm_title: Delete Entry? delete_entry: Delete entry edit_entry: Edit entry From 3bc960e6c1a88dc51b69c1f28b70c2c508097812 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:01:17 -0400 Subject: [PATCH 003/736] Bump sentry-ruby from 5.20.1 to 5.21.0 (#1306) Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.20.1 to 5.21.0. - [Release notes](https://github.com/getsentry/sentry-ruby/releases) - [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-ruby/compare/5.20.1...5.21.0) --- updated-dependencies: - dependency-name: sentry-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 155b548a..c8775f92 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -400,10 +400,10 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sentry-rails (5.20.1) + sentry-rails (5.21.0) railties (>= 5.0) - sentry-ruby (~> 5.20.1) - sentry-ruby (5.20.1) + sentry-ruby (~> 5.21.0) + sentry-ruby (5.21.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) simplecov (0.22.0) From eabec71f702969f19cb0a0c521775e01dde8348b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:01:35 -0400 Subject: [PATCH 004/736] Bump rails-settings-cached from 2.9.4 to 2.9.5 (#1305) Bumps [rails-settings-cached](https://github.com/huacnlee/rails-settings-cached) from 2.9.4 to 2.9.5. - [Release notes](https://github.com/huacnlee/rails-settings-cached/releases) - [Changelog](https://github.com/huacnlee/rails-settings-cached/blob/main/CHANGELOG.md) - [Commits](https://github.com/huacnlee/rails-settings-cached/compare/v2.9.4...v2.9.5) --- updated-dependencies: - dependency-name: rails-settings-cached dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c8775f92..9bf5160a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -291,7 +291,7 @@ GEM nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.7) + rack (3.1.8) rack-session (2.0.0) rack (>= 3.0.0) rack-test (2.1.0) @@ -323,7 +323,7 @@ GEM rails-i18n (7.0.9) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - rails-settings-cached (2.9.4) + rails-settings-cached (2.9.5) activerecord (>= 5.0.0) railties (>= 5.0.0) railties (7.2.1) @@ -461,7 +461,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.18) + zeitwerk (2.7.0) PLATFORMS aarch64-linux From 437aa4bd3949cf6e02aa42d9c47e5b73e239ad8f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:01:44 -0400 Subject: [PATCH 005/736] Bump importmap-rails from 2.0.2 to 2.0.3 (#1301) Bumps [importmap-rails](https://github.com/rails/importmap-rails) from 2.0.2 to 2.0.3. - [Release notes](https://github.com/rails/importmap-rails/releases) - [Commits](https://github.com/rails/importmap-rails/compare/v2.0.2...v2.0.3) --- updated-dependencies: - dependency-name: importmap-rails dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9bf5160a..fd4ddac3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -203,7 +203,7 @@ GEM image_processing (1.13.0) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) - importmap-rails (2.0.2) + importmap-rails (2.0.3) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) From 7f7140b1cca7c7cae657729bb03972003a26cbed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:01:56 -0400 Subject: [PATCH 006/736] Bump ruby-lsp-rails from 0.3.18 to 0.3.19 (#1300) Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.18 to 0.3.19. - [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases) - [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.18...v0.3.19) --- updated-dependencies: - dependency-name: ruby-lsp-rails dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index fd4ddac3..145834fb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -278,7 +278,7 @@ GEM ast (~> 2.4.1) racc pg (1.5.8) - prism (1.1.0) + prism (1.2.0) propshaft (1.1.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -377,13 +377,13 @@ GEM rubocop-minitest rubocop-performance rubocop-rails - ruby-lsp (0.19.1) + ruby-lsp (0.20.0) language_server-protocol (~> 3.17.0) - prism (>= 1.1, < 2.0) + prism (>= 1.2, < 2.0) rbs (>= 3, < 4) sorbet-runtime (>= 0.5.10782) - ruby-lsp-rails (0.3.18) - ruby-lsp (>= 0.19.0, < 0.20.0) + ruby-lsp-rails (0.3.19) + ruby-lsp (>= 0.20.0, < 0.21.0) ruby-progressbar (1.13.0) ruby-vips (2.2.2) ffi (~> 1.12) @@ -413,7 +413,7 @@ GEM simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) - sorbet-runtime (0.5.11597) + sorbet-runtime (0.5.11602) stackprof (0.2.26) stimulus-rails (1.3.4) railties (>= 6.0.0) From d4e7a983f445c37369b1f73730669b43ff885dc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:02:10 -0400 Subject: [PATCH 007/736] Bump tailwindcss-rails from 2.7.7 to 2.7.9 (#1304) Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.7 to 2.7.9. - [Release notes](https://github.com/rails/tailwindcss-rails/releases) - [Changelog](https://github.com/rails/tailwindcss-rails/blob/v2.7.9/CHANGELOG.md) - [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.7...v2.7.9) --- updated-dependencies: - dependency-name: tailwindcss-rails dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 145834fb..a8b6ceb9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -419,17 +419,17 @@ GEM railties (>= 6.0.0) stringio (3.1.1) stripe (13.0.0) - tailwindcss-rails (2.7.7) + tailwindcss-rails (2.7.9) railties (>= 7.0.0) - tailwindcss-rails (2.7.7-aarch64-linux) + tailwindcss-rails (2.7.9-aarch64-linux) railties (>= 7.0.0) - tailwindcss-rails (2.7.7-arm-linux) + tailwindcss-rails (2.7.9-arm-linux) railties (>= 7.0.0) - tailwindcss-rails (2.7.7-arm64-darwin) + tailwindcss-rails (2.7.9-arm64-darwin) railties (>= 7.0.0) - tailwindcss-rails (2.7.7-x86_64-darwin) + tailwindcss-rails (2.7.9-x86_64-darwin) railties (>= 7.0.0) - tailwindcss-rails (2.7.7-x86_64-linux) + tailwindcss-rails (2.7.9-x86_64-linux) railties (>= 7.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) From fa3b8b078c773b6650fd6361c124f967b71f4989 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:44:13 -0400 Subject: [PATCH 008/736] Bump good_job from 4.3.0 to 4.4.1 (#1302) Bumps [good_job](https://github.com/bensheldon/good_job) from 4.3.0 to 4.4.1. - [Release notes](https://github.com/bensheldon/good_job/releases) - [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md) - [Commits](https://github.com/bensheldon/good_job/compare/v4.3.0...v4.4.1) --- updated-dependencies: - dependency-name: good_job dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a8b6ceb9..21810fe3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -174,7 +174,7 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - good_job (4.3.0) + good_job (4.4.1) activejob (>= 6.1.0) activerecord (>= 6.1.0) concurrent-ruby (>= 1.3.1) From 4ad28d6effdcba2227a33c350b4b225f385107f2 Mon Sep 17 00:00:00 2001 From: oxdev03 <140103378+oxdev03@users.noreply.github.com> Date: Mon, 14 Oct 2024 23:09:27 +0200 Subject: [PATCH 009/736] Add BiomeJS for Linting and Formatting JavaScript relates to #1295 (#1299) * chore: add formatting and linting for javascript code relates to #1295 * use spaces instaed * add to recommended extensions * only enforce lint * auto save --- .devcontainer/Dockerfile | 4 + .devcontainer/devcontainer.json | 10 +- .github/workflows/ci.yml | 20 ++ .gitignore | 12 +- .vscode/extensions.json | 6 + .vscode/settings.json | 6 + app/javascript/application.js | 4 +- .../account_collapse_controller.js | 42 +-- app/javascript/controllers/application.js | 24 +- .../controllers/bulk_select_controller.js | 123 ++++--- .../controllers/clipboard_controller.js | 20 +- .../controllers/color_avatar_controller.js | 13 +- .../controllers/color_select_controller.js | 54 +-- .../controllers/deletion_controller.js | 24 +- .../controllers/element_removal_controller.js | 5 +- .../controllers/hotkey_controller.js | 2 +- app/javascript/controllers/index.js | 6 +- .../list_keyboard_navigation_controller.js | 31 +- app/javascript/controllers/menu_controller.js | 28 +- .../controllers/modal_controller.js | 6 +- .../controllers/money_field_controller.js | 10 +- .../controllers/pie_chart_controller.js | 22 +- .../profile_image_preview_controller.js | 10 +- app/javascript/controllers/tabs_controller.js | 2 +- .../time_series_chart_controller.js | 318 ++++++++++-------- .../controllers/tooltip_controller.js | 28 +- .../controllers/trade_form_controller.js | 55 +-- biome.json | 34 ++ package-lock.json | 180 ++++++++++ package.json | 18 + 30 files changed, 740 insertions(+), 377 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 biome.json create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index c2c4015d..c89d3a3f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -17,4 +17,8 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ RUN gem install bundler RUN gem install foreman +# Install Node.js 20 +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ +&& apt-get install -y nodejs + WORKDIR /workspace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0f08d701..60b1866e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,5 +10,13 @@ "remoteEnv": { "PATH": "/workspace/bin:${containerEnv:PATH}" }, - "postCreateCommand": "bundle install" + "postCreateCommand": "bundle install && npm install", + "customizations": { + "vscode": { + "extensions": [ + "biomejs.biome", + "EditorConfig.EditorConfig" + ] + } + } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c182886a..6551335b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,26 @@ jobs: - name: Lint code for consistent style run: bin/rubocop -f github + lint_js: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm install + shell: bash + + - name: Lint/Format js code + run: npm run lint + test: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.gitignore b/.gitignore index d8d688a0..e8aaee4a 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,12 @@ .idea # Ignore VS Code -.vscode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets # Ignore macOS specific files */.DS_Store @@ -59,4 +64,7 @@ compose-dev.yaml gcp-storage-keyfile.json coverage -.cursorrules \ No newline at end of file +.cursorrules + +# Ignore node related files +node_modules \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..866ab2b9 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "biomejs.biome", + "EditorConfig.EditorConfig" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..f94a8b2b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "[javascript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome", + } +} \ No newline at end of file diff --git a/app/javascript/application.js b/app/javascript/application.js index 0d7b4940..874eae81 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,3 +1,3 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails -import "@hotwired/turbo-rails" -import "controllers" +import "@hotwired/turbo-rails"; +import "controllers"; diff --git a/app/javascript/controllers/account_collapse_controller.js b/app/javascript/controllers/account_collapse_controller.js index 9e597eea..11c51cde 100644 --- a/app/javascript/controllers/account_collapse_controller.js +++ b/app/javascript/controllers/account_collapse_controller.js @@ -1,51 +1,51 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="account-collapse" export default class extends Controller { - static values = { type: String } - initialToggle = false - STORAGE_NAME = "accountCollapseStates" + static values = { type: String }; + initialToggle = false; + STORAGE_NAME = "accountCollapseStates"; connect() { - this.element.addEventListener("toggle", this.onToggle) - this.updateFromLocalStorage() + this.element.addEventListener("toggle", this.onToggle); + this.updateFromLocalStorage(); } disconnect() { - this.element.removeEventListener("toggle", this.onToggle) + this.element.removeEventListener("toggle", this.onToggle); } onToggle = () => { if (this.initialToggle) { - this.initialToggle = false - return + this.initialToggle = false; + return; } - const items = this.getItemsFromLocalStorage() + const items = this.getItemsFromLocalStorage(); if (items.has(this.typeValue)) { - items.delete(this.typeValue) + items.delete(this.typeValue); } else { - items.add(this.typeValue) + items.add(this.typeValue); } - localStorage.setItem(this.STORAGE_NAME, JSON.stringify([...items])) - } + localStorage.setItem(this.STORAGE_NAME, JSON.stringify([...items])); + }; updateFromLocalStorage() { - const items = this.getItemsFromLocalStorage() + const items = this.getItemsFromLocalStorage(); if (items.has(this.typeValue)) { - this.initialToggle = true - this.element.setAttribute("open", "") + this.initialToggle = true; + this.element.setAttribute("open", ""); } } getItemsFromLocalStorage() { try { - const items = localStorage.getItem(this.STORAGE_NAME) - return new Set(items ? JSON.parse(items) : []) + const items = localStorage.getItem(this.STORAGE_NAME); + return new Set(items ? JSON.parse(items) : []); } catch (error) { - console.error("Error parsing items from localStorage:", error) - return new Set() + console.error("Error parsing items from localStorage:", error); + return new Set(); } } } diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js index f25e31b6..6004862f 100644 --- a/app/javascript/controllers/application.js +++ b/app/javascript/controllers/application.js @@ -1,10 +1,10 @@ -import { Application } from "@hotwired/stimulus" +import { Application } from "@hotwired/stimulus"; -const application = Application.start() +const application = Application.start(); // Configure Stimulus development experience -application.debug = false -window.Stimulus = application +application.debug = false; +window.Stimulus = application; Turbo.setConfirmMethod((message) => { const dialog = document.getElementById("turbo-confirm"); @@ -34,10 +34,14 @@ Turbo.setConfirmMethod((message) => { dialog.showModal(); return new Promise((resolve) => { - dialog.addEventListener("close", () => { - resolve(dialog.returnValue == "confirm") - }, { once: true }) - }) -}) + dialog.addEventListener( + "close", + () => { + resolve(dialog.returnValue === "confirm"); + }, + { once: true }, + ); + }); +}); -export { application } +export { application }; diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js index c8ae14f2..e72704c7 100644 --- a/app/javascript/controllers/bulk_select_controller.js +++ b/app/javascript/controllers/bulk_select_controller.js @@ -1,81 +1,95 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="bulk-select" export default class extends Controller { - static targets = ["row", "group", "selectionBar", "selectionBarText", "bulkEditDrawerTitle"] + static targets = [ + "row", + "group", + "selectionBar", + "selectionBarText", + "bulkEditDrawerTitle", + ]; static values = { resource: String, - selectedIds: { type: Array, default: [] } - } + selectedIds: { type: Array, default: [] }, + }; connect() { - document.addEventListener("turbo:load", this._updateView) + document.addEventListener("turbo:load", this._updateView); - this._updateView() + this._updateView(); } disconnect() { - document.removeEventListener("turbo:load", this._updateView) + document.removeEventListener("turbo:load", this._updateView); } bulkEditDrawerTitleTargetConnected(element) { - element.innerText = `Edit ${this.selectedIdsValue.length} ${this._pluralizedResourceName()}` + element.innerText = `Edit ${ + this.selectedIdsValue.length + } ${this._pluralizedResourceName()}`; } submitBulkRequest(e) { const form = e.target.closest("form"); - const scope = e.params.scope - this._addHiddenFormInputsForSelectedIds(form, `${scope}[entry_ids][]`, this.selectedIdsValue) - form.requestSubmit() + const scope = e.params.scope; + this._addHiddenFormInputsForSelectedIds( + form, + `${scope}[entry_ids][]`, + this.selectedIdsValue, + ); + form.requestSubmit(); } togglePageSelection(e) { if (e.target.checked) { - this._selectAll() + this._selectAll(); } else { - this.deselectAll() + this.deselectAll(); } } toggleGroupSelection(e) { - const group = this.groupTargets.find(group => group.contains(e.target)) + const group = this.groupTargets.find((group) => group.contains(e.target)); - this._rowsForGroup(group).forEach(row => { + this._rowsForGroup(group).forEach((row) => { if (e.target.checked) { - this._addToSelection(row.dataset.id) + this._addToSelection(row.dataset.id); } else { - this._removeFromSelection(row.dataset.id) + this._removeFromSelection(row.dataset.id); } - }) + }); } toggleRowSelection(e) { if (e.target.checked) { - this._addToSelection(e.target.dataset.id) + this._addToSelection(e.target.dataset.id); } else { - this._removeFromSelection(e.target.dataset.id) + this._removeFromSelection(e.target.dataset.id); } } deselectAll() { - this.selectedIdsValue = [] - this.element.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false) + this.selectedIdsValue = []; + this.element.querySelectorAll('input[type="checkbox"]').forEach((el) => { + el.checked = false; + }); } selectedIdsValueChanged() { - this._updateView() + this._updateView(); } _addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) { this._resetFormInputs(form, paramName); - transactionIds.forEach(id => { + transactionIds.forEach((id) => { const input = document.createElement("input"); - input.type = 'hidden' - input.name = paramName - input.value = id - form.appendChild(input) - }) + input.type = "hidden"; + input.name = paramName; + input.value = id; + form.appendChild(input); + }); } _resetFormInputs(form, paramName) { @@ -84,51 +98,58 @@ export default class extends Controller { } _rowsForGroup(group) { - return this.rowTargets.filter(row => group.contains(row)) + return this.rowTargets.filter((row) => group.contains(row)); } _addToSelection(idToAdd) { this.selectedIdsValue = Array.from( - new Set([...this.selectedIdsValue, idToAdd]) - ) + new Set([...this.selectedIdsValue, idToAdd]), + ); } _removeFromSelection(idToRemove) { - this.selectedIdsValue = this.selectedIdsValue.filter(id => id !== idToRemove) + this.selectedIdsValue = this.selectedIdsValue.filter( + (id) => id !== idToRemove, + ); } _selectAll() { - this.selectedIdsValue = this.rowTargets.map(t => t.dataset.id) + this.selectedIdsValue = this.rowTargets.map((t) => t.dataset.id); } _updateView = () => { - this._updateSelectionBar() - this._updateGroups() - this._updateRows() - } + this._updateSelectionBar(); + this._updateGroups(); + this._updateRows(); + }; _updateSelectionBar() { - const count = this.selectedIdsValue.length - this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} selected` - this.selectionBarTarget.hidden = count === 0 - this.selectionBarTarget.querySelector("input[type='checkbox']").checked = count > 0 + const count = this.selectedIdsValue.length; + this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} selected`; + this.selectionBarTarget.hidden = count === 0; + this.selectionBarTarget.querySelector("input[type='checkbox']").checked = + count > 0; } _pluralizedResourceName() { - return `${this.resourceValue}${this.selectedIdsValue.length === 1 ? "" : "s"}` + return `${this.resourceValue}${ + this.selectedIdsValue.length === 1 ? "" : "s" + }`; } _updateGroups() { - this.groupTargets.forEach(group => { - const rows = this.rowTargets.filter(row => group.contains(row)) - const groupSelected = rows.length > 0 && rows.every(row => this.selectedIdsValue.includes(row.dataset.id)) - group.querySelector("input[type='checkbox']").checked = groupSelected - }) + this.groupTargets.forEach((group) => { + const rows = this.rowTargets.filter((row) => group.contains(row)); + const groupSelected = + rows.length > 0 && + rows.every((row) => this.selectedIdsValue.includes(row.dataset.id)); + group.querySelector("input[type='checkbox']").checked = groupSelected; + }); } _updateRows() { - this.rowTargets.forEach(row => { - row.checked = this.selectedIdsValue.includes(row.dataset.id) - }) + this.rowTargets.forEach((row) => { + row.checked = this.selectedIdsValue.includes(row.dataset.id); + }); } } diff --git a/app/javascript/controllers/clipboard_controller.js b/app/javascript/controllers/clipboard_controller.js index 34e29a28..7e0b1a49 100644 --- a/app/javascript/controllers/clipboard_controller.js +++ b/app/javascript/controllers/clipboard_controller.js @@ -1,28 +1,28 @@ -import { Controller } from "@hotwired/stimulus" - +import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = ["source", "iconDefault", "iconSuccess"] + static targets = ["source", "iconDefault", "iconSuccess"]; copy(event) { event.preventDefault(); - if (this.sourceTarget && this.sourceTarget.textContent) { - navigator.clipboard.writeText(this.sourceTarget.textContent) + if (this.sourceTarget?.textContent) { + navigator.clipboard + .writeText(this.sourceTarget.textContent) .then(() => { this.showSuccess(); }) .catch((error) => { - console.error('Failed to copy text: ', error); + console.error("Failed to copy text: ", error); }); } } showSuccess() { - this.iconDefaultTarget.classList.add('hidden'); - this.iconSuccessTarget.classList.remove('hidden'); + this.iconDefaultTarget.classList.add("hidden"); + this.iconSuccessTarget.classList.remove("hidden"); setTimeout(() => { - this.iconDefaultTarget.classList.remove('hidden'); - this.iconSuccessTarget.classList.add('hidden'); + this.iconDefaultTarget.classList.remove("hidden"); + this.iconSuccessTarget.classList.add("hidden"); }, 3000); } } diff --git a/app/javascript/controllers/color_avatar_controller.js b/app/javascript/controllers/color_avatar_controller.js index 7a1ba0d0..276ae023 100644 --- a/app/javascript/controllers/color_avatar_controller.js +++ b/app/javascript/controllers/color_avatar_controller.js @@ -3,10 +3,7 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="color-avatar" // Used by the transaction merchant form to show a preview of what the avatar will look like export default class extends Controller { - static targets = [ - "name", - "avatar" - ]; + static targets = ["name", "avatar"]; connect() { this.nameTarget.addEventListener("input", this.handleNameChange); @@ -17,8 +14,10 @@ export default class extends Controller { } handleNameChange = (e) => { - this.avatarTarget.textContent = (e.currentTarget.value?.[0] || "?").toUpperCase(); - } + this.avatarTarget.textContent = ( + e.currentTarget.value?.[0] || "?" + ).toUpperCase(); + }; handleColorChange(e) { const color = e.currentTarget.value; @@ -26,4 +25,4 @@ export default class extends Controller { this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`; this.avatarTarget.style.color = color; } -} \ No newline at end of file +} diff --git a/app/javascript/controllers/color_select_controller.js b/app/javascript/controllers/color_select_controller.js index e18bb84f..71b9cea4 100644 --- a/app/javascript/controllers/color_select_controller.js +++ b/app/javascript/controllers/color_select_controller.js @@ -1,59 +1,65 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = [ "input", "decoration" ] - static values = { selection: String } + static targets = ["input", "decoration"]; + static values = { selection: String }; connect() { - this.#renderOptions() + this.#renderOptions(); } select({ target }) { - this.selectionValue = target.dataset.value + this.selectionValue = target.dataset.value; } selectionValueChanged() { - this.#options.forEach(option => { + this.#options.forEach((option) => { if (option.dataset.value === this.selectionValue) { - this.#check(option) - this.inputTarget.value = this.selectionValue + this.#check(option); + this.inputTarget.value = this.selectionValue; } else { - this.#uncheck(option) + this.#uncheck(option); } - }) + }); } #renderOptions() { - this.#options.forEach(option => option.style.backgroundColor = option.dataset.value) + this.#options.forEach((option) => { + option.style.backgroundColor = option.dataset.value; + }); } #check(option) { - option.setAttribute("aria-checked", "true") - option.style.boxShadow = `0px 0px 0px 4px ${hexToRGBA(option.dataset.value, 0.2)}` - this.decorationTarget.style.backgroundColor = option.dataset.value + option.setAttribute("aria-checked", "true"); + option.style.boxShadow = `0px 0px 0px 4px ${hexToRGBA( + option.dataset.value, + 0.2, + )}`; + this.decorationTarget.style.backgroundColor = option.dataset.value; } #uncheck(option) { - option.setAttribute("aria-checked", "false") - option.style.boxShadow = "none" + option.setAttribute("aria-checked", "false"); + option.style.boxShadow = "none"; } get #options() { - return Array.from(this.element.querySelectorAll("[role='radio']")) + return Array.from(this.element.querySelectorAll("[role='radio']")); } } function hexToRGBA(hex, alpha = 1) { - hex = hex.replace(/^#/, ''); + let hexCode = hex.replace(/^#/, ""); + let calculatedAlpha = alpha; - if (hex.length === 8) { - alpha = parseInt(hex.slice(6, 8), 16) / 255; - hex = hex.slice(0, 6); + if (hexCode.length === 8) { + calculatedAlpha = Number.parseInt(hexCode.slice(6, 8), 16) / 255; + hexCode = hexCode.slice(0, 6); } - let r = parseInt(hex.slice(0, 2), 16); - let g = parseInt(hex.slice(2, 4), 16); - let b = parseInt(hex.slice(4, 6), 16); + const r = Number.parseInt(hexCode.slice(0, 2), 16); + const g = Number.parseInt(hexCode.slice(2, 4), 16); + const b = Number.parseInt(hexCode.slice(4, 6), 16); - return `rgba(${r}, ${g}, ${b}, ${alpha})`; + return `rgba(${r}, ${g}, ${b}, ${calculatedAlpha})`; } diff --git a/app/javascript/controllers/deletion_controller.js b/app/javascript/controllers/deletion_controller.js index af3f0b04..cd49065d 100644 --- a/app/javascript/controllers/deletion_controller.js +++ b/app/javascript/controllers/deletion_controller.js @@ -1,30 +1,30 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = ["replacementField", "submitButton"] - static classes = [ "dangerousAction", "safeAction" ] + static targets = ["replacementField", "submitButton"]; + static classes = ["dangerousAction", "safeAction"]; static values = { submitTextWhenReplacing: String, - submitTextWhenNotReplacing: String - } + submitTextWhenNotReplacing: String, + }; updateSubmitButton() { if (this.replacementFieldTarget.value) { - this.submitButtonTarget.value = this.submitTextWhenReplacingValue - this.#markSafe() + this.submitButtonTarget.value = this.submitTextWhenReplacingValue; + this.#markSafe(); } else { - this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue - this.#markDangerous() + this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue; + this.#markDangerous(); } } #markSafe() { - this.submitButtonTarget.classList.remove(...this.dangerousActionClasses) - this.submitButtonTarget.classList.add(...this.safeActionClasses) + this.submitButtonTarget.classList.remove(...this.dangerousActionClasses); + this.submitButtonTarget.classList.add(...this.safeActionClasses); } #markDangerous() { - this.submitButtonTarget.classList.remove(...this.safeActionClasses) - this.submitButtonTarget.classList.add(...this.dangerousActionClasses) + this.submitButtonTarget.classList.remove(...this.safeActionClasses); + this.submitButtonTarget.classList.add(...this.dangerousActionClasses); } } diff --git a/app/javascript/controllers/element_removal_controller.js b/app/javascript/controllers/element_removal_controller.js index b14906a9..fade773d 100644 --- a/app/javascript/controllers/element_removal_controller.js +++ b/app/javascript/controllers/element_removal_controller.js @@ -1,9 +1,8 @@ -import { Controller } from '@hotwired/stimulus' +import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="element-removal" export default class extends Controller { remove() { - this.element.remove() + this.element.remove(); } } - diff --git a/app/javascript/controllers/hotkey_controller.js b/app/javascript/controllers/hotkey_controller.js index b7b346e3..f01713a9 100644 --- a/app/javascript/controllers/hotkey_controller.js +++ b/app/javascript/controllers/hotkey_controller.js @@ -1,5 +1,5 @@ -import { Controller } from "@hotwired/stimulus"; import { install, uninstall } from "@github/hotkey"; +import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="hotkey" export default class extends Controller { diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 54ad4cad..74c6c0a2 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -1,10 +1,10 @@ // Import and register all your controllers from the importmap under controllers/* -import { application } from "controllers/application" +import { application } from "controllers/application"; // Eager load all controllers defined in the import map under controllers/**/*_controller -import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" -eagerLoadControllersFrom("controllers", application) +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"; +eagerLoadControllersFrom("controllers", application); // Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!) // import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading" diff --git a/app/javascript/controllers/list_keyboard_navigation_controller.js b/app/javascript/controllers/list_keyboard_navigation_controller.js index 7e28069e..500e0f26 100644 --- a/app/javascript/controllers/list_keyboard_navigation_controller.js +++ b/app/javascript/controllers/list_keyboard_navigation_controller.js @@ -1,39 +1,40 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="list-keyboard-navigation" export default class extends Controller { focusPrevious() { - this.focusLinkTargetInDirection(-1) + this.focusLinkTargetInDirection(-1); } focusNext() { - this.focusLinkTargetInDirection(1) + this.focusLinkTargetInDirection(1); } focusLinkTargetInDirection(direction) { - const element = this.getLinkTargetInDirection(direction) - element?.focus() + const element = this.getLinkTargetInDirection(direction); + element?.focus(); } getLinkTargetInDirection(direction) { - const indexOfLastFocus = this.indexOfLastFocus() - let nextIndex = (indexOfLastFocus + direction) % this.focusableLinks.length - if (nextIndex < 0) nextIndex = this.focusableLinks.length - 1 - - return this.focusableLinks[nextIndex] + const indexOfLastFocus = this.indexOfLastFocus(); + let nextIndex = (indexOfLastFocus + direction) % this.focusableLinks.length; + if (nextIndex < 0) nextIndex = this.focusableLinks.length - 1; + + return this.focusableLinks[nextIndex]; } indexOfLastFocus(targets = this.focusableLinks) { - const indexOfActiveElement = targets.indexOf(document.activeElement) + const indexOfActiveElement = targets.indexOf(document.activeElement); if (indexOfActiveElement !== -1) { - return indexOfActiveElement - } else { - return targets.findIndex(target => target.getAttribute("tabindex") === "0") + return indexOfActiveElement; } + return targets.findIndex( + (target) => target.getAttribute("tabindex") === "0", + ); } get focusableLinks() { - return Array.from(this.element.querySelectorAll("a[href]")) + return Array.from(this.element.querySelectorAll("a[href]")); } } diff --git a/app/javascript/controllers/menu_controller.js b/app/javascript/controllers/menu_controller.js index 3107a206..d5cfec2b 100644 --- a/app/javascript/controllers/menu_controller.js +++ b/app/javascript/controllers/menu_controller.js @@ -1,5 +1,11 @@ +import { + autoUpdate, + computePosition, + flip, + offset, + shift, +} from "@floating-ui/dom"; import { Controller } from "@hotwired/stimulus"; -import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom'; /** * A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms. @@ -70,8 +76,10 @@ export default class extends Controller { } focusFirstElement() { - const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; - const firstFocusableElement = this.contentTarget.querySelectorAll(focusableElements)[0]; + const focusableElements = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const firstFocusableElement = + this.contentTarget.querySelectorAll(focusableElements)[0]; if (firstFocusableElement) { firstFocusableElement.focus(); } @@ -79,7 +87,11 @@ export default class extends Controller { startAutoUpdate() { if (!this._cleanup) { - this._cleanup = autoUpdate(this.buttonTarget, this.contentTarget, this.boundUpdate); + this._cleanup = autoUpdate( + this.buttonTarget, + this.contentTarget, + this.boundUpdate, + ); } } @@ -93,14 +105,10 @@ export default class extends Controller { update() { computePosition(this.buttonTarget, this.contentTarget, { placement: this.placementValue, - middleware: [ - offset(this.offsetValue), - flip(), - shift({ padding: 5 }) - ], + middleware: [offset(this.offsetValue), flip(), shift({ padding: 5 })], }).then(({ x, y }) => { Object.assign(this.contentTarget.style, { - position: 'fixed', + position: "fixed", left: `${x}px`, top: `${y}px`, }); diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js index 87cdd3cd..a988dbb8 100644 --- a/app/javascript/controllers/modal_controller.js +++ b/app/javascript/controllers/modal_controller.js @@ -1,10 +1,10 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="modal" export default class extends Controller { connect() { - if (this.element.open) return - else this.element.showModal() + if (this.element.open) return; + this.element.showModal(); } // Hide the dialog when the user clicks outside of it diff --git a/app/javascript/controllers/money_field_controller.js b/app/javascript/controllers/money_field_controller.js index 70ef4bfe..2aab2d16 100644 --- a/app/javascript/controllers/money_field_controller.js +++ b/app/javascript/controllers/money_field_controller.js @@ -12,14 +12,16 @@ export default class extends Controller { } updateAmount(currency) { - (new CurrenciesService).get(currency).then((currency) => { + new CurrenciesService().get(currency).then((currency) => { this.amountTarget.step = currency.step; - if (isFinite(this.amountTarget.value)) { - this.amountTarget.value = parseFloat(this.amountTarget.value).toFixed(currency.default_precision) + if (Number.isFinite(this.amountTarget.value)) { + this.amountTarget.value = Number.parseFloat( + this.amountTarget.value, + ).toFixed(currency.default_precision); } this.symbolTarget.innerText = currency.symbol; }); } -} \ No newline at end of file +} diff --git a/app/javascript/controllers/pie_chart_controller.js b/app/javascript/controllers/pie_chart_controller.js index 1bb41447..e11cb870 100644 --- a/app/javascript/controllers/pie_chart_controller.js +++ b/app/javascript/controllers/pie_chart_controller.js @@ -104,27 +104,25 @@ export default class extends Controller { } get #d3Svg() { - if (this.#d3SvgMemo) { - return this.#d3SvgMemo; - } else { - return (this.#d3SvgMemo = this.#createMainSvg()); + if (!this.#d3SvgMemo) { + this.#d3SvgMemo = this.#createMainSvg(); } + return this.#d3SvgMemo; } get #d3Group() { - if (this.#d3GroupMemo) { - return this.#d3GroupMemo; - } else { - return (this.#d3GroupMemo = this.#createMainGroup()); + if (!this.#d3GroupMemo) { + this.#d3GroupMemo = this.#createMainGroup(); } + + return this.#d3ContentMemo; } get #d3Content() { - if (this.#d3ContentMemo) { - return this.#d3ContentMemo; - } else { - return (this.#d3ContentMemo = this.#createContent()); + if (!this.#d3ContentMemo) { + this.#d3ContentMemo = this.#createContent(); } + return this.#d3ContentMemo; } #createMainSvg() { diff --git a/app/javascript/controllers/profile_image_preview_controller.js b/app/javascript/controllers/profile_image_preview_controller.js index 50896625..b03842be 100644 --- a/app/javascript/controllers/profile_image_preview_controller.js +++ b/app/javascript/controllers/profile_image_preview_controller.js @@ -1,7 +1,13 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = ["imagePreview", "fileField", "deleteField", "clearBtn", "template"] + static targets = [ + "imagePreview", + "fileField", + "deleteField", + "clearBtn", + "template", + ]; preview(event) { const file = event.target.files[0]; diff --git a/app/javascript/controllers/tabs_controller.js b/app/javascript/controllers/tabs_controller.js index 16838e2a..7fb3e0c5 100644 --- a/app/javascript/controllers/tabs_controller.js +++ b/app/javascript/controllers/tabs_controller.js @@ -28,7 +28,7 @@ export default class extends Controller { updateClasses = (selectedId) => { this.btnTargets.forEach((btn) => - btn.classList.remove(...this.activeClasses) + btn.classList.remove(...this.activeClasses), ); this.tabTargets.forEach((tab) => tab.classList.add("hidden")); diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index e9d3b6c2..ce27460f 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -1,6 +1,6 @@ -import { Controller } from "@hotwired/stimulus" -import tailwindColors from "@maybe/tailwindcolors" -import * as d3 from "d3" +import { Controller } from "@hotwired/stimulus"; +import tailwindColors from "@maybe/tailwindcolors"; +import * as d3 from "d3"; export default class extends Controller { static values = { @@ -8,8 +8,8 @@ export default class extends Controller { strokeWidth: { type: Number, default: 2 }, useLabels: { type: Boolean, default: true }, useTooltip: { type: Boolean, default: true }, - usePercentSign: Boolean - } + usePercentSign: Boolean, + }; _d3SvgMemo = null; _d3GroupMemo = null; @@ -19,68 +19,63 @@ export default class extends Controller { _normalDataPoints = []; connect() { - this._install() - document.addEventListener("turbo:load", this._reinstall) + this._install(); + document.addEventListener("turbo:load", this._reinstall); } disconnect() { - this._teardown() - document.removeEventListener("turbo:load", this._reinstall) + this._teardown(); + document.removeEventListener("turbo:load", this._reinstall); } - _reinstall = () => { - this._teardown() - this._install() - } + this._teardown(); + this._install(); + }; _teardown() { - this._d3SvgMemo = null - this._d3GroupMemo = null - this._d3Tooltip = null - this._normalDataPoints = [] + this._d3SvgMemo = null; + this._d3GroupMemo = null; + this._d3Tooltip = null; + this._normalDataPoints = []; - this._d3Container.selectAll("*").remove() + this._d3Container.selectAll("*").remove(); } _install() { - this._normalizeDataPoints() - this._rememberInitialContainerSize() - this._draw() + this._normalizeDataPoints(); + this._rememberInitialContainerSize(); + this._draw(); } - _normalizeDataPoints() { this._normalDataPoints = (this.dataValue.values || []).map((d) => ({ ...d, date: new Date(d.date), value: d.value.amount ? +d.value.amount : +d.value, - currency: d.value.currency - })) + currency: d.value.currency, + })); } - _rememberInitialContainerSize() { - this._d3InitialContainerWidth = this._d3Container.node().clientWidth - this._d3InitialContainerHeight = this._d3Container.node().clientHeight + this._d3InitialContainerWidth = this._d3Container.node().clientWidth; + this._d3InitialContainerHeight = this._d3Container.node().clientHeight; } - _draw() { if (this._normalDataPoints.length < 2) { - this._drawEmpty() + this._drawEmpty(); } else { - this._drawChart() + this._drawChart(); } } - _drawEmpty() { - this._d3Svg.selectAll(".tick").remove() - this._d3Svg.selectAll(".domain").remove() + this._d3Svg.selectAll(".tick").remove(); + this._d3Svg.selectAll(".domain").remove(); - this._drawDashedLineEmptyState() - this._drawCenteredCircleEmptyState() + this._drawDashedLineEmptyState(); + this._drawCenteredCircleEmptyState(); } _drawDashedLineEmptyState() { @@ -91,7 +86,7 @@ export default class extends Controller { .attr("x2", this._d3InitialContainerWidth / 2) .attr("y2", this._d3InitialContainerHeight) .attr("stroke", tailwindColors.gray[300]) - .attr("stroke-dasharray", "4, 4") + .attr("stroke-dasharray", "4, 4"); } _drawCenteredCircleEmptyState() { @@ -100,26 +95,25 @@ export default class extends Controller { .attr("cx", this._d3InitialContainerWidth / 2) .attr("cy", this._d3InitialContainerHeight / 2) .attr("r", 4) - .style("fill", tailwindColors.gray[400]) + .style("fill", tailwindColors.gray[400]); } - _drawChart() { - this._drawTrendline() + this._drawTrendline(); if (this.useLabelsValue) { - this._drawXAxisLabels() - this._drawGradientBelowTrendline() + this._drawXAxisLabels(); + this._drawGradientBelowTrendline(); } if (this.useTooltipValue) { - this._drawTooltip() - this._trackMouseForShowingTooltip() + this._drawTooltip(); + this._trackMouseForShowingTooltip(); } } _drawTrendline() { - this._installTrendlineSplit() + this._installTrendlineSplit(); this._d3Group .append("path") @@ -129,7 +123,7 @@ export default class extends Controller { .attr("d", this._d3Line) .attr("stroke-linejoin", "round") .attr("stroke-linecap", "round") - .attr("stroke-width", this.strokeWidthValue) + .attr("stroke-width", this.strokeWidthValue); } _installTrendlineSplit() { @@ -139,38 +133,41 @@ export default class extends Controller { .attr("id", `${this.element.id}-split-gradient`) .attr("gradientUnits", "userSpaceOnUse") .attr("x1", this._d3XScale.range()[0]) - .attr("x2", this._d3XScale.range()[1]) + .attr("x2", this._d3XScale.range()[1]); - gradient.append("stop") + gradient + .append("stop") .attr("class", "start-color") .attr("offset", "0%") - .attr("stop-color", this._trendColor) + .attr("stop-color", this._trendColor); - gradient.append("stop") + gradient + .append("stop") .attr("class", "middle-color") .attr("offset", "100%") - .attr("stop-color", this._trendColor) + .attr("stop-color", this._trendColor); - gradient.append("stop") + gradient + .append("stop") .attr("class", "end-color") .attr("offset", "100%") - .attr("stop-color", tailwindColors.gray[300]) + .attr("stop-color", tailwindColors.gray[300]); } _setTrendlineSplitAt(percent) { this._d3Svg .select(`#${this.element.id}-split-gradient`) .select(".middle-color") - .attr("offset", `${percent * 100}%`) + .attr("offset", `${percent * 100}%`); this._d3Svg .select(`#${this.element.id}-split-gradient`) .select(".end-color") - .attr("offset", `${percent * 100}%`) + .attr("offset", `${percent * 100}%`); this._d3Svg .select(`#${this.element.id}-trendline-gradient-rect`) - .attr("width", this._d3ContainerWidth * percent) + .attr("width", this._d3ContainerWidth * percent); } _drawXAxisLabels() { @@ -181,24 +178,28 @@ export default class extends Controller { .call( d3 .axisBottom(this._d3XScale) - .tickValues([this._normalDataPoints[0].date, this._normalDataPoints[this._normalDataPoints.length - 1].date]) + .tickValues([ + this._normalDataPoints[0].date, + this._normalDataPoints[this._normalDataPoints.length - 1].date, + ]) .tickSize(0) - .tickFormat(d3.timeFormat("%d %b %Y")) + .tickFormat(d3.timeFormat("%d %b %Y")), ) .select(".domain") - .remove() + .remove(); // Style ticks - this._d3Group.selectAll(".tick text") + this._d3Group + .selectAll(".tick text") .style("fill", tailwindColors.gray[500]) .style("font-size", "12px") .style("font-weight", "500") .attr("text-anchor", "middle") .attr("dx", (_d, i) => { // We know we only have 2 values - return i === 0 ? "5em" : "-5em" + return i === 0 ? "5em" : "-5em"; }) - .attr("dy", "0em") + .attr("dy", "0em"); } _drawGradientBelowTrendline() { @@ -210,20 +211,23 @@ export default class extends Controller { .attr("gradientUnits", "userSpaceOnUse") .attr("x1", 0) .attr("x2", 0) - .attr("y1", this._d3YScale(d3.max(this._normalDataPoints, d => d.value))) - .attr("y2", this._d3ContainerHeight) + .attr( + "y1", + this._d3YScale(d3.max(this._normalDataPoints, (d) => d.value)), + ) + .attr("y2", this._d3ContainerHeight); gradient .append("stop") .attr("offset", 0) .attr("stop-color", this._trendColor) - .attr("stop-opacity", 0.06) + .attr("stop-opacity", 0.06); gradient .append("stop") .attr("offset", 0.5) .attr("stop-color", this._trendColor) - .attr("stop-opacity", 0) + .attr("stop-opacity", 0); // Clip path makes gradient start at the trendline this._d3Group @@ -231,11 +235,14 @@ export default class extends Controller { .attr("id", `${this.element.id}-clip-below-trendline`) .append("path") .datum(this._normalDataPoints) - .attr("d", d3.area() - .x(d => this._d3XScale(d.date)) - .y0(this._d3ContainerHeight) - .y1(d => this._d3YScale(d.value)) - ) + .attr( + "d", + d3 + .area() + .x((d) => this._d3XScale(d.date)) + .y0(this._d3ContainerHeight) + .y1((d) => this._d3YScale(d.value)), + ); // Apply the gradient + clip path this._d3Group @@ -244,7 +251,7 @@ export default class extends Controller { .attr("width", this._d3ContainerWidth) .attr("height", this._d3ContainerHeight) .attr("clip-path", `url(#${this.element.id}-clip-below-trendline)`) - .style("fill", `url(#${this.element.id}-trendline-gradient)`) + .style("fill", `url(#${this.element.id}-trendline-gradient)`); } _drawTooltip() { @@ -258,11 +265,11 @@ export default class extends Controller { .style("border", `1px solid ${tailwindColors["alpha-black"][100]}`) .style("border-radius", "10px") .style("pointer-events", "none") - .style("opacity", 0) // Starts as hidden + .style("opacity", 0); // Starts as hidden } _trackMouseForShowingTooltip() { - const bisectDate = d3.bisector(d => d.date).left + const bisectDate = d3.bisector((d) => d.date).left; this._d3Group .append("rect") @@ -271,24 +278,32 @@ export default class extends Controller { .attr("fill", "none") .attr("pointer-events", "all") .on("mousemove", (event) => { - const estimatedTooltipWidth = 250 - const pageWidth = document.body.clientWidth - const tooltipX = event.pageX + 10 - const overflowX = tooltipX + estimatedTooltipWidth - pageWidth - const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX + const estimatedTooltipWidth = 250; + const pageWidth = document.body.clientWidth; + const tooltipX = event.pageX + 10; + const overflowX = tooltipX + estimatedTooltipWidth - pageWidth; + const adjustedX = + overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX; - const [xPos] = d3.pointer(event) - const x0 = bisectDate(this._normalDataPoints, this._d3XScale.invert(xPos), 1) - const d0 = this._normalDataPoints[x0 - 1] - const d1 = this._normalDataPoints[x0] - const d = xPos - this._d3XScale(d0.date) > this._d3XScale(d1.date) - xPos ? d1 : d0 - const xPercent = this._d3XScale(d.date) / this._d3ContainerWidth + const [xPos] = d3.pointer(event); + const x0 = bisectDate( + this._normalDataPoints, + this._d3XScale.invert(xPos), + 1, + ); + const d0 = this._normalDataPoints[x0 - 1]; + const d1 = this._normalDataPoints[x0]; + const d = + xPos - this._d3XScale(d0.date) > this._d3XScale(d1.date) - xPos + ? d1 + : d0; + const xPercent = this._d3XScale(d.date) / this._d3ContainerWidth; - this._setTrendlineSplitAt(xPercent) + this._setTrendlineSplitAt(xPercent); // Reset - this._d3Group.selectAll(".data-point-circle").remove() - this._d3Group.selectAll(".guideline").remove() + this._d3Group.selectAll(".data-point-circle").remove(); + this._d3Group.selectAll(".guideline").remove(); // Guideline this._d3Group @@ -299,7 +314,7 @@ export default class extends Controller { .attr("x2", this._d3XScale(d.date)) .attr("y2", this._d3ContainerHeight) .attr("stroke", tailwindColors.gray[300]) - .attr("stroke-dasharray", "4, 4") + .attr("stroke-dasharray", "4, 4"); // Big circle this._d3Group @@ -310,7 +325,7 @@ export default class extends Controller { .attr("r", 8) .attr("fill", this._trendColor) .attr("fill-opacity", "0.1") - .attr("pointer-events", "none") + .attr("pointer-events", "none"); // Small circle this._d3Group @@ -320,31 +335,32 @@ export default class extends Controller { .attr("cy", this._d3YScale(d.value)) .attr("r", 3) .attr("fill", this._trendColor) - .attr("pointer-events", "none") + .attr("pointer-events", "none"); // Render tooltip this._d3Tooltip .html(this._tooltipTemplate(d)) .style("opacity", 1) .style("z-index", 999) - .style("left", adjustedX + "px") - .style("top", event.pageY - 10 + "px") + .style("left", `${adjustedX}px`) + .style("top", `${event.pageY - 10}px`); }) .on("mouseout", (event) => { - const hoveringOnGuideline = event.toElement?.classList.contains("guideline") + const hoveringOnGuideline = + event.toElement?.classList.contains("guideline"); if (!hoveringOnGuideline) { - this._d3Group.selectAll(".guideline").remove() - this._d3Group.selectAll(".data-point-circle").remove() - this._d3Tooltip.style("opacity", 0) + this._d3Group.selectAll(".guideline").remove(); + this._d3Group.selectAll(".data-point-circle").remove(); + this._d3Tooltip.style("opacity", 0); - this._setTrendlineSplitAt(1) + this._setTrendlineSplitAt(1); } - }) + }); } _tooltipTemplate(datum) { - return (` + return `
${d3.timeFormat("%b %d, %Y")(datum.date)}
@@ -364,15 +380,21 @@ export default class extends Controller { ${this._tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""} - ${this.usePercentSignValue || datum.trend.value === 0 || datum.trend.value.amount === 0 ? ` + ${ + this.usePercentSignValue || + datum.trend.value === 0 || + datum.trend.value.amount === 0 + ? ` - ` : ` + ` + : ` ${this._tooltipChange(datum)} (${datum.trend.percent}%) - `} + ` + } - `) + `; } _tooltipTrendColor(datum) { @@ -380,30 +402,28 @@ export default class extends Controller { up: tailwindColors.success, down: tailwindColors.error, flat: tailwindColors.gray[500], - }[datum.trend.direction] + }[datum.trend.direction]; } _tooltipValue(datum) { if (datum.currency) { - return this._currencyValue(datum) - } else { - return datum.value + return this._currencyValue(datum); } + return datum.value; } _tooltipChange(datum) { if (datum.currency) { - return this._currencyChange(datum) - } else { - return this._decimalChange(datum) + return this._currencyChange(datum); } + return this._decimalChange(datum); } _currencyValue(datum) { return Intl.NumberFormat(undefined, { style: "currency", currency: datum.currency, - }).format(datum.value) + }).format(datum.value); } _currencyChange(datum) { @@ -411,109 +431,113 @@ export default class extends Controller { style: "currency", currency: datum.currency, signDisplay: "always", - }).format(datum.trend.value.amount) + }).format(datum.trend.value.amount); } _decimalChange(datum) { return Intl.NumberFormat(undefined, { style: "decimal", signDisplay: "always", - }).format(datum.trend.value) + }).format(datum.trend.value); } - _createMainSvg() { return this._d3Container .append("svg") .attr("width", this._d3InitialContainerWidth) .attr("height", this._d3InitialContainerHeight) - .attr("viewBox", [0, 0, this._d3InitialContainerWidth, this._d3InitialContainerHeight]) + .attr("viewBox", [ + 0, + 0, + this._d3InitialContainerWidth, + this._d3InitialContainerHeight, + ]); } _createMainGroup() { return this._d3Svg .append("g") - .attr("transform", `translate(${this._margin.left},${this._margin.top})`) + .attr("transform", `translate(${this._margin.left},${this._margin.top})`); } - get _d3Svg() { - if (this._d3SvgMemo) { - return this._d3SvgMemo - } else { - return this._d3SvgMemo = this._createMainSvg() + if (!this._d3SvgMemo) { + this._d3SvgMemo = this._createMainSvg(); } + return this._d3SvgMemo; } get _d3Group() { - if (this._d3GroupMemo) { - return this._d3GroupMemo - } else { - return this._d3GroupMemo = this._createMainGroup() + if (!this._d3GroupMemo) { + this._d3GroupMemo = this._createMainGroup(); } + return this._d3GroupMemo; } get _margin() { if (this.useLabelsValue) { - return { top: 20, right: 0, bottom: 30, left: 0 } - } else { - return { top: 0, right: 0, bottom: 0, left: 0 } + return { top: 20, right: 0, bottom: 30, left: 0 }; } + return { top: 0, right: 0, bottom: 0, left: 0 }; } get _d3ContainerWidth() { - return this._d3InitialContainerWidth - this._margin.left - this._margin.right + return ( + this._d3InitialContainerWidth - this._margin.left - this._margin.right + ); } get _d3ContainerHeight() { - return this._d3InitialContainerHeight - this._margin.top - this._margin.bottom + return ( + this._d3InitialContainerHeight - this._margin.top - this._margin.bottom + ); } get _d3Container() { - return d3.select(this.element) + return d3.select(this.element); } get _trendColor() { if (this._trendDirection === "flat") { - return tailwindColors.gray[500] - } else if (this._trendDirection === this._favorableDirection) { - return tailwindColors.green[500] - } else { - return tailwindColors.error + return tailwindColors.gray[500]; } + if (this._trendDirection === this._favorableDirection) { + return tailwindColors.green[500]; + } + return tailwindColors.error; } get _trendDirection() { - return this.dataValue.trend.direction + return this.dataValue.trend.direction; } get _favorableDirection() { - return this.dataValue.trend.favorable_direction + return this.dataValue.trend.favorable_direction; } get _d3Line() { return d3 .line() - .x(d => this._d3XScale(d.date)) - .y(d => this._d3YScale(d.value)) + .x((d) => this._d3XScale(d.date)) + .y((d) => this._d3YScale(d.value)); } get _d3XScale() { return d3 .scaleTime() .rangeRound([0, this._d3ContainerWidth]) - .domain(d3.extent(this._normalDataPoints, d => d.date)) + .domain(d3.extent(this._normalDataPoints, (d) => d.date)); } get _d3YScale() { - const reductionPercent = this.useLabelsValue ? 0.15 : 0.05 - const dataMin = d3.min(this._normalDataPoints, d => d.value) - const dataMax = d3.max(this._normalDataPoints, d => d.value) - const padding = (dataMax - dataMin) * reductionPercent + const reductionPercent = this.useLabelsValue ? 0.15 : 0.05; + const dataMin = d3.min(this._normalDataPoints, (d) => d.value); + const dataMax = d3.max(this._normalDataPoints, (d) => d.value); + const padding = (dataMax - dataMin) * reductionPercent; return d3 .scaleLinear() .rangeRound([this._d3ContainerHeight, 0]) - .domain([dataMin - padding, dataMax + padding]) + .domain([dataMin - padding, dataMax + padding]); } -} \ No newline at end of file +} diff --git a/app/javascript/controllers/tooltip_controller.js b/app/javascript/controllers/tooltip_controller.js index 7013607f..f691b2ec 100644 --- a/app/javascript/controllers/tooltip_controller.js +++ b/app/javascript/controllers/tooltip_controller.js @@ -1,11 +1,11 @@ -import { Controller } from '@hotwired/stimulus' import { + autoUpdate, computePosition, flip, - shift, offset, - autoUpdate -} from '@floating-ui/dom'; + shift, +} from "@floating-ui/dom"; +import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = ["tooltip"]; @@ -39,20 +39,20 @@ export default class extends Controller { } show = () => { - this.tooltipTarget.style.display = 'block'; + this.tooltipTarget.style.display = "block"; this.update(); // Ensure immediate update when shown - } + }; hide = () => { - this.tooltipTarget.style.display = 'none'; - } + this.tooltipTarget.style.display = "none"; + }; startAutoUpdate() { if (!this._cleanup) { this._cleanup = autoUpdate( this.element, this.tooltipTarget, - this.boundUpdate + this.boundUpdate, ); } } @@ -69,9 +69,13 @@ export default class extends Controller { computePosition(this.element, this.tooltipTarget, { placement: this.placementValue, middleware: [ - offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }), + offset({ + mainAxis: this.offsetValue, + crossAxis: this.crossAxisValue, + alignmentAxis: this.alignmentAxisValue, + }), flip(), - shift({ padding: 5 }) + shift({ padding: 5 }), ], }).then(({ x, y, placement, middlewareData }) => { Object.assign(this.tooltipTarget.style, { @@ -80,4 +84,4 @@ export default class extends Controller { }); }); } -} \ No newline at end of file +} diff --git a/app/javascript/controllers/trade_form_controller.js b/app/javascript/controllers/trade_form_controller.js index 61751c02..cd435d55 100644 --- a/app/javascript/controllers/trade_form_controller.js +++ b/app/javascript/controllers/trade_form_controller.js @@ -1,55 +1,62 @@ -import {Controller} from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; const TRADE_TYPES = { BUY: "buy", SELL: "sell", TRANSFER_IN: "transfer_in", TRANSFER_OUT: "transfer_out", - INTEREST: "interest" -} + INTEREST: "interest", +}; const FIELD_VISIBILITY = { - [TRADE_TYPES.BUY]: {ticker: true, qty: true, price: true}, - [TRADE_TYPES.SELL]: {ticker: true, qty: true, price: true}, - [TRADE_TYPES.TRANSFER_IN]: {amount: true, transferAccount: true}, - [TRADE_TYPES.TRANSFER_OUT]: {amount: true, transferAccount: true}, - [TRADE_TYPES.INTEREST]: {amount: true} -} + [TRADE_TYPES.BUY]: { ticker: true, qty: true, price: true }, + [TRADE_TYPES.SELL]: { ticker: true, qty: true, price: true }, + [TRADE_TYPES.TRANSFER_IN]: { amount: true, transferAccount: true }, + [TRADE_TYPES.TRANSFER_OUT]: { amount: true, transferAccount: true }, + [TRADE_TYPES.INTEREST]: { amount: true }, +}; // Connects to data-controller="trade-form" export default class extends Controller { - static targets = ["typeInput", "tickerInput", "amountInput", "transferAccountInput", "qtyInput", "priceInput"] + static targets = [ + "typeInput", + "tickerInput", + "amountInput", + "transferAccountInput", + "qtyInput", + "priceInput", + ]; connect() { - this.handleTypeChange = this.handleTypeChange.bind(this) - this.typeInputTarget.addEventListener("change", this.handleTypeChange) - this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY) + this.handleTypeChange = this.handleTypeChange.bind(this); + this.typeInputTarget.addEventListener("change", this.handleTypeChange); + this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY); } disconnect() { - this.typeInputTarget.removeEventListener("change", this.handleTypeChange) + this.typeInputTarget.removeEventListener("change", this.handleTypeChange); } handleTypeChange(event) { - this.updateFields(event.target.value) + this.updateFields(event.target.value); } updateFields(type) { - const visibleFields = FIELD_VISIBILITY[type] || {} + const visibleFields = FIELD_VISIBILITY[type] || {}; Object.entries(this.fieldTargets).forEach(([field, target]) => { - const isVisible = visibleFields[field] || false + const isVisible = visibleFields[field] || false; // Update visibility - target.hidden = !isVisible + target.hidden = !isVisible; // Update required status based on visibility if (isVisible) { - target.setAttribute('required', '') + target.setAttribute("required", ""); } else { - target.removeAttribute('required') + target.removeAttribute("required"); } - }) + }); } get fieldTargets() { @@ -58,7 +65,7 @@ export default class extends Controller { amount: this.amountInputTarget, transferAccount: this.transferAccountInputTarget, qty: this.qtyInputTarget, - price: this.priceInputTarget - } + price: this.priceInputTarget, + }; } -} \ No newline at end of file +} diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..568b6d96 --- /dev/null +++ b/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.3/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "ignore": [], + "include": ["./app/javascript/**/*.js"] + }, + "formatter": { + "enabled": true, + "useEditorconfig": true + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noForEach": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..0e2cd5a5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,180 @@ +{ + "name": "maybe", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "maybe", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@biomejs/biome": "1.9.3" + } + }, + "node_modules/@biomejs/biome": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.3.tgz", + "integrity": "sha512-POjAPz0APAmX33WOQFGQrwLvlu7WLV4CFJMlB12b6ZSg+2q6fYu9kZwLCOA+x83zXfcPd1RpuWOKJW0GbBwLIQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.3", + "@biomejs/cli-darwin-x64": "1.9.3", + "@biomejs/cli-linux-arm64": "1.9.3", + "@biomejs/cli-linux-arm64-musl": "1.9.3", + "@biomejs/cli-linux-x64": "1.9.3", + "@biomejs/cli-linux-x64-musl": "1.9.3", + "@biomejs/cli-win32-arm64": "1.9.3", + "@biomejs/cli-win32-x64": "1.9.3" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.3.tgz", + "integrity": "sha512-QZzD2XrjJDUyIZK+aR2i5DDxCJfdwiYbUKu9GzkCUJpL78uSelAHAPy7m0GuPMVtF/Uo+OKv97W3P9nuWZangQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.3.tgz", + "integrity": "sha512-vSCoIBJE0BN3SWDFuAY/tRavpUtNoqiceJ5PrU3xDfsLcm/U6N93JSM0M9OAiC/X7mPPfejtr6Yc9vSgWlEgVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.3.tgz", + "integrity": "sha512-vJkAimD2+sVviNTbaWOGqEBy31cW0ZB52KtpVIbkuma7PlfII3tsLhFa+cwbRAcRBkobBBhqZ06hXoZAN8NODQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.3.tgz", + "integrity": "sha512-VBzyhaqqqwP3bAkkBrhVq50i3Uj9+RWuj+pYmXrMDgjS5+SKYGE56BwNw4l8hR3SmYbLSbEo15GcV043CDSk+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.3.tgz", + "integrity": "sha512-x220V4c+romd26Mu1ptU+EudMXVS4xmzKxPVb9mgnfYlN4Yx9vD5NZraSx/onJnd3Gh/y8iPUdU5CDZJKg9COA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.3.tgz", + "integrity": "sha512-TJmnOG2+NOGM72mlczEsNki9UT+XAsMFAOo8J0me/N47EJ/vkLXxf481evfHLlxMejTY6IN8SdRSiPVLv6AHlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.3.tgz", + "integrity": "sha512-lg/yZis2HdQGsycUvHWSzo9kOvnGgvtrYRgoCEwPBwwAL8/6crOp3+f47tPwI/LI1dZrhSji7PNsGKGHbwyAhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.3.tgz", + "integrity": "sha512-cQMy2zanBkVLpmmxXdK6YePzmZx0s5Z7KEnwmrW54rcXK3myCNbQa09SwGZ8i/8sLw0H9F3X7K4rxVNGU8/D4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..7ba6f216 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "devDependencies": { + "@biomejs/biome": "1.9.3" + }, + "name": "maybe", + "version": "1.0.0", + "description": "The OS for your personal finances", + "scripts": { + "style:check": "biome check", + "style:fix":"biome check --write", + "lint": "biome lint", + "lint:fix" : "biome lint --write", + "format:check" : "biome format", + "format" : "biome format --write" + }, + "author": "", + "license": "ISC" +} From f3bb80dde65993f5f74b116e9c715097652e4cc0 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 14 Oct 2024 17:21:51 -0400 Subject: [PATCH 010/736] Fix pie chart --- .gitignore | 2 -- app/javascript/controllers/pie_chart_controller.js | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index e8aaee4a..a630037b 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,6 @@ # Ignore VS Code .vscode/* !.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json !.vscode/extensions.json !.vscode/*.code-snippets diff --git a/app/javascript/controllers/pie_chart_controller.js b/app/javascript/controllers/pie_chart_controller.js index e11cb870..96cdc56f 100644 --- a/app/javascript/controllers/pie_chart_controller.js +++ b/app/javascript/controllers/pie_chart_controller.js @@ -115,7 +115,7 @@ export default class extends Controller { this.#d3GroupMemo = this.#createMainGroup(); } - return this.#d3ContentMemo; + return this.#d3GroupMemo; } get #d3Content() { From 76decc06c3d9f59139deca990cf6c0f6e5ee7a72 Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Wed, 16 Oct 2024 17:09:52 +0100 Subject: [PATCH 011/736] Maintain order (#1318) --- app/helpers/accounts_helper.rb | 2 +- app/views/pages/_account_percentages_table.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 0bb56ff2..542c80ef 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -86,7 +86,7 @@ module AccountsHelper def account_groups(period: nil) assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities) - [ assets.children, liabilities.children ].flatten + [ assets.children.sort_by(&:name), liabilities.children.sort_by(&:name) ].flatten end private diff --git a/app/views/pages/_account_percentages_table.html.erb b/app/views/pages/_account_percentages_table.html.erb index 3fa8126d..ab37414b 100644 --- a/app/views/pages/_account_percentages_table.html.erb +++ b/app/views/pages/_account_percentages_table.html.erb @@ -15,6 +15,6 @@
- <%= render partial: "pages/account_group_disclosure", collection: account_groups.sort_by(&:percent_of_total).reverse, as: :accountable_group %> + <%= render partial: "pages/account_group_disclosure", collection: account_groups.sort_by(&:name), as: :accountable_group %>
From 7f4c1755efaf6291a5a13a0ad7fbb126d85f284e Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 16 Oct 2024 19:14:43 +0200 Subject: [PATCH 012/736] add dashboard account pill tooltips (#1315) * add dashboard account pill tooltips * Update app/views/shared/_text_tooltip.erb Co-authored-by: Zach Gollwitzer Signed-off-by: Guillem Arias Fauste --------- Signed-off-by: Guillem Arias Fauste Co-authored-by: Zach Gollwitzer --- app/views/pages/dashboard.html.erb | 9 ++++++--- app/views/shared/_text_tooltip.erb | 5 +++++ 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 app/views/shared/_text_tooltip.erb diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 64fecd9f..9a70d493 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -72,9 +72,10 @@
<% @top_earners.first(3).each do |account| %> - <%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25" do %> + <%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25", data: { controller: "tooltip" } do %> <%= image_tag account_logo_url(account), class: "w-5 h-5" %> +<%= Money.new(account.income, account.currency) %> + <%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %> <% end %> <% end %> <% if @top_earners.count > 3 %> @@ -104,9 +105,10 @@
<% @top_spenders.first(3).each do |account| %> - <%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25" do %> + <%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25", data: { controller: "tooltip" } do %> <%= image_tag account_logo_url(account), class: "w-5 h-5" %> -<%= Money.new(account.spending, account.currency) %> + <%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %> <% end %> <% end %> <% if @top_spenders.count > 3 %> @@ -138,9 +140,10 @@
<% @top_savers.first(3).each do |account| %> <% unless account.savings_rate.infinite? %> - <%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25" do %> + <%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25", data: { controller: "tooltip" } do %> <%= image_tag account_logo_url(account), class: "w-5 h-5" %> <%= account.savings_rate > 0 ? "+" : "-" %><%= number_to_percentage(account.savings_rate.abs * 100, precision: 2) %> + <%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %> <% end %> <% end %> <% end %> diff --git a/app/views/shared/_text_tooltip.erb b/app/views/shared/_text_tooltip.erb new file mode 100644 index 00000000..03de7e48 --- /dev/null +++ b/app/views/shared/_text_tooltip.erb @@ -0,0 +1,5 @@ + \ No newline at end of file From 61bf53f2332eae7347ecef0473ca9846ab5565a6 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 17 Oct 2024 09:52:06 -0500 Subject: [PATCH 013/736] Rescue RecordNotUnique Fixes #1319 --- app/models/exchange_rate/provided.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index 1927e372..d1e2aea2 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -52,7 +52,9 @@ module ExchangeRate::Provided rate: response.rate, date: date - rate.save! if cache + if cache + rate.save! rescue ActiveRecord::RecordNotUnique + end rate else nil From 4118cc8a3193fc4194d346f32e19429faa7815fb Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 17 Oct 2024 10:16:34 -0500 Subject: [PATCH 014/736] Fix for scrollbars on alerts Fixes #1320 --- app/views/issues/_issue.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/issues/_issue.html.erb b/app/views/issues/_issue.html.erb index cabb360c..76ee7c62 100644 --- a/app/views/issues/_issue.html.erb +++ b/app/views/issues/_issue.html.erb @@ -4,9 +4,9 @@ <% text_class = issue.critical? || issue.error? ? "text-error" : "text-warning" %> <%= tag.div class: "flex gap-6 items-center rounded-xl px-4 py-3 #{priority_class}" do %> -
+
<%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %> -

<%= issue.title %>

+

<%= issue.title %>

From 629565f7d8a3e81352d9343495f57e7955b3d175 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 17 Oct 2024 10:20:42 -0500 Subject: [PATCH 015/736] Updated bug report template --- .github/ISSUE_TEMPLATE/bug_report.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 760e2c01..28987582 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -20,6 +20,9 @@ Steps to reproduce the behavior: **Expected behavior** A clear and concise description of what you expected to happen. +**What version of Maybe are you using?** +This could be "Hosted" (i.e. app.maybe.co) or "Self-hosted". If "Self-hosted", please include the version you're currently on. + **Screenshots / Recordings** If applicable, add screenshots or short video recordings to help show the bug in more detail. From b98f35af0eda0c14ad543e4ebd33420aa972627d Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 17 Oct 2024 10:39:56 -0500 Subject: [PATCH 016/736] Another tweak to the bug template --- .github/ISSUE_TEMPLATE/bug_report.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 28987582..9a250838 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -23,6 +23,9 @@ A clear and concise description of what you expected to happen. **What version of Maybe are you using?** This could be "Hosted" (i.e. app.maybe.co) or "Self-hosted". If "Self-hosted", please include the version you're currently on. +**What operating system and browser are you using?** +The more info the better. + **Screenshots / Recordings** If applicable, add screenshots or short video recordings to help show the bug in more detail. From d4bfcfb6f4786b00ee7b2b5d655f204839a6e18f Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 17 Oct 2024 10:52:04 -0500 Subject: [PATCH 017/736] Fix for transaction drawer securities missing prices Fixes #1321 --- app/views/account/trades/show.html.erb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/views/account/trades/show.html.erb b/app/views/account/trades/show.html.erb index 7293f731..0f07e9ad 100644 --- a/app/views/account/trades/show.html.erb +++ b/app/views/account/trades/show.html.erb @@ -41,12 +41,14 @@
<% end %> -
-
<%= t(".current_market_price_label") %>
-
<%= format_money trade.security.current_price %>
-
+ <% if trade.security.current_price.present? %> +
+
<%= t(".current_market_price_label") %>
+
<%= format_money trade.security.current_price %>
+
+ <% end %> - <% if trade.buy? %> + <% if trade.buy? && trade.unrealized_gain_loss.present? %>
<%= t(".total_return_label") %>
From 75a390f03e39d7843c461892197173bf1ccc98e6 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 17 Oct 2024 15:45:13 -0500 Subject: [PATCH 018/736] Account indexes to address some performance issues --- .../20241017204250_add_accounts_indexes.rb | 7 ++++ db/schema.rb | 34 +++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20241017204250_add_accounts_indexes.rb diff --git a/db/migrate/20241017204250_add_accounts_indexes.rb b/db/migrate/20241017204250_add_accounts_indexes.rb new file mode 100644 index 00000000..bdeb992f --- /dev/null +++ b/db/migrate/20241017204250_add_accounts_indexes.rb @@ -0,0 +1,7 @@ +class AddAccountsIndexes < ActiveRecord::Migration[7.2] + def change + add_index :accounts, [ :family_id, :accountable_type ] + add_index :accounts, [ :accountable_id, :accountable_type ] + add_index :accounts, [ :family_id, :id ] + end +end diff --git a/db/schema.rb b/db/schema.rb index d58dcce0..49e43264 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_10_09_132959) do +ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -120,9 +120,12 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_09_132959) do t.boolean "is_active", default: true, null: false t.date "last_sync_date" t.uuid "institution_id" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" + t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" + t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" + t.index ["family_id", "id"], name: "index_accounts_on_family_id_and_id" t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["import_id"], name: "index_accounts_on_import_id" t.index ["institution_id"], name: "index_accounts_on_institution_id" @@ -313,6 +316,29 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_09_132959) do t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" end + create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "impersonation_session_id", null: false + t.string "controller" + t.string "action" + t.text "path" + t.string "method" + t.string "ip_address" + t.text "user_agent" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["impersonation_session_id"], name: "index_impersonation_session_logs_on_impersonation_session_id" + end + + create_table "impersonation_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "impersonator_id", null: false + t.uuid "impersonated_id", null: false + t.string "status", default: "pending", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["impersonated_id"], name: "index_impersonation_sessions_on_impersonated_id" + t.index ["impersonator_id"], name: "index_impersonation_sessions_on_impersonator_id" + end + create_table "import_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "type", null: false t.string "key" @@ -509,6 +535,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_09_132959) do t.string "last_alerted_upgrade_commit_sha" t.enum "role", default: "member", null: false, enum_type: "user_role" t.boolean "active", default: true, null: false + t.boolean "super_admin", default: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" end @@ -539,6 +566,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_09_132959) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "categories", "families" + add_foreign_key "impersonation_session_logs", "impersonation_sessions" + add_foreign_key "impersonation_sessions", "users", column: "impersonated_id" + add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" add_foreign_key "import_rows", "imports" add_foreign_key "imports", "families" add_foreign_key "institutions", "families" From 4a3685f5033fea349e5cbbd17a721389502e6ac4 Mon Sep 17 00:00:00 2001 From: Ender Ahmet Yurt Date: Fri, 18 Oct 2024 16:10:18 +0300 Subject: [PATCH 019/736] Redirect upload step (#1323) * Redirect upload step * Change redirect page regarding state of the import --- app/controllers/imports_controller.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index ef374f6c..8d311215 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -24,7 +24,11 @@ class ImportsController < ApplicationController end def show - redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding." unless @import.publishable? + if !@import.uploaded? + redirect_to import_upload_path(@import), alert: "Please finalize your file upload." + elsif !@import.publishable? + redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding." + end end def destroy From c7c281073f8a5b20b6a125d539f5f537e280d0a7 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 18 Oct 2024 11:26:58 -0500 Subject: [PATCH 020/736] Impersonation (#1325) * Initial impersonation * Impersonation audit * Keep super admin separate * Remove vscode settings * Comment cleanup * Comment out impersonation fixtures for now * Remove unused controlelr * Add impersonation testing (#1326) * Add impersonation testing * Remove unused method * Update schema.rb * Update brakeman --------- Co-authored-by: Zach Gollwitzer --- .gitignore | 1 - .vscode/settings.json | 6 - Gemfile.lock | 2 +- app/controllers/application_controller.rb | 2 +- app/controllers/concerns/authentication.rb | 6 +- app/controllers/concerns/impersonatable.rb | 21 ++++ .../impersonation_sessions_controller.rb | 58 +++++++++ app/helpers/impersonation_sessions_helper.rb | 2 + app/models/current.rb | 16 ++- app/models/impersonation_session.rb | 39 ++++++ app/models/impersonation_session_log.rb | 3 + app/models/session.rb | 4 + app/models/user.rb | 9 +- .../_approval_bar.html.erb | 21 ++++ .../_super_admin_bar.html.erb | 35 ++++++ app/views/layouts/application.html.erb | 3 + app/views/shared/_text_tooltip.erb | 2 +- .../views/impersonation_sessions/en.yml | 15 +++ config/routes.rb | 11 ++ ...20241009214601_add_super_admin_to_users.rb | 23 ++++ ...017162347_create_impersonation_sessions.rb | 12 ++ ...62536_create_impersonation_session_logs.rb | 14 +++ db/schema.rb | 6 +- .../impersonation_sessions_controller_test.rb | 112 ++++++++++++++++++ test/fixtures/impersonation_session_logs.yml | 11 ++ test/fixtures/impersonation_sessions.yml | 4 + test/fixtures/users.yml | 8 ++ test/models/impersonation_session_log_test.rb | 7 ++ test/models/impersonation_session_test.rb | 40 +++++++ 29 files changed, 477 insertions(+), 16 deletions(-) delete mode 100644 .vscode/settings.json create mode 100644 app/controllers/concerns/impersonatable.rb create mode 100644 app/controllers/impersonation_sessions_controller.rb create mode 100644 app/helpers/impersonation_sessions_helper.rb create mode 100644 app/models/impersonation_session.rb create mode 100644 app/models/impersonation_session_log.rb create mode 100644 app/views/impersonation_sessions/_approval_bar.html.erb create mode 100644 app/views/impersonation_sessions/_super_admin_bar.html.erb create mode 100644 config/locales/views/impersonation_sessions/en.yml create mode 100644 db/migrate/20241009214601_add_super_admin_to_users.rb create mode 100644 db/migrate/20241017162347_create_impersonation_sessions.rb create mode 100644 db/migrate/20241017162536_create_impersonation_session_logs.rb create mode 100644 test/controllers/impersonation_sessions_controller_test.rb create mode 100644 test/fixtures/impersonation_session_logs.yml create mode 100644 test/fixtures/impersonation_sessions.yml create mode 100644 test/models/impersonation_session_log_test.rb create mode 100644 test/models/impersonation_session_test.rb diff --git a/.gitignore b/.gitignore index a630037b..3e1cbccd 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,6 @@ # Ignore VS Code .vscode/* -!.vscode/settings.json !.vscode/extensions.json !.vscode/*.code-snippets diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index f94a8b2b..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "[javascript]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "biomejs.biome", - } -} \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 21810fe3..51ea10a8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -110,7 +110,7 @@ GEM bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.2.1) + brakeman (6.2.2) racc builder (3.3.0) capybara (3.40.0) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c5145b17..b6fa8e0c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,5 @@ class ApplicationController < ActionController::Base - include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation + include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable include Pagy::Backend private diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index cd210cca..a66d1b3f 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -14,7 +14,7 @@ module Authentication private def authenticate_user! - if session_record = Session.find_by_id(cookies.signed[:session_token]) + if session_record = find_session_by_cookie Current.session = session_record else if self_hosted_first_login? @@ -25,6 +25,10 @@ module Authentication end end + def find_session_by_cookie + Session.find_by(id: cookies.signed[:session_token]) + end + def create_session_for(user) session = user.sessions.create! cookies.signed.permanent[:session_token] = { value: session.id, httponly: true } diff --git a/app/controllers/concerns/impersonatable.rb b/app/controllers/concerns/impersonatable.rb new file mode 100644 index 00000000..857b68c4 --- /dev/null +++ b/app/controllers/concerns/impersonatable.rb @@ -0,0 +1,21 @@ +module Impersonatable + extend ActiveSupport::Concern + + included do + after_action :create_impersonation_session_log + end + + private + def create_impersonation_session_log + return unless Current.session&.active_impersonator_session.present? + + Current.session.active_impersonator_session.logs.create!( + controller: controller_name, + action: action_name, + path: request.fullpath, + method: request.method, + ip_address: request.ip, + user_agent: request.user_agent + ) + end +end diff --git a/app/controllers/impersonation_sessions_controller.rb b/app/controllers/impersonation_sessions_controller.rb new file mode 100644 index 00000000..1a8c5db7 --- /dev/null +++ b/app/controllers/impersonation_sessions_controller.rb @@ -0,0 +1,58 @@ +class ImpersonationSessionsController < ApplicationController + before_action :require_super_admin!, only: [ :create, :join, :leave ] + before_action :set_impersonation_session, only: [ :approve, :reject, :complete ] + + def create + Current.true_user.request_impersonation_for(session_params[:impersonated_id]) + redirect_to root_path, notice: t(".success") + end + + def join + @impersonation_session = Current.true_user.impersonator_support_sessions.find_by(id: params[:impersonation_session_id]) + Current.session.update!(active_impersonator_session: @impersonation_session) + redirect_to root_path, notice: t(".success") + end + + def leave + Current.session.update!(active_impersonator_session: nil) + redirect_to root_path, notice: t(".success") + end + + def approve + raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user + + @impersonation_session.approve! + redirect_to root_path, notice: t(".success") + end + + def reject + raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user + + @impersonation_session.reject! + redirect_to root_path, notice: t(".success") + end + + def complete + @impersonation_session.complete! + redirect_to root_path, notice: t(".success") + end + + private + def session_params + params.require(:impersonation_session).permit(:impersonated_id) + end + + def set_impersonation_session + @impersonation_session = + Current.true_user.impersonated_support_sessions.find_by(id: params[:id]) || + Current.true_user.impersonator_support_sessions.find_by(id: params[:id]) + end + + def require_super_admin! + raise_unauthorized! unless Current.true_user&.super_admin? + end + + def raise_unauthorized! + raise ActionController::RoutingError.new("Not Found") + end +end diff --git a/app/helpers/impersonation_sessions_helper.rb b/app/helpers/impersonation_sessions_helper.rb new file mode 100644 index 00000000..f955b896 --- /dev/null +++ b/app/helpers/impersonation_sessions_helper.rb @@ -0,0 +1,2 @@ +module ImpersonationSessionsHelper +end diff --git a/app/models/current.rb b/app/models/current.rb index d12fc0a9..86b9e97a 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,7 +1,19 @@ class Current < ActiveSupport::CurrentAttributes - attribute :session attribute :user_agent, :ip_address - delegate :user, to: :session, allow_nil: true + attribute :session + delegate :family, to: :user, allow_nil: true + + def user + impersonated_user || session&.user + end + + def impersonated_user + session&.active_impersonator_session&.impersonated + end + + def true_user + session&.user + end end diff --git a/app/models/impersonation_session.rb b/app/models/impersonation_session.rb new file mode 100644 index 00000000..0e0fd582 --- /dev/null +++ b/app/models/impersonation_session.rb @@ -0,0 +1,39 @@ +class ImpersonationSession < ApplicationRecord + belongs_to :impersonator, class_name: "User" + belongs_to :impersonated, class_name: "User" + + has_many :logs, class_name: "ImpersonationSessionLog" + + enum :status, { pending: "pending", in_progress: "in_progress", complete: "complete", rejected: "rejected" } + + scope :initiated, -> { where(status: [ :pending, :in_progress ]) } + + validate :impersonator_is_super_admin + validate :impersonated_is_not_super_admin + validate :impersonator_different_from_impersonated + + def approve! + update! status: :in_progress + end + + def reject! + update! status: :rejected + end + + def complete! + update! status: :complete + end + + private + def impersonator_is_super_admin + errors.add(:impersonator, "must be a super admin to impersonate") unless impersonator.super_admin? + end + + def impersonated_is_not_super_admin + errors.add(:impersonated, "cannot be a super admin") if impersonated.super_admin? + end + + def impersonator_different_from_impersonated + errors.add(:impersonator, "cannot be the same as the impersonated user") if impersonator == impersonated + end +end diff --git a/app/models/impersonation_session_log.rb b/app/models/impersonation_session_log.rb new file mode 100644 index 00000000..d7fa5ac3 --- /dev/null +++ b/app/models/impersonation_session_log.rb @@ -0,0 +1,3 @@ +class ImpersonationSessionLog < ApplicationRecord + belongs_to :impersonation_session +end diff --git a/app/models/session.rb b/app/models/session.rb index 8e94aa81..ce26938a 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,5 +1,9 @@ class Session < ApplicationRecord belongs_to :user + belongs_to :active_impersonator_session, + -> { where(status: :in_progress) }, + class_name: "ImpersonationSession", + optional: true before_create do self.user_agent = Current.user_agent diff --git a/app/models/user.rb b/app/models/user.rb index fe2a72c9..789e39df 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,8 @@ class User < ApplicationRecord belongs_to :family has_many :sessions, dependent: :destroy + has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy + has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy accepts_nested_attributes_for :family validates :email, presence: true, uniqueness: true @@ -11,7 +13,7 @@ class User < ApplicationRecord normalizes :first_name, :last_name, with: ->(value) { value.strip.presence } - enum :role, { member: "member", admin: "admin" }, validate: true + enum :role, { member: "member", admin: "admin", super_admin: "super_admin" }, validate: true has_one_attached :profile_image do |attachable| attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ] @@ -23,6 +25,11 @@ class User < ApplicationRecord password_salt&.last(10) end + def request_impersonation_for(user_id) + impersonated = User.find(user_id) + impersonator_support_sessions.create!(impersonated: impersonated) + end + def display_name [ first_name, last_name ].compact.join(" ").presence || email end diff --git a/app/views/impersonation_sessions/_approval_bar.html.erb b/app/views/impersonation_sessions/_approval_bar.html.erb new file mode 100644 index 00000000..2bc83087 --- /dev/null +++ b/app/views/impersonation_sessions/_approval_bar.html.erb @@ -0,0 +1,21 @@ +<% pending_session = Current.true_user.impersonated_support_sessions.pending.first %> +<% in_progress_session = Current.true_user.impersonated_support_sessions.in_progress.first %> + +
+
+ <%= lucide_icon "alert-triangle", class: "w-6 h-6 text-white mr-2" %> + Access <%= in_progress_session.present? ? "Session" : "Request" %> +
+
+ <% if pending_session.present? %> +

Maybe support staff has requested access to your account (likely to help you with a support request). If you approve the request, all activity they take will be logged for security and audit purposes.

+ <%= button_to "Approve", approve_impersonation_session_path(pending_session), method: :put, class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" %> + <%= button_to "Reject", reject_impersonation_session_path(pending_session), method: :put, class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> + <% elsif in_progress_session.present? %> +

Someone from the Maybe Finance team is currently viewing your data. You may end the session at any time.

+ <%= button_to "End Session", complete_impersonation_session_path(in_progress_session), method: :put, class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> + <% else %> +

Something went wrong. Please contact us.

+ <% end %> +
+
diff --git a/app/views/impersonation_sessions/_super_admin_bar.html.erb b/app/views/impersonation_sessions/_super_admin_bar.html.erb new file mode 100644 index 00000000..fddb270b --- /dev/null +++ b/app/views/impersonation_sessions/_super_admin_bar.html.erb @@ -0,0 +1,35 @@ +
+
+ <%= lucide_icon "alert-triangle", class: "w-6 h-6 text-white mr-2" %> + Super Admin +
+
+ <% if Current.session.active_impersonator_session.present? %> +
+
+ Impersonating: <%= Current.impersonated_user.email %> +
+ <%= button_to "Leave", leave_impersonation_sessions_path, method: :delete, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> + <%= button_to "Terminate", complete_impersonation_session_path(Current.session.active_impersonator_session), method: :put, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> +
+ <% else %> + <% if Current.true_user.impersonator_support_sessions.in_progress.any? %> + <%= form_with url: join_impersonation_sessions_path, class: "flex items-center space-x-2 mr-4" do |f| %> + <%= f.select :impersonation_session_id, + Current.true_user.impersonator_support_sessions.in_progress.map { |session| + ["#{session.impersonated.email} (#{session.status})", session.id] + }, + { prompt: "Join a session" }, + { class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono" } %> + <%= f.submit "Join", + class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> + <% end %> + <% end %> + + <%= form_with model: ImpersonationSession.new, class: "flex items-center space-x-2" do |f| %> + <%= f.text_field :impersonated_id, class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono w-96", placeholder: "UUID", autocomplete: "off" %> + <%= f.submit "Request Impersonation", class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> + <% end %> + <% end %> +
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e7712e13..386e839d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -25,6 +25,9 @@ + <%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? %> + <%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %> +
<%= render_flash_notifications %> diff --git a/app/views/shared/_text_tooltip.erb b/app/views/shared/_text_tooltip.erb index 03de7e48..53f93dd3 100644 --- a/app/views/shared/_text_tooltip.erb +++ b/app/views/shared/_text_tooltip.erb @@ -2,4 +2,4 @@
<%= tooltip_text %>
-
\ No newline at end of file +
diff --git a/config/locales/views/impersonation_sessions/en.yml b/config/locales/views/impersonation_sessions/en.yml new file mode 100644 index 00000000..be18173f --- /dev/null +++ b/config/locales/views/impersonation_sessions/en.yml @@ -0,0 +1,15 @@ +--- +en: + impersonation_sessions: + create: + success: "Request sent to user. Waiting for approval." + join: + success: "Joined session" + leave: + success: "Left session" + approve: + success: "Request approved" + reject: + success: "Request rejected" + complete: + success: "Session completed" \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 9a84ce22..2cfa2b2d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -110,6 +110,17 @@ Rails.application.routes.draw do resources :currencies, only: %i[show] + resources :impersonation_sessions, only: [ :create ] do + post :join, on: :collection + delete :leave, on: :collection + + member do + put :approve + put :reject + put :complete + end + end + # Stripe webhook endpoint post "webhooks/stripe", to: "webhooks#stripe" diff --git a/db/migrate/20241009214601_add_super_admin_to_users.rb b/db/migrate/20241009214601_add_super_admin_to_users.rb new file mode 100644 index 00000000..cc1fadb4 --- /dev/null +++ b/db/migrate/20241009214601_add_super_admin_to_users.rb @@ -0,0 +1,23 @@ +class AddSuperAdminToUsers < ActiveRecord::Migration[7.2] + def change + reversible do |dir| + dir.up do + change_column :users, :role, :string, default: 'member' + + execute <<-SQL + DROP TYPE user_role; + SQL + end + + dir.down do + execute <<-SQL + CREATE TYPE user_role AS ENUM ('admin', 'member'); + SQL + + change_column_default :users, :role, nil + change_column :users, :role, :user_role, using: 'role::user_role' + change_column_default :users, :role, 'member' + end + end + end +end diff --git a/db/migrate/20241017162347_create_impersonation_sessions.rb b/db/migrate/20241017162347_create_impersonation_sessions.rb new file mode 100644 index 00000000..1e7cf38e --- /dev/null +++ b/db/migrate/20241017162347_create_impersonation_sessions.rb @@ -0,0 +1,12 @@ +class CreateImpersonationSessions < ActiveRecord::Migration[7.2] + def change + create_table :impersonation_sessions, id: :uuid do |t| + t.references :impersonator, null: false, foreign_key: { to_table: :users }, type: :uuid + t.references :impersonated, null: false, foreign_key: { to_table: :users }, type: :uuid + t.string :status, null: false, default: 'pending' + t.timestamps + end + + add_reference :sessions, :active_impersonator_session, type: :uuid, foreign_key: { to_table: :impersonation_sessions } + end +end diff --git a/db/migrate/20241017162536_create_impersonation_session_logs.rb b/db/migrate/20241017162536_create_impersonation_session_logs.rb new file mode 100644 index 00000000..ebf68d91 --- /dev/null +++ b/db/migrate/20241017162536_create_impersonation_session_logs.rb @@ -0,0 +1,14 @@ +class CreateImpersonationSessionLogs < ActiveRecord::Migration[7.2] + def change + create_table :impersonation_session_logs, id: :uuid do |t| + t.references :impersonation_session, type: :uuid, foreign_key: true, null: false + t.string :controller + t.string :action + t.text :path + t.string :method + t.string :ip_address + t.text :user_agent + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 49e43264..c5a313ea 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -19,7 +19,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do # Note that some types may not work with other database engines. Be careful if changing database. create_enum "account_status", ["ok", "syncing", "error"] create_enum "import_status", ["pending", "importing", "complete", "failed"] - create_enum "user_role", ["admin", "member"] + create_enum "user_role", ["admin", "member", "super_admin"] create_table "account_balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false @@ -493,6 +493,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do t.string "ip_address" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "active_impersonator_session_id" + t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id" t.index ["user_id"], name: "index_sessions_on_user_id" end @@ -535,7 +537,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do t.string "last_alerted_upgrade_commit_sha" t.enum "role", default: "member", null: false, enum_type: "user_role" t.boolean "active", default: true, null: false - t.boolean "super_admin", default: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" end @@ -573,6 +574,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do add_foreign_key "imports", "families" add_foreign_key "institutions", "families" add_foreign_key "merchants", "families" + add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" add_foreign_key "sessions", "users" add_foreign_key "taggings", "tags" add_foreign_key "tags", "families" diff --git a/test/controllers/impersonation_sessions_controller_test.rb b/test/controllers/impersonation_sessions_controller_test.rb new file mode 100644 index 00000000..65fe4bf5 --- /dev/null +++ b/test/controllers/impersonation_sessions_controller_test.rb @@ -0,0 +1,112 @@ +require "test_helper" + +class ImpersonationSessionsControllerTest < ActionDispatch::IntegrationTest + test "impersonation session logs all activity for auditing" do + sign_in impersonator = users(:maybe_support_staff) + impersonated = users(:family_member) + + impersonator_session = impersonation_sessions(:in_progress) + + post join_impersonation_sessions_path, params: { impersonation_session_id: impersonator_session.id } + + assert_difference "impersonator_session.logs.count", 2 do + get root_path + get account_path(impersonated.family.accounts.first) + end + end + + test "super admin can request an impersonation session" do + sign_in users(:maybe_support_staff) + + post impersonation_sessions_path, params: { impersonation_session: { impersonated_id: users(:family_member).id } } + + assert_equal "Request sent to user. Waiting for approval.", flash[:notice] + assert_redirected_to root_path + end + + test "super admin can join and leave an in progress impersonation session" do + sign_in super_admin = users(:maybe_support_staff) + + impersonator_session = impersonation_sessions(:in_progress) + + super_admin_session = super_admin.sessions.order(created_at: :desc).first + + assert_nil super_admin_session.active_impersonator_session + + # Joining the session + post join_impersonation_sessions_path, params: { impersonation_session_id: impersonator_session.id } + assert_equal impersonator_session, super_admin_session.reload.active_impersonator_session + assert_equal "Joined session", flash[:notice] + assert_redirected_to root_path + + follow_redirect! + + # Leaving the session + delete leave_impersonation_sessions_path + assert_nil super_admin_session.reload.active_impersonator_session + assert_equal "Left session", flash[:notice] + assert_redirected_to root_path + + # Impersonation session still in progress because nobody has ended it yet + assert_equal "in_progress", impersonator_session.reload.status + end + + test "super admin can complete an impersonation session" do + sign_in super_admin = users(:maybe_support_staff) + + impersonator_session = impersonation_sessions(:in_progress) + + put complete_impersonation_session_path(impersonator_session) + + assert_equal "Session completed", flash[:notice] + assert_nil super_admin.sessions.order(created_at: :desc).first.active_impersonator_session + assert_equal "complete", impersonator_session.reload.status + assert_redirected_to root_path + end + + test "regular user can complete an impersonation session" do + sign_in regular_user = users(:family_member) + + impersonator_session = impersonation_sessions(:in_progress) + + put complete_impersonation_session_path(impersonator_session) + + assert_equal "Session completed", flash[:notice] + assert_equal "complete", impersonator_session.reload.status + assert_redirected_to root_path + end + + test "super admin cannot accept an impersonation session" do + sign_in super_admin = users(:maybe_support_staff) + + impersonator_session = impersonation_sessions(:in_progress) + + put approve_impersonation_session_path(impersonator_session) + + assert_response :not_found + end + + test "regular user can accept an impersonation session" do + sign_in regular_user = users(:family_member) + + impersonator_session = impersonation_sessions(:in_progress) + + put approve_impersonation_session_path(impersonator_session) + + assert_equal "Request approved", flash[:notice] + assert_equal "in_progress", impersonator_session.reload.status + assert_redirected_to root_path + end + + test "regular user can reject an impersonation session" do + sign_in regular_user = users(:family_member) + + impersonator_session = impersonation_sessions(:in_progress) + + put reject_impersonation_session_path(impersonator_session) + + assert_equal "Request rejected", flash[:notice] + assert_equal "rejected", impersonator_session.reload.status + assert_redirected_to root_path + end +end diff --git a/test/fixtures/impersonation_session_logs.yml b/test/fixtures/impersonation_session_logs.yml new file mode 100644 index 00000000..e11ea93e --- /dev/null +++ b/test/fixtures/impersonation_session_logs.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +# This model initially had no columns defined. If you add columns to the +# model remove the "{}" from the fixture names and add the columns immediately +# below each fixture, per the syntax in the comments below +# +#one: {} +# column: value +# +#two: {} +# column: value diff --git a/test/fixtures/impersonation_sessions.yml b/test/fixtures/impersonation_sessions.yml new file mode 100644 index 00000000..becd9e7c --- /dev/null +++ b/test/fixtures/impersonation_sessions.yml @@ -0,0 +1,4 @@ +in_progress: + impersonator: maybe_support_staff + impersonated: family_member + status: in_progress diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 3c9aa4ed..b717c9cb 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -5,6 +5,14 @@ empty: email: user1@email.com password_digest: <%= BCrypt::Password.create('password') %> +maybe_support_staff: + family: empty + first_name: Support + last_name: Admin + email: support@maybe.co + password_digest: <%= BCrypt::Password.create('password') %> + role: super_admin + family_admin: family: dylan_family first_name: Bob diff --git a/test/models/impersonation_session_log_test.rb b/test/models/impersonation_session_log_test.rb new file mode 100644 index 00000000..f620ebb1 --- /dev/null +++ b/test/models/impersonation_session_log_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ImpersonationSessionLogTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/impersonation_session_test.rb b/test/models/impersonation_session_test.rb new file mode 100644 index 00000000..1946e6da --- /dev/null +++ b/test/models/impersonation_session_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class ImpersonationSessionTest < ActiveSupport::TestCase + test "only super admin can impersonate" do + regular_user = users(:family_member) + + assert_not regular_user.super_admin? + + assert_raises(ActiveRecord::RecordInvalid) do + ImpersonationSession.create!( + impersonator: regular_user, + impersonated: users(:maybe_support_staff) + ) + end + end + + test "super admin cannot be impersonated" do + super_admin = users(:maybe_support_staff) + + assert super_admin.super_admin? + + assert_raises(ActiveRecord::RecordInvalid) do + ImpersonationSession.create!( + impersonator: users(:family_member), + impersonated: super_admin + ) + end + end + + test "impersonation session must have different impersonator and impersonated" do + super_admin = users(:maybe_support_staff) + + assert_raises(ActiveRecord::RecordInvalid) do + ImpersonationSession.create!( + impersonator: super_admin, + impersonated: super_admin + ) + end + end +end From e8e100e1d8e8d95ff44f65f46c76a1eb64c9a4a7 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 18 Oct 2024 14:37:42 -0400 Subject: [PATCH 021/736] Rework account views and addition flow (#1324) * Move accountable partials * Split accountables into separate view partials * Fix test * Add form to permitted partials * Fix failing system tests * Update new account modal views * New sync algorithm implementation * Update account system test assertions to match new behavior * Fix off by 1 date error * Revert new balance sync algorithm * Add missing account overviews --- app/controllers/account/logos_controller.rb | 10 - app/controllers/accounts_controller.rb | 10 +- app/helpers/accounts_helper.rb | 10 + app/helpers/application_helper.rb | 8 +- .../time_series_chart_controller.js | 7 +- app/models/account.rb | 2 + app/models/account/balance/calculator.rb | 57 +++++ app/models/account/balance/converter.rb | 46 ++++ app/models/account/balance/loader.rb | 37 ++++ app/models/account/balance/syncer.rb | 134 +++-------- app/models/credit_card.rb | 4 + app/models/crypto.rb | 4 + app/models/depository.rb | 4 + app/models/investment.rb | 4 + app/models/loan.rb | 4 + app/models/other_asset.rb | 4 + app/models/other_liability.rb | 4 + app/models/property.rb | 4 + app/models/vehicle.rb | 4 + app/views/account/logos/show.svg.erb | 21 -- app/views/accounts/_account_list.html.erb | 4 +- app/views/accounts/_account_type.html.erb | 1 - app/views/accounts/_empty.html.erb | 2 +- app/views/accounts/_entry_method.html.erb | 8 +- app/views/accounts/_form.html.erb | 15 +- app/views/accounts/_header.html.erb | 2 +- app/views/accounts/_logo.html.erb | 14 ++ app/views/accounts/_menu.html.erb | 33 +++ .../accounts/_new_account_setup_bar.html.erb | 8 + app/views/accounts/_sync_all_button.html.erb | 4 +- .../accountables/_default_header.html.erb | 7 + .../accountables/_default_tabs.html.erb | 7 + app/views/accounts/accountables/_tab.html.erb | 8 + .../accountables/_transactions.html.erb | 5 + .../accountables/_valuations.html.erb | 5 + .../_form.html.erb} | 0 .../accountables/credit_card/_header.html.erb | 1 + .../credit_card/_overview.html.erb | 4 + .../accountables/credit_card/_tabs.html.erb | 15 ++ .../_form.html.erb} | 0 .../accountables/crypto/_header.html.erb | 1 + .../accountables/crypto/_tabs.html.erb | 1 + .../_form.html.erb} | 0 .../accountables/depository/_header.html.erb | 1 + .../accountables/depository/_tabs.html.erb | 1 + .../_form.html.erb} | 0 .../accountables/investment/_header.html.erb | 1 + .../accountables/investment/_tabs.html.erb | 28 +++ .../investment}/_tooltip.html.erb | 1 + .../{_loan.html.erb => loan/_form.html.erb} | 0 .../accountables/loan/_header.html.erb | 1 + .../accountables/loan/_overview.html.erb | 4 + .../accounts/accountables/loan/_tabs.html.erb | 15 ++ .../_form.html.erb} | 0 .../accountables/other_asset/_header.html.erb | 1 + .../accountables/other_asset/_tabs.html.erb | 1 + .../_form.html.erb} | 0 .../other_liability/_header.html.erb | 1 + .../other_liability/_tabs.html.erb | 1 + .../_form.html.erb} | 0 .../accountables/property/_header.html.erb | 11 + .../accountables/property/_overview.html.erb | 4 + .../accountables/property/_tabs.html.erb | 15 ++ .../_form.html.erb} | 0 .../accountables/vehicle/_header.html.erb | 1 + .../accountables/vehicle/_overview.html.erb | 4 + .../accountables/vehicle/_tabs.html.erb | 15 ++ app/views/accounts/index.html.erb | 2 +- app/views/accounts/new.html.erb | 56 +---- app/views/accounts/show.html.erb | 89 ++------ app/views/accounts/summary.html.erb | 4 +- app/views/imports/new.html.erb | 129 ++++++----- app/views/layouts/_sidebar.html.erb | 4 +- app/views/pages/_account_group_disclosure.erb | 2 +- app/views/pages/dashboard.html.erb | 16 +- app/views/shared/_circle_logo.html.erb | 2 +- .../shared/_no_account_empty_state.html.erb | 2 +- config/brakeman.ignore | 82 ++++++- config/locales/views/accounts/en.yml | 208 +++++++++--------- config/locales/views/imports/en.yml | 2 +- config/routes.rb | 2 - test/helpers/application_helper_test.rb | 6 - test/models/account/balance/syncer_test.rb | 9 - test/system/.keep | 0 test/system/accounts_test.rb | 14 +- test/system/tooltips_test.rb | 28 --- test/system/trades_test.rb | 2 +- test/system/transactions_test.rb | 1 + 88 files changed, 763 insertions(+), 526 deletions(-) delete mode 100644 app/controllers/account/logos_controller.rb create mode 100644 app/models/account/balance/calculator.rb create mode 100644 app/models/account/balance/converter.rb create mode 100644 app/models/account/balance/loader.rb delete mode 100644 app/views/account/logos/show.svg.erb create mode 100644 app/views/accounts/_logo.html.erb create mode 100644 app/views/accounts/_menu.html.erb create mode 100644 app/views/accounts/_new_account_setup_bar.html.erb create mode 100644 app/views/accounts/accountables/_default_header.html.erb create mode 100644 app/views/accounts/accountables/_default_tabs.html.erb create mode 100644 app/views/accounts/accountables/_tab.html.erb create mode 100644 app/views/accounts/accountables/_transactions.html.erb create mode 100644 app/views/accounts/accountables/_valuations.html.erb rename app/views/accounts/accountables/{_credit_card.html.erb => credit_card/_form.html.erb} (100%) create mode 100644 app/views/accounts/accountables/credit_card/_header.html.erb create mode 100644 app/views/accounts/accountables/credit_card/_tabs.html.erb rename app/views/accounts/accountables/{_crypto.html.erb => crypto/_form.html.erb} (100%) create mode 100644 app/views/accounts/accountables/crypto/_header.html.erb create mode 100644 app/views/accounts/accountables/crypto/_tabs.html.erb rename app/views/accounts/accountables/{_depository.html.erb => depository/_form.html.erb} (100%) create mode 100644 app/views/accounts/accountables/depository/_header.html.erb create mode 100644 app/views/accounts/accountables/depository/_tabs.html.erb rename app/views/accounts/accountables/{_investment.html.erb => investment/_form.html.erb} (100%) create mode 100644 app/views/accounts/accountables/investment/_header.html.erb create mode 100644 app/views/accounts/accountables/investment/_tabs.html.erb rename app/views/accounts/{ => accountables/investment}/_tooltip.html.erb (99%) rename app/views/accounts/accountables/{_loan.html.erb => loan/_form.html.erb} (100%) create mode 100644 app/views/accounts/accountables/loan/_header.html.erb create mode 100644 app/views/accounts/accountables/loan/_tabs.html.erb rename app/views/accounts/accountables/{_other_asset.html.erb => other_asset/_form.html.erb} (100%) create mode 100644 app/views/accounts/accountables/other_asset/_header.html.erb create mode 100644 app/views/accounts/accountables/other_asset/_tabs.html.erb rename app/views/accounts/accountables/{_other_liability.html.erb => other_liability/_form.html.erb} (100%) create mode 100644 app/views/accounts/accountables/other_liability/_header.html.erb create mode 100644 app/views/accounts/accountables/other_liability/_tabs.html.erb rename app/views/accounts/accountables/{_property.html.erb => property/_form.html.erb} (100%) create mode 100644 app/views/accounts/accountables/property/_header.html.erb create mode 100644 app/views/accounts/accountables/property/_tabs.html.erb rename app/views/accounts/accountables/{_vehicle.html.erb => vehicle/_form.html.erb} (100%) create mode 100644 app/views/accounts/accountables/vehicle/_header.html.erb create mode 100644 app/views/accounts/accountables/vehicle/_tabs.html.erb delete mode 100644 test/system/.keep delete mode 100644 test/system/tooltips_test.rb diff --git a/app/controllers/account/logos_controller.rb b/app/controllers/account/logos_controller.rb deleted file mode 100644 index dd56e665..00000000 --- a/app/controllers/account/logos_controller.rb +++ /dev/null @@ -1,10 +0,0 @@ -class Account::LogosController < ApplicationController - def show - @account = Current.family.accounts.find(params[:account_id]) - render_placeholder - end - - def render_placeholder - render formats: :svg - end -end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 4dcbbb27..033ed3b8 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -23,11 +23,8 @@ class AccountsController < ApplicationController end def new - @account = Account.new( - accountable: Accountable.from_type(params[:type])&.new, - currency: Current.family.currency - ) - + @account = Account.new(currency: Current.family.currency) + @account.accountable = Accountable.from_type(params[:type])&.new if params[:type].present? @account.accountable.address = Address.new if @account.accountable.is_a?(Property) if params[:institution_id] @@ -36,8 +33,6 @@ class AccountsController < ApplicationController end def show - @series = @account.series(period: @period) - @trend = @series.trend end def edit @@ -57,6 +52,7 @@ class AccountsController < ApplicationController start_date: account_params[:start_date], start_balance: account_params[:start_balance] @account.sync_later + redirect_back_or_to account_path(@account), notice: t(".success") end diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 542c80ef..b65033c5 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -1,4 +1,14 @@ module AccountsHelper + def permitted_accountable_partial(account, name = nil) + permitted_names = %w[tooltip header tabs form] + folder = account.accountable_type.underscore + name ||= account.accountable_type.underscore + + raise "Unpermitted accountable partial: #{name}" unless permitted_names.include?(name) + + "accounts/accountables/#{folder}/#{name}" + end + def summary_card(title:, &block) content = capture(&block) render "accounts/summary_card", title: title, content: content diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 8cbbcf21..19aa187e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -9,10 +9,6 @@ module ApplicationHelper content_for(:header_title) { page_title } end - def permitted_accountable_partial(name) - name.underscore - end - def family_notifications_stream turbo_stream_from [ Current.family, :notifications ] if Current.family end @@ -80,8 +76,8 @@ module ApplicationHelper color = hex || "#1570EF" # blue-600 <<-STYLE.strip - background-color: color-mix(in srgb, #{color} 5%, white); - border-color: color-mix(in srgb, #{color} 10%, white); + background-color: color-mix(in srgb, #{color} 10%, white); + border-color: color-mix(in srgb, #{color} 30%, white); color: #{color}; STYLE end diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index ce27460f..7660f3f4 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -51,12 +51,17 @@ export default class extends Controller { _normalizeDataPoints() { this._normalDataPoints = (this.dataValue.values || []).map((d) => ({ ...d, - date: new Date(d.date), + date: this._parseDate(d.date), value: d.value.amount ? +d.value.amount : +d.value, currency: d.value.currency, })); } + _parseDate(dateString) { + const [year, month, day] = dateString.split("-").map(Number); + return new Date(year, month - 1, day); + } + _rememberInitialContainerSize() { this._d3InitialContainerWidth = this._d3Container.node().clientWidth; this._d3InitialContainerHeight = this._d3Container.node().clientHeight; diff --git a/app/models/account.rb b/app/models/account.rb index 61d473d8..9dd49d60 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -27,6 +27,8 @@ class Account < ApplicationRecord scope :alphabetically, -> { order(:name) } scope :ungrouped, -> { where(institution_id: nil) } + has_one_attached :logo + delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy accepts_nested_attributes_for :accountable diff --git a/app/models/account/balance/calculator.rb b/app/models/account/balance/calculator.rb new file mode 100644 index 00000000..68d85853 --- /dev/null +++ b/app/models/account/balance/calculator.rb @@ -0,0 +1,57 @@ +class Account::Balance::Calculator + def initialize(account, sync_start_date) + @account = account + @sync_start_date = sync_start_date + end + + def calculate(is_partial_sync: false) + cached_entries = account.entries.where("date >= ?", sync_start_date).to_a + sync_starting_balance = is_partial_sync ? find_start_balance_for_partial_sync : find_start_balance_for_full_sync(cached_entries) + + prior_balance = sync_starting_balance + + (sync_start_date..Date.current).map do |date| + current_balance = calculate_balance_for_date(date, entries: cached_entries, prior_balance:) + + prior_balance = current_balance + + build_balance(date, current_balance) + end + end + + private + attr_reader :account, :sync_start_date + + def find_start_balance_for_partial_sync + account.balances.find_by(currency: account.currency, date: sync_start_date - 1.day).balance + end + + def find_start_balance_for_full_sync(cached_entries) + account.balance + net_entry_flows(cached_entries) + end + + def calculate_balance_for_date(date, entries:, prior_balance:) + valuation = entries.find { |e| e.date == date && e.account_valuation? } + + return valuation.amount if valuation + + entries = entries.select { |e| e.date == date } + + prior_balance - net_entry_flows(entries) + end + + def net_entry_flows(entries, target_currency = account.currency) + converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) } + + flows = converted_entry_amounts.sum(&:amount) + + account.liability? ? flows * -1 : flows + end + + def build_balance(date, balance, currency = nil) + account.balances.build \ + date: date, + balance: balance, + currency: currency || account.currency + end +end diff --git a/app/models/account/balance/converter.rb b/app/models/account/balance/converter.rb new file mode 100644 index 00000000..f5e55749 --- /dev/null +++ b/app/models/account/balance/converter.rb @@ -0,0 +1,46 @@ +class Account::Balance::Converter + def initialize(account, sync_start_date) + @account = account + @sync_start_date = sync_start_date + end + + def convert(balances) + calculate_converted_balances(balances) + end + + private + attr_reader :account, :sync_start_date + + def calculate_converted_balances(balances) + from_currency = account.currency + to_currency = account.family.currency + + if ExchangeRate.exchange_rates_provider.nil? + account.observe_missing_exchange_rate_provider + return [] + end + + exchange_rates = ExchangeRate.find_rates from: from_currency, + to: to_currency, + start_date: sync_start_date + + missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date) + + if missing_exchange_rates.any? + account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates) + return [] + end + + balances.map do |balance| + exchange_rate = exchange_rates.find { |er| er.date == balance.date } + build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency) + end + end + + def build_balance(date, balance, currency = nil) + account.balances.build \ + date: date, + balance: balance, + currency: currency || account.currency + end +end diff --git a/app/models/account/balance/loader.rb b/app/models/account/balance/loader.rb new file mode 100644 index 00000000..cb6ba0bd --- /dev/null +++ b/app/models/account/balance/loader.rb @@ -0,0 +1,37 @@ +class Account::Balance::Loader + def initialize(account) + @account = account + end + + def load(balances, start_date) + Account::Balance.transaction do + upsert_balances!(balances) + purge_stale_balances!(start_date) + + account.reload + + update_account_balance!(balances) + end + end + + private + attr_reader :account + + def update_account_balance!(balances) + last_balance = balances.select { |db| db.currency == account.currency }.last&.balance + account.update! balance: last_balance if last_balance.present? + end + + def upsert_balances!(balances) + current_time = Time.now + balances_to_upsert = balances.map do |balance| + balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time) + end + + account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency]) + end + + def purge_stale_balances!(start_date) + account.balances.delete_by("date < ?", start_date) + end +end diff --git a/app/models/account/balance/syncer.rb b/app/models/account/balance/syncer.rb index a937955d..d0e4546e 100644 --- a/app/models/account/balance/syncer.rb +++ b/app/models/account/balance/syncer.rb @@ -1,133 +1,51 @@ class Account::Balance::Syncer def initialize(account, start_date: nil) @account = account + @provided_start_date = start_date @sync_start_date = calculate_sync_start_date(start_date) + @loader = Account::Balance::Loader.new(account) + @converter = Account::Balance::Converter.new(account, sync_start_date) + @calculator = Account::Balance::Calculator.new(account, sync_start_date) end def run - daily_balances = calculate_daily_balances - daily_balances += calculate_converted_balances(daily_balances) if account.currency != account.family.currency + daily_balances = calculator.calculate(is_partial_sync: is_partial_sync?) + daily_balances += converter.convert(daily_balances) if account.currency != account.family.currency - Account::Balance.transaction do - upsert_balances!(daily_balances) - purge_stale_balances! - - if daily_balances.any? - account.reload - last_balance = daily_balances.select { |db| db.currency == account.currency }.last&.balance - account.update! balance: last_balance - end - end + loader.load(daily_balances, account_start_date) rescue Money::ConversionError => e account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ]) end private - attr_reader :sync_start_date, :account - - def upsert_balances!(balances) - current_time = Time.now - balances_to_upsert = balances.map do |balance| - balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time) - end - - account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency]) - end - - def purge_stale_balances! - account.balances.delete_by("date < ?", account_start_date) - end - - def calculate_balance_for_date(date, entries:, prior_balance:) - valuation = entries.find { |e| e.date == date && e.account_valuation? } - - return valuation.amount if valuation - return derived_sync_start_balance(entries) unless prior_balance - - entries = entries.select { |e| e.date == date } - - prior_balance - net_entry_flows(entries) - end - - def calculate_daily_balances - entries = account.entries.where("date >= ?", sync_start_date).to_a - prior_balance = find_prior_balance - - (sync_start_date..Date.current).map do |date| - current_balance = calculate_balance_for_date(date, entries:, prior_balance:) - - prior_balance = current_balance - - build_balance(date, current_balance) - end - end - - def calculate_converted_balances(balances) - from_currency = account.currency - to_currency = account.family.currency - - if ExchangeRate.exchange_rates_provider.nil? - account.observe_missing_exchange_rate_provider - return [] - end - - exchange_rates = ExchangeRate.find_rates from: from_currency, - to: to_currency, - start_date: sync_start_date - - missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date) - - if missing_exchange_rates.any? - account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates) - return [] - end - - balances.map do |balance| - exchange_rate = exchange_rates.find { |er| er.date == balance.date } - build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency) - end - end - - def build_balance(date, balance, currency = nil) - account.balances.build \ - date: date, - balance: balance, - currency: currency || account.currency - end - - def derived_sync_start_balance(entries) - transactions_and_trades = entries.reject { |e| e.account_valuation? }.select { |e| e.date > sync_start_date } - - account.balance + net_entry_flows(transactions_and_trades) - end - - def find_prior_balance - account.balances.where(currency: account.currency).where("date < ?", sync_start_date).order(date: :desc).first&.balance - end - - def net_entry_flows(entries, target_currency = account.currency) - converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) } - - flows = converted_entry_amounts.sum(&:amount) - - account.liability? ? flows * -1 : flows - end + attr_reader :sync_start_date, :provided_start_date, :account, :loader, :converter, :calculator def account_start_date @account_start_date ||= begin - oldest_entry_date = account.entries.chronological.first.try(:date) + oldest_entry = account.entries.chronological.first - return Date.current unless oldest_entry_date + return Date.current unless oldest_entry.present? - oldest_entry_is_valuation = account.entries.account_valuations.where(date: oldest_entry_date).exists? - - oldest_entry_date -= 1 unless oldest_entry_is_valuation - oldest_entry_date + if oldest_entry.account_valuation? + oldest_entry.date + else + oldest_entry.date - 1.day + end end end def calculate_sync_start_date(provided_start_date) - [ provided_start_date, account_start_date ].compact.max + return provided_start_date if provided_start_date.present? && prior_balance_available?(provided_start_date) + + account_start_date + end + + def prior_balance_available?(date) + account.balances.find_by(currency: account.currency, date: date - 1.day).present? + end + + def is_partial_sync? + sync_start_date == provided_start_date && sync_start_date < Date.current end end diff --git a/app/models/credit_card.rb b/app/models/credit_card.rb index 5c8bb0ae..bdde91c8 100644 --- a/app/models/credit_card.rb +++ b/app/models/credit_card.rb @@ -12,4 +12,8 @@ class CreditCard < ApplicationRecord def annual_fee_money annual_fee ? Money.new(annual_fee, account.currency) : nil end + + def color + "#F13636" + end end diff --git a/app/models/crypto.rb b/app/models/crypto.rb index 5aec6157..e4f81ae4 100644 --- a/app/models/crypto.rb +++ b/app/models/crypto.rb @@ -1,3 +1,7 @@ class Crypto < ApplicationRecord include Accountable + + def color + "#737373" + end end diff --git a/app/models/depository.rb b/app/models/depository.rb index 8ae1924d..90abe087 100644 --- a/app/models/depository.rb +++ b/app/models/depository.rb @@ -1,3 +1,7 @@ class Depository < ApplicationRecord include Accountable + + def color + "#875BF7" + end end diff --git a/app/models/investment.rb b/app/models/investment.rb index d5c583fe..1912899f 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -46,4 +46,8 @@ class Investment < ApplicationRecord rescue Money::ConversionError TimeSeries.new([]) end + + def color + "#1570EF" + end end diff --git a/app/models/loan.rb b/app/models/loan.rb index be8e8c2b..5051b69b 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -16,4 +16,8 @@ class Loan < ApplicationRecord Money.new(payment.round, account.currency) end + + def color + "#D444F1" + end end diff --git a/app/models/other_asset.rb b/app/models/other_asset.rb index d9434f6b..90ca9e32 100644 --- a/app/models/other_asset.rb +++ b/app/models/other_asset.rb @@ -1,3 +1,7 @@ class OtherAsset < ApplicationRecord include Accountable + + def color + "#12B76A" + end end diff --git a/app/models/other_liability.rb b/app/models/other_liability.rb index 83be97f5..04a3737b 100644 --- a/app/models/other_liability.rb +++ b/app/models/other_liability.rb @@ -1,3 +1,7 @@ class OtherLiability < ApplicationRecord include Accountable + + def color + "#737373" + end end diff --git a/app/models/property.rb b/app/models/property.rb index a23519ed..304c4d78 100644 --- a/app/models/property.rb +++ b/app/models/property.rb @@ -19,6 +19,10 @@ class Property < ApplicationRecord TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount) end + def color + "#06AED4" + end + private def first_valuation_amount account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money diff --git a/app/models/vehicle.rb b/app/models/vehicle.rb index 2ab34d7b..741070c2 100644 --- a/app/models/vehicle.rb +++ b/app/models/vehicle.rb @@ -15,6 +15,10 @@ class Vehicle < ApplicationRecord TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount) end + def color + "#F23E94" + end + private def first_valuation_amount account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money diff --git a/app/views/account/logos/show.svg.erb b/app/views/account/logos/show.svg.erb deleted file mode 100644 index 19186e26..00000000 --- a/app/views/account/logos/show.svg.erb +++ /dev/null @@ -1,21 +0,0 @@ - diff --git a/app/views/accounts/_account_list.html.erb b/app/views/accounts/_account_list.html.erb index b42df152..46e8004b 100644 --- a/app/views/accounts/_account_list.html.erb +++ b/app/views/accounts/_account_list.html.erb @@ -30,7 +30,7 @@ <% group.children.sort_by(&:name).each do |account_value_node| %> <% account = account_value_node.original %> <%= link_to account_path(account), class: "flex items-center w-full gap-3 px-3 py-2 mb-1 hover:bg-gray-100 rounded-[10px]" do %> - <%= image_tag account_logo_url(account), class: "w-6 h-6" %> + <%= render "accounts/logo", account: account, size: "sm" %>

<%= account_value_node.name %>

<% if account.subtype %> @@ -63,7 +63,7 @@ <% end %> <%= link_to new_account_path(step: "method", type: type.name.demodulize), class: "flex items-center min-h-10 gap-4 px-3 py-2 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %> <%= lucide_icon("plus", class: "w-5 h-5") %> -

New <%= type.model_name.human.downcase %>

+ <%= t(".new_account", type: type.model_name.human.downcase) %> <% end %> <% end %> diff --git a/app/views/accounts/_account_type.html.erb b/app/views/accounts/_account_type.html.erb index d8b400c7..d4b69527 100644 --- a/app/views/accounts/_account_type.html.erb +++ b/app/views/accounts/_account_type.html.erb @@ -1,5 +1,4 @@ <%= link_to new_account_path( - step: "method", type: type.class.name.demodulize, institution_id: params[:institution_id] ), diff --git a/app/views/accounts/_empty.html.erb b/app/views/accounts/_empty.html.erb index e938c863..68c64316 100644 --- a/app/views/accounts/_empty.html.erb +++ b/app/views/accounts/_empty.html.erb @@ -3,7 +3,7 @@ <%= tag.p t(".no_accounts"), class: "text-gray-900 mb-1 font-medium" %> <%= tag.p t(".empty_message"), class: "text-gray-500 mb-4" %> - <%= link_to new_account_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %> + <%= link_to new_account_path(step: "method"), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %> <%= lucide_icon("plus", class: "w-5 h-5") %> <%= t(".new_account") %> <% end %> diff --git a/app/views/accounts/_entry_method.html.erb b/app/views/accounts/_entry_method.html.erb index ea73af73..1e9b6971 100644 --- a/app/views/accounts/_entry_method.html.erb +++ b/app/views/accounts/_entry_method.html.erb @@ -1,12 +1,14 @@ -<% if local_assigns[:disabled] && disabled %> - +<%# locals: (text:, icon:, disabled: false) %> + +<% if disabled %> + <%= lucide_icon(icon, class: "text-gray-500 w-5 h-5") %> <%= text %> <% else %> - <%= link_to new_account_path(type: type.class.name.demodulize, institution_id: params[:institution_id]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %> + <%= link_to new_account_path(institution_id: params[:institution_id]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %> <%= lucide_icon(icon, class: "text-gray-500 w-5 h-5") %> diff --git a/app/views/accounts/_form.html.erb b/app/views/accounts/_form.html.erb index 4fa0cce1..e85448e0 100644 --- a/app/views/accounts/_form.html.erb +++ b/app/views/accounts/_form.html.erb @@ -2,8 +2,8 @@ <%= styled_form_with model: account, url: url, scope: :account, class: "flex flex-col gap-4 justify-between grow", data: { turbo: false } do |f| %>
- <%= f.hidden_field :accountable_type %> - <%= f.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label"), autofocus: true %> + <%= f.select :accountable_type, Accountable::TYPES.map { |type| [type.titleize, type] }, { label: t(".accountable_type"), prompt: t(".type_prompt") }, required: true, autofocus: true %> + <%= f.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label") %> <% if account.new_record? %> <%= f.hidden_field :institution_id %> @@ -13,15 +13,10 @@ <%= f.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %> - <% if account.new_record? %> -
-
<%= f.date_field :start_date, label: t(".start_date"), max: Date.yesterday, min: Account::Entry.min_supported_date %>
-
<%= f.money_field :start_balance, label: t(".start_balance"), placeholder: 90, hide_currency: true, default_currency: Current.family.currency %>
-
+ <% if account.accountable %> + <%= render permitted_accountable_partial(account, "form"), f: f %> <% end %> - - <%= render "accounts/accountables/#{permitted_accountable_partial(account.accountable_type)}", f: f %>
- <%= f.submit "#{account.new_record? ? "Add" : "Update"} #{account.accountable.model_name.human.downcase}" %> + <%= f.submit %> <% end %> diff --git a/app/views/accounts/_header.html.erb b/app/views/accounts/_header.html.erb index f8c8eb3e..348fc9b9 100644 --- a/app/views/accounts/_header.html.erb +++ b/app/views/accounts/_header.html.erb @@ -13,7 +13,7 @@ <% end %> - <%= link_to new_account_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %> + <%= link_to new_account_path(step: "method"), class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %> <%= lucide_icon("plus", class: "w-5 h-5") %>

<%= t(".new") %>

<% end %> diff --git a/app/views/accounts/_logo.html.erb b/app/views/accounts/_logo.html.erb new file mode 100644 index 00000000..9036ad18 --- /dev/null +++ b/app/views/accounts/_logo.html.erb @@ -0,0 +1,14 @@ +<%# locals: (account:, size: "md") %> + +<% size_classes = { + "sm" => "w-6 h-6", + "md" => "w-9 h-9", + "lg" => "w-10 h-10", + "full" => "w-full h-full" +} %> + +<% if account.logo.attached? %> + <%= image_tag account.logo, class: size_classes[size] %> +<% else %> + <%= circle_logo(account.name, hex: account.accountable.color, size: size) %> +<% end %> diff --git a/app/views/accounts/_menu.html.erb b/app/views/accounts/_menu.html.erb new file mode 100644 index 00000000..8ebb53f1 --- /dev/null +++ b/app/views/accounts/_menu.html.erb @@ -0,0 +1,33 @@ +<%# locals: (account:) %> + +<%= contextual_menu do %> +
+ <%= link_to edit_account_path(account), + data: { turbo_frame: :modal }, + class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> + <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> + + <%= t(".edit") %> + <% end %> + + <%= link_to new_import_path, + class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> + <%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %> + + <%= t(".import") %> + <% end %> + + <%= button_to account_path(account), + method: :delete, + class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg", + data: { + turbo_confirm: { + title: t(".confirm_title"), + body: t(".confirm_body_html"), + accept: t(".confirm_accept", name: account.name) + } + } do %> + <%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account + <% end %> +
+<% end %> diff --git a/app/views/accounts/_new_account_setup_bar.html.erb b/app/views/accounts/_new_account_setup_bar.html.erb new file mode 100644 index 00000000..16b6e430 --- /dev/null +++ b/app/views/accounts/_new_account_setup_bar.html.erb @@ -0,0 +1,8 @@ +
+

Setup your new account

+ +
+ <%= link_to "Track balances only", new_account_valuation_path(@account), class: "btn btn--ghost", data: { turbo_frame: dom_id(@account.entries.account_valuations.new) } %> + <%= link_to "Add your first transaction", new_transaction_path(account_id: @account.id), class: "btn btn--primary", data: { turbo_frame: :modal } %> +
+
diff --git a/app/views/accounts/_sync_all_button.html.erb b/app/views/accounts/_sync_all_button.html.erb index 0cf16bbd..8300df19 100644 --- a/app/views/accounts/_sync_all_button.html.erb +++ b/app/views/accounts/_sync_all_button.html.erb @@ -1,4 +1,4 @@ -<%= button_to sync_all_accounts_path, class: "btn btn--outline flex items-center gap-2", title: "Sync All" do %> +<%= button_to sync_all_accounts_path, class: "btn btn--outline flex items-center gap-2", title: t(".sync") do %> <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> - <%= t("accounts.sync_all.button_text") %> + <%= t(".sync") %> <% end %> diff --git a/app/views/accounts/accountables/_default_header.html.erb b/app/views/accounts/accountables/_default_header.html.erb new file mode 100644 index 00000000..5feae417 --- /dev/null +++ b/app/views/accounts/accountables/_default_header.html.erb @@ -0,0 +1,7 @@ +
+ <%= render "accounts/logo", account: account %> + +
+

<%= account.name %>

+
+
diff --git a/app/views/accounts/accountables/_default_tabs.html.erb b/app/views/accounts/accountables/_default_tabs.html.erb new file mode 100644 index 00000000..0a2f355a --- /dev/null +++ b/app/views/accounts/accountables/_default_tabs.html.erb @@ -0,0 +1,7 @@ +<%# locals: (account:) %> + +<% if account.transactions.any? %> + <%= render "accounts/accountables/transactions", account: account %> +<% else %> + <%= render "accounts/accountables/valuations", account: account %> +<% end %> diff --git a/app/views/accounts/accountables/_tab.html.erb b/app/views/accounts/accountables/_tab.html.erb new file mode 100644 index 00000000..4ddd0e6e --- /dev/null +++ b/app/views/accounts/accountables/_tab.html.erb @@ -0,0 +1,8 @@ +<%# locals: (account:, key:, is_selected:) %> + +<%= link_to key.titleize, + account_path(account, tab: key), + class: [ + "px-2 py-1.5 rounded-md border border-transparent", + "bg-white shadow-xs border-alpha-black-50": is_selected + ] %> diff --git a/app/views/accounts/accountables/_transactions.html.erb b/app/views/accounts/accountables/_transactions.html.erb new file mode 100644 index 00000000..d6943303 --- /dev/null +++ b/app/views/accounts/accountables/_transactions.html.erb @@ -0,0 +1,5 @@ +<%# locals: (account:) %> + +<%= turbo_frame_tag dom_id(account, :transactions), src: account_transactions_path(account) do %> + <%= render "account/entries/loading" %> +<% end %> diff --git a/app/views/accounts/accountables/_valuations.html.erb b/app/views/accounts/accountables/_valuations.html.erb new file mode 100644 index 00000000..b4bf1fc3 --- /dev/null +++ b/app/views/accounts/accountables/_valuations.html.erb @@ -0,0 +1,5 @@ +<%# locals: (account:) %> + +<%= turbo_frame_tag dom_id(account, :valuations), src: account_valuations_path(account) do %> + <%= render "account/entries/loading" %> +<% end %> diff --git a/app/views/accounts/accountables/_credit_card.html.erb b/app/views/accounts/accountables/credit_card/_form.html.erb similarity index 100% rename from app/views/accounts/accountables/_credit_card.html.erb rename to app/views/accounts/accountables/credit_card/_form.html.erb diff --git a/app/views/accounts/accountables/credit_card/_header.html.erb b/app/views/accounts/accountables/credit_card/_header.html.erb new file mode 100644 index 00000000..51889135 --- /dev/null +++ b/app/views/accounts/accountables/credit_card/_header.html.erb @@ -0,0 +1 @@ +<%= render "accounts/accountables/default_header", account: account %> diff --git a/app/views/accounts/accountables/credit_card/_overview.html.erb b/app/views/accounts/accountables/credit_card/_overview.html.erb index 3121528b..668326b5 100644 --- a/app/views/accounts/accountables/credit_card/_overview.html.erb +++ b/app/views/accounts/accountables/credit_card/_overview.html.erb @@ -25,3 +25,7 @@ <%= format_money(account.credit_card.annual_fee_money || Money.new(0, account.currency)) %> <% end %>
+ +
+ <%= link_to "Edit account details", edit_account_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %> +
\ No newline at end of file diff --git a/app/views/accounts/accountables/credit_card/_tabs.html.erb b/app/views/accounts/accountables/credit_card/_tabs.html.erb new file mode 100644 index 00000000..781cc6d0 --- /dev/null +++ b/app/views/accounts/accountables/credit_card/_tabs.html.erb @@ -0,0 +1,15 @@ +<%# locals: (account:, selected_tab:) %> + +
+ <%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %> + <%= render "accounts/accountables/tab", account: account, key: "transactions", is_selected: selected_tab == "transactions" %> +
+ +
+ <% case selected_tab %> + <% when nil, "overview" %> + <%= render "accounts/accountables/credit_card/overview", account: account %> + <% when "transactions" %> + <%= render "accounts/accountables/transactions", account: account %> + <% end %> +
diff --git a/app/views/accounts/accountables/_crypto.html.erb b/app/views/accounts/accountables/crypto/_form.html.erb similarity index 100% rename from app/views/accounts/accountables/_crypto.html.erb rename to app/views/accounts/accountables/crypto/_form.html.erb diff --git a/app/views/accounts/accountables/crypto/_header.html.erb b/app/views/accounts/accountables/crypto/_header.html.erb new file mode 100644 index 00000000..51889135 --- /dev/null +++ b/app/views/accounts/accountables/crypto/_header.html.erb @@ -0,0 +1 @@ +<%= render "accounts/accountables/default_header", account: account %> diff --git a/app/views/accounts/accountables/crypto/_tabs.html.erb b/app/views/accounts/accountables/crypto/_tabs.html.erb new file mode 100644 index 00000000..381f7273 --- /dev/null +++ b/app/views/accounts/accountables/crypto/_tabs.html.erb @@ -0,0 +1 @@ +<%= render "accounts/accountables/default_tabs", account: account %> diff --git a/app/views/accounts/accountables/_depository.html.erb b/app/views/accounts/accountables/depository/_form.html.erb similarity index 100% rename from app/views/accounts/accountables/_depository.html.erb rename to app/views/accounts/accountables/depository/_form.html.erb diff --git a/app/views/accounts/accountables/depository/_header.html.erb b/app/views/accounts/accountables/depository/_header.html.erb new file mode 100644 index 00000000..51889135 --- /dev/null +++ b/app/views/accounts/accountables/depository/_header.html.erb @@ -0,0 +1 @@ +<%= render "accounts/accountables/default_header", account: account %> diff --git a/app/views/accounts/accountables/depository/_tabs.html.erb b/app/views/accounts/accountables/depository/_tabs.html.erb new file mode 100644 index 00000000..381f7273 --- /dev/null +++ b/app/views/accounts/accountables/depository/_tabs.html.erb @@ -0,0 +1 @@ +<%= render "accounts/accountables/default_tabs", account: account %> diff --git a/app/views/accounts/accountables/_investment.html.erb b/app/views/accounts/accountables/investment/_form.html.erb similarity index 100% rename from app/views/accounts/accountables/_investment.html.erb rename to app/views/accounts/accountables/investment/_form.html.erb diff --git a/app/views/accounts/accountables/investment/_header.html.erb b/app/views/accounts/accountables/investment/_header.html.erb new file mode 100644 index 00000000..51889135 --- /dev/null +++ b/app/views/accounts/accountables/investment/_header.html.erb @@ -0,0 +1 @@ +<%= render "accounts/accountables/default_header", account: account %> diff --git a/app/views/accounts/accountables/investment/_tabs.html.erb b/app/views/accounts/accountables/investment/_tabs.html.erb new file mode 100644 index 00000000..8da4905d --- /dev/null +++ b/app/views/accounts/accountables/investment/_tabs.html.erb @@ -0,0 +1,28 @@ +<%# locals: (account:, selected_tab:) %> + +<% if account.entries.account_trades.any? || account.entries.account_transactions.any? %> +
+ <%= render "accounts/accountables/tab", account: account, key: "holdings", is_selected: selected_tab.in?([nil, "holdings"]) %> + <%= render "accounts/accountables/tab", account: account, key: "cash", is_selected: selected_tab == "cash" %> + <%= render "accounts/accountables/tab", account: account, key: "transactions", is_selected: selected_tab == "transactions" %> +
+ +
+ <% case selected_tab %> + <% when nil, "holdings" %> + <%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account) do %> + <%= render "account/entries/loading" %> + <% end %> + <% when "cash" %> + <%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account) do %> + <%= render "account/entries/loading" %> + <% end %> + <% when "transactions" %> + <%= turbo_frame_tag dom_id(account, :trades), src: account_trades_path(account) do %> + <%= render "account/entries/loading" %> + <% end %> + <% end %> +
+<% else %> + <%= render "accounts/accountables/valuations", account: account %> +<% end %> diff --git a/app/views/accounts/_tooltip.html.erb b/app/views/accounts/accountables/investment/_tooltip.html.erb similarity index 99% rename from app/views/accounts/_tooltip.html.erb rename to app/views/accounts/accountables/investment/_tooltip.html.erb index 952d2ad1..83432ebc 100644 --- a/app/views/accounts/_tooltip.html.erb +++ b/app/views/accounts/accountables/investment/_tooltip.html.erb @@ -1,4 +1,5 @@ <%# locals: (account:) -%> +
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %> + +
+ <%= link_to "Edit account details", edit_account_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %> +
\ No newline at end of file diff --git a/app/views/accounts/accountables/loan/_tabs.html.erb b/app/views/accounts/accountables/loan/_tabs.html.erb new file mode 100644 index 00000000..3ed1efcf --- /dev/null +++ b/app/views/accounts/accountables/loan/_tabs.html.erb @@ -0,0 +1,15 @@ +<%# locals: (account:, selected_tab:) %> + +
+ <%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %> + <%= render "accounts/accountables/tab", account: account, key: "value", is_selected: selected_tab == "value" %> +
+ +
+ <% case selected_tab %> + <% when nil, "overview" %> + <%= render "accounts/accountables/loan/overview", account: account %> + <% when "value" %> + <%= render "accounts/accountables/valuations", account: account %> + <% end %> +
diff --git a/app/views/accounts/accountables/_other_asset.html.erb b/app/views/accounts/accountables/other_asset/_form.html.erb similarity index 100% rename from app/views/accounts/accountables/_other_asset.html.erb rename to app/views/accounts/accountables/other_asset/_form.html.erb diff --git a/app/views/accounts/accountables/other_asset/_header.html.erb b/app/views/accounts/accountables/other_asset/_header.html.erb new file mode 100644 index 00000000..51889135 --- /dev/null +++ b/app/views/accounts/accountables/other_asset/_header.html.erb @@ -0,0 +1 @@ +<%= render "accounts/accountables/default_header", account: account %> diff --git a/app/views/accounts/accountables/other_asset/_tabs.html.erb b/app/views/accounts/accountables/other_asset/_tabs.html.erb new file mode 100644 index 00000000..480136bf --- /dev/null +++ b/app/views/accounts/accountables/other_asset/_tabs.html.erb @@ -0,0 +1 @@ +<%= render "accounts/accountables/valuations", account: account %> diff --git a/app/views/accounts/accountables/_other_liability.html.erb b/app/views/accounts/accountables/other_liability/_form.html.erb similarity index 100% rename from app/views/accounts/accountables/_other_liability.html.erb rename to app/views/accounts/accountables/other_liability/_form.html.erb diff --git a/app/views/accounts/accountables/other_liability/_header.html.erb b/app/views/accounts/accountables/other_liability/_header.html.erb new file mode 100644 index 00000000..51889135 --- /dev/null +++ b/app/views/accounts/accountables/other_liability/_header.html.erb @@ -0,0 +1 @@ +<%= render "accounts/accountables/default_header", account: account %> diff --git a/app/views/accounts/accountables/other_liability/_tabs.html.erb b/app/views/accounts/accountables/other_liability/_tabs.html.erb new file mode 100644 index 00000000..480136bf --- /dev/null +++ b/app/views/accounts/accountables/other_liability/_tabs.html.erb @@ -0,0 +1 @@ +<%= render "accounts/accountables/valuations", account: account %> diff --git a/app/views/accounts/accountables/_property.html.erb b/app/views/accounts/accountables/property/_form.html.erb similarity index 100% rename from app/views/accounts/accountables/_property.html.erb rename to app/views/accounts/accountables/property/_form.html.erb diff --git a/app/views/accounts/accountables/property/_header.html.erb b/app/views/accounts/accountables/property/_header.html.erb new file mode 100644 index 00000000..27701588 --- /dev/null +++ b/app/views/accounts/accountables/property/_header.html.erb @@ -0,0 +1,11 @@ +
+ <%= render "accounts/logo", account: account %> + +
+

<%= account.name %>

+ + <% if account.property.address&.line1.present? %> +

<%= account.property.address %>

+ <% end %> +
+
diff --git a/app/views/accounts/accountables/property/_overview.html.erb b/app/views/accounts/accountables/property/_overview.html.erb index f7ede76a..c0fde9f9 100644 --- a/app/views/accounts/accountables/property/_overview.html.erb +++ b/app/views/accounts/accountables/property/_overview.html.erb @@ -27,3 +27,7 @@ <%= account.property.area || t(".unknown") %> <% end %>
+ +
+ <%= link_to "Edit account details", edit_account_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %> +
diff --git a/app/views/accounts/accountables/property/_tabs.html.erb b/app/views/accounts/accountables/property/_tabs.html.erb new file mode 100644 index 00000000..07ff76a6 --- /dev/null +++ b/app/views/accounts/accountables/property/_tabs.html.erb @@ -0,0 +1,15 @@ +<%# locals: (account:, selected_tab:) %> + +
+ <%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %> + <%= render "accounts/accountables/tab", account: account, key: "value", is_selected: selected_tab == "value" %> +
+ +
+ <% case selected_tab %> + <% when nil, "overview" %> + <%= render "accounts/accountables/property/overview", account: account %> + <% when "value" %> + <%= render "accounts/accountables/valuations", account: account %> + <% end %> +
diff --git a/app/views/accounts/accountables/_vehicle.html.erb b/app/views/accounts/accountables/vehicle/_form.html.erb similarity index 100% rename from app/views/accounts/accountables/_vehicle.html.erb rename to app/views/accounts/accountables/vehicle/_form.html.erb diff --git a/app/views/accounts/accountables/vehicle/_header.html.erb b/app/views/accounts/accountables/vehicle/_header.html.erb new file mode 100644 index 00000000..51889135 --- /dev/null +++ b/app/views/accounts/accountables/vehicle/_header.html.erb @@ -0,0 +1 @@ +<%= render "accounts/accountables/default_header", account: account %> diff --git a/app/views/accounts/accountables/vehicle/_overview.html.erb b/app/views/accounts/accountables/vehicle/_overview.html.erb index 2455c67b..7d72431e 100644 --- a/app/views/accounts/accountables/vehicle/_overview.html.erb +++ b/app/views/accounts/accountables/vehicle/_overview.html.erb @@ -31,3 +31,7 @@
<% end %>
+ +
+ <%= link_to "Edit account details", edit_account_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %> +
diff --git a/app/views/accounts/accountables/vehicle/_tabs.html.erb b/app/views/accounts/accountables/vehicle/_tabs.html.erb new file mode 100644 index 00000000..a79c89c2 --- /dev/null +++ b/app/views/accounts/accountables/vehicle/_tabs.html.erb @@ -0,0 +1,15 @@ +<%# locals: (account:, selected_tab:) %> + +
+ <%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %> + <%= render "accounts/accountables/tab", account: account, key: "value", is_selected: selected_tab == "value" %> +
+ +
+ <% case selected_tab %> + <% when nil, "overview" %> + <%= render "accounts/accountables/vehicle/overview", account: account %> + <% when "value" %> + <%= render "accounts/accountables/valuations", account: account %> + <% end %> +
diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index adf33f50..fedf238e 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -20,7 +20,7 @@ <%= render "sync_all_button" %> - <%= link_to new_account_path, + <%= link_to new_account_path(step: "method"), data: { turbo_frame: "modal" }, class: "btn btn--primary flex items-center gap-1" do %> <%= lucide_icon("plus", class: "w-5 h-5") %> diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb index bb544e85..9e8a562f 100644 --- a/app/views/accounts/new.html.erb +++ b/app/views/accounts/new.html.erb @@ -1,58 +1,24 @@

<%= t(".title") %>

<%= modal do %>
- <% if @account.accountable.blank? %> -
- <%= t ".select_accountable_type" %> -
-
- - - <%= render "account_type", type: Depository.new, bg_color: "bg-blue-500/5", text_color: "text-blue-500", icon: "landmark" %> - <%= render "account_type", type: Investment.new, bg_color: "bg-green-500/5", text_color: "text-green-500", icon: "line-chart" %> - <%= render "account_type", type: Crypto.new, bg_color: "bg-orange-500/5", text_color: "text-orange-500", icon: "bitcoin" %> - <%= render "account_type", type: Property.new, bg_color: "bg-pink-500/5", text_color: "text-pink-500", icon: "home" %> - <%= render "account_type", type: Vehicle.new, bg_color: "bg-cyan-500/5", text_color: "text-cyan-500", icon: "car-front" %> - <%= render "account_type", type: CreditCard.new, bg_color: "bg-violet-500/5", text_color: "text-violet-500", icon: "credit-card" %> - <%= render "account_type", type: Loan.new, bg_color: "bg-yellow-500/5", text_color: "text-yellow-500", icon: "hand-coins" %> - <%= render "account_type", type: OtherAsset.new, bg_color: "bg-green-500/5", text_color: "text-green-500", icon: "plus" %> - <%= render "account_type", type: OtherLiability.new, bg_color: "bg-red-500/5", text_color: "text-red-500", icon: "minus" %> -
-
-
-
- Select - <%= lucide_icon("corner-down-left", class: "inline w-3 h-3") %> -
-
- Navigate - <%= lucide_icon("arrow-up", class: "inline w-3 h-3") %> - <%= lucide_icon("arrow-down", class: "inline w-3 h-3") %> -
-
-
- - ESC -
-
- <% elsif params[:step] == 'method' && @account.accountable.present? %> + <% if params[:step] == 'method' %>
- <%= link_to new_account_path, class: "flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 back focus:outline-gray-300 focus:outline" do %> - <%= lucide_icon("arrow-left", class: "text-gray-500 w-5 h-5") %> - <% end %> How would you like to add it?
- <%= render "entry_method", type: @account.accountable, text: "Enter account balance manually", icon: "keyboard" %> - <%= link_to new_import_path, class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %> + + <%= render "entry_method", text: t(".manual_entry"), icon: "keyboard" %> + + <%= link_to new_import_path(import: { type: "AccountImport" }), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %> <%= lucide_icon("sheet", class: "text-gray-500 w-5 h-5") %> - Upload CSV + <%= t(".csv_entry") %> <% end %> - <%= render "entry_method", type: @account.accountable, text: "Securely link bank account with data provider (coming soon)", icon: "link-2", disabled: true %> + + <%= render "entry_method", text: t(".connected_entry"), icon: "link-2", disabled: true %>
@@ -73,13 +39,13 @@
<% else %>
- <%= link_to new_account_path(step: "method", type: params[:type]), class: "flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 focus:outline-gray-300 focus:outline" do %> + <%= link_to new_account_path(step: "method"), class: "flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 focus:outline-gray-300 focus:outline" do %> <%= lucide_icon("arrow-left", class: "text-gray-500 w-5 h-5") %> <% end %> - Add <%= @account.accountable.model_name.human.downcase %> + Add account
-
+
<%= render "form", account: @account, url: new_account_form_url(@account) %>
<% end %> diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index d9041766..c49b64e2 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -1,56 +1,25 @@ <%= turbo_stream_from @account %> -<%= tag.div id: dom_id(@account), class: "space-y-4" do %> -
-
- <%= image_tag account_logo_url(@account), class: "w-8 h-8" %> -
-

<%= @account.name %>

+<% series = @account.series(period: @period) %> +<% trend = series.trend %> - <% if @account.property? && @account.property.address&.line1.present? %> -

<%= @account.property.address %>

- <% end %> -
-
-
+<%= tag.div id: dom_id(@account), class: "space-y-4" do %> +
+ <%= render permitted_accountable_partial(@account, "header"), account: @account %> + +
<%= button_to sync_account_path(@account), method: :post, class: "flex items-center gap-2", title: "Sync Account" do %> <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> <% end %> - <%= contextual_menu do %> -
- <%= link_to edit_account_path(@account), - data: { turbo_frame: :modal }, - class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> - <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> - - <%= t(".edit") %> - <% end %> - - <%= link_to new_import_path, - class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> - <%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %> - - <%= t(".import") %> - <% end %> - - <%= button_to account_path(@account), - method: :delete, - class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg", - data: { - turbo_confirm: { - title: t(".confirm_title"), - body: t(".confirm_body_html"), - accept: t(".confirm_accept", name: @account.name) - } - } do %> - <%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account - <% end %> -
- <% end %> + <%= render "menu", account: @account %>
+ <% if @account.entries.empty? && @account.depository? %> + <%= render "accounts/new_account_setup_bar", account: @account %> + <% end %> + <% if @account.highest_priority_issue %> <%= render partial: "issues/issue", locals: { issue: @account.highest_priority_issue } %> <% end %> @@ -66,47 +35,35 @@ <%= tag.p t(".total_owed"), class: "text-sm font-medium text-gray-500" %> <% end %>
- <%= render "tooltip", account: @account if @account.investment? %> + + <%= render permitted_accountable_partial(@account, "tooltip"), account: @account if @account.investment? %>
+ <%= tag.p format_money(@account.value), class: "text-gray-900 text-3xl font-medium" %> +
- <% if @series.trend.direction.flat? %> + <% if trend.direction.flat? %> <%= tag.span t(".no_change"), class: "text-gray-500" %> <% else %> - <%= tag.span format_money(@series.trend.value), style: "color: #{@trend.color}" %> - <%= tag.span "(#{@trend.percent}%)", style: "color: #{@trend.color}" %> + <%= tag.span format_money(trend.value), style: "color: #{trend.color}" %> + <%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %> <% end %> <%= tag.span period_label(@period), class: "text-gray-500" %>
+ <%= form_with url: account_path(@account), method: :get, data: { controller: "auto-submit-form" } do |form| %> <%= period_select form: form, selected: @period.name %> <% end %>
+
- <%= render partial: "shared/line_chart", locals: { series: @series } %> + <%= render "shared/line_chart", series: @account.series(period: @period) %>
- <% selected_tab = selected_account_tab(@account) %> - <% selected_tab_key = selected_tab[:key] %> - <% selected_tab_partial_path = selected_tab[:partial_path] %> - <% selected_tab_route = selected_tab[:route] %> - -
- <% account_tabs(@account).each do |tab| %> - <%= link_to tab[:label], tab[:path], class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab_key == tab[:key]] %> - <% end %> -
-
- <% if selected_tab_route.present? %> - <%= turbo_frame_tag dom_id(@account, selected_tab_key), src: selected_tab_route do %> - <%= render "account/entries/loading" %> - <% end %> - <% else %> - <%= render selected_tab_partial_path, account: @account %> - <% end %> + <%= render permitted_accountable_partial(@account, "tabs"), account: @account, selected_tab: params[:tab] %>
<% end %> diff --git a/app/views/accounts/summary.html.erb b/app/views/accounts/summary.html.erb index feffec64..db9ab322 100644 --- a/app/views/accounts/summary.html.erb +++ b/app/views/accounts/summary.html.erb @@ -41,7 +41,7 @@

Assets

- <%= link_to new_account_path, class: "btn btn--secondary flex items-center gap-1", data: { turbo_frame: "modal" } do %> + <%= link_to new_account_path(step: "method"), class: "btn btn--secondary flex items-center gap-1", data: { turbo_frame: "modal" } do %> <%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>

<%= t(".new") %>

<% end %> @@ -66,7 +66,7 @@

Liabilities

- <%= link_to new_account_path, class: "btn btn--secondary flex items-center gap-1", data: { turbo_frame: "modal" } do %> + <%= link_to new_account_path(step: "method"), class: "btn btn--secondary flex items-center gap-1", data: { turbo_frame: "modal" } do %> <%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>

<%= t(".new") %>

<% end %> diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index b0005dc8..7cbcf685 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -15,14 +15,14 @@

<%= t(".sources") %>

  • - <% if @pending_import.present? %> + <% if @pending_import.present? && (params[:type].nil? || params[:type] == @pending_import.type) %> <%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
    <%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
    - <%= t(".resume") %> + <%= t(".resume", type: @pending_import.type.titleize) %>
    <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> @@ -33,75 +33,84 @@
<% end %> -
  • - <%= button_to imports_path(import: { type: "TransactionImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> -
    -
    - <%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %> + + <% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == "TransactionImport") %> +
  • + <%= button_to imports_path(import: { type: "TransactionImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> +
    +
    + <%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %> +
    + + <%= t(".import_transactions") %> +
    - - <%= t(".import_transactions") %> - + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> + <% end %> + +
    +
    - <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> - <% end %> +
  • + <% end %> -
    -
    -
    - - -
  • - <%= button_to imports_path(import: { type: "TradeImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> -
    -
    - <%= lucide_icon("square-percent", class: "w-5 h-5 text-yellow-500") %> + <% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == "TradeImport") %> +
  • + <%= button_to imports_path(import: { type: "TradeImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> +
    +
    + <%= lucide_icon("square-percent", class: "w-5 h-5 text-yellow-500") %> +
    + + <%= t(".import_portfolio") %> +
    - - <%= t(".import_portfolio") %> - + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> + <% end %> + +
    +
    - <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> - <% end %> +
  • + <% end %> -
    -
    -
    - - -
  • - <%= button_to imports_path(import: { type: "AccountImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> -
    -
    - <%= lucide_icon("building", class: "w-5 h-5 text-violet-500") %> + <% if params[:type].nil? || params[:type] == "AccountImport" %> +
  • + <%= button_to imports_path(import: { type: "AccountImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> +
    +
    + <%= lucide_icon("building", class: "w-5 h-5 text-violet-500") %> +
    + + <%= t(".import_accounts") %> +
    - - <%= t(".import_accounts") %> - + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> + <% end %> + +
    +
    - <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> - <% end %> +
  • + <% end %> -
    -
    -
    - + <% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == "MintImport" || params[:type] == "TransactionImport") %> +
  • + <%= button_to imports_path(import: { type: "MintImport" }), class: "flex items-center justify-between p-4 group w-full", data: { turbo: false } do %> +
    + <%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %> + + <%= t(".import_mint") %> + +
    + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> + <% end %> -
  • - <%= button_to imports_path(import: { type: "MintImport" }), class: "flex items-center justify-between p-4 group w-full", data: { turbo: false } do %> -
    - <%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %> - - <%= t(".import_mint") %> - +
    +
    - <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> - <% end %> - -
    -
    -
    -
  • + + <% end %>
    diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index e62a104a..e038247e 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -103,7 +103,7 @@ <%= period_select form: form, selected: "last_30_days", classes: "w-full border-none pl-2 pr-7 text-xs bg-transparent gap-1 cursor-pointer font-semibold tracking-wide focus:outline-none focus:ring-0" %> <% end %>
    - <%= link_to new_account_path, id: "sidebar-new-account", class: "block hover:bg-gray-100 font-semibold text-gray-900 flex items-center rounded", title: t(".new_account"), data: { turbo_frame: "modal" } do %> + <%= link_to new_account_path(step: "method"), id: "sidebar-new-account", class: "block hover:bg-gray-100 font-semibold text-gray-900 flex items-center rounded p-1", title: t(".new_account"), data: { turbo_frame: "modal" } do %> <%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %> <% end %>
    @@ -114,7 +114,7 @@ <%= render "accounts/account_list", group: group %> <% end %> <% else %> - <%= link_to new_account_path, class: "flex items-center min-h-10 gap-4 px-3 py-2 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %> + <%= link_to new_account_path(step: "method"), class: "flex items-center min-h-10 gap-4 px-3 py-2 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %> <%= lucide_icon("plus", class: "w-5 h-5") %> <%= tag.p t(".new_account") %> <% end %> diff --git a/app/views/pages/_account_group_disclosure.erb b/app/views/pages/_account_group_disclosure.erb index 6fc6f7e9..e3e1128d 100644 --- a/app/views/pages/_account_group_disclosure.erb +++ b/app/views/pages/_account_group_disclosure.erb @@ -25,7 +25,7 @@ <% accountable_group.children.map do |account_value_node| %>
    - <%= image_tag account_logo_url(account_value_node.original), class: "w-8 h-8" %> + <%= render "accounts/logo", account: account_value_node.original, size: "sm" %>

    <%= account_value_node.name %>

    diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 9a70d493..b25e8497 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -17,7 +17,7 @@
    <% end %> - <%= link_to new_account_path, class: "flex items-center gap-1 btn btn--primary", data: { turbo_frame: "modal" } do %> + <%= link_to new_account_path(step: "method"), class: "flex items-center gap-1 btn btn--primary", data: { turbo_frame: "modal" } do %> <%= lucide_icon("plus", class: "w-5 h-5") %> <%= t(".new") %> <% end %> @@ -70,10 +70,10 @@ data-time-series-chart-use-labels-value="false" data-time-series-chart-use-tooltip-value="false">
    -
    +
    <% @top_earners.first(3).each do |account| %> <%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25", data: { controller: "tooltip" } do %> - <%= image_tag account_logo_url(account), class: "w-5 h-5" %> + <%= render "accounts/logo", account: account, size: "sm" %> +<%= Money.new(account.income, account.currency) %> <%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %> <% end %> @@ -103,12 +103,12 @@ data-time-series-chart-use-labels-value="false" data-time-series-chart-use-tooltip-value="false">
    -
    +
    <% @top_spenders.first(3).each do |account| %> <%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25", data: { controller: "tooltip" } do %> - <%= image_tag account_logo_url(account), class: "w-5 h-5" %> + <%= render "accounts/logo", account: account, size: "sm" %> -<%= Money.new(account.spending, account.currency) %> - <%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %> + <%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %> <% end %> <% end %> <% if @top_spenders.count > 3 %> @@ -141,9 +141,9 @@ <% @top_savers.first(3).each do |account| %> <% unless account.savings_rate.infinite? %> <%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25", data: { controller: "tooltip" } do %> - <%= image_tag account_logo_url(account), class: "w-5 h-5" %> + <%= render "accounts/logo", account: account, size: "sm" %> <%= account.savings_rate > 0 ? "+" : "-" %><%= number_to_percentage(account.savings_rate.abs * 100, precision: 2) %> - <%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %> + <%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %> <% end %> <% end %> <% end %> diff --git a/app/views/shared/_circle_logo.html.erb b/app/views/shared/_circle_logo.html.erb index 328cb476..17f1948a 100644 --- a/app/views/shared/_circle_logo.html.erb +++ b/app/views/shared/_circle_logo.html.erb @@ -2,7 +2,7 @@ <% size_classes = { "sm" => "w-6 h-6", - "md" => "w-8 h-8", + "md" => "w-9 h-9", "lg" => "w-10 h-10", "full" => "w-full h-full" } %> diff --git a/app/views/shared/_no_account_empty_state.html.erb b/app/views/shared/_no_account_empty_state.html.erb index cfb79003..b4eda597 100644 --- a/app/views/shared/_no_account_empty_state.html.erb +++ b/app/views/shared/_no_account_empty_state.html.erb @@ -7,7 +7,7 @@

    <%= t(".no_account_subtitle") %>

    - <%= link_to new_account_path, class: "btn btn--primary flex items-center gap-1", data: { turbo_frame: "modal" } do %> + <%= link_to new_account_path(step: "method"), class: "btn btn--primary flex items-center gap-1", data: { turbo_frame: "modal" } do %> <%= lucide_icon("plus", class: "w-5 h-5") %> <%= t(".new_account") %> <% end %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 71a47b67..78648c48 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -23,6 +23,74 @@ ], "note": "" }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "42595161ffdc9ce9a10c4ba2a75fd2bb668e273bc4e683880b0ea906d0bd28f8", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/views/accounts/show.html.erb", + "line": 8, + "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => permitted_accountable_partial(Current.family.accounts.find(params[:id]), \"header\"), { :account => Current.family.accounts.find(params[:id]) })", + "render_path": [ + { + "type": "controller", + "class": "AccountsController", + "method": "show", + "line": 39, + "file": "app/controllers/accounts_controller.rb", + "rendered": { + "name": "accounts/show", + "file": "app/views/accounts/show.html.erb" + } + } + ], + "location": { + "type": "template", + "template": "accounts/show" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "cwe_id": [ + 22 + ], + "note": "" + }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "a35b18785608dbdf35607501363573576ed8c304039f8387997acd1408ca1025", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/views/accounts/show.html.erb", + "line": 35, + "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => permitted_accountable_partial(Current.family.accounts.find(params[:id]), \"tooltip\"), { :account => Current.family.accounts.find(params[:id]) })", + "render_path": [ + { + "type": "controller", + "class": "AccountsController", + "method": "show", + "line": 39, + "file": "app/controllers/accounts_controller.rb", + "rendered": { + "name": "accounts/show", + "file": "app/views/accounts/show.html.erb" + } + } + ], + "location": { + "type": "template", + "template": "accounts/show" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "cwe_id": [ + 22 + ], + "note": "" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, @@ -38,7 +106,7 @@ "type": "controller", "class": "PagesController", "method": "changelog", - "line": 35, + "line": 36, "file": "app/controllers/pages_controller.rb", "rendered": { "name": "pages/changelog", @@ -60,19 +128,19 @@ { "warning_type": "Dynamic Render Path", "warning_code": 15, - "fingerprint": "b7a59d6dd91f4d30873b271659636c7975e25b47f436b4f03900a08809af2e92", + "fingerprint": "c5c512a13c34c9696024bd4e2367a657a5c140b5b6a0f5c352e9b69965f63e1b", "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/accounts/show.html.erb", - "line": 105, + "line": 63, "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(action => selected_account_tab(Current.family.accounts.find(params[:id]))[:partial_path], { :account => Current.family.accounts.find(params[:id]) })", + "code": "render(action => permitted_accountable_partial(Current.family.accounts.find(params[:id]), \"tabs\"), { :account => Current.family.accounts.find(params[:id]), :selected_tab => params[:tab] })", "render_path": [ { "type": "controller", "class": "AccountsController", "method": "show", - "line": 38, + "line": 39, "file": "app/controllers/accounts_controller.rb", "rendered": { "name": "accounts/show", @@ -98,7 +166,7 @@ "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/import/configurations/show.html.erb", - "line": 13, + "line": 15, "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(partial => permitted_import_configuration_path(Current.family.imports.find(params[:import_id])), { :locals => ({ :import => Current.family.imports.find(params[:import_id]) }) })", "render_path": [ @@ -126,6 +194,6 @@ "note": "" } ], - "updated": "2024-09-28 13:27:09 -0400", + "updated": "2024-10-17 11:30:15 -0400", "brakeman_version": "6.2.1" } diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index cb6668ef..0e12a0f5 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -1,37 +1,88 @@ --- en: accounts: + sync_all_button: + sync: Sync all account: has_issues: Issue detected. troubleshoot: Troubleshoot + account_list: + new_account: "New %{type}" + empty: + no_accounts: No accounts yet + empty_message: Add an account either via connection, importing or entering manually. + new_account: New account + form: + name_label: Account name + name_placeholder: Example account name + institution: Financial institution + ungrouped: "(none)" + balance: Today's balance + accountable_type: Account type + type_prompt: Select a type + header: + accounts: Accounts + manage: Manage accounts + new: New account + institution_accounts: + add_account_to_institution: Add new account + has_issues: Issue detected, see accounts + syncing: Syncing... + status: "Last synced %{last_synced_at} ago" + status_never: Requires data sync + edit: Edit institution + delete: Delete institution + confirm_title: Delete financial institution? + confirm_body: Don't worry, none of the accounts within this institution will be affected by this deletion. Accounts will be ungrouped and all historical data will remain intact. + confirm_accept: Delete institution + new_account: Add account + institutionless_accounts: + other_accounts: Other accounts + menu: + edit: Edit + import: Import transactions + confirm_title: Delete account? + confirm_body_html: "

    By deleting this account, you will erase its value history, affecting various aspects of your overall account. This action will have a direct impact on your net worth calculations and the account graphs.


    After deletion, there is no way you'll be able to restore the account information because you'll need to add it as a new account.

    " + confirm_accept: 'Delete "%{name}"' accountables: - investment: - prompt: Select a subtype - none: None credit_card: - annual_fee: Annual fee - annual_fee_placeholder: '99' - apr: APR - apr_placeholder: '15.99' - available_credit: Available credit - available_credit_placeholder: '10000' - expiration_date: Expiration date - minimum_payment: Minimum payment - minimum_payment_placeholder: '100' + form: + available_credit: Available credit + available_credit_placeholder: '10000' + minimum_payment: Minimum payment + minimum_payment_placeholder: '100' + apr: APR + apr_placeholder: '15.99' + expiration_date: Expiration date + annual_fee: Annual fee + annual_fee_placeholder: '99' overview: amount_owed: Amount Owed - annual_fee: Annual Fee - apr: APR available_credit: Available Credit - expiration_date: Expiration Date minimum_payment: Minimum Payment + apr: APR + expiration_date: Expiration Date + annual_fee: Annual Fee unknown: Unknown depository: - prompt: Select a subtype - none: None + form: + none: None + prompt: Select a subtype + investment: + form: + none: None + prompt: Select a subtype + tooltip: + cash: Cash + holdings: Holdings + total_value_tooltip: The total value is the sum of cash balance and your holdings value, minus margin loans. loan: - interest_rate: Interest rate - interest_rate_placeholder: '5.25' + form: + interest_rate: Interest rate + interest_rate_placeholder: '5.25' + rate_type: Rate type + term_months: Term (months) + term_months_placeholder: '360' overview: interest_rate: Interest Rate monthly_payment: Monthly Payment @@ -41,18 +92,19 @@ en: term: Term type: Type unknown: Unknown - rate_type: Rate type - term_months: Term (months) - term_months_placeholder: '360' property: - additional_info: Additional info - area_unit: Area unit - area_value: Area value - city: City - country: Country - line1: Address line 1 - line2: Address line 2 - optional: optional + form: + additional_info: Additional info + area_unit: Area unit + area_value: Area value + city: City + country: Country + line1: Address line 1 + line2: Address line 2 + optional: optional + postal_code: Postal code + state: State + year_built: Year built overview: living_area: Living Area market_value: Market Value @@ -60,17 +112,17 @@ en: trend: Trend unknown: Unknown year_built: Year Built - postal_code: Postal code - state: State - year_built: Year built vehicle: - make: Make - make_placeholder: Toyota - mileage: Mileage - mileage_placeholder: '15000' - mileage_unit: Unit - model: Model - model_placeholder: Camry + form: + make: Make + make_placeholder: Toyota + mileage: Mileage + mileage_placeholder: '15000' + mileage_unit: Unit + model: Model + model_placeholder: Camry + year: Year + year_placeholder: '2023' overview: current_price: Current Price make_model: Make & Model @@ -79,70 +131,22 @@ en: trend: Trend unknown: Unknown year: Year - year: Year - year_placeholder: '2023' - create: - success: New account created successfully - destroy: - success: Account deleted successfully edit: - edit: Edit %{account} - empty: - empty_message: Add an account either via connection, importing or entering manually. - new_account: New account - no_accounts: No accounts yet - form: - institution: Financial institution - ungrouped: "(none)" - balance: Current balance - name_label: Account name - name_placeholder: Example account name - start_balance: Start balance (optional) - start_date: Start date (optional) - header: - accounts: Accounts - manage: Manage accounts - new: New account + edit: "Edit %{account}" index: accounts: Accounts add_institution: Add institution new_account: New account - institution_accounts: - add_account_to_institution: Add new account - confirm_accept: Delete institution - confirm_body: Don't worry, none of the accounts within this institution will - be affected by this deletion. Accounts will be ungrouped and all historical - data will remain intact. - confirm_title: Delete financial institution? - delete: Delete institution - edit: Edit institution - has_issues: Issue detected, see accounts - new_account: Add account - status: Last synced %{last_synced_at} ago - status_never: Requires data sync - syncing: Syncing... - institutionless_accounts: - other_accounts: Other accounts new: - select_accountable_type: What would you like to add? title: Add an account + manual_entry: Enter account manually + csv_entry: Import accounts CSV + connected_entry: Securely link account with Plaid (coming soon) show: cash: Cash - confirm_accept: Delete "%{name}" - confirm_body_html: "

    By deleting this account, you will erase its value history, - affecting various aspects of your overall account. This action will have a - direct impact on your net worth calculations and the account graphs.


    After deletion, there is no way you'll be able to restore the account - information because you'll need to add it as a new account.

    " - confirm_title: Delete account? - edit: Edit holdings: Holdings - import: Import transactions no_change: No change overview: Overview - sync_message_missing_rates: Since exchange rates haven't been synced, balance - graphs may not reflect accurate values. - sync_message_unknown_error: An error has occurred during the sync. total_owed: Total Owed total_value: Total Value trades: Transactions @@ -151,21 +155,17 @@ en: summary: new: New no_assets: No assets found - no_assets_description: Add an asset either via connection, importing or entering - manually. + no_assets_description: Add an asset either via connection, importing or entering manually. no_liabilities: No liabilities found - no_liabilities_description: Add a liability either via connection, importing - or entering manually. - sync_all: - button_text: Sync all - success: Successfully queued accounts for syncing. - tooltip: - cash: Cash - holdings: Holdings - total_value_tooltip: The total value is the sum of cash balance and your holdings - value, minus margin loans. + no_liabilities_description: Add a liability either via connection, importing or entering manually. + create: + success: New account created successfully + destroy: + success: Account deleted successfully update: success: Account updated + sync_all: + success: Successfully queued accounts for syncing. credit_cards: create: success: Credit card created successfully diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index 7227a9f2..e5fd7920 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -69,7 +69,7 @@ en: import_mint: Import from Mint import_portfolio: Import investments import_transactions: Import transactions - resume: Resume latest import + resume: Resume %{type} sources: Sources title: New CSV Import ready: diff --git a/config/routes.rb b/config/routes.rb index 2cfa2b2d..079943a7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,8 +61,6 @@ Rails.application.routes.draw do end scope module: :account do - resource :logo, only: :show - resources :holdings, only: %i[index new show destroy] resources :cashes, only: :index diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index d4c1be45..e4c3bf36 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -11,12 +11,6 @@ class ApplicationHelperTest < ActionView::TestCase assert_equal "Test Header Title", content_for(:header_title) end - test "#permitted_accountable_partial(accountable_type)" do - assert_equal "account", permitted_accountable_partial("Account") - assert_equal "user", permitted_accountable_partial("User") - assert_equal "admin_user", permitted_accountable_partial("AdminUser") - end - def setup @account1 = Account.new(currency: "USD", balance: 1) @account2 = Account.new(currency: "USD", balance: 2) diff --git a/test/models/account/balance/syncer_test.rb b/test/models/account/balance/syncer_test.rb index 9bfeffcb..748b3e6a 100644 --- a/test/models/account/balance/syncer_test.rb +++ b/test/models/account/balance/syncer_test.rb @@ -35,15 +35,6 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase assert_equal [ 19600, 19500, 19500, 20000, 20000, 20000 ], @account.balances.chronological.map(&:balance) end - test "syncs account with trades only" do - aapl = securities(:aapl) - create_trade(aapl, account: @investment_account, date: 1.day.ago.to_date, qty: 10) - - run_sync_for @investment_account - - assert_equal [ 52140, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance) - end - test "syncs account with valuations and transactions" do create_valuation(account: @account, date: 5.days.ago.to_date, amount: 20000) create_transaction(account: @account, date: 3.days.ago.to_date, amount: -500) diff --git a/test/system/.keep b/test/system/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb index 1b7248c0..97f3fd91 100644 --- a/test/system/accounts_test.rb +++ b/test/system/accounts_test.rb @@ -80,19 +80,15 @@ class AccountsTest < ApplicationSystemTestCase end def assert_account_created(accountable_type, &block) - click_link humanized_accountable(accountable_type) - click_link "Enter account balance manually" + click_link "Enter account manually" account_name = "[system test] #{accountable_type} Account" + select accountable_type.titleize, from: "Account type" fill_in "Account name", with: account_name fill_in "account[balance]", with: 100.99 - fill_in "Start date (optional)", with: 10.days.ago.to_date - fill_in "account[start_balance]", with: 95.25 - yield if block_given? - - click_button "Add #{humanized_accountable(accountable_type).downcase}" + click_button "Create Account" find("details", text: humanized_accountable(accountable_type)).click assert_text account_name @@ -107,8 +103,10 @@ class AccountsTest < ApplicationSystemTestCase click_on "Edit" end + yield if block_given? + fill_in "Account name", with: "Updated account name" - click_button "Update #{humanized_accountable(accountable_type).downcase}" + click_button "Update Account" assert_selector "h2", text: "Updated account name" end diff --git a/test/system/tooltips_test.rb b/test/system/tooltips_test.rb deleted file mode 100644 index 7666269c..00000000 --- a/test/system/tooltips_test.rb +++ /dev/null @@ -1,28 +0,0 @@ -require "application_system_test_case" - -class TooltipsTest < ApplicationSystemTestCase - include ActionView::Helpers::NumberHelper - include ApplicationHelper - - setup do - sign_in @user = users(:family_admin) - @account = accounts(:investment) - end - - test "can see account information tooltip" do - visit account_path(@account) - tooltip_element = find('[data-controller="tooltip"]') - tooltip_element.hover - tooltip_contents = find('[data-tooltip-target="tooltip"]') - assert tooltip_contents.visible? - within tooltip_contents do - assert_text I18n.t("accounts.tooltip.total_value_tooltip") - assert_text I18n.t("accounts.tooltip.holdings") - assert_text format_money(@account.investment.holdings_value, precision: 0) - assert_text I18n.t("accounts.tooltip.cash") - assert_text format_money(@account.balance_money, precision: 0) - end - find("body").click - assert find('[data-tooltip-target="tooltip"]', visible: false) - end -end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index 541a445c..c9f7835b 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -62,6 +62,6 @@ class TradesTest < ApplicationSystemTestCase end def visit_account_trades - visit account_url(@account, tab: "trades") + visit account_url(@account, tab: "transactions") end end diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb index 4544c6c4..956e800b 100644 --- a/test/system/transactions_test.rb +++ b/test/system/transactions_test.rb @@ -156,6 +156,7 @@ class TransactionsTest < ApplicationSystemTestCase test "can create deposit transaction for investment account" do investment_account = accounts(:investment) + investment_account.entries.create!(name: "Investment account", date: Date.current, amount: 1000, currency: "USD", entryable: Account::Transaction.new) transfer_date = Date.current visit account_path(investment_account) click_on "New transaction" From 263d65ea7ede093b8bd906ea2ef11e3d6f2e6566 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 18 Oct 2024 17:18:54 -0400 Subject: [PATCH 022/736] Basic account onboarding (#1328) * Basic account onboarding * Cleanup --- app/controllers/accounts_controller.rb | 2 +- app/controllers/credit_cards_controller.rb | 2 +- app/controllers/loans_controller.rb | 2 +- app/controllers/properties_controller.rb | 2 +- app/controllers/vehicles_controller.rb | 2 +- app/models/account.rb | 3 ++ app/models/account/balance/calculator.rb | 4 +- app/views/accounts/_form.html.erb | 4 ++ .../accounts/_new_account_setup_bar.html.erb | 8 ---- .../accountables/_default_tabs.html.erb | 14 +++++-- .../accountables/_value_onboarding.html.erb | 16 +++++++ .../accountables/credit_card/_tabs.html.erb | 35 ++++++++++------ .../accountables/crypto/_tabs.html.erb | 2 +- .../accountables/depository/_tabs.html.erb | 2 +- .../accountables/investment/_tabs.html.erb | 42 +++++++++++-------- .../accounts/accountables/loan/_tabs.html.erb | 34 +++++++++------ app/views/accounts/show.html.erb | 4 -- app/views/shared/_text_tooltip.erb | 2 +- config/locales/views/accounts/en.yml | 2 + db/migrate/20241018201653_add_account_mode.rb | 5 +++ db/schema.rb | 6 +-- test/fixtures/accounts.yml | 8 ++++ test/models/account/balance/syncer_test.rb | 13 +++++- test/system/accounts_test.rb | 5 ++- 24 files changed, 146 insertions(+), 73 deletions(-) delete mode 100644 app/views/accounts/_new_account_setup_bar.html.erb create mode 100644 app/views/accounts/accountables/_value_onboarding.html.erb create mode 100644 db/migrate/20241018201653_add_account_mode.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 033ed3b8..21419365 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -79,6 +79,6 @@ class AccountsController < ApplicationController end def account_params - params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id) + params.require(:account).permit(:name, :accountable_type, :mode, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id) end end diff --git a/app/controllers/credit_cards_controller.rb b/app/controllers/credit_cards_controller.rb index 5854c9fa..bb416ce7 100644 --- a/app/controllers/credit_cards_controller.rb +++ b/app/controllers/credit_cards_controller.rb @@ -27,7 +27,7 @@ class CreditCardsController < ApplicationController def account_params params.require(:account) .permit( - :name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type, + :name, :balance, :institution_id, :mode, :start_date, :start_balance, :currency, :accountable_type, accountable_attributes: [ :id, :available_credit, diff --git a/app/controllers/loans_controller.rb b/app/controllers/loans_controller.rb index b084e566..1b704290 100644 --- a/app/controllers/loans_controller.rb +++ b/app/controllers/loans_controller.rb @@ -27,7 +27,7 @@ class LoansController < ApplicationController def account_params params.require(:account) .permit( - :name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type, + :name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type, accountable_attributes: [ :id, :rate_type, diff --git a/app/controllers/properties_controller.rb b/app/controllers/properties_controller.rb index 71d46f0e..e37344d4 100644 --- a/app/controllers/properties_controller.rb +++ b/app/controllers/properties_controller.rb @@ -27,7 +27,7 @@ class PropertiesController < ApplicationController def account_params params.require(:account) .permit( - :name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type, + :name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type, accountable_attributes: [ :id, :year_built, diff --git a/app/controllers/vehicles_controller.rb b/app/controllers/vehicles_controller.rb index e9822b9b..fe41a42f 100644 --- a/app/controllers/vehicles_controller.rb +++ b/app/controllers/vehicles_controller.rb @@ -27,7 +27,7 @@ class VehiclesController < ApplicationController def account_params params.require(:account) .permit( - :name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type, + :name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type, accountable_attributes: [ :id, :make, diff --git a/app/models/account.rb b/app/models/account.rb index 9dd49d60..c2c91f37 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,7 +1,10 @@ class Account < ApplicationRecord + VALUE_MODES = %w[balance transactions] + include Syncable, Monetizable, Issuable validates :name, :balance, :currency, presence: true + validates :mode, inclusion: { in: VALUE_MODES }, allow_nil: true belongs_to :family belongs_to :institution, optional: true diff --git a/app/models/account/balance/calculator.rb b/app/models/account/balance/calculator.rb index 68d85853..04dfb381 100644 --- a/app/models/account/balance/calculator.rb +++ b/app/models/account/balance/calculator.rb @@ -23,11 +23,11 @@ class Account::Balance::Calculator attr_reader :account, :sync_start_date def find_start_balance_for_partial_sync - account.balances.find_by(currency: account.currency, date: sync_start_date - 1.day).balance + account.balances.find_by(currency: account.currency, date: sync_start_date - 1.day)&.balance end def find_start_balance_for_full_sync(cached_entries) - account.balance + net_entry_flows(cached_entries) + account.balance + net_entry_flows(cached_entries.select { |e| e.account_transaction? }) end def calculate_balance_for_date(date, entries:, prior_balance:) diff --git a/app/views/accounts/_form.html.erb b/app/views/accounts/_form.html.erb index e85448e0..65dee8df 100644 --- a/app/views/accounts/_form.html.erb +++ b/app/views/accounts/_form.html.erb @@ -2,6 +2,10 @@ <%= styled_form_with model: account, url: url, scope: :account, class: "flex flex-col gap-4 justify-between grow", data: { turbo: false } do |f| %>
    + <% unless account.new_record? %> + <%= f.select :mode, Account::VALUE_MODES.map { |mode| [mode.titleize, mode] }, { label: t(".mode"), prompt: t(".mode_prompt") } %> + <% end %> + <%= f.select :accountable_type, Accountable::TYPES.map { |type| [type.titleize, type] }, { label: t(".accountable_type"), prompt: t(".type_prompt") }, required: true, autofocus: true %> <%= f.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label") %> diff --git a/app/views/accounts/_new_account_setup_bar.html.erb b/app/views/accounts/_new_account_setup_bar.html.erb deleted file mode 100644 index 16b6e430..00000000 --- a/app/views/accounts/_new_account_setup_bar.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -
    -

    Setup your new account

    - -
    - <%= link_to "Track balances only", new_account_valuation_path(@account), class: "btn btn--ghost", data: { turbo_frame: dom_id(@account.entries.account_valuations.new) } %> - <%= link_to "Add your first transaction", new_transaction_path(account_id: @account.id), class: "btn btn--primary", data: { turbo_frame: :modal } %> -
    -
    diff --git a/app/views/accounts/accountables/_default_tabs.html.erb b/app/views/accounts/accountables/_default_tabs.html.erb index 0a2f355a..9b6274ef 100644 --- a/app/views/accounts/accountables/_default_tabs.html.erb +++ b/app/views/accounts/accountables/_default_tabs.html.erb @@ -1,7 +1,13 @@ -<%# locals: (account:) %> +<%# locals: (account:, selected_tab:) %> -<% if account.transactions.any? %> - <%= render "accounts/accountables/transactions", account: account %> +<% if account.mode.nil? %> + <%= render "accounts/accountables/value_onboarding", account: account %> <% else %> - <%= render "accounts/accountables/valuations", account: account %> +
    + <% if account.mode == "transactions" %> + <%= render "accounts/accountables/transactions", account: account %> + <% else %> + <%= render "accounts/accountables/valuations", account: account %> + <% end %> +
    <% end %> diff --git a/app/views/accounts/accountables/_value_onboarding.html.erb b/app/views/accounts/accountables/_value_onboarding.html.erb new file mode 100644 index 00000000..26b20b6a --- /dev/null +++ b/app/views/accounts/accountables/_value_onboarding.html.erb @@ -0,0 +1,16 @@ +<%# locals: (account:) %> + +
    +

    How would you like to track value for this account?

    +

    We will use this to determine what data to show for this account.

    +
    + <%= button_to account_path(account, { account: { mode: "balance" } }), method: :put, class: "btn btn--outline", data: { controller: "tooltip", turbo: false } do %> + <%= render partial: "shared/text_tooltip", locals: { tooltip_text: "Choose this if you only need to track the historical value of this account over time and do not plan on importing any transactions." } %> + Balance only + <% end %> + <%= button_to account_path(account, { account: { mode: "transactions" } }), method: :put, class: "btn btn--primary", data: { controller: "tooltip", turbo: false } do %> + <%= render partial: "shared/text_tooltip", locals: { tooltip_text: "Choose this if you plan on importing transactions into this account for budgeting and other analytics." } %> + Transactions + <% end %> +
    +
    \ No newline at end of file diff --git a/app/views/accounts/accountables/credit_card/_tabs.html.erb b/app/views/accounts/accountables/credit_card/_tabs.html.erb index 781cc6d0..d08ff8e2 100644 --- a/app/views/accounts/accountables/credit_card/_tabs.html.erb +++ b/app/views/accounts/accountables/credit_card/_tabs.html.erb @@ -1,15 +1,26 @@ <%# locals: (account:, selected_tab:) %> -
    - <%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %> - <%= render "accounts/accountables/tab", account: account, key: "transactions", is_selected: selected_tab == "transactions" %> -
    +<% if account.mode.nil? %> + <%= render "accounts/accountables/value_onboarding", account: account %> +<% else %> +
    + <%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %> -
    - <% case selected_tab %> - <% when nil, "overview" %> - <%= render "accounts/accountables/credit_card/overview", account: account %> - <% when "transactions" %> - <%= render "accounts/accountables/transactions", account: account %> - <% end %> -
    + <% if account.mode == "transactions" %> + <%= render "accounts/accountables/tab", account: account, key: "transactions", is_selected: selected_tab == "transactions" %> + <% else %> + <%= render "accounts/accountables/tab", account: account, key: "value", is_selected: selected_tab == "value" %> + <% end %> +
    + +
    + <% case selected_tab %> + <% when nil, "overview" %> + <%= render "accounts/accountables/credit_card/overview", account: account %> + <% when "transactions" %> + <%= render "accounts/accountables/transactions", account: account %> + <% when "value" %> + <%= render "accounts/accountables/valuations", account: account %> + <% end %> +
    +<% end %> diff --git a/app/views/accounts/accountables/crypto/_tabs.html.erb b/app/views/accounts/accountables/crypto/_tabs.html.erb index 381f7273..be74694e 100644 --- a/app/views/accounts/accountables/crypto/_tabs.html.erb +++ b/app/views/accounts/accountables/crypto/_tabs.html.erb @@ -1 +1 @@ -<%= render "accounts/accountables/default_tabs", account: account %> +<%= render "accounts/accountables/default_tabs", account: account, selected_tab: selected_tab %> diff --git a/app/views/accounts/accountables/depository/_tabs.html.erb b/app/views/accounts/accountables/depository/_tabs.html.erb index 381f7273..be74694e 100644 --- a/app/views/accounts/accountables/depository/_tabs.html.erb +++ b/app/views/accounts/accountables/depository/_tabs.html.erb @@ -1 +1 @@ -<%= render "accounts/accountables/default_tabs", account: account %> +<%= render "accounts/accountables/default_tabs", account: account, selected_tab: selected_tab %> diff --git a/app/views/accounts/accountables/investment/_tabs.html.erb b/app/views/accounts/accountables/investment/_tabs.html.erb index 8da4905d..bff1d3ce 100644 --- a/app/views/accounts/accountables/investment/_tabs.html.erb +++ b/app/views/accounts/accountables/investment/_tabs.html.erb @@ -1,28 +1,34 @@ <%# locals: (account:, selected_tab:) %> -<% if account.entries.account_trades.any? || account.entries.account_transactions.any? %> +<% if account.mode.nil? %> + <%= render "accounts/accountables/value_onboarding", account: account %> +<% else %>
    - <%= render "accounts/accountables/tab", account: account, key: "holdings", is_selected: selected_tab.in?([nil, "holdings"]) %> - <%= render "accounts/accountables/tab", account: account, key: "cash", is_selected: selected_tab == "cash" %> - <%= render "accounts/accountables/tab", account: account, key: "transactions", is_selected: selected_tab == "transactions" %> + <% if account.mode == "transactions" %> + <%= render "accounts/accountables/tab", account: account, key: "holdings", is_selected: selected_tab.in?([nil, "holdings"]) %> + <%= render "accounts/accountables/tab", account: account, key: "cash", is_selected: selected_tab == "cash" %> + <%= render "accounts/accountables/tab", account: account, key: "transactions", is_selected: selected_tab == "transactions" %> + <% end %>
    - <% case selected_tab %> - <% when nil, "holdings" %> - <%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account) do %> - <%= render "account/entries/loading" %> - <% end %> - <% when "cash" %> - <%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account) do %> - <%= render "account/entries/loading" %> - <% end %> - <% when "transactions" %> - <%= turbo_frame_tag dom_id(account, :trades), src: account_trades_path(account) do %> - <%= render "account/entries/loading" %> + <% if account.mode == "transactions" %> + <% case selected_tab %> + <% when nil, "holdings" %> + <%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account) do %> + <%= render "account/entries/loading" %> + <% end %> + <% when "cash" %> + <%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account) do %> + <%= render "account/entries/loading" %> + <% end %> + <% when "transactions" %> + <%= turbo_frame_tag dom_id(account, :trades), src: account_trades_path(account) do %> + <%= render "account/entries/loading" %> + <% end %> <% end %> + <% else %> + <%= render "accounts/accountables/valuations", account: account %> <% end %>
    -<% else %> - <%= render "accounts/accountables/valuations", account: account %> <% end %> diff --git a/app/views/accounts/accountables/loan/_tabs.html.erb b/app/views/accounts/accountables/loan/_tabs.html.erb index 3ed1efcf..8c5ca4fe 100644 --- a/app/views/accounts/accountables/loan/_tabs.html.erb +++ b/app/views/accounts/accountables/loan/_tabs.html.erb @@ -1,15 +1,25 @@ <%# locals: (account:, selected_tab:) %> -
    - <%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %> - <%= render "accounts/accountables/tab", account: account, key: "value", is_selected: selected_tab == "value" %> -
    +<% if account.mode.nil? %> + <%= render "accounts/accountables/value_onboarding", account: account %> +<% else %> +
    + <%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %> + <% if account.mode == "transactions" %> + <%= render "accounts/accountables/tab", account: account, key: "transactions", is_selected: selected_tab == "transactions" %> + <% else %> + <%= render "accounts/accountables/tab", account: account, key: "value", is_selected: selected_tab == "value" %> + <% end %> +
    -
    - <% case selected_tab %> - <% when nil, "overview" %> - <%= render "accounts/accountables/loan/overview", account: account %> - <% when "value" %> - <%= render "accounts/accountables/valuations", account: account %> - <% end %> -
    +
    + <% case selected_tab %> + <% when nil, "overview" %> + <%= render "accounts/accountables/loan/overview", account: account %> + <% when "transactions" %> + <%= render "accounts/accountables/transactions", account: account %> + <% when "value" %> + <%= render "accounts/accountables/valuations", account: account %> + <% end %> +
    +<% end %> diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index c49b64e2..d250a38f 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -16,10 +16,6 @@
    - <% if @account.entries.empty? && @account.depository? %> - <%= render "accounts/new_account_setup_bar", account: @account %> - <% end %> - <% if @account.highest_priority_issue %> <%= render partial: "issues/issue", locals: { issue: @account.highest_priority_issue } %> <% end %> diff --git a/app/views/shared/_text_tooltip.erb b/app/views/shared/_text_tooltip.erb index 53f93dd3..f6823c80 100644 --- a/app/views/shared/_text_tooltip.erb +++ b/app/views/shared/_text_tooltip.erb @@ -1,5 +1,5 @@ \ No newline at end of file +
    diff --git a/app/views/accounts/accountables/credit_card/_overview.html.erb b/app/views/accounts/accountables/credit_card/_overview.html.erb index 668326b5..032d8b52 100644 --- a/app/views/accounts/accountables/credit_card/_overview.html.erb +++ b/app/views/accounts/accountables/credit_card/_overview.html.erb @@ -28,4 +28,4 @@
    <%= link_to "Edit account details", edit_account_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %> -
    \ No newline at end of file + diff --git a/app/views/accounts/accountables/loan/_overview.html.erb b/app/views/accounts/accountables/loan/_overview.html.erb index cdd8c131..7d1b19b5 100644 --- a/app/views/accounts/accountables/loan/_overview.html.erb +++ b/app/views/accounts/accountables/loan/_overview.html.erb @@ -46,4 +46,4 @@
    <%= link_to "Edit account details", edit_account_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %> -
    \ No newline at end of file + From 07264e86cbf2e490d1210e7f6434656334ed18f6 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Sat, 19 Oct 2024 14:54:51 -0500 Subject: [PATCH 024/736] Add accounts count to Intercom --- config/initializers/intercom.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/initializers/intercom.rb b/config/initializers/intercom.rb index 03069487..c0d9220b 100644 --- a/config/initializers/intercom.rb +++ b/config/initializers/intercom.rb @@ -82,6 +82,9 @@ if ENV["INTERCOM_APP_ID"].present? && ENV["INTERCOM_IDENTITY_VERIFICATION_KEY"]. # :number_of_messages => Proc.new { |app| app.messages.count }, # :is_interesting => :is_interesting? # } + config.company.custom_data = { + accounts_count: Proc.new { |family| family.accounts.count } + } # == Company Plan name # This is the name of the plan a company is currently paying (or not paying) for. From 720d7aedafacb9e74915e2d7d12cbf4feed26c47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 09:52:27 -0400 Subject: [PATCH 025/736] Bump stripe from 13.0.0 to 13.0.1 (#1345) Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.0.0 to 13.0.1. - [Release notes](https://github.com/stripe/stripe-ruby/releases) - [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md) - [Commits](https://github.com/stripe/stripe-ruby/compare/v13.0.0...v13.0.1) --- updated-dependencies: - dependency-name: stripe dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 51ea10a8..84959661 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -418,7 +418,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.1) - stripe (13.0.0) + stripe (13.0.1) tailwindcss-rails (2.7.9) railties (>= 7.0.0) tailwindcss-rails (2.7.9-aarch64-linux) From cb752370cbc05930f0a3b5d7ebfeda64596e4dae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 09:52:37 -0400 Subject: [PATCH 026/736] Bump aws-sdk-s3 from 1.167.0 to 1.169.0 (#1344) Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.167.0 to 1.169.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-s3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 84959661..85b10ccc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,17 +82,17 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) aws-eventstream (1.3.0) - aws-partitions (1.985.0) - aws-sdk-core (3.209.1) + aws-partitions (1.992.0) + aws-sdk-core (3.210.0) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) + aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.94.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-kms (1.95.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.167.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-s3 (1.169.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.10.0) From 3cc4cba2b34669b7453fe1bde18491ca571ffe86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 09:52:49 -0400 Subject: [PATCH 027/736] Bump octokit from 9.1.0 to 9.2.0 (#1342) Bumps [octokit](https://github.com/octokit/octokit.rb) from 9.1.0 to 9.2.0. - [Release notes](https://github.com/octokit/octokit.rb/releases) - [Changelog](https://github.com/octokit/octokit.rb/blob/main/RELEASE.md) - [Commits](https://github.com/octokit/octokit.rb/compare/v9.1.0...v9.2.0) --- updated-dependencies: - dependency-name: octokit dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 85b10ccc..f47117fb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -269,7 +269,7 @@ GEM racc (~> 1.4) nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) - octokit (9.1.0) + octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) pagy (9.1.0) From b0747628095b9ca2ab93c5daccf0704e8132e16c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 09:53:33 -0400 Subject: [PATCH 028/736] Bump faker from 3.4.2 to 3.5.1 (#1338) Bumps [faker](https://github.com/faker-ruby/faker) from 3.4.2 to 3.5.1. - [Release notes](https://github.com/faker-ruby/faker/releases) - [Changelog](https://github.com/faker-ruby/faker/blob/main/CHANGELOG.md) - [Commits](https://github.com/faker-ruby/faker/compare/v3.4.2...v3.5.1) --- updated-dependencies: - dependency-name: faker dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f47117fb..612711a9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -151,7 +151,7 @@ GEM erubi (1.13.0) et-orbi (1.2.11) tzinfo - faker (3.4.2) + faker (3.5.1) i18n (>= 1.8.11, < 2) faraday (2.12.0) faraday-net_http (>= 2.0, < 3.4) From a2e8fb5ce1afb1c9cbac0d2a8f2c0a6cfdc65d6f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 09:53:41 -0400 Subject: [PATCH 029/736] Bump good_job from 4.4.1 to 4.4.2 (#1336) Bumps [good_job](https://github.com/bensheldon/good_job) from 4.4.1 to 4.4.2. - [Release notes](https://github.com/bensheldon/good_job/releases) - [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md) - [Commits](https://github.com/bensheldon/good_job/compare/v4.4.1...v4.4.2) --- updated-dependencies: - dependency-name: good_job dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 612711a9..3dbc3c21 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -174,7 +174,7 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - good_job (4.4.1) + good_job (4.4.2) activejob (>= 6.1.0) activerecord (>= 6.1.0) concurrent-ruby (>= 1.3.1) @@ -461,7 +461,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.0) + zeitwerk (2.7.1) PLATFORMS aarch64-linux From da7f19d5ab533a9275f1fd4a3681a29f5ab3175e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 09:53:51 -0400 Subject: [PATCH 030/736] Bump erb_lint from 0.6.0 to 0.7.0 (#1337) Bumps [erb_lint](https://github.com/Shopify/erb-lint) from 0.6.0 to 0.7.0. - [Release notes](https://github.com/Shopify/erb-lint/releases) - [Commits](https://github.com/Shopify/erb-lint/compare/v0.6.0...v0.7.0) --- updated-dependencies: - dependency-name: erb_lint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3dbc3c21..4d53dd0f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -141,7 +141,7 @@ GEM dotenv (= 3.1.4) railties (>= 6.1) drb (2.2.1) - erb_lint (0.6.0) + erb_lint (0.7.0) activesupport better_html (>= 2.0.1) parser (>= 2.7.1.4) @@ -273,8 +273,8 @@ GEM faraday (>= 1, < 3) sawyer (~> 0.9) pagy (9.1.0) - parallel (1.25.1) - parser (3.3.4.0) + parallel (1.26.3) + parser (3.3.5.0) ast (~> 2.4.1) racc pg (1.5.8) @@ -348,18 +348,17 @@ GEM reline (0.5.10) io-console (~> 0.5) rexml (3.3.8) - rubocop (1.65.1) + rubocop (1.67.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.4, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + rubocop-ast (>= 1.32.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.3) + rubocop-ast (1.32.3) parser (>= 3.3.1.0) rubocop-minitest (0.35.0) rubocop (>= 1.61, < 2.0) @@ -440,7 +439,7 @@ GEM railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.5.0) + unicode-display_width (2.6.0) uri (0.13.1) useragent (0.16.10) vcr (6.3.1) From 5ff9012d3e42e39cbe3b33824866d3eb068a0105 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 10:10:15 -0400 Subject: [PATCH 031/736] Bump rails from 7.2.1 to 7.2.1.1 (#1340) Bumps [rails](https://github.com/rails/rails) from 7.2.1 to 7.2.1.1. - [Release notes](https://github.com/rails/rails/releases) - [Commits](https://github.com/rails/rails/compare/v7.2.1...v7.2.1.1) --- updated-dependencies: - dependency-name: rails dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 108 +++++++++++++++++++++++++-------------------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4d53dd0f..90891d2d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,29 +8,29 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.2.1) - actionpack (= 7.2.1) - activesupport (= 7.2.1) + actioncable (7.2.1.1) + actionpack (= 7.2.1.1) + activesupport (= 7.2.1.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.1) - actionpack (= 7.2.1) - activejob (= 7.2.1) - activerecord (= 7.2.1) - activestorage (= 7.2.1) - activesupport (= 7.2.1) + actionmailbox (7.2.1.1) + actionpack (= 7.2.1.1) + activejob (= 7.2.1.1) + activerecord (= 7.2.1.1) + activestorage (= 7.2.1.1) + activesupport (= 7.2.1.1) mail (>= 2.8.0) - actionmailer (7.2.1) - actionpack (= 7.2.1) - actionview (= 7.2.1) - activejob (= 7.2.1) - activesupport (= 7.2.1) + actionmailer (7.2.1.1) + actionpack (= 7.2.1.1) + actionview (= 7.2.1.1) + activejob (= 7.2.1.1) + activesupport (= 7.2.1.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.1) - actionview (= 7.2.1) - activesupport (= 7.2.1) + actionpack (7.2.1.1) + actionview (= 7.2.1.1) + activesupport (= 7.2.1.1) nokogiri (>= 1.8.5) racc rack (>= 2.2.4, < 3.2) @@ -39,35 +39,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.1) - actionpack (= 7.2.1) - activerecord (= 7.2.1) - activestorage (= 7.2.1) - activesupport (= 7.2.1) + actiontext (7.2.1.1) + actionpack (= 7.2.1.1) + activerecord (= 7.2.1.1) + activestorage (= 7.2.1.1) + activesupport (= 7.2.1.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.1) - activesupport (= 7.2.1) + actionview (7.2.1.1) + activesupport (= 7.2.1.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.1) - activesupport (= 7.2.1) + activejob (7.2.1.1) + activesupport (= 7.2.1.1) globalid (>= 0.3.6) - activemodel (7.2.1) - activesupport (= 7.2.1) - activerecord (7.2.1) - activemodel (= 7.2.1) - activesupport (= 7.2.1) + activemodel (7.2.1.1) + activesupport (= 7.2.1.1) + activerecord (7.2.1.1) + activemodel (= 7.2.1.1) + activesupport (= 7.2.1.1) timeout (>= 0.4.0) - activestorage (7.2.1) - actionpack (= 7.2.1) - activejob (= 7.2.1) - activerecord (= 7.2.1) - activesupport (= 7.2.1) + activestorage (7.2.1.1) + actionpack (= 7.2.1.1) + activejob (= 7.2.1.1) + activerecord (= 7.2.1.1) + activesupport (= 7.2.1.1) marcel (~> 1.0) - activesupport (7.2.1) + activesupport (7.2.1.1) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) @@ -247,7 +247,7 @@ GEM multipart-post (2.4.1) net-http (0.4.1) uri - net-imap (0.4.14) + net-imap (0.5.0) date net-protocol net-pop (0.1.2) @@ -299,20 +299,20 @@ GEM rackup (2.1.0) rack (>= 3) webrick (~> 1.8) - rails (7.2.1) - actioncable (= 7.2.1) - actionmailbox (= 7.2.1) - actionmailer (= 7.2.1) - actionpack (= 7.2.1) - actiontext (= 7.2.1) - actionview (= 7.2.1) - activejob (= 7.2.1) - activemodel (= 7.2.1) - activerecord (= 7.2.1) - activestorage (= 7.2.1) - activesupport (= 7.2.1) + rails (7.2.1.1) + actioncable (= 7.2.1.1) + actionmailbox (= 7.2.1.1) + actionmailer (= 7.2.1.1) + actionpack (= 7.2.1.1) + actiontext (= 7.2.1.1) + actionview (= 7.2.1.1) + activejob (= 7.2.1.1) + activemodel (= 7.2.1.1) + activerecord (= 7.2.1.1) + activestorage (= 7.2.1.1) + activesupport (= 7.2.1.1) bundler (>= 1.15.0) - railties (= 7.2.1) + railties (= 7.2.1.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -326,9 +326,9 @@ GEM rails-settings-cached (2.9.5) activerecord (>= 5.0.0) railties (>= 5.0.0) - railties (7.2.1) - actionpack (= 7.2.1) - activesupport (= 7.2.1) + railties (7.2.1.1) + actionpack (= 7.2.1.1) + activesupport (= 7.2.1.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) From 9b6a2cce561d2d639c4511d59e5389b71c5f41f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 10:12:35 -0400 Subject: [PATCH 032/736] Bump turbo-rails from 2.0.10 to 2.0.11 (#1343) Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.10 to 2.0.11. - [Release notes](https://github.com/hotwired/turbo-rails/releases) - [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.10...v2.0.11) --- updated-dependencies: - dependency-name: turbo-rails dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 90891d2d..960efddc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -434,7 +434,7 @@ GEM unicode-display_width (>= 1.1.1, < 3) thor (1.3.2) timeout (0.4.1) - turbo-rails (2.0.10) + turbo-rails (2.0.11) actionpack (>= 6.0.0) railties (>= 6.0.0) tzinfo (2.0.6) From 1b654faf9a271322178dbc6ec29b27686b572f1b Mon Sep 17 00:00:00 2001 From: Nico Date: Mon, 21 Oct 2024 11:13:55 -0300 Subject: [PATCH 033/736] Fixes issue with mapping values during the transactions import (#1327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adds custom debounce timeout to autosubmit form controller - There's a default debounce timeout based on element type - You can parameterize debounce timeout on a data-attribute * Adds corrections based on js_lint * Restores sleep on test --------- Co-authored-by: Nicolás Galdámez --- .../auto_submit_form_controller.js | 25 +++++++++++++++++-- test/system/imports_test.rb | 2 +- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/javascript/controllers/auto_submit_form_controller.js b/app/javascript/controllers/auto_submit_form_controller.js index 56eb9fd2..817afb68 100644 --- a/app/javascript/controllers/auto_submit_form_controller.js +++ b/app/javascript/controllers/auto_submit_form_controller.js @@ -24,10 +24,31 @@ export default class extends Controller { }); } - handleInput = () => { + handleInput = (event) => { + const target = event.target + clearTimeout(this.timeout); this.timeout = setTimeout(() => { this.element.requestSubmit(); - }, 500); + }, this.#debounceTimeout(target)); }; + + #debounceTimeout(element) { + if(element.dataset.autosubmitDebounceTimeout) { + return Number.parseInt(element.dataset.autosubmitDebounceTimeout); + } + + const type = element.type || element.tagName; + + switch (type.toLowerCase()) { + case 'input': + case 'textarea': + return 500; + case 'select-one': + case 'select-multiple': + return 0; + default: + return 500; + } + } } diff --git a/test/system/imports_test.rb b/test/system/imports_test.rb index 02270bb9..d0692c25 100644 --- a/test/system/imports_test.rb +++ b/test/system/imports_test.rb @@ -101,7 +101,7 @@ class ImportsTest < ApplicationSystemTestCase within(form) do select = form.find("select") select "Depository", from: select["id"] - sleep 1 + sleep 0.5 end end From a27b17deae4c679ba90a40d751142bce3926babe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 10:14:05 -0400 Subject: [PATCH 034/736] Bump ruby-lsp-rails from 0.3.19 to 0.3.20 (#1339) Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.19 to 0.3.20. - [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases) - [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.19...v0.3.20) --- updated-dependencies: - dependency-name: ruby-lsp-rails dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 960efddc..93611cb2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -376,12 +376,12 @@ GEM rubocop-minitest rubocop-performance rubocop-rails - ruby-lsp (0.20.0) + ruby-lsp (0.20.1) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 4) sorbet-runtime (>= 0.5.10782) - ruby-lsp-rails (0.3.19) + ruby-lsp-rails (0.3.20) ruby-lsp (>= 0.20.0, < 0.21.0) ruby-progressbar (1.13.0) ruby-vips (2.2.2) @@ -412,7 +412,7 @@ GEM simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) - sorbet-runtime (0.5.11602) + sorbet-runtime (0.5.11609) stackprof (0.2.26) stimulus-rails (1.3.4) railties (>= 6.0.0) From 728b10d08e017338876ceb945fe472430d66739a Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 21 Oct 2024 12:26:39 -0400 Subject: [PATCH 035/736] Fix trade import mapping bug --- .../import/configurations_controller.rb | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index b399f683..b1ae9e50 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -20,6 +20,21 @@ class Import::ConfigurationsController < ApplicationController end def import_params - params.require(:import).permit(:date_col_label, :date_format, :name_col_label, :category_col_label, :tags_col_label, :amount_col_label, :signage_convention, :account_col_label, :notes_col_label, :entity_type_col_label) + params.require(:import).permit( + :date_col_label, + :amount_col_label, + :name_col_label, + :category_col_label, + :tags_col_label, + :account_col_label, + :qty_col_label, + :ticker_col_label, + :price_col_label, + :entity_type_col_label, + :notes_col_label, + :currency_col_label, + :date_format, + :signage_convention + ) end end From a4e87ffb4da73e7782bf3bf8eaa4b4f5aaad2aaf Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Mon, 21 Oct 2024 20:20:52 -0500 Subject: [PATCH 036/736] Delete extensions.json --- .vscode/extensions.json | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 866ab2b9..00000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "recommendations": [ - "biomejs.biome", - "EditorConfig.EditorConfig" - ] -} \ No newline at end of file From 93136209680898edaa7f7f8f62c7b258bcede9a1 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Tue, 22 Oct 2024 13:10:51 -0500 Subject: [PATCH 037/736] Updated Synth env variable description --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 9bce6ab7..2622021b 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,8 @@ # For users who have other applications listening at 3000, this allows them to set a value puma will listen to. PORT=3000 -# Exchange Rate API -# This is used to convert between different currencies in the app. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com. +# Exchange Rate & US Stock Pricing API +# This is used to convert between different currencies in the app. In addition, it fetches US stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com. SYNTH_API_KEY= # SMTP Configuration From d3a6f7e0f057681c4459a2761c9f30a41e26051a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:11:26 -0400 Subject: [PATCH 038/736] Bump tailwindcss-rails from 2.7.9 to 3.0.0 (#1341) Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.9 to 3.0.0. - [Release notes](https://github.com/rails/tailwindcss-rails/releases) - [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md) - [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.9...v3.0.0) --- updated-dependencies: - dependency-name: tailwindcss-rails dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 93611cb2..27f6b877 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -418,18 +418,15 @@ GEM railties (>= 6.0.0) stringio (3.1.1) stripe (13.0.1) - tailwindcss-rails (2.7.9) - railties (>= 7.0.0) - tailwindcss-rails (2.7.9-aarch64-linux) - railties (>= 7.0.0) - tailwindcss-rails (2.7.9-arm-linux) - railties (>= 7.0.0) - tailwindcss-rails (2.7.9-arm64-darwin) - railties (>= 7.0.0) - tailwindcss-rails (2.7.9-x86_64-darwin) - railties (>= 7.0.0) - tailwindcss-rails (2.7.9-x86_64-linux) + tailwindcss-rails (3.0.0) railties (>= 7.0.0) + tailwindcss-ruby + tailwindcss-ruby (3.4.14) + tailwindcss-ruby (3.4.14-aarch64-linux) + tailwindcss-ruby (3.4.14-arm-linux) + tailwindcss-ruby (3.4.14-arm64-darwin) + tailwindcss-ruby (3.4.14-x86_64-darwin) + tailwindcss-ruby (3.4.14-x86_64-linux) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) thor (1.3.2) From 73e184ad3d11e085013611f01849f690b672e7dc Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Tue, 22 Oct 2024 14:30:57 -0500 Subject: [PATCH 039/736] Stock Exchanges with seed (#1351) * Stock Exchanges with seed * Run the seed file on migration * Fix for enum column --- app/models/stock_exchange.rb | 2 + config/exchanges.yml | 1020 +++++++++++++++++ .../20241022170439_create_stock_exchanges.rb | 30 + ...0241022192319_fix_user_role_column_type.rb | 16 + db/schema.rb | 23 +- db/seeds.rb | 5 + db/seeds/exchanges.rb | 31 + 7 files changed, 1126 insertions(+), 1 deletion(-) create mode 100644 app/models/stock_exchange.rb create mode 100644 config/exchanges.yml create mode 100644 db/migrate/20241022170439_create_stock_exchanges.rb create mode 100644 db/migrate/20241022192319_fix_user_role_column_type.rb create mode 100644 db/seeds/exchanges.rb diff --git a/app/models/stock_exchange.rb b/app/models/stock_exchange.rb new file mode 100644 index 00000000..7141198e --- /dev/null +++ b/app/models/stock_exchange.rb @@ -0,0 +1,2 @@ +class StockExchange < ApplicationRecord +end diff --git a/config/exchanges.yml b/config/exchanges.yml new file mode 100644 index 00000000..9b429d14 --- /dev/null +++ b/config/exchanges.yml @@ -0,0 +1,1020 @@ +- name: NASDAQ Stock Exchange + acronym: NASDAQ + mic: XNAS + country: USA + country_code: US + city: New York + website: www.nasdaq.com + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: New York Stock Exchange + acronym: NYSE + mic: XNYS + country: USA + country_code: US + city: New York + website: www.nyse.com + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: NYSE ARCA + acronym: NYSEARCA + mic: ARCX + country: USA + country_code: US + city: New York + website: www.nyse.com + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: OTC Markets + acronym: + mic: OTCM + country: USA + country_code: US + city: New York + website: www.otcmarkets.com + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: Buenos Aires Stock Exchange + acronym: BCBA + mic: XBUE + country: Argentina + country_code: AR + city: Buenos Aires + website: www.bcba.sba.com.ar + timezone: + timezone: America/Argentina/Buenos_Aires + abbr: -03 + abbr_dst: -03 + currency: + code: ARS + symbol: AR$ + name: Argentine Peso +- name: Bahrein Bourse + acronym: BSE + mic: XBAH + country: Bahrain + country_code: BH + city: Manama + website: www.bahrainbourse.com.bh + timezone: + timezone: Asia/Bahrain + abbr: +03 + abbr_dst: +03 + currency: + code: BHD + symbol: BD + name: Bahraini Dinar +- name: Euronext Brussels + acronym: Euronext + mic: XBRU + country: Belgium + country_code: BE + city: Brussels + website: www.euronext.com + timezone: + timezone: Europe/Brussels + abbr: CET + abbr_dst: CEST + currency: + code: EUR + symbol: € + name: Euro +- name: B3 - Brasil Bolsa Balcão S.A + acronym: Bovespa + mic: BVMF + country: Brazil + country_code: BR + city: Sao Paulo + website: www.bmfbovespa.com.br + timezone: + timezone: America/Sao_Paulo + abbr: -03 + abbr_dst: -03 + currency: + code: BRL + symbol: R$ + name: Brazilian Real +- name: Toronto Stock Exchange + acronym: TSX + mic: XTSE + country: Canada + country_code: CA + city: Toronto + website: www.tse.com + timezone: + timezone: America/Toronto + abbr: EST + abbr_dst: EDT + currency: + code: CAD + symbol: CA$ + name: Canadian Dollar +- name: Canadian Securities Exchange + acronym: CNSX + mic: XCNQ + country: Canada + country_code: CA + city: Toronto + website: www.cnsx.ca + timezone: + timezone: America/Toronto + abbr: EST + abbr_dst: EDT + currency: + code: CAD + symbol: CA$ + name: Canadian Dollar +- name: Santiago Stock Exchange + acronym: BVS + mic: XSGO + country: Chile + country_code: CL + city: Santiago + website: www.bolsadesantiago.com + timezone: + timezone: America/Santiago + abbr: -03 + abbr_dst: -04 + currency: + code: CLP + symbol: CL$ + name: Chilean Peso +- name: Shanghai Stock Exchange + acronym: SSE + mic: XSHG + country: China + country_code: CN + city: Shanghai + website: www.sse.com.cn + timezone: + timezone: Asia/Shanghai + abbr: CST + abbr_dst: CST + currency: + code: CNY + symbol: CN¥ + name: Chinese Yuan +- name: Shenzhen Stock Exchange + acronym: SZSE + mic: XSHE + country: China + country_code: CN + city: Shenzhen + website: www.szse.cn + timezone: + timezone: Asia/Shanghai + abbr: CST + abbr_dst: CST + currency: + code: CNY + symbol: CN¥ + name: Chinese Yuan +- name: Bolsa de Valores de Colombia + acronym: BVC + mic: XBOG + country: Colombia + country_code: CO + city: Bogota + website: www.bvc.com.co + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: COP + symbol: CO$ + name: Colombian Peso +- name: Copenhagen Stock Exchange + acronym: OMXC + mic: XCSE + country: Denmark + country_code: DK + city: Copenhagen + website: www.nasdaqomxnordic.com + timezone: + timezone: Europe/Copenhagen + abbr: CET + abbr_dst: CEST + currency: + code: DKK + symbol: Dkr + name: Danish Krone +- name: Eqyptian Exchange + acronym: EGX + mic: XCAI + country: Egypt + country_code: EG + city: Cairo + website: www.egyptse.com + timezone: + timezone: Africa/Cairo + abbr: EET + abbr_dst: EET + currency: + code: EGP + symbol: EGP + name: Egyptian Pound +- name: Tallinn Stock Exchange + acronym: OMXT + mic: XTAL + country: Estonia + country_code: EE + city: Tallinn + website: www.nasdaqbaltic.com + timezone: + timezone: Europe/Tallinn + abbr: EET + abbr_dst: EEST + currency: + code: EUR + symbol: € + name: Euro +- name: Helsinki Stock Exchange + acronym: OMXH + mic: XHEL + country: Finland + country_code: FI + city: Helsinki + website: www.nasdaqomxnordic.com + timezone: + timezone: Europe/Helsinki + abbr: EET + abbr_dst: EEST + currency: + code: EUR + symbol: € + name: Euro +- name: Euronext Paris + acronym: Euronext + mic: XPAR + country: France + country_code: FR + city: Paris + website: www.euronext.com + timezone: + timezone: Europe/Paris + abbr: CET + abbr_dst: CEST + currency: + code: EUR + symbol: € + name: Euro +- name: Deutsche Börse + acronym: FSX + mic: XFRA + country: Germany + country_code: DE + city: Frankfurt + website: www.deutsche-boerse.com + timezone: + timezone: Europe/Berlin + abbr: CET + abbr_dst: CEST + currency: + code: EUR + symbol: € + name: Euro +- name: Börse Stuttgart + acronym: XSTU + mic: XSTU + country: Germany + country_code: DE + city: Stuttgart + website: www.boerse-stuttgart.de + timezone: + timezone: Europe/Berlin + abbr: CET + abbr_dst: CEST + currency: + code: EUR + symbol: € + name: Euro +- name: Deutsche Börse Xetra + acronym: XETR + mic: XETRA + country: Germany + country_code: DE + city: Frankfurt + website: + timezone: + timezone: Europe/Berlin + abbr: CET + abbr_dst: CEST + currency: + code: EUR + symbol: € + name: Euro +- name: Hong Kong Stock Exchange + acronym: HKEX + mic: XHKG + country: Hong Kong + country_code: HK + city: Hong Kong + website: www.hkex.com.hk + timezone: + timezone: Asia/Hong_Kong + abbr: HKT + abbr_dst: HKT + currency: + code: HKD + symbol: HK$ + name: Hong Kong Dollar +- name: Nasdaq Island + acronym: XICE + mic: XICE + country: Iceland + country_code: IS + city: Reykjavík + website: www.nasdaqomxnordic.com + timezone: + timezone: Atlantic/Reykjavik + abbr: GMT + abbr_dst: GMT + currency: + code: ISK + symbol: Ikr + name: Icelandic Króna +- name: Bombay Stock Exchange + acronym: MSE + mic: XBOM + country: India + country_code: IN + city: Mumbai + website: www.bseindia.com + timezone: + timezone: Asia/Kolkata + abbr: IST + abbr_dst: IST + currency: + code: INR + symbol: Rs + name: Indian Rupee +- name: National Stock Exchange India + acronym: NSE + mic: XNSE + country: India + country_code: IN + city: Mumbai + website: www.nseindia.com + timezone: + timezone: Asia/Kolkata + abbr: IST + abbr_dst: IST + currency: + code: INR + symbol: Rs + name: Indian Rupee +- name: Jakarta Stock Exchange + acronym: IDX + mic: XIDX + country: Indonesia + country_code: ID + city: Jakarta + website: www.idx.co.id + timezone: + timezone: Asia/Jakarta + abbr: WIB + abbr_dst: WIB + currency: + code: IDR + symbol: Rp + name: Indonesian Rupiah +- name: Tel Aviv Stock Exchange + acronym: TASE + mic: XTAE + country: Israel + country_code: IL + city: Tel Aviv + website: www.tase.co.il + timezone: + timezone: Asia/Jerusalem + abbr: IST + abbr_dst: IDT + currency: + code: ILS + symbol: ₪ + name: Israeli New Sheqel +- name: Borsa Italiana + acronym: MIL + mic: XMIL + country: Italy + country_code: IT + city: Milano + website: www.borsaitaliana.it + timezone: + timezone: Europe/Rome + abbr: CET + abbr_dst: CEST + currency: + code: EUR + symbol: € + name: Euro +- name: Nagoya Stock Exchange + acronym: NSE + mic: XNGO + country: Japan + country_code: JP + city: Nagoya + website: www.nse.or.jp + timezone: + timezone: Asia/Tokyo + abbr: JST + abbr_dst: JST + currency: + code: JPY + symbol: ¥ + name: Japanese Yen +- name: Fukuoka Stock Exchange + acronym: XFKA + mic: XFKA + country: Japan + country_code: JP + city: Fukuoka + website: www.fse.or.jp + timezone: + timezone: Asia/Tokyo + abbr: JST + abbr_dst: JST + currency: + code: JPY + symbol: ¥ + name: Japanese Yen +- name: Sapporo Stock Exchange + acronym: XSAP + mic: XSAP + country: Japan + country_code: JP + city: Sapporo + website: www.sse.or.jp + timezone: + timezone: Asia/Tokyo + abbr: JST + abbr_dst: JST + currency: + code: JPY + symbol: ¥ + name: Japanese Yen +- name: Nasdaq Riga + acronym: OMXR + mic: XRIS + country: Latvia + country_code: LV + city: Riga + website: www.nasdaqbaltic.com + timezone: + timezone: Europe/Riga + abbr: EET + abbr_dst: EEST + currency: + code: EUR + symbol: € + name: Euro +- name: Nasdaq Vilnius + acronym: OMXV + mic: XLIT + country: Lithuania + country_code: LT + city: Vilnius + website: www.nasdaqbaltic.com + timezone: + timezone: Europe/Vilnius + abbr: EET + abbr_dst: EEST + currency: + code: EUR + symbol: € + name: Euro +- name: Malaysia Stock Exchange + acronym: MYX + mic: XKLS + country: Malaysia + country_code: MY + city: Kuala Lumpur + website: www.bursamalaysia.com + timezone: + timezone: Asia/Kuala_Lumpur + abbr: +08 + abbr_dst: +08 + currency: + code: MYR + symbol: RM + name: Malaysian Ringgit +- name: Mexican Stock Exchange + acronym: BMV + mic: XMEX + country: Mexico + country_code: MX + city: Mexico City + website: www.bmv.com.mx + timezone: + timezone: America/Mexico_City + abbr: CST + abbr_dst: CDT + currency: + code: MXN + symbol: MX$ + name: Mexican Peso +- name: Euronext Amsterdam + acronym: Euronext + mic: XAMS + country: Netherlands + country_code: NL + city: Amsterdam + website: www.euronext.com + timezone: + timezone: Europe/Amsterdam + abbr: CET + abbr_dst: CEST + currency: + code: EUR + symbol: € + name: Euro +- name: New Zealand Stock Exchange + acronym: NZX + mic: XNZE + country: New Zealand + country_code: NZ + city: Wellington + website: www.nzx.com + timezone: + timezone: Pacific/Auckland + abbr: NZDT + abbr_dst: NZST + currency: + code: NZD + symbol: NZ$ + name: New Zealand Dollar +- name: Nigerian Stock Exchange + acronym: NSE + mic: XNSA + country: Nigeria + country_code: NG + city: Lagos + website: www.nse.com.ng + timezone: + timezone: Africa/Lagos + abbr: WAT + abbr_dst: WAT + currency: + code: NGN + symbol: ₦ + name: Nigerian Naira +- name: Oslo Stock Exchange + acronym: OSE + mic: XOSL + country: Norway + country_code: NO + city: Oslo + website: www.oslobors.no + timezone: + timezone: Europe/Oslo + abbr: CET + abbr_dst: CEST + currency: + code: NOK + symbol: Nkr + name: Norwegian Krone +- name: Bolsa de Valores de Lima + acronym: BVL + mic: XLIM + country: Peru + country_code: PE + city: Lima + website: www.bvl.com.pe + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: PEN + symbol: S/. + name: Peruvian Nuevo Sol +- name: Warsaw Stock Exchange + acronym: GPW + mic: XWAR + country: Poland + country_code: PL + city: Warsaw + website: www.gpw.pl + timezone: + timezone: Europe/Warsaw + abbr: CET + abbr_dst: CEST + currency: + code: EUR + symbol: € + name: Euro +- name: Euronext Lisbon + acronym: Euronext + mic: XLIS + country: Portugal + country_code: PT + city: Lisboa + website: www.euronext.com + timezone: + timezone: Europe/Lisbon + abbr: WET + abbr_dst: WEST + currency: + code: EUR + symbol: € + name: Euro +- name: Qatar Stock Exchange + acronym: QE + mic: DSMD + country: Qatar + country_code: QA + city: Doha + website: www.qatarexchange.qa + timezone: + timezone: Asia/Qatar + abbr: +03 + abbr_dst: +03 + currency: + code: QAR + symbol: QR + name: Qatari Rial +- name: Moscow Stock Exchange + acronym: MOEX + mic: MISX + country: Russia + country_code: RU + city: Moscow + website: www.moex.com + timezone: + timezone: Europe/Moscow + abbr: MSK + abbr_dst: MSK + currency: + code: RUB + symbol: RUB + name: Russian Ruble +- name: Saudi Stock Exchange + acronym: TADAWUL + mic: XSAU + country: Saudi Arabia + country_code: SA + city: Riyadh + website: www.tadawul.com.sa + timezone: + timezone: Asia/Riyadh + abbr: +03 + abbr_dst: +03 + currency: + code: SAR + symbol: SR + name: Saudi Riyal +- name: Belgrade Stock Exchange + acronym: BELEX + mic: XBEL + country: Serbia + country_code: RS + city: Belgrade + website: www.belex.rs + timezone: + timezone: Europe/Belgrade + abbr: CET + abbr_dst: CEST + currency: + code: EUR + symbol: € + name: Euro +- name: Singapore Stock Exchange + acronym: SGX + mic: XSES + country: Singapore + country_code: SG + city: Singapore + website: www.sgx.com + timezone: + timezone: Asia/Singapore + abbr: +08 + abbr_dst: +08 + currency: + code: SGD + symbol: S$ + name: Singapore Dollar +- name: Johannesburg Stock Exchange + acronym: JSE + mic: XJSE + country: South Africa + country_code: ZA + city: Johannesburg + website: www.jse.co.za + timezone: + timezone: Africa/Johannesburg + abbr: SAST + abbr_dst: SAST + currency: + code: ZAR + symbol: R + name: South African Rand +- name: Korean Stock Exchange + acronym: KRX + mic: XKRX + country: South Korea + country_code: KR + city: Seoul + website: http://eng.krx.co.kr + timezone: + timezone: Asia/Seoul + abbr: KST + abbr_dst: KST + currency: + code: KRW + symbol: ₩ + name: South Korean Won +- name: Bolsas y Mercados Españoles + acronym: BME + mic: BMEX + country: Spain + country_code: ES + city: Madrid + website: www.bolsasymercados.es + timezone: + timezone: Europe/Madrid + abbr: CET + abbr_dst: CEST + currency: + code: EUR + symbol: € + name: Euro +- name: Stockholm Stock Exchange + acronym: OMX + mic: XSTO + country: Sweden + country_code: SE + city: Stockholm + website: www.nasdaqomxnordic.com + timezone: + timezone: Europe/Stockholm + abbr: CET + abbr_dst: CEST + currency: + code: EUR + symbol: € + name: Euro +- name: SIX Swiss Exchange + acronym: SIX + mic: XSWX + country: Switzerland + country_code: CH + city: Zurich + website: www.six-swiss-exchange.com + timezone: + timezone: Europe/Zurich + abbr: CET + abbr_dst: CEST + currency: + code: CHF + symbol: CHF + name: Swiss Franc +- name: Taiwan Stock Exchange + acronym: TWSE + mic: XTAI + country: Taiwan + country_code: TW + city: Taipei + website: www.twse.com.tw/en/ + timezone: + timezone: Asia/Taipei + abbr: CST + abbr_dst: CST + currency: + code: TWD + symbol: NT$ + name: New Taiwan Dollar +- name: Stock Exchange of Thailand + acronym: SET + mic: XBKK + country: Thailand + country_code: TH + city: Bangkok + website: www.set.or.th + timezone: + timezone: Asia/Bangkok + abbr: +07 + abbr_dst: +07 + currency: + code: THB + symbol: ฿ + name: Thai Baht +- name: Istanbul Stock Exchange + acronym: BIST + mic: XIST + country: Turkey + country_code: TR + city: Istanbul + website: www.borsaistanbul.com + timezone: + timezone: Europe/Istanbul + abbr: +03 + abbr_dst: +03 + currency: + code: TRY + symbol: TL + name: Turkish Lira +- name: Dubai Financial Market + acronym: DFM + mic: XDFM + country: United Arab Emirates + country_code: AE + city: Dubai + website: www.dfm.co.ae + timezone: + timezone: Asia/Dubai + abbr: +04 + abbr_dst: +04 + currency: + code: AED + symbol: AED + name: United Arab Emirates Dirham +- name: London Stock Exchange + acronym: LSE + mic: XLON + country: United Kingdom + country_code: GB + city: London + website: www.londonstockexchange.com + timezone: + timezone: Europe/London + abbr: GMT + abbr_dst: BST + currency: + code: GBP + symbol: £ + name: British Pound Sterling +- name: Ho Chi Minh Stock Exchange + acronym: HOSE + mic: XSTC + country: Vietnam + country_code: VN + city: Ho Chi Minh City + website: www.hsx.vn + timezone: + timezone: Asia/Ho_Chi_Minh + abbr: +07 + abbr_dst: +07 + currency: + code: VND + symbol: ₫ + name: Vietnamese Dong +- name: American Stock Exchange + acronym: AMEX + mic: XASE + country: USA + country_code: US + city: New York + website: www.nyse.com + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: Cboe BZX U.S. Equities Exchang + acronym: BATS + mic: XCBO + country: USA + country_code: US + city: Chicago + website: markets.cboe.com + timezone: + timezone: America/Chicago + abbr: CDT + abbr_dst: CDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: US Mutual Funds + acronym: NMFQS + mic: NMFQS + country: USA + country_code: US + city: New York + website: + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: OTC Bulletin Board + acronym: OTCBB + mic: OOTC + country: USA + country_code: US + city: Washington + website: www.otcmarkets.com + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: OTC Grey Market + acronym: OTCGREY + mic: PSGM + country: USA + country_code: US + city: New York + website: www.otcmarkets.com + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: OTCQB Marketplace + acronym: OTCQB + mic: OTCB + country: USA + country_code: US + city: New York + website: www.otcmarkets.com + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: OTCQX Marketplace + acronym: OTCQX + mic: OTCQ + country: USA + country_code: US + city: New York + website: www.otcmarkets.com + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: OTC PINK current + acronym: PINK + mic: PINC + country: USA + country_code: US + city: New York + website: www.otcmarkets.com + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar +- name: Investors Exchange + acronym: IEX + mic: IEXG + country: USA + country_code: US + city: New York + website: www.iextrading.com + timezone: + timezone: America/New_York + abbr: EST + abbr_dst: EDT + currency: + code: USD + symbol: $ + name: US Dollar \ No newline at end of file diff --git a/db/migrate/20241022170439_create_stock_exchanges.rb b/db/migrate/20241022170439_create_stock_exchanges.rb new file mode 100644 index 00000000..f2e6ff5b --- /dev/null +++ b/db/migrate/20241022170439_create_stock_exchanges.rb @@ -0,0 +1,30 @@ +class CreateStockExchanges < ActiveRecord::Migration[7.2] + def change + create_table :stock_exchanges, id: :uuid do |t| + t.string :name, null: false + t.string :acronym + t.string :mic, null: false + t.string :country, null: false + t.string :country_code, null: false + t.string :city, null: false + t.string :website + t.string :timezone_name, null: false + t.string :timezone_abbr, null: false + t.string :timezone_abbr_dst + t.string :currency_code, null: false + t.string :currency_symbol, null: false + t.string :currency_name, null: false + t.timestamps + end + + add_index :stock_exchanges, :country + add_index :stock_exchanges, :country_code + add_index :stock_exchanges, :currency_code + + reversible do |dir| + dir.up do + load Rails.root.join('db/seeds/exchanges.rb') + end + end + end +end diff --git a/db/migrate/20241022192319_fix_user_role_column_type.rb b/db/migrate/20241022192319_fix_user_role_column_type.rb new file mode 100644 index 00000000..0a2b82fc --- /dev/null +++ b/db/migrate/20241022192319_fix_user_role_column_type.rb @@ -0,0 +1,16 @@ +class FixUserRoleColumnType < ActiveRecord::Migration[7.2] + def change + # First remove any constraints/references to the enum + execute <<-SQL + ALTER TABLE users ALTER COLUMN role TYPE varchar USING role::text; + SQL + + # Then set the default + change_column_default :users, :role, 'member' + + # Finally drop the enum type + execute <<-SQL + DROP TYPE IF EXISTS user_role; + SQL + end +end diff --git a/db/schema.rb b/db/schema.rb index 09baa1b2..67536577 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_10_18_201653) do +ActiveRecord::Schema[7.2].define(version: 2024_10_22_192319) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -506,6 +506,27 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_18_201653) do t.index ["var"], name: "index_settings_on_var", unique: true end + create_table "stock_exchanges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name", null: false + t.string "acronym" + t.string "mic", null: false + t.string "country", null: false + t.string "country_code", null: false + t.string "city", null: false + t.string "website" + t.string "timezone_name", null: false + t.string "timezone_abbr", null: false + t.string "timezone_abbr_dst" + t.string "currency_code", null: false + t.string "currency_symbol", null: false + t.string "currency_name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["country"], name: "index_stock_exchanges_on_country" + t.index ["country_code"], name: "index_stock_exchanges_on_country_code" + t.index ["currency_code"], name: "index_stock_exchanges_on_currency_code" + end + create_table "taggings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "tag_id", null: false t.string "taggable_type" diff --git a/db/seeds.rb b/db/seeds.rb index 4d7837d9..948b2395 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -3,3 +3,8 @@ # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). puts 'Run the following command to create demo data: `rake demo_data:reset`' if Rails.env.development? + +Dir[Rails.root.join('db', 'seeds', '*.rb')].sort.each do |file| + puts "Loading seed file: #{File.basename(file)}" + require file +end diff --git a/db/seeds/exchanges.rb b/db/seeds/exchanges.rb new file mode 100644 index 00000000..3e43d3b8 --- /dev/null +++ b/db/seeds/exchanges.rb @@ -0,0 +1,31 @@ +# Load exchanges from YAML configuration +exchanges_config = YAML.load_file(Rails.root.join('config', 'exchanges.yml')) + +exchanges_config.each do |exchange| + next unless exchange['mic'].present? # Skip any invalid entries + + StockExchange.find_or_create_by!(mic: exchange['mic']) do |ex| + ex.name = exchange['name'] + ex.acronym = exchange['acronym'] + ex.country = exchange['country'] + ex.country_code = exchange['country_code'] + ex.city = exchange['city'] + ex.website = exchange['website'] + + # Timezone details + if exchange['timezone'] + ex.timezone_name = exchange['timezone']['timezone'] + ex.timezone_abbr = exchange['timezone']['abbr'] + ex.timezone_abbr_dst = exchange['timezone']['abbr_dst'] + end + + # Currency details + if exchange['currency'] + ex.currency_code = exchange['currency']['code'] + ex.currency_symbol = exchange['currency']['symbol'] + ex.currency_name = exchange['currency']['name'] + end + end +end + +puts "Created #{StockExchange.count} stock exchanges" From 1d20de770f7968ff85c1c76ab7f19c3faeeaf5f9 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 23 Oct 2024 11:20:55 -0400 Subject: [PATCH 040/736] User Onboarding + Bug Fixes (#1352) * Bump min supported date to 20 years * Add basic onboarding * User onboarding * Complete onboarding flow * Cleanup, add user profile update test --- app/assets/images/.keep | 0 app/assets/images/logo-color.png | Bin 0 -> 9326 bytes app/controllers/application_controller.rb | 2 +- app/controllers/concerns/.keep | 0 app/controllers/concerns/onboardable.rb | 17 + app/controllers/onboardings_controller.rb | 19 + app/controllers/sessions_controller.rb | 2 +- .../settings/billings_controller.rb | 3 + .../settings/preferences_controller.rb | 25 +- .../settings/profiles_controller.rb | 35 +- app/controllers/users_controller.rb | 51 +++ app/helpers/application_helper.rb | 26 ++ app/helpers/languages_helper.rb | 370 ++++++++++++++++++ app/helpers/styled_form_builder.rb | 18 +- .../controllers/onboarding_controller.js | 29 ++ .../profile_image_preview_controller.js | 46 +-- app/models/account/entry.rb | 2 +- app/models/family.rb | 3 + app/models/user.rb | 2 +- app/views/layouts/_footer.html.erb | 7 + app/views/layouts/_sidebar.html.erb | 22 +- app/views/layouts/application.html.erb | 2 +- app/views/layouts/auth.html.erb | 48 +-- app/views/onboardings/_header.html.erb | 8 + app/views/onboardings/preferences.html.erb | 88 +++++ app/views/onboardings/profile.html.erb | 40 ++ app/views/onboardings/show.html.erb | 11 + app/views/registrations/new.html.erb | 2 +- app/views/sessions/new.html.erb | 2 +- app/views/settings/_user_avatar.html.erb | 7 + .../settings/_user_avatar_field.html.erb | 52 +++ app/views/settings/billings/show.html.erb | 2 +- app/views/settings/preferences/show.html.erb | 25 +- app/views/settings/profiles/show.html.erb | 44 +-- app/views/shared/_money_field.html.erb | 7 +- app/views/shared/_notification.html.erb | 2 +- app/views/shared/_user_profile_image.html.erb | 1 - config/locales/views/accounts/en.yml | 139 ++++--- .../views/impersonation_sessions/en.yml | 20 +- config/locales/views/imports/en.yml | 5 +- config/locales/views/layout/en.yml | 12 +- config/locales/views/onboardings/en.yml | 28 ++ config/locales/views/pages/en.yml | 2 +- config/locales/views/registrations/en.yml | 1 + config/locales/views/sessions/en.yml | 1 - config/locales/views/settings/en.yml | 17 +- config/locales/views/users/en.yml | 7 + config/routes.rb | 19 +- .../20241022221544_add_onboarding_fields.rb | 7 + db/schema.rb | 7 +- test/controllers/sessions_controller_test.rb | 2 +- .../settings/profiles_controller_test.rb | 31 -- test/controllers/users_controller_test.rb | 64 +++ test/fixtures/users.yml | 4 + test/system/imports_test.rb | 2 +- 55 files changed, 1088 insertions(+), 300 deletions(-) delete mode 100644 app/assets/images/.keep create mode 100644 app/assets/images/logo-color.png delete mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/concerns/onboardable.rb create mode 100644 app/controllers/onboardings_controller.rb create mode 100644 app/controllers/users_controller.rb create mode 100644 app/helpers/languages_helper.rb create mode 100644 app/javascript/controllers/onboarding_controller.js create mode 100644 app/views/layouts/_footer.html.erb create mode 100644 app/views/onboardings/_header.html.erb create mode 100644 app/views/onboardings/preferences.html.erb create mode 100644 app/views/onboardings/profile.html.erb create mode 100644 app/views/onboardings/show.html.erb create mode 100644 app/views/settings/_user_avatar.html.erb create mode 100644 app/views/settings/_user_avatar_field.html.erb delete mode 100644 app/views/shared/_user_profile_image.html.erb create mode 100644 config/locales/views/onboardings/en.yml create mode 100644 config/locales/views/users/en.yml create mode 100644 db/migrate/20241022221544_add_onboarding_fields.rb create mode 100644 test/controllers/users_controller_test.rb diff --git a/app/assets/images/.keep b/app/assets/images/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/assets/images/logo-color.png b/app/assets/images/logo-color.png new file mode 100644 index 0000000000000000000000000000000000000000..f536c33e4abfb5719db349cd080a5e0c366962f7 GIT binary patch literal 9326 zcmV-!B$3;RP)*+54og>vYkrl zM@nQWRjyPyv6zbDa#UqP)(^kRHeI$PN>NNGdXN+)88k`pA%YlE1a=n-?t69~_ukX~ zy8GPOT>^aCR{4iV8qCg}nS0OqPWRVecb`k5e|Ws&gX!2Kljx&V6r!ww97 zbwsxOQi?temggmQmbq9uL(=b2*;z4MB0h_QzFjFaM~Is(yDSGh@jkRhC>!4a>tf1w zOxouejwz(s_)ffU;&-iNZRdh?J|M{tAICp!s^m{2HIChP=#5$2YX<0NzdiVk$aIIz z8k38I9&P;aA?nXPOTE+2l2j$ds?w*#SaB648Iey-43yw2k0sCZ2D{s_ge+tIj6;TV z2f-_+@8V-TvP`mxogA8@#3KMyft}cC;O4ug4a9tE8t)y*Fm&y0R(hN&zPs7Tj@|n@ zzO8M@+l22X8 zjSHr!apA=j)Cz--glmfvJ{l>z>%OaA9jI3Y=$88izkvzgp_3?8tXr*#IUHCHloMQZcIt`hN!xlU4w;JZCdDwIH@H`GkUj zCyJbb=_ew$I6SjlvJ9W)xmjM`XyBANtPHN1=hT?IfSUUc(byGlH!G{x4QGJ+(OqA< zwt4go19bDBbZ^5{?$K{;%sxsB-}o)+udPsLd6`P^j1&c2s>Jo88jY=z(XWRs3_6o4 zMut041=qvm*(V*6E4Y;ys+6qAtqPtQIRQs8%Fx~FWBMBKUAwFS#(I9UBn2vKnOoZFkk>>?xF6YF`a_DloM~Tr&%pq>ri4b5 zTVrF=93STvtZDa6|CaK}{b|g6*L^?Py7M&wbn|a^XDGyz`uEzYZ_w7x#kjw{HGfBwGFV_UHZ=L3j( z)e-4q#CV>4~a@>S9qB05C;+I_c{Z*Rj{CV*3t)gDzCAi=v!`zoo>pfaJg{O9eXUvW~mj z=uo+_P8H@+tS-yy+^lrxU!b%1e@;Y%s>@n|I96_a`4nz954eJ z>6^<9G8zzYW}zkxNiv|S8~}_y73*tUcGji0vdoK*{29r5hjh-E`=?(KplXmEQR~q! zug`st1|38!oY+<&R>?YiU=^$>Q1UJXNj<94u$CxRL93Km5rbhcnbA79pd|Yp8MZ9! zhGyWY1#vVf$z@sCXSOK#Ov5@?Zfu;^#VyUu%T|fqjae;jJq221lGj8 z)fE~n&C}`+zCwvs4u9~TrKy(%NP6)D3Q%wH1ofAnH!-NBVyhH~?nI_jT)ugTkG0t+ zeG>yUGKe+(U)1*s6Ce`z8jg0H6%3VzA$t00aK&iR(T?GqIeup#7L{I2G6fmu$U|=D zGZ`5TaLzR3Z{a-yJf+51QM)*sTOu9m_oae&3_5Gln|oAU4E!ro-HrU(8jo|TH9U|R z9ZT=UA5zupg1JgozH#_n#|CpP<7g+<6sEmQkwQ2At|TUXliP%1k)m8Wmql8{00@7vU z)NzXN7S6`>@*lt`?mx$Zj@-611=lkE=`H~v5ZhqTw-wEJGm}yD6OsA~Ns?_~4YiWm zb<~P-ZUJP7Mf}wWO?5fR1;UM*h&pOtxz5+zQ>{r#vft}_=wSy*`A)4yttF}rHQuwUfvh`8T7m#ZpAL5a6v~bqFN-HzuVK zm~=umQGkSR8`Y$g)m&4PU;Ax@*n|-)xBwgEgl|>;)ZgRW_P1px>RrlLuICf6N4=`a z0i91U(9F$j@ezOoJj;XSIRi-gbj^8PO#0PfBz_j5yK7JB56y*CqnJ-ye1@P!o@1$% zBH<#|tjMAYE+5#)Sa!beW^P@7gP`8S(phH)pAF{bP281d&Jb^`(fUIVAPwoeP{eOU zOC_q(HAfRCa;^opNx_gZ8uK9>%q-JTcQKx3kDw{!JKJN-k>jw&S$~!Y-e^N*!C>(j zlHE7y@tN}il*Krs7Eu)Q&=4yei&9+siC@giUa+jv6)%Zp>lGD$`a|9rhao*!+K$>e2hN(jGXz@uW;6ISJ_Q&Um2j{cm?R>y%XqIF1H@o_>tvYHtLPfY_(xd}*2B#s@1?%F zWEgrer&DqI#}=C}ImA2g-L*geQ`3DW4LqGPOVFR~y!Bu4$j|>GaAyzeTz|6ps21j> z^M5`|-3PwmE|0M!eMv?}@INcNu(_9&NOYu?YAg$IB?BO#zFS#cCEmtCp{k|={CCiu zvo-H)Nka!LKDivV7t=W@A5xwmom9on8shKu6I@VAmH36G9Ho*bX-2Q^13u#t=P7Ya zwGLf8W7wqS?8h$E?r(^iyj%Wi7!r4<3@@(Z95Vv5qEcPwYaww&dV4K!PXN zULGjcn2q2SW1c(*piow(AY-v^xI~lcWK{ zBEI2zripQ)=?f@zA}Ia9K&p;?OXAlb&@j$Q%w<)u6KiJIh;wJTdgcUGGc$xtysn!h zT&$9iQWjO)8gXsuib)h!6CFyk-&g@iBtf7V|bS*f?~ z-Ayz$LNq>#P>CGxWIM$|&m2rgiA5r|u#a*`@!8*&{8xU7<-%!Cw}y(cu7{{i;e&_2 zN9E&Bi!Q$&NdeYQt_gM$uyAj8GxCy&ScZ~Pl8|!3n2df1&`7VGiD{T+v1*#f%c&m6 zJc88;C_F8s8e()a4@teGIv>Ez&EPb3&@a1~1-kPuCWfseV|t_3pf#~9SoWWen;t4p zzQEPT|Fbk^E@KOtc zY0ac_B1){=Dq0RiiNkPyYqPi|aJ{}t%C<)M>W=Gfr}*~UXhT=6h4Jhlw~m)Y<+?(v zmuuKagqu%&O?qew#O|tck@-c7ZU+dpg(*%&^rBo#0!nzZr%W!{ zvxZgnET=};XH5lC4i+zcFBJoX&t!{^?vN*jr!oJpQmZ~OeZX%UOQ*Q_?(drw{|2s!%u=1mt*y9X~O#g(8?7xZ!pTE_VS*&{?YGqZOy^5D^dDzjmz|agAs?(s zP=*$Q1(HZ^)P}Rg5WTNRoBKr)fL5ua*qD}l-__hd{XMVTZ7O)8x;DhA5u(NcPcWMR zXqkq89Q(bmU*6tsK~T|Jp6JJU%3MXIew8o0H;XM&Sx6_+O-H=S^@nc^x!DXG*vuJ$oy+(UBLy$d~Z_$T#JW(<6 zP%Lm9z!`k}mDFaY@}Z(y;BNNqHNuxDzl_KTPqTB-=7MZmP?Uojvx8Lt#F$;n9rdLf z&6cEMocI7?O<)155`w6+4D}tY5}tgJ8YdsN!9y^bkJ+HcFCYUAt`Hk7IQgg@bEY2U z{L~}F^G_!a#KfQY3(7|>pz_eWh_y3=hrwTaY#M80IFk-G4d`fK8pI6`|gAJtJT zp92uI_l%N!r2yG-Nxf7)ZX@I1?v|_ufbd8@L09kpkW4i$g@i!8r|Ll_bg77__ez)7 z`%BX4zet_IqSL&1KTK$|@s|n(ZYD{1)+P}OB+N@Mq;RKfcOuzz0@fT@k|0J2RVfF46P+p>HXB=#anUTIXEb(YU2f3k~2cV$UJIudJ3npsxC#jX=Ttl8^QOepg z-5`MX@7#YoT{3!;&nQd7mZ728YYy*w{Oljg(`Sz*wJ&`2{#$7G$Q1@p5jSAj8?+7( zb??FY;6+5L4fOxI<~vIpFHpbiB#@$6axL;GZSV8Cb**K6K_wL{GL!uom6{o72uW3m zWVad=Ozk)0nC2nG=i{Ip#WG#JD(hIOAV=n0SrswZ57cihySCTNYRt`Shg>{*J(AnV z-YVRp1!)+CWdL8lbmx1yz4Q?Ep`s5T_>^b|c5C_HWjrGwp4r-;O3;RrUxD@OMug zrS{T;$sD3wHvKN%J$kuWTc^K3s#n+hXJsJkJSf*`rGJ{%kW+ebpT%0wMx6@OfNQTn zb&0c*-Af|Z$Y`&&DFx zqqO4wc}8Q+T{I{+WPj^gRPelIk=pR7)0i~jmKD@P+7P|!f`860EFXvK_ULWn#RJ{}3gy zS#Sp{tXgGI4p3g`t30R$V{O($v-7FeFL^x9w2>Gk6(Xtn3)Zkm=br3#u`+9rqb>H@9Q=_lJcXvBQj?Om00Dj11x zcS=15&V0KMUULAYmZZ<`E&P_eb@IbJ-q;TyN6e+9uH{ob5aH)nk12T@_@~ys!xxQQ zXPSyq8Khp-&9FTbDptq`@zTcN#e@Y)Ak{zxNcL5yaT7GawClq?V0)T@@gtBHS4uKqtwfM~LD2v*smk!+U(>oZBM=wSf0!pN!h z2OS_plBM4BG8rzzck`5>C2*7N+wZL1T+l}GqRS4=PO-S7BTee|)-*9?)y&GXI=ykZ z5Fy@09vTZTiDE#gQTd|femPDZF7hT$=KKGwb?j+GX1Q-tJzku$6txe)^yr{iHsgpz zTN^H3onqJ2S{Xmt`HD7rYH~MBngAD8$XOVn27p6cp^-2}E9G;%JUHq00rBw2i(lZ& zCw_+Zj$B)BIU}y4KGo7Z((~(Ir^W6GO^wL-0-qLx!U}Q8Hz|0_6N@yVJY1Kd+S>p^ z)|0mA3a!&w&&7wGQVZ@1iMp0%;ryo2UGxhVAEwJk_t@{|dh4_XbMByKy&el$1p$12 zXHu87fCdRo>?l5Qmma$^_D!j_9yhj${!nl zjb^(rwh=63O92KyHn|%YWwHM>*Si8$;Ob0bb{1`hbfYBMHF^);u@Uj2SLfa=?@j?mn-B|%D5a0&6}W}l#Yo_lndj`Ai| zSQ&R`vqiV;e>3gS>Lc(vjpEumx*Tf&coiLyCs$vjdUt4G@8NsAOd8E|F}UxdwV)Fc zUI??@3~rsltGAR|WoaWCTMZoD7H%}rIfqrDBFk-W6FEs=AO?)%Y}v6fBQ z3S~`l-U)Pg@&HZ4=A@|VtdkM~oH}Lc7|U(@-op3IKTD_4kaOAZ%2)R_Ux;+!e_#C` zI*^Ukg)Z7w($9jNuDX2d!(Zf;qHES|+aO(ur(%H6MBbE}_8#OlL`Jn-J@x{ckgH{n zR*EtWCDXb%)x(Pp4^S)8B?IlK_K?@)nf^!Tr`zXRP)J+kRDo8yPdm8PNM?&@yL^md zg$8H|sjY=XjkLLuHC z0_=nJ{=0*3qtBf9Zn8m1Vako*mc57Qzg}^jHOgE*o4uGdCexuO*3Z)aY(FIbcmBzg zL#mdm;93xjeZ0%PxY|ORk$Wb-?H-Sz1!*MJZ%)Z1E%Iq^d+Qz@ML*cKC(Xv>xs7H1 z(O{MKXJc0AsMnp75qP3%`zD}#1pI_7YLbTt&5>P&CRC*^-#`OoNIBTszr>prPeJES4y*X4e&aujX0n?+ux*L+V^(45T%Q0t>jTX zcV8nfN0I!&;ty$|zs8nR2Y1A1{dcDCug=kpBn^AkC%VQi&={B ze9@sr)S;)~9I9!&54+hcOD1c3ItG~An)UhExF}l6q!l6UhruXN*xn}>0q(dh#18?w zeeTxw@9qB`JG~+qY$jHBW^eznt2LZD_a=&!p6U!tD`^XZH77n*DjJ$?we7|hvI@Po z5X=hIB6Zi)J@b#rJ@Y@{y^V2%>IRL$7AEqBjI!-7<8QCd0_2`f6E;}U(3ix+^nZKq zVfxhrZ|5DMNp%cO!l>2{##^VW4L-g$Yu)8-8yUQ}AY(kvpa}P{VsUJy_#E zI{OrzE>=?e$wAg?MTz+!595o%CDQOcm)g!aeqMl@L5^d9GZ$%9%{DXDbxtMfm`K>e zpm$ZZI+u$KHA-Cv^76bIaunYkWRxvVO?oSs=6xZvUFfE1;;=K|%kCCvQG#XD3Jt8x z$}p7})56kL`|I>LLcI5)#rJI5P;4*YhI|Kv93T)hdmdIyG>=0o zp9@gN)pL60H}P(2;=;O7EqYi^x})A;S($VDhG@oV!Q4^}919we)I!p3O|s33rcL}>x&vQ# zSm4|?Nu<$I+V5@anzoH?7mRI~Ht@y9qOMo~n1POg{dWUZW3VE-r_C%~EjrKKz17@u zEX<{JkXkt24BPo<8$dbb$D1K8=Qz>j;Z5AKnzOl#1gnMViJ^ydKV}?g84b39Vi_0O zVKK=Sn=x$2Rau=5tKhg=UT5J&mJZBNAwBJSP+mhGnuqBURO3dWb;mX%%TWYNc<7wZd zRs-y|%v2WhK2{jkWYdnWI0RRQ`SNR+G^)X|^8u8-^uTW}ex?1f-9NP}5A;uVqOx~! zwwq3)_@o|YB`0=$t=*11&r--X^}i|g*>|B)YRcIbHzn#U3^%KBay`otRIq{lgPK7V zQ#$40IE2(mOBHr6yrpC0_rcn3F1CVsy2IKmSBs!RlVjaBy z^F1~ssEVWSJN?nmpAR6Wmw7yO#jhX5;69>%ub`>-2c2)yd|aiT?nu`)!ix0OpBq|0 z*A=vO3DviR`$m)KMRnh_?OC{=Ubh3g&Nnzb%tdcgY*W-}8#__0!kLGg4~*uc72hp& zh_zTvx6h_csHVU*`vRMy<(Bil#xD8z_|Nkc?bP$N3l2Bl*#2nybpfQ06IcEEXK<}g z)EUkb)hYVc#uIe5T1Qp6q`nfeD8)vlM7vR5@9@z=cr^C$%4wDEn~#)MS@%Dteegaf zOXI8^Y`0+=JJp-O9d?U-66@{HUWQFODbg(nelt(qbYd%CYh6p*oUGuCft07i1pOlF z*$2aZ2N9|0yW0QZ=*wa7iU8^3`M3S|+hF*g)-=Rc9M4nbB0XN5qUX>tSdt+@NHIAB})`f^Xpc#Z#7I7rJw74 z`3~4HVX@V0=znMc!K?wkX`)9n78-IW;q%ttUqN}aD?hG8wrWG3BXQSJ4 z{X4O;-N_1$1L|GDGF1nB#W#0?#8ji^VK>k!SYDpTM@@ApSj`U5@;gT^qjxv2#$OGi zL0tLs|LDXo-}Y*Ae@y^w1qzzv2uAS<{P1vHBi#DX_sUIY<2o(JF0G)F*MlqS-+hE% zd&SarK%VAGNeEbi>YWBZX0KL5vaH*;83eKo;N+Y4%^uT+U$RWhZf4*t1(7RS`{iKR z!|!aonN;@H55$4H-+JO#?|3b9eq8`ph}*vWg*V;h2!;v&#K23or7@@M|zF?x-UHw@5AA4`YtoT4&23;_h7z*oZ800sSz zFU??ZQ&bl_iR#U|!yzOt-`qsyDm1B4+MM9*W=*HyAD11 zsblmyAAf5Az1l-9j`Y82W>*a}kS|kUr74)x6v`fxvA&5d1b{0F=u{o8hNY?DKlRHN zP=_E>8(%*Ten$C*k8N}Z#}2jce8c}WsvjFbZ}hPQ-m^dQ1n&7BW!^?%mi9mSsW$z? cYfg$J2#SSayZ`_I07*qoM6N<$f<+lc-~a#s literal 0 HcmV?d00001 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b6fa8e0c..8fd5c552 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,5 @@ class ApplicationController < ActionController::Base - include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable + include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable include Pagy::Backend private diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/controllers/concerns/onboardable.rb b/app/controllers/concerns/onboardable.rb new file mode 100644 index 00000000..80b15990 --- /dev/null +++ b/app/controllers/concerns/onboardable.rb @@ -0,0 +1,17 @@ +module Onboardable + extend ActiveSupport::Concern + + included do + before_action :redirect_to_onboarding, if: :needs_onboarding? + end + + private + def redirect_to_onboarding + redirect_to onboarding_path + end + + def needs_onboarding? + Current.user && Current.user.onboarded_at.blank? && + !%w[/users /onboarding /sessions].any? { |path| request.path.start_with?(path) } + end +end diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb new file mode 100644 index 00000000..4fb5386f --- /dev/null +++ b/app/controllers/onboardings_controller.rb @@ -0,0 +1,19 @@ +class OnboardingsController < ApplicationController + layout "application" + + before_action :set_user + + def show + end + + def profile + end + + def preferences + end + + private + def set_user + @user = Current.user + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index b6a23195..a1fa08c2 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -19,7 +19,7 @@ class SessionsController < ApplicationController def destroy @session.destroy - redirect_to root_path, notice: t(".logout_successful") + redirect_to new_session_path, notice: t(".logout_successful") end private diff --git a/app/controllers/settings/billings_controller.rb b/app/controllers/settings/billings_controller.rb index d6dc4053..2eb6c49b 100644 --- a/app/controllers/settings/billings_controller.rb +++ b/app/controllers/settings/billings_controller.rb @@ -1,2 +1,5 @@ class Settings::BillingsController < SettingsController + def show + @user = Current.user + end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 6389d9a3..4f4fc1f8 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -1,26 +1,5 @@ class Settings::PreferencesController < SettingsController - def edit + def show + @user = Current.user end - - def update - preference_params_with_family = preference_params - - if Current.family && preference_params[:family_attributes] - family_attributes = preference_params[:family_attributes].merge({ id: Current.family.id }) - preference_params_with_family[:family_attributes] = family_attributes - end - - if Current.user.update(preference_params_with_family) - redirect_to settings_preferences_path, notice: t(".success") - else - redirect_to settings_preferences_path, notice: t(".success") - render :show, status: :unprocessable_entity - end - end - - private - - def preference_params - params.require(:user).permit(family_attributes: [ :id, :currency, :locale ]) - end end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index c6b93c2c..0caca54c 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -1,38 +1,5 @@ class Settings::ProfilesController < SettingsController def show + @user = Current.user end - - def update - user_params_with_family = user_params - - if params[:user][:delete_profile_image] == "true" - Current.user.profile_image.purge - end - - if Current.family && user_params_with_family[:family_attributes] - family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id }) - user_params_with_family[:family_attributes] = family_attributes - end - - if Current.user.update(user_params_with_family) - redirect_to settings_profile_path, notice: t(".success") - else - redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence - end - end - - def destroy - if Current.user.deactivate - Current.session.destroy - redirect_to root_path, notice: t(".success") - else - redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence - end - end - - private - def user_params - params.require(:user).permit(:first_name, :last_name, :profile_image, - family_attributes: [ :name, :id ]) - end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 00000000..2dfae623 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,51 @@ +class UsersController < ApplicationController + before_action :set_user + + def update + @user = Current.user + + @user.update!(user_params.except(:redirect_to, :delete_profile_image)) + @user.profile_image.purge if should_purge_profile_image? + + handle_redirect(t(".success")) + end + + def destroy + if @user.deactivate + Current.session.destroy + redirect_to root_path, notice: t(".success") + else + redirect_to settings_profile_path, alert: @user.errors.full_messages.to_sentence + end + end + + private + def handle_redirect(notice) + case user_params[:redirect_to] + when "onboarding_preferences" + redirect_to preferences_onboarding_path + when "home" + redirect_to root_path + when "preferences" + redirect_to settings_preferences_path, notice: notice + else + redirect_to settings_profile_path, notice: notice + end + end + + def should_purge_profile_image? + user_params[:delete_profile_image] == "1" && + user_params[:profile_image].blank? + end + + def user_params + params.require(:user).permit( + :first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, + family_attributes: [ :name, :currency, :country, :locale, :date_format, :id ] + ) + end + + def set_user + @user = Current.user + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 19aa187e..ca83e38d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,6 +1,19 @@ module ApplicationHelper include Pagy::Frontend + def date_format_options + [ + [ "DD-MM-YYYY", "%d-%m-%Y" ], + [ "MM-DD-YYYY", "%m-%d-%Y" ], + [ "YYYY-MM-DD", "%Y-%m-%d" ], + [ "DD/MM/YYYY", "%d/%m/%Y" ], + [ "YYYY/MM/DD", "%Y/%m/%d" ], + [ "MM/DD/YYYY", "%m/%d/%Y" ], + [ "D/MM/YYYY", "%e/%m/%Y" ], + [ "YYYY.MM.DD", "%Y.%m.%d" ] + ] + end + def title(page_title) content_for(:title) { page_title } end @@ -132,6 +145,19 @@ module ApplicationHelper end end + # Wrapper around I18n.l to support custom date formats + def format_date(object, format = :default, options = {}) + date = object.to_date + + format_code = options[:format_code] || Current.family&.date_format + + if format_code.present? + date.strftime(format_code) + else + I18n.l(date, format: format, **options) + end + end + def format_money(number_or_money, options = {}) return nil unless number_or_money diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb new file mode 100644 index 00000000..db47b3a0 --- /dev/null +++ b/app/helpers/languages_helper.rb @@ -0,0 +1,370 @@ +module LanguagesHelper + LANGUAGE_MAPPING = { + en: "English", + ru: "Russian", + ar: "Arabic", + bg: "Bulgarian", + 'ca-CAT': "Catalan (Catalonia)", + ca: "Catalan", + 'da-DK': "Danish (Denmark)", + 'de-AT': "German (Austria)", + 'de-CH': "German (Switzerland)", + de: "German", + ee: "Ewe", + 'en-AU': "English (Australia)", + 'en-BORK': "English (Bork)", + 'en-CA': "English (Canada)", + 'en-GB': "English (United Kingdom)", + 'en-IND': "English (India)", + 'en-KE': "English (Kenya)", + 'en-MS': "English (Malaysia)", + 'en-NEP': "English (Nepal)", + 'en-NG': "English (Nigeria)", + 'en-NZ': "English (New Zealand)", + 'en-PAK': "English (Pakistan)", + 'en-SG': "English (Singapore)", + 'en-TH': "English (Thailand)", + 'en-UG': "English (Uganda)", + 'en-US': "English (United States)", + 'en-ZA': "English (South Africa)", + 'en-au-ocker': "English (Australian Ocker)", + 'es-AR': "Spanish (Argentina)", + 'es-MX': "Spanish (Mexico)", + es: "Spanish", + fa: "Persian", + 'fi-FI': "Finnish (Finland)", + fr: "French", + 'fr-CA': "French (Canada)", + 'fr-CH': "French (Switzerland)", + he: "Hebrew", + hy: "Armenian", + id: "Indonesian", + it: "Italian", + ja: "Japanese", + ko: "Korean", + lt: "Lithuanian", + lv: "Latvian", + 'mi-NZ': "Maori (New Zealand)", + 'nb-NO': "Norwegian Bokmål (Norway)", + nl: "Dutch", + 'no-NO': "Norwegian (Norway)", + pl: "Polish", + 'pt-BR': "Portuguese (Brazil)", + pt: "Portuguese", + sk: "Slovak", + sv: "Swedish", + th: "Thai", + tr: "Turkish", + uk: "Ukrainian", + vi: "Vietnamese", + 'zh-CN': "Chinese (Simplified)", + 'zh-TW': "Chinese (Traditional)", + af: "Afrikaans", + az: "Azerbaijani", + be: "Belarusian", + bn: "Bengali", + bs: "Bosnian", + cs: "Czech", + cy: "Welsh", + da: "Danish", + 'de-DE': "German (Germany)", + dz: "Dzongkha", + 'el-CY': "Greek (Cyprus)", + el: "Greek", + 'en-CY': "English (Cyprus)", + 'en-IE': "English (Ireland)", + 'en-IN': "English (India)", + 'en-TT': "English (Trinidad and Tobago)", + eo: "Esperanto", + 'es-419': "Spanish (Latin America)", + 'es-CL': "Spanish (Chile)", + 'es-CO': "Spanish (Colombia)", + 'es-CR': "Spanish (Costa Rica)", + 'es-EC': "Spanish (Ecuador)", + 'es-ES': "Spanish (Spain)", + 'es-NI': "Spanish (Nicaragua)", + 'es-PA': "Spanish (Panama)", + 'es-PE': "Spanish (Peru)", + 'es-US': "Spanish (United States)", + 'es-VE': "Spanish (Venezuela)", + et: "Estonian", + eu: "Basque", + fi: "Finnish", + 'fr-FR': "French (France)", + fy: "Western Frisian", + gd: "Scottish Gaelic", + gl: "Galician", + 'hi-IN': "Hindi (India)", + hi: "Hindi", + hr: "Croatian", + hu: "Hungarian", + is: "Icelandic", + 'it-CH': "Italian (Switzerland)", + ka: "Georgian", + kk: "Kazakh", + km: "Khmer", + kn: "Kannada", + lb: "Luxembourgish", + lo: "Lao", + mg: "Malagasy", + mk: "Macedonian", + ml: "Malayalam", + mn: "Mongolian", + 'mr-IN': "Marathi (India)", + ms: "Malay", + nb: "Norwegian Bokmål", + ne: "Nepali", + nn: "Norwegian Nynorsk", + oc: "Occitan", + or: "Odia", + pa: "Punjabi", + rm: "Romansh", + ro: "Romanian", + sc: "Sardinian", + sl: "Slovenian", + sq: "Albanian", + sr: "Serbian", + st: "Southern Sotho", + 'sv-FI': "Swedish (Finland)", + 'sv-SE': "Swedish (Sweden)", + sw: "Swahili", + ta: "Tamil", + te: "Telugu", + tl: "Tagalog", + tt: "Tatar", + ug: "Uyghur", + ur: "Urdu", + uz: "Uzbek", + wo: "Wolof" + }.freeze + + # Locales that we don't have files for, but which are available in Rails + EXCLUDED_LOCALES = [ + "en-BORK", + "en-au-ocker", + "ca-CAT", + "da-DK", + "de-AT", + "de-CH", + "ee", + "en-IND", + "en-KE", + "en-MS", + "en-NEP", + "en-NG", + "en-PAK", + "en-SG", + "en-TH", + "en-UG" + ].freeze + + COUNTRY_MAPPING = { + AF: "Afghanistan", + AL: "Albania", + DZ: "Algeria", + AD: "Andorra", + AO: "Angola", + AG: "Antigua and Barbuda", + AR: "Argentina", + AM: "Armenia", + AU: "Australia", + AT: "Austria", + AZ: "Azerbaijan", + BS: "Bahamas", + BH: "Bahrain", + BD: "Bangladesh", + BB: "Barbados", + BY: "Belarus", + BE: "Belgium", + BZ: "Belize", + BJ: "Benin", + BT: "Bhutan", + BO: "Bolivia", + BA: "Bosnia and Herzegovina", + BW: "Botswana", + BR: "Brazil", + BN: "Brunei", + BG: "Bulgaria", + BF: "Burkina Faso", + BI: "Burundi", + KH: "Cambodia", + CM: "Cameroon", + CA: "Canada", + CV: "Cape Verde", + CF: "Central African Republic", + TD: "Chad", + CL: "Chile", + CN: "China", + CO: "Colombia", + KM: "Comoros", + CG: "Congo", + CD: "Congo, Democratic Republic of the", + CR: "Costa Rica", + CI: "Côte d'Ivoire", + HR: "Croatia", + CU: "Cuba", + CY: "Cyprus", + CZ: "Czech Republic", + DK: "Denmark", + DJ: "Djibouti", + DM: "Dominica", + DO: "Dominican Republic", + EC: "Ecuador", + EG: "Egypt", + SV: "El Salvador", + GQ: "Equatorial Guinea", + ER: "Eritrea", + EE: "Estonia", + ET: "Ethiopia", + FJ: "Fiji", + FI: "Finland", + FR: "France", + GA: "Gabon", + GM: "Gambia", + GE: "Georgia", + DE: "Germany", + GH: "Ghana", + GR: "Greece", + GD: "Grenada", + GT: "Guatemala", + GN: "Guinea", + GW: "Guinea-Bissau", + GY: "Guyana", + HT: "Haiti", + HN: "Honduras", + HU: "Hungary", + IS: "Iceland", + IN: "India", + ID: "Indonesia", + IR: "Iran", + IQ: "Iraq", + IE: "Ireland", + IL: "Israel", + IT: "Italy", + JM: "Jamaica", + JP: "Japan", + JO: "Jordan", + KZ: "Kazakhstan", + KE: "Kenya", + KI: "Kiribati", + KP: "North Korea", + KR: "South Korea", + KW: "Kuwait", + KG: "Kyrgyzstan", + LA: "Laos", + LV: "Latvia", + LB: "Lebanon", + LS: "Lesotho", + LR: "Liberia", + LY: "Libya", + LI: "Liechtenstein", + LT: "Lithuania", + LU: "Luxembourg", + MK: "North Macedonia", + MG: "Madagascar", + MW: "Malawi", + MY: "Malaysia", + MV: "Maldives", + ML: "Mali", + MT: "Malta", + MH: "Marshall Islands", + MR: "Mauritania", + MU: "Mauritius", + MX: "Mexico", + FM: "Micronesia", + MD: "Moldova", + MC: "Monaco", + MN: "Mongolia", + ME: "Montenegro", + MA: "Morocco", + MZ: "Mozambique", + MM: "Myanmar", + NA: "Namibia", + NR: "Nauru", + NP: "Nepal", + NL: "Netherlands", + NZ: "New Zealand", + NI: "Nicaragua", + NE: "Niger", + NG: "Nigeria", + NO: "Norway", + OM: "Oman", + PK: "Pakistan", + PW: "Palau", + PA: "Panama", + PG: "Papua New Guinea", + PY: "Paraguay", + PE: "Peru", + PH: "Philippines", + PL: "Poland", + PT: "Portugal", + QA: "Qatar", + RO: "Romania", + RU: "Russia", + RW: "Rwanda", + KN: "Saint Kitts and Nevis", + LC: "Saint Lucia", + VC: "Saint Vincent and the Grenadines", + WS: "Samoa", + SM: "San Marino", + ST: "Sao Tome and Principe", + SA: "Saudi Arabia", + SN: "Senegal", + RS: "Serbia", + SC: "Seychelles", + SL: "Sierra Leone", + SG: "Singapore", + SK: "Slovakia", + SI: "Slovenia", + SB: "Solomon Islands", + SO: "Somalia", + ZA: "South Africa", + SS: "South Sudan", + ES: "Spain", + LK: "Sri Lanka", + SD: "Sudan", + SR: "Suriname", + SE: "Sweden", + CH: "Switzerland", + SY: "Syria", + TW: "Taiwan", + TJ: "Tajikistan", + TZ: "Tanzania", + TH: "Thailand", + TL: "Timor-Leste", + TG: "Togo", + TO: "Tonga", + TT: "Trinidad and Tobago", + TN: "Tunisia", + TR: "Turkey", + TM: "Turkmenistan", + TV: "Tuvalu", + UG: "Uganda", + UA: "Ukraine", + AE: "United Arab Emirates", + GB: "United Kingdom", + US: "United States", + UY: "Uruguay", + UZ: "Uzbekistan", + VU: "Vanuatu", + VA: "Vatican City", + VE: "Venezuela", + VN: "Vietnam", + YE: "Yemen", + ZM: "Zambia", + ZW: "Zimbabwe" + }.freeze + + def country_options + COUNTRY_MAPPING.keys.map { |key| [ COUNTRY_MAPPING[key], key ] } + end + + def language_options + I18n.available_locales + .reject { |locale| EXCLUDED_LOCALES.include?(locale.to_s) } + .map do |locale| + label = LANGUAGE_MAPPING[locale.to_sym] || locale.to_s.humanize + [ "#{label} (#{locale})", locale ] + end + end +end diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb index 885509d6..f9e060af 100644 --- a/app/helpers/styled_form_builder.rb +++ b/app/helpers/styled_form_builder.rb @@ -24,7 +24,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder def select(method, choices, options = {}, html_options = {}) merged_html_options = { class: "form-field__input" }.merge(html_options) - label = build_label(method, options) + label = build_label(method, options.merge(required: merged_html_options[:required])) field = super(method, choices, options, merged_html_options) build_styled_field(label, field, options, remove_padding_right: true) @@ -33,7 +33,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder def collection_select(method, collection, value_method, text_method, options = {}, html_options = {}) merged_html_options = { class: "form-field__input" }.merge(html_options) - label = build_label(method, options) + label = build_label(method, options.merge(required: merged_html_options[:required])) field = super(method, collection, value_method, text_method, options, merged_html_options) build_styled_field(label, field, options, remove_padding_right: true) @@ -68,7 +68,17 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder def build_label(method, options) return "".html_safe unless options[:label] - return label(method, class: "form-field__label") if options[:label] == true - label(method, options[:label], class: "form-field__label") + + label_text = options[:label] + + if options[:required] + label_text = @template.safe_join([ + label_text == true ? method.to_s.humanize : label_text, + @template.tag.span("*", class: "text-red-500 ml-0.5") + ]) + end + + return label(method, class: "form-field__label") if label_text == true + label(method, label_text, class: "form-field__label") end end diff --git a/app/javascript/controllers/onboarding_controller.js b/app/javascript/controllers/onboarding_controller.js new file mode 100644 index 00000000..2f9d031b --- /dev/null +++ b/app/javascript/controllers/onboarding_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="onboarding" +export default class extends Controller { + setLocale(event) { + this.refreshWithParam("locale", event.target.value); + } + + setDateFormat(event) { + this.refreshWithParam("date_format", event.target.value); + } + + setCurrency(event) { + this.refreshWithParam("currency", event.target.value); + } + + refreshWithParam(key, value) { + const url = new URL(window.location); + url.searchParams.set(key, value); + + // Preserve existing params by getting the current search string + // and appending our new param to it + const currentParams = new URLSearchParams(window.location.search); + currentParams.set(key, value); + + // Refresh the page with all params + window.location.search = currentParams.toString(); + } +} diff --git a/app/javascript/controllers/profile_image_preview_controller.js b/app/javascript/controllers/profile_image_preview_controller.js index b03842be..7e568bee 100644 --- a/app/javascript/controllers/profile_image_preview_controller.js +++ b/app/javascript/controllers/profile_image_preview_controller.js @@ -2,32 +2,34 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = [ - "imagePreview", - "fileField", - "deleteField", + "attachedImage", + "previewImage", + "placeholderImage", + "deleteProfileImage", + "input", "clearBtn", - "template", ]; - preview(event) { - const file = event.target.files[0]; - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - this.imagePreviewTarget.innerHTML = `Preview`; - this.templateTarget.classList.add("hidden"); - this.clearBtnTarget.classList.remove("hidden"); - }; - reader.readAsDataURL(file); - } + clearFileInput() { + this.inputTarget.value = null; + this.clearBtnTarget.classList.add("hidden"); + this.placeholderImageTarget.classList.remove("hidden"); + this.attachedImageTarget.classList.add("hidden"); + this.previewImageTarget.classList.add("hidden"); + this.deleteProfileImageTarget.value = "1"; } - clear() { - this.deleteFieldTarget.value = true; - this.fileFieldTarget.value = null; - this.templateTarget.classList.remove("hidden"); - this.imagePreviewTarget.innerHTML = this.templateTarget.innerHTML; - this.clearBtnTarget.classList.add("hidden"); - this.element.submit(); + showFileInputPreview(event) { + const file = event.target.files[0]; + if (!file) return; + + this.placeholderImageTarget.classList.add("hidden"); + this.attachedImageTarget.classList.add("hidden"); + this.previewImageTarget.classList.remove("hidden"); + this.clearBtnTarget.classList.remove("hidden"); + this.deleteProfileImageTarget.value = "0"; + + this.previewImageTarget.querySelector("img").src = + URL.createObjectURL(file); } } diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 5b5ada42..e04756f1 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -67,7 +67,7 @@ class Account::Entry < ApplicationRecord class << self # arbitrary cutoff date to avoid expensive sync operations def min_supported_date - 10.years.ago.to_date + 20.years.ago.to_date end def daily_totals(entries, currency, period: Period.last_30_days) diff --git a/app/models/family.rb b/app/models/family.rb index 24da7ddd..c4949a4d 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,4 +1,6 @@ class Family < ApplicationRecord + DATE_FORMATS = [ "%m-%d-%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ] + include Providable has_many :users, dependent: :destroy @@ -13,6 +15,7 @@ class Family < ApplicationRecord has_many :issues, through: :accounts validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } + validates :date_format, inclusion: { in: DATE_FORMATS } def snapshot(period = Period.all) query = accounts.active.joins(:balances) diff --git a/app/models/user.rb b/app/models/user.rb index 789e39df..68eaec43 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,7 +5,7 @@ class User < ApplicationRecord has_many :sessions, dependent: :destroy has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy - accepts_nested_attributes_for :family + accepts_nested_attributes_for :family, update_only: true validates :email, presence: true, uniqueness: true validate :ensure_valid_profile_image diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb new file mode 100644 index 00000000..69694a1d --- /dev/null +++ b/app/views/layouts/_footer.html.erb @@ -0,0 +1,7 @@ + +
    +
    +

    © <%= Date.current.year %>, Maybe Finance, Inc.

    +

    <%= link_to t(".privacy_policy"), "https://maybe.co/privacy", class: "underline hover:text-gray-600" %> • <%= link_to t(".terms_of_service"), "https://maybe.co/tos", class: "underline hover:text-gray-600" %>

    +
    +
    diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index e038247e..8c997673 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -4,24 +4,16 @@ <% end %>