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 `