Compare commits
49 Commits
v0.1.0
...
v0.2.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b75b41a5e2 | ||
|
|
2cc89195bf | ||
|
|
aa3342b0dc | ||
|
|
b611dfdf37 | ||
|
|
ba49fea89a | ||
|
|
89e107e36c | ||
|
|
d93fbbcaa8 | ||
|
|
e6403fab70 | ||
|
|
6baffe7539 | ||
|
|
1d20de770f | ||
|
|
73e184ad3d | ||
|
|
d3a6f7e0f0 | ||
|
|
9313620968 | ||
|
|
a4e87ffb4d | ||
|
|
728b10d08e | ||
|
|
a27b17deae | ||
|
|
1b654faf9a | ||
|
|
9b6a2cce56 | ||
|
|
5ff9012d3e | ||
|
|
da7f19d5ab | ||
|
|
a2e8fb5ce1 | ||
|
|
b074762809 | ||
|
|
3cc4cba2b3 | ||
|
|
cb752370cb | ||
|
|
720d7aedaf | ||
|
|
07264e86cb | ||
|
|
3c0fdd84ee | ||
|
|
263d65ea7e | ||
|
|
e8e100e1d8 | ||
|
|
c7c281073f | ||
|
|
4a3685f503 | ||
|
|
75a390f03e | ||
|
|
d4bfcfb6f4 | ||
|
|
b98f35af0e | ||
|
|
629565f7d8 | ||
|
|
4118cc8a31 | ||
|
|
61bf53f233 | ||
|
|
7f4c1755ef | ||
|
|
76decc06c3 | ||
|
|
f3bb80dde6 | ||
|
|
4ad28d6eff | ||
|
|
fa3b8b078c | ||
|
|
d4e7a983f4 | ||
|
|
7f7140b1cc | ||
|
|
437aa4bd39 | ||
|
|
eabec71f70 | ||
|
|
3bc960e6c1 | ||
|
|
57a81e44ef | ||
|
|
e357c0485f |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
.env.example
17
.env.example
@@ -1,11 +1,24 @@
|
||||
# ================================ PLEASE READ ==========================================
|
||||
# This file outlines all the possible environment variables supported by the Maybe app.
|
||||
#
|
||||
# This includes several features that are for our "hosted" version of Maybe, which most
|
||||
# open-source contributors won't need.
|
||||
#
|
||||
# If you are developing locally, you should be referencing `.env.local.example` instead.
|
||||
# =======================================================================================
|
||||
|
||||
# Custom port config
|
||||
# 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=
|
||||
|
||||
# Non-US Stock Pricing API
|
||||
# This is used to fetch non-US stock prices. We use Marketstack.com for this and while they offer a free tier, it is quite limited. You'll almost certainly need their Basic plan, which is $9.99 per month.
|
||||
MARKETSTACK_API_KEY=
|
||||
|
||||
# SMTP Configuration
|
||||
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
|
||||
# Resend.com is a good option that offers a free tier for sending emails.
|
||||
|
||||
8
.env.local.example
Normal file
8
.env.local.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# To enable / disable self-hosting features.
|
||||
SELF_HOSTED=false
|
||||
|
||||
# Enable Synth market data (careful, this will use your API credits)
|
||||
SYNTH_API_KEY=yourapikeyhere
|
||||
|
||||
# Enable Marketstack market data (careful, this will use your API credits)
|
||||
MARKETSTACK_API_KEY=yourapikeyhere
|
||||
8
.env.test
Normal file
8
.env.test
Normal file
@@ -0,0 +1,8 @@
|
||||
SELF_HOSTED=false
|
||||
SYNTH_API_KEY=fookey
|
||||
|
||||
# Set to true if you want SimpleCov reports generated
|
||||
COVERAGE=false
|
||||
|
||||
# Set to true to run test suite serially
|
||||
DISABLE_PARALLELIZATION=false
|
||||
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -20,6 +20,12 @@ 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.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -10,8 +10,8 @@
|
||||
# Ignore all environment files (except templates).
|
||||
/.env*
|
||||
!/.env*.erb
|
||||
!.env.example
|
||||
!.env.test.example
|
||||
!.env.test
|
||||
!.env*.example
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
/log/*
|
||||
@@ -43,7 +43,9 @@
|
||||
.idea
|
||||
|
||||
# Ignore VS Code
|
||||
.vscode
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Ignore macOS specific files
|
||||
*/.DS_Store
|
||||
@@ -59,4 +61,7 @@ compose-dev.yaml
|
||||
gcp-storage-keyfile.json
|
||||
|
||||
coverage
|
||||
.cursorrules
|
||||
.cursorrules
|
||||
|
||||
# Ignore node related files
|
||||
node_modules
|
||||
194
Gemfile.lock
194
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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -174,7 +174,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.3.0)
|
||||
good_job (4.4.2)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -269,16 +269,16 @@ 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)
|
||||
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)
|
||||
prism (1.1.0)
|
||||
prism (1.2.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -323,12 +323,12 @@ 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)
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -377,13 +376,13 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.19.1)
|
||||
ruby-lsp (0.20.1)
|
||||
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.20)
|
||||
ruby-lsp (>= 0.20.0, < 0.21.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
@@ -400,10 +399,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)
|
||||
@@ -413,34 +412,31 @@ 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.11609)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.1)
|
||||
stripe (13.0.0)
|
||||
tailwindcss-rails (2.7.7)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.7-aarch64-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.7-arm-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.7-arm64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.7-x86_64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.7-x86_64-linux)
|
||||
stripe (13.0.1)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
@@ -461,7 +457,7 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.18)
|
||||
zeitwerk (2.7.1)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
|
||||
@@ -49,7 +49,7 @@ After cloning the repo, the basic setup commands are:
|
||||
|
||||
```sh
|
||||
cd maybe
|
||||
cp .env.example .env
|
||||
cp .env.local.example .env.local
|
||||
bin/setup
|
||||
bin/dev
|
||||
|
||||
|
||||
BIN
app/assets/images/logo-color.png
Normal file
BIN
app/assets/images/logo-color.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
BIN
app/assets/images/maybe-plus-background.png
Normal file
BIN
app/assets/images/maybe-plus-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
2811
app/assets/images/maybe-plus-background.svg
Normal file
2811
app/assets/images/maybe-plus-background.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 299 KiB |
BIN
app/assets/images/maybe-plus-logo.png
Normal file
BIN
app/assets/images/maybe-plus-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
6
app/assets/images/stripe-logo.svg
Normal file
6
app/assets/images/stripe-logo.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame 1321315963">
|
||||
<rect width="20" height="20" rx="10" fill="#635BFF"/>
|
||||
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M9.35663 7.69056C9.35663 7.20077 9.75747 7.01238 10.4214 7.01238C11.3734 7.01238 12.5759 7.30124 13.5279 7.81615V4.86482C12.4882 4.45037 11.461 4.28711 10.4214 4.28711C7.87854 4.28711 6.1875 5.61835 6.1875 7.84127C6.1875 11.3075 10.9475 10.7549 10.9475 12.2494C10.9475 12.8271 10.4464 13.0155 9.74495 13.0155C8.70527 13.0155 7.37749 12.5885 6.32529 12.0108V14.9998C7.49023 15.5022 8.66769 15.7157 9.74495 15.7157C12.3504 15.7157 14.1416 14.4221 14.1416 12.1741C14.1291 8.43154 9.35663 9.09716 9.35663 7.69056Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 775 B |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -83,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
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
|
||||
include Pagy::Backend
|
||||
|
||||
helper_method :require_upgrade?, :subscription_pending?
|
||||
|
||||
private
|
||||
def require_upgrade?
|
||||
return false if self_hosted?
|
||||
return false unless Current.session
|
||||
return false if Current.family.subscribed?
|
||||
return false if subscription_pending? || request.path == settings_billing_path
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def subscription_pending?
|
||||
subscribed_at = Current.session.subscribed_at
|
||||
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
|
||||
end
|
||||
|
||||
def with_sidebar
|
||||
return "turbo_rails/frame" if turbo_frame_request?
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class CategoriesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category, only: %i[edit update]
|
||||
before_action :set_category, only: %i[edit update destroy]
|
||||
before_action :set_transaction, only: :create
|
||||
|
||||
def index
|
||||
@@ -13,12 +13,14 @@ class CategoriesController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
Category.transaction do
|
||||
category = Current.family.categories.create!(category_params)
|
||||
@transaction.update!(category_id: category.id) if @transaction
|
||||
end
|
||||
@category = Current.family.categories.new(category_params)
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
if @category.save
|
||||
@transaction.update(category_id: @category.id) if @transaction
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
else
|
||||
redirect_back_or_to transactions_path, alert: t(".failure", error: @category.errors.full_messages.to_sentence)
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@@ -30,6 +32,12 @@ class CategoriesController < ApplicationController
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@category.destroy
|
||||
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.categories.find(params[:id])
|
||||
|
||||
@@ -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 }
|
||||
|
||||
21
app/controllers/concerns/impersonatable.rb
Normal file
21
app/controllers/concerns/impersonatable.rb
Normal file
@@ -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
|
||||
17
app/controllers/concerns/onboardable.rb
Normal file
17
app/controllers/concerns/onboardable.rb
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
58
app/controllers/impersonation_sessions_controller.rb
Normal file
58
app/controllers/impersonation_sessions_controller.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,8 +12,13 @@ class MerchantsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.merchants.create!(merchant_params)
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
@merchant = Current.family.merchants.new(merchant_params)
|
||||
|
||||
if @merchant.save
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
else
|
||||
redirect_to merchants_path, alert: t(".error", error: @merchant.errors.full_messages.to_sentence)
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
|
||||
19
app/controllers/onboardings_controller.rb
Normal file
19
app/controllers/onboardings_controller.rb
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
5
app/controllers/securities_controller.rb
Normal file
5
app/controllers/securities_controller.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class SecuritiesController < ApplicationController
|
||||
def import
|
||||
SecuritiesImportJob.perform_later(params[:exchange_mic])
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
class Settings::BillingsController < SettingsController
|
||||
def show
|
||||
@user = Current.user
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
class SubscriptionsController < ApplicationController
|
||||
def new
|
||||
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
|
||||
|
||||
if Current.family.stripe_customer_id.blank?
|
||||
customer = client.v1.customers.create(
|
||||
customer = stripe_client.v1.customers.create(
|
||||
email: Current.family.primary_user.email,
|
||||
metadata: { family_id: Current.family.id }
|
||||
)
|
||||
Current.family.update(stripe_customer_id: customer.id)
|
||||
end
|
||||
|
||||
session = client.v1.checkout.sessions.create({
|
||||
session = stripe_client.v1.checkout.sessions.create({
|
||||
customer: Current.family.stripe_customer_id,
|
||||
line_items: [ {
|
||||
price: ENV["STRIPE_PLAN_ID"],
|
||||
@@ -18,7 +16,7 @@ class SubscriptionsController < ApplicationController
|
||||
} ],
|
||||
mode: "subscription",
|
||||
allow_promotion_codes: true,
|
||||
success_url: settings_billing_url,
|
||||
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: settings_billing_url
|
||||
})
|
||||
|
||||
@@ -26,12 +24,24 @@ class SubscriptionsController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
|
||||
|
||||
portal_session = client.v1.billing_portal.sessions.create(
|
||||
portal_session = stripe_client.v1.billing_portal.sessions.create(
|
||||
customer: Current.family.stripe_customer_id,
|
||||
return_url: settings_billing_url
|
||||
)
|
||||
|
||||
redirect_to portal_session.url, allow_other_host: true, status: :see_other
|
||||
end
|
||||
|
||||
def success
|
||||
checkout_session = stripe_client.v1.checkout.sessions.retrieve(params[:session_id])
|
||||
Current.session.update(subscribed_at: Time.at(checkout_session.created))
|
||||
redirect_to root_path, notice: "You have successfully subscribed to Maybe+."
|
||||
rescue Stripe::InvalidRequestError
|
||||
redirect_to settings_billing_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed."
|
||||
end
|
||||
|
||||
private
|
||||
def stripe_client
|
||||
@stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class TagsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag, only: %i[edit update]
|
||||
before_action :set_tag, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@tags = Current.family.tags.alphabetically
|
||||
@@ -12,8 +12,13 @@ class TagsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.tags.create!(tag_params)
|
||||
redirect_to tags_path, notice: t(".created")
|
||||
@tag = Current.family.tags.new(tag_params)
|
||||
|
||||
if @tag.save
|
||||
redirect_to tags_path, notice: t(".created")
|
||||
else
|
||||
redirect_to tags_path, alert: t(".error", error: @tag.errors.full_messages.to_sentence)
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@@ -24,6 +29,11 @@ class TagsController < ApplicationController
|
||||
redirect_to tags_path, notice: t(".updated")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@tag.destroy!
|
||||
redirect_to tags_path, notice: t(".deleted")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tag
|
||||
|
||||
51
app/controllers/users_controller.rb
Normal file
51
app/controllers/users_controller.rb
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -86,7 +96,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
|
||||
|
||||
@@ -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
|
||||
@@ -9,10 +22,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 +89,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
|
||||
@@ -136,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
|
||||
|
||||
|
||||
@@ -4,4 +4,8 @@ module CategoriesHelper
|
||||
name: "Uncategorized",
|
||||
color: Category::UNCATEGORIZED_COLOR
|
||||
end
|
||||
|
||||
def family_categories
|
||||
[ null_category ].concat(Current.family.categories.alphabetically)
|
||||
end
|
||||
end
|
||||
|
||||
2
app/helpers/impersonation_sessions_helper.rb
Normal file
2
app/helpers/impersonation_sessions_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module ImpersonationSessionsHelper
|
||||
end
|
||||
366
app/helpers/languages_helper.rb
Normal file
366
app/helpers/languages_helper.rb
Normal file
@@ -0,0 +1,366 @@
|
||||
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
|
||||
|
||||
EXCLUDED_LOCALES = [
|
||||
# Test locales
|
||||
"en-BORK",
|
||||
"en-au-ocker",
|
||||
# Duplicate locales
|
||||
"fr-FR",
|
||||
"de-DE",
|
||||
"hi-IN",
|
||||
"sv-SE",
|
||||
"ca-CAT",
|
||||
"en-US",
|
||||
"fi-FI",
|
||||
"en-IND"
|
||||
].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
|
||||
.sort_by { |label, locale| label }
|
||||
end
|
||||
end
|
||||
2
app/helpers/securities_helper.rb
Normal file
2
app/helpers/securities_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module SecuritiesHelper
|
||||
end
|
||||
@@ -5,10 +5,10 @@ module SettingsHelper
|
||||
{ name: I18n.t("settings.nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
|
||||
{ name: I18n.t("settings.nav.billing_label"), path: :settings_billing_path },
|
||||
{ name: I18n.t("settings.nav.accounts_label"), path: :accounts_path },
|
||||
{ name: I18n.t("settings.nav.imports_label"), path: :imports_path },
|
||||
{ name: I18n.t("settings.nav.tags_label"), path: :tags_path },
|
||||
{ name: I18n.t("settings.nav.categories_label"), path: :categories_path },
|
||||
{ name: I18n.t("settings.nav.merchants_label"), path: :merchants_path },
|
||||
{ name: I18n.t("settings.nav.imports_label"), path: :imports_path },
|
||||
{ name: I18n.t("settings.nav.whats_new_label"), path: :changelog_path },
|
||||
{ name: I18n.t("settings.nav.feedback_label"), path: :feedback_path }
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -49,7 +49,12 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
end
|
||||
|
||||
def submit(value = nil, options = {})
|
||||
merged_options = { class: "btn btn--primary w-full" }.merge(options)
|
||||
default_options = {
|
||||
data: { turbo_submits_with: "Submitting..." },
|
||||
class: "btn btn--primary w-full"
|
||||
}
|
||||
|
||||
merged_options = default_options.merge(options)
|
||||
value, options = nil, value if value.is_a?(Hash)
|
||||
super(value, merged_options)
|
||||
end
|
||||
@@ -68,7 +73,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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,134 +1,155 @@
|
||||
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);
|
||||
_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) {
|
||||
_resetFormInputs(form, paramName) {
|
||||
const existingInputs = form.querySelectorAll(`input[name='${paramName}']`);
|
||||
existingInputs.forEach((input) => input.remove());
|
||||
}
|
||||
|
||||
#rowsForGroup(group) {
|
||||
return this.rowTargets.filter(row => group.contains(row))
|
||||
_rowsForGroup(group) {
|
||||
return this.rowTargets.filter((row) => group.contains(row));
|
||||
}
|
||||
|
||||
#addToSelection(idToAdd) {
|
||||
_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)
|
||||
_removeFromSelection(idToRemove) {
|
||||
this.selectedIdsValue = this.selectedIdsValue.filter(
|
||||
(id) => id !== idToRemove,
|
||||
);
|
||||
}
|
||||
|
||||
#selectAll() {
|
||||
this.selectedIdsValue = this.rowTargets.map(t => t.dataset.id)
|
||||
_selectAll() {
|
||||
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;
|
||||
}
|
||||
|
||||
#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
|
||||
_pluralizedResourceName() {
|
||||
return `${this.resourceValue}${
|
||||
this.selectedIdsValue.length === 1 ? "" : "s"
|
||||
}`;
|
||||
}
|
||||
|
||||
#pluralizedResourceName() {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
#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
|
||||
})
|
||||
}
|
||||
|
||||
#updateRows() {
|
||||
this.rowTargets.forEach(row => {
|
||||
row.checked = this.selectedIdsValue.includes(row.dataset.id)
|
||||
})
|
||||
_updateRows() {
|
||||
this.rowTargets.forEach((row) => {
|
||||
row.checked = this.selectedIdsValue.includes(row.dataset.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
app/javascript/controllers/onboarding_controller.js
Normal file
29
app/javascript/controllers/onboarding_controller.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.#d3GroupMemo;
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["imagePreview", "fileField", "deleteField", "clearBtn", "template"]
|
||||
static targets = [
|
||||
"attachedImage",
|
||||
"previewImage",
|
||||
"placeholderImage",
|
||||
"deleteProfileImage",
|
||||
"input",
|
||||
"clearBtn",
|
||||
];
|
||||
|
||||
preview(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-full h-full rounded-full object-cover" />`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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,247 +8,259 @@ 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;
|
||||
#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 = [];
|
||||
|
||||
this._d3Container.selectAll("*").remove();
|
||||
}
|
||||
|
||||
#teardown() {
|
||||
this.#d3SvgMemo = null
|
||||
this.#d3GroupMemo = null
|
||||
this.#d3Tooltip = null
|
||||
this.#normalDataPoints = []
|
||||
|
||||
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),
|
||||
date: this._parseDate(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
|
||||
_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;
|
||||
}
|
||||
|
||||
#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")
|
||||
.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])
|
||||
.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)
|
||||
.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")
|
||||
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
|
||||
_setTrendlineSplitAt(percent) {
|
||||
this._d3Svg
|
||||
.select(`#${this.element.id}-split-gradient`)
|
||||
.select(".middle-color")
|
||||
.attr("offset", `${percent * 100}%`)
|
||||
.attr("offset", `${percent * 100}%`);
|
||||
|
||||
this.#d3Svg
|
||||
this._d3Svg
|
||||
.select(`#${this.element.id}-split-gradient`)
|
||||
.select(".end-color")
|
||||
.attr("offset", `${percent * 100}%`)
|
||||
.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"))
|
||||
.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() {
|
||||
_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-opacity", 0.06)
|
||||
.attr("stop-color", this._trendColor)
|
||||
.attr("stop-opacity", 0.06);
|
||||
|
||||
gradient
|
||||
.append("stop")
|
||||
.attr("offset", 0.5)
|
||||
.attr("stop-color", this.#trendColor)
|
||||
.attr("stop-opacity", 0)
|
||||
.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)
|
||||
.attr("d", d3.area()
|
||||
.x(d => this.#d3XScale(d.date))
|
||||
.y0(this.#d3ContainerHeight)
|
||||
.y1(d => this.#d3YScale(d.value))
|
||||
)
|
||||
.datum(this._normalDataPoints)
|
||||
.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
|
||||
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)`)
|
||||
.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")
|
||||
@@ -258,93 +270,102 @@ 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
|
||||
_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) => {
|
||||
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
|
||||
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")
|
||||
.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")
|
||||
.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("pointer-events", "none")
|
||||
.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")
|
||||
.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 (`
|
||||
_tooltipTemplate(datum) {
|
||||
return `
|
||||
<div style="margin-bottom: 4px; color: ${tailwindColors.gray[500]};">
|
||||
${d3.timeFormat("%b %d, %Y")(datum.date)}
|
||||
</div>
|
||||
@@ -356,164 +377,172 @@ export default class extends Controller {
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="4"
|
||||
stroke="${this.#tooltipTrendColor(datum)}"
|
||||
stroke="${this._tooltipTrendColor(datum)}"
|
||||
fill="transparent"
|
||||
stroke-width="1"></circle>
|
||||
</svg>
|
||||
|
||||
${this.#tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
|
||||
${this._tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
|
||||
</div>
|
||||
|
||||
${this.usePercentSignValue || datum.trend.value === 0 || datum.trend.value.amount === 0 ? `
|
||||
${
|
||||
this.usePercentSignValue ||
|
||||
datum.trend.value === 0 ||
|
||||
datum.trend.value.amount === 0
|
||||
? `
|
||||
<span style="width: 80px;"></span>
|
||||
` : `
|
||||
<span style="color: ${this.#tooltipTrendColor(datum)};">
|
||||
${this.#tooltipChange(datum)} (${datum.trend.percent}%)
|
||||
`
|
||||
: `
|
||||
<span style="color: ${this._tooltipTrendColor(datum)};">
|
||||
${this._tooltipChange(datum)} (${datum.trend.percent}%)
|
||||
</span>
|
||||
`}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`)
|
||||
`;
|
||||
}
|
||||
|
||||
#tooltipTrendColor(datum) {
|
||||
_tooltipTrendColor(datum) {
|
||||
return {
|
||||
up: tailwindColors.success,
|
||||
down: tailwindColors.error,
|
||||
flat: tailwindColors.gray[500],
|
||||
}[datum.trend.direction]
|
||||
}[datum.trend.direction];
|
||||
}
|
||||
|
||||
#tooltipValue(datum) {
|
||||
_tooltipValue(datum) {
|
||||
if (datum.currency) {
|
||||
return this.#currencyValue(datum)
|
||||
} else {
|
||||
return datum.value
|
||||
return this._currencyValue(datum);
|
||||
}
|
||||
return datum.value;
|
||||
}
|
||||
|
||||
#tooltipChange(datum) {
|
||||
_tooltipChange(datum) {
|
||||
if (datum.currency) {
|
||||
return this.#currencyChange(datum)
|
||||
} else {
|
||||
return this.#decimalChange(datum)
|
||||
return this._currencyChange(datum);
|
||||
}
|
||||
return this._decimalChange(datum);
|
||||
}
|
||||
|
||||
#currencyValue(datum) {
|
||||
_currencyValue(datum) {
|
||||
return Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: datum.currency,
|
||||
}).format(datum.value)
|
||||
}).format(datum.value);
|
||||
}
|
||||
|
||||
#currencyChange(datum) {
|
||||
_currencyChange(datum) {
|
||||
return Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: datum.currency,
|
||||
signDisplay: "always",
|
||||
}).format(datum.trend.value.amount)
|
||||
}).format(datum.trend.value.amount);
|
||||
}
|
||||
|
||||
#decimalChange(datum) {
|
||||
_decimalChange(datum) {
|
||||
return Intl.NumberFormat(undefined, {
|
||||
style: "decimal",
|
||||
signDisplay: "always",
|
||||
}).format(datum.trend.value)
|
||||
}).format(datum.trend.value);
|
||||
}
|
||||
|
||||
|
||||
#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
|
||||
} else {
|
||||
return this.#d3SvgMemo = this.#createMainSvg()
|
||||
get _d3Svg() {
|
||||
if (!this._d3SvgMemo) {
|
||||
this._d3SvgMemo = this._createMainSvg();
|
||||
}
|
||||
return this._d3SvgMemo;
|
||||
}
|
||||
|
||||
get #d3Group() {
|
||||
if (this.#d3GroupMemo) {
|
||||
return this.#d3GroupMemo
|
||||
} else {
|
||||
return this.#d3GroupMemo = this.#createMainGroup()
|
||||
get _d3Group() {
|
||||
if (!this._d3GroupMemo) {
|
||||
this._d3GroupMemo = this._createMainGroup();
|
||||
}
|
||||
return this._d3GroupMemo;
|
||||
}
|
||||
|
||||
get #margin() {
|
||||
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
|
||||
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() {
|
||||
return d3.select(this.element)
|
||||
get _d3Container() {
|
||||
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
|
||||
get _trendColor() {
|
||||
if (this._trendDirection === "flat") {
|
||||
return tailwindColors.gray[500];
|
||||
}
|
||||
if (this._trendDirection === this._favorableDirection) {
|
||||
return tailwindColors.green[500];
|
||||
}
|
||||
return tailwindColors.error;
|
||||
}
|
||||
|
||||
get #trendDirection() {
|
||||
return this.dataValue.trend.direction
|
||||
get _trendDirection() {
|
||||
return this.dataValue.trend.direction;
|
||||
}
|
||||
|
||||
get #favorableDirection() {
|
||||
return this.dataValue.trend.favorable_direction
|
||||
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() {
|
||||
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
|
||||
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;
|
||||
|
||||
return d3
|
||||
.scaleLinear()
|
||||
.rangeRound([this.#d3ContainerHeight, 0])
|
||||
.domain([dataMin - padding, dataMax + padding])
|
||||
.rangeRound([this._d3ContainerHeight, 0])
|
||||
.domain([dataMin - padding, dataMax + padding]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
app/jobs/securities_import_job.rb
Normal file
13
app/jobs/securities_import_job.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class SecuritiesImportJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(country_code = nil)
|
||||
exchanges = StockExchange.in_country(country_code)
|
||||
market_stack_client = Provider::Marketstack.new(ENV["MARKETSTACK_API_KEY"])
|
||||
|
||||
exchanges.each do |exchange|
|
||||
importer = Security::Importer.new(market_stack_client, exchange.mic)
|
||||
importer.import
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -27,6 +30,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
|
||||
|
||||
57
app/models/account/balance/calculator.rb
Normal file
57
app/models/account/balance/calculator.rb
Normal file
@@ -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.select { |e| e.account_transaction? })
|
||||
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
|
||||
46
app/models/account/balance/converter.rb
Normal file
46
app/models/account/balance/converter.rb
Normal file
@@ -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
|
||||
37
app/models/account/balance/loader.rb
Normal file
37
app/models/account/balance/loader.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,19 @@ class Account::Transaction < ApplicationRecord
|
||||
class << self
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.joins(:category).where(categories: { name: params[:categories] }) if params[:categories].present?
|
||||
if params[:categories].present?
|
||||
if params[:categories].exclude?("Uncategorized")
|
||||
query = query
|
||||
.joins(:category)
|
||||
.where(categories: { name: params[:categories] })
|
||||
else
|
||||
query = query
|
||||
.left_joins(:category)
|
||||
.where(categories: { name: params[:categories] })
|
||||
.or(query.where(category_id: nil))
|
||||
end
|
||||
end
|
||||
|
||||
query = query.joins(:merchant).where(merchants: { name: params[:merchants] }) if params[:merchants].present?
|
||||
|
||||
if params[:tags].present?
|
||||
|
||||
@@ -14,6 +14,14 @@ class AccountImport < Import
|
||||
)
|
||||
|
||||
account.save!
|
||||
|
||||
account.entries.create!(
|
||||
amount: row.amount,
|
||||
currency: row.currency,
|
||||
date: Date.current,
|
||||
name: "Imported account value",
|
||||
entryable: Account::Valuation.new
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ class Category < ApplicationRecord
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
validates :name, uniqueness: { scope: :family_id }
|
||||
|
||||
before_update :clear_internal_category, if: :name_changed?
|
||||
|
||||
|
||||
@@ -33,4 +33,8 @@ module Accountable
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
|
||||
def mode_required?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
class Crypto < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
def color
|
||||
"#737373"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,7 +71,8 @@ class Demo::Generator
|
||||
first_name: "Demo",
|
||||
last_name: "User",
|
||||
role: "admin",
|
||||
password: "password"
|
||||
password: "password",
|
||||
onboarded_at: Time.current
|
||||
end
|
||||
|
||||
def create_tags!
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
class Depository < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
def color
|
||||
"#875BF7"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -129,7 +132,7 @@ class Family < ApplicationRecord
|
||||
end
|
||||
|
||||
def subscribed?
|
||||
stripe_subscription_status.present? && stripe_subscription_status == "active"
|
||||
stripe_subscription_status == "active"
|
||||
end
|
||||
|
||||
def primary_user
|
||||
|
||||
39
app/models/impersonation_session.rb
Normal file
39
app/models/impersonation_session.rb
Normal file
@@ -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
|
||||
3
app/models/impersonation_session_log.rb
Normal file
3
app/models/impersonation_session_log.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class ImpersonationSessionLog < ApplicationRecord
|
||||
belongs_to :impersonation_session
|
||||
end
|
||||
@@ -46,4 +46,8 @@ class Investment < ApplicationRecord
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
|
||||
def color
|
||||
"#1570EF"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,4 +16,8 @@ class Loan < ApplicationRecord
|
||||
|
||||
Money.new(payment.round, account.currency)
|
||||
end
|
||||
|
||||
def color
|
||||
"#D444F1"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ class Merchant < ApplicationRecord
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
validates :name, uniqueness: { scope: :family }
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
class OtherAsset < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
def color
|
||||
"#12B76A"
|
||||
end
|
||||
|
||||
def mode_required?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
class OtherLiability < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
def color
|
||||
"#737373"
|
||||
end
|
||||
|
||||
def mode_required?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,14 @@ class Property < ApplicationRecord
|
||||
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
|
||||
end
|
||||
|
||||
def color
|
||||
"#06AED4"
|
||||
end
|
||||
|
||||
def mode_required?
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
def first_valuation_amount
|
||||
account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money
|
||||
|
||||
119
app/models/provider/marketstack.rb
Normal file
119
app/models/provider/marketstack.rb
Normal file
@@ -0,0 +1,119 @@
|
||||
class Provider::Marketstack
|
||||
include Retryable
|
||||
|
||||
def initialize(api_key)
|
||||
@api_key = api_key
|
||||
end
|
||||
|
||||
def fetch_security_prices(ticker:, start_date:, end_date:)
|
||||
prices = paginate("#{base_url}/eod", {
|
||||
symbols: ticker,
|
||||
date_from: start_date.to_s,
|
||||
date_to: end_date.to_s
|
||||
}) do |body|
|
||||
body.dig("data").map do |price|
|
||||
{
|
||||
date: price["date"],
|
||||
price: price["close"]&.to_f,
|
||||
currency: "USD"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
SecurityPriceResponse.new(
|
||||
prices: prices,
|
||||
success?: true,
|
||||
raw_response: prices.to_json
|
||||
)
|
||||
rescue StandardError => error
|
||||
SecurityPriceResponse.new(
|
||||
success?: false,
|
||||
error: error,
|
||||
raw_response: error
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_tickers(exchange_mic: nil)
|
||||
url = exchange_mic ? "#{base_url}/tickers?exchange=#{exchange_mic}" : "#{base_url}/tickers"
|
||||
tickers = paginate(url) do |body|
|
||||
body.dig("data").map do |ticker|
|
||||
{
|
||||
name: ticker["name"],
|
||||
symbol: ticker["symbol"],
|
||||
exchange: exchange_mic || ticker.dig("stock_exchange", "mic"),
|
||||
country_code: ticker.dig("stock_exchange", "country_code")
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
TickerResponse.new(
|
||||
tickers: tickers,
|
||||
success?: true,
|
||||
raw_response: tickers.to_json
|
||||
)
|
||||
rescue StandardError => error
|
||||
TickerResponse.new(
|
||||
success?: false,
|
||||
error: error,
|
||||
raw_response: error
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :api_key
|
||||
|
||||
SecurityPriceResponse = Struct.new(:prices, :success?, :error, :raw_response, keyword_init: true)
|
||||
TickerResponse = Struct.new(:tickers, :success?, :error, :raw_response, keyword_init: true)
|
||||
|
||||
def base_url
|
||||
"https://api.marketstack.com/v1"
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= Faraday.new(url: base_url) do |faraday|
|
||||
faraday.params["access_key"] = api_key
|
||||
end
|
||||
end
|
||||
|
||||
def build_error(response)
|
||||
Provider::Base::ProviderError.new(<<~ERROR)
|
||||
Failed to fetch data from #{self.class}
|
||||
Status: #{response.status}
|
||||
Body: #{response.body.inspect}
|
||||
ERROR
|
||||
end
|
||||
|
||||
def fetch_page(url, page, params = {})
|
||||
client.get(url) do |req|
|
||||
params.each { |k, v| req.params[k.to_s] = v.to_s }
|
||||
req.params["offset"] = (page - 1) * 100 # Marketstack uses offset-based pagination
|
||||
req.params["limit"] = 10000 # Maximum allowed by Marketstack
|
||||
end
|
||||
end
|
||||
|
||||
def paginate(url, params = {})
|
||||
results = []
|
||||
page = 1
|
||||
total_results = Float::INFINITY
|
||||
|
||||
while results.length < total_results
|
||||
response = fetch_page(url, page, params)
|
||||
|
||||
if response.success?
|
||||
body = JSON.parse(response.body)
|
||||
page_results = yield(body)
|
||||
results.concat(page_results)
|
||||
|
||||
total_results = body.dig("pagination", "total")
|
||||
page += 1
|
||||
else
|
||||
raise build_error(response)
|
||||
end
|
||||
|
||||
break if results.length >= total_results
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
end
|
||||
27
app/models/security/importer.rb
Normal file
27
app/models/security/importer.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
class Security::Importer
|
||||
def initialize(provider, stock_exchange = nil)
|
||||
@provider = provider
|
||||
@stock_exchange = stock_exchange
|
||||
end
|
||||
|
||||
def import
|
||||
securities = @provider.fetch_tickers(exchange_mic: @stock_exchange)&.tickers
|
||||
|
||||
stock_exchanges = StockExchange.where(mic: securities.map { |s| s[:exchange] }).index_by(&:mic)
|
||||
existing_securities = Security.where(ticker: securities.map { |s| s[:symbol] }, stock_exchange_id: stock_exchanges.values.map(&:id)).pluck(:ticker, :stock_exchange_id).to_set
|
||||
|
||||
securities_to_create = securities.map do |security|
|
||||
stock_exchange_id = stock_exchanges[security[:exchange]]&.id
|
||||
next if existing_securities.include?([ security[:symbol], stock_exchange_id ])
|
||||
|
||||
{
|
||||
name: security[:name],
|
||||
ticker: security[:symbol],
|
||||
stock_exchange_id: stock_exchange_id,
|
||||
country_code: security[:country_code]
|
||||
}
|
||||
end.compact
|
||||
|
||||
Security.insert_all(securities_to_create) unless securities_to_create.empty?
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
3
app/models/stock_exchange.rb
Normal file
3
app/models/stock_exchange.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class StockExchange < ApplicationRecord
|
||||
scope :in_country, ->(country_code) { where(country_code: country_code) }
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user