Compare commits
90 Commits
v0.1.0
...
v0.2.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56ab092f6b | ||
|
|
b3ef995d1f | ||
|
|
a113d573d6 | ||
|
|
3b928775a8 | ||
|
|
31d9d926f7 | ||
|
|
154a1a971b | ||
|
|
e434ed0e1f | ||
|
|
2722254be9 | ||
|
|
455257bf51 | ||
|
|
f2739b79fb | ||
|
|
cee9692b35 | ||
|
|
18266c3352 | ||
|
|
c3400856c7 | ||
|
|
a0ad33e47c | ||
|
|
65d46397d7 | ||
|
|
905eb7bbe8 | ||
|
|
65db49273c | ||
|
|
12e4f1067d | ||
|
|
85779b4038 | ||
|
|
793bd852a0 | ||
|
|
09b269273a | ||
|
|
47288a1629 | ||
|
|
2b61821336 | ||
|
|
7946cd7819 | ||
|
|
e7f09e6f71 | ||
|
|
5e2b932648 | ||
|
|
5533b84895 | ||
|
|
c9917674aa | ||
|
|
cd91e66618 | ||
|
|
490f44589e | ||
|
|
bf695972e4 | ||
|
|
7d8028b505 | ||
|
|
c2561b5fb4 | ||
|
|
e5eb69bdc7 | ||
|
|
3cd364af09 | ||
|
|
277fb3dc39 | ||
|
|
439e50bb3e | ||
|
|
2141cbb041 | ||
|
|
d78f582af2 | ||
|
|
2adb54da99 | ||
|
|
45935db5f3 | ||
|
|
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 |
@@ -1,4 +1,4 @@
|
||||
ARG RUBY_VERSION=3.3.4
|
||||
ARG RUBY_VERSION=3.3.5
|
||||
FROM ruby:${RUBY_VERSION}-slim-bullseye
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
.env.example
13
.env.example
@@ -1,9 +1,18 @@
|
||||
# ================================ 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 & Stock Pricing API
|
||||
# This is used to convert between different currencies in the app. In addition, it fetches global stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
|
||||
SYNTH_API_KEY=
|
||||
|
||||
# SMTP Configuration
|
||||
|
||||
5
.env.local.example
Normal file
5
.env.local.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# To enable / disable self-hosting features.
|
||||
SELF_HOSTED=false
|
||||
|
||||
# Enable Synth market data (careful, this will use your API credits)
|
||||
SYNTH_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
|
||||
@@ -1 +1 @@
|
||||
3.3.4
|
||||
3.3.5
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.3.4
|
||||
ARG RUBY_VERSION=3.3.5
|
||||
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
|
||||
|
||||
# Rails app lives here
|
||||
|
||||
3
Gemfile
3
Gemfile
@@ -3,7 +3,7 @@ source "https://rubygems.org"
|
||||
ruby file: ".ruby-version"
|
||||
|
||||
# Rails
|
||||
gem "rails", "~> 7.2.1"
|
||||
gem "rails", "~> 7.2.2"
|
||||
|
||||
# Drivers
|
||||
gem "pg", "~> 1.5"
|
||||
@@ -21,6 +21,7 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
||||
# Hotwire
|
||||
gem "stimulus-rails"
|
||||
gem "turbo-rails"
|
||||
gem "hotwire_combobox"
|
||||
|
||||
# Background Jobs
|
||||
gem "good_job"
|
||||
|
||||
227
Gemfile.lock
227
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.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
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.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
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.2)
|
||||
actionpack (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.1)
|
||||
actionview (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
actionpack (7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
@@ -39,36 +39,37 @@ 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.2)
|
||||
actionpack (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
actionview (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
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.2)
|
||||
activesupport (= 7.2.2)
|
||||
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.2)
|
||||
activesupport (= 7.2.2)
|
||||
activerecord (7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
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.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.1)
|
||||
activesupport (7.2.2)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
@@ -82,23 +83,24 @@ 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)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.3.0)
|
||||
better_html (2.1.1)
|
||||
actionview (>= 6.0)
|
||||
activesupport (>= 6.0)
|
||||
@@ -110,7 +112,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)
|
||||
@@ -131,7 +133,7 @@ GEM
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.0)
|
||||
date (3.3.4)
|
||||
date (3.4.0)
|
||||
debug (1.9.2)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
@@ -141,7 +143,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 +153,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 +176,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)
|
||||
@@ -188,6 +190,10 @@ GEM
|
||||
actioncable (>= 6.0.0)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 6.0.0)
|
||||
hotwire_combobox (0.3.2)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.14)
|
||||
@@ -203,7 +209,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)
|
||||
@@ -228,7 +234,7 @@ GEM
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.1)
|
||||
loofah (2.22.0)
|
||||
loofah (2.23.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -241,13 +247,13 @@ GEM
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.1)
|
||||
mocha (2.4.5)
|
||||
mocha (2.5.0)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
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)
|
||||
@@ -256,7 +262,7 @@ GEM
|
||||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.3)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.16.7-aarch64-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-arm-linux)
|
||||
@@ -269,16 +275,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)
|
||||
pagy (9.1.1)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.5.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.8)
|
||||
prism (1.1.0)
|
||||
pg (1.5.9)
|
||||
prism (1.2.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
@@ -291,28 +297,27 @@ 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)
|
||||
rack (>= 1.3)
|
||||
rackup (2.1.0)
|
||||
rackup (2.2.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.2)
|
||||
actioncable (= 7.2.2)
|
||||
actionmailbox (= 7.2.2)
|
||||
actionmailer (= 7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actiontext (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.1)
|
||||
railties (= 7.2.2)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -323,12 +328,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.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -347,19 +352,18 @@ GEM
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.10)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.3.8)
|
||||
rubocop (1.65.1)
|
||||
rexml (3.3.9)
|
||||
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 +381,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.21)
|
||||
ruby-lsp (>= 0.20.0, < 0.21.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
@@ -394,16 +398,16 @@ GEM
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.3.1)
|
||||
selenium-webdriver (4.25.0)
|
||||
selenium-webdriver (4.26.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
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 +417,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.11618)
|
||||
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.1.0)
|
||||
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)
|
||||
@@ -454,14 +455,13 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.8.2)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.18)
|
||||
zeitwerk (2.7.1)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -489,6 +489,7 @@ DEPENDENCIES
|
||||
good_job
|
||||
holidays
|
||||
hotwire-livereload
|
||||
hotwire_combobox
|
||||
i18n-tasks
|
||||
image_processing (>= 1.2)
|
||||
importmap-rails
|
||||
@@ -502,7 +503,7 @@ DEPENDENCIES
|
||||
pg (~> 1.5)
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails (~> 7.2.1)
|
||||
rails (~> 7.2.2)
|
||||
rails-settings-cached
|
||||
redcarpet
|
||||
rubocop-rails-omakase
|
||||
@@ -522,7 +523,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.4p94
|
||||
ruby 3.3.5p100
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.9
|
||||
2.5.22
|
||||
|
||||
@@ -42,14 +42,14 @@ The instructions below are for developers to get started with contributing to th
|
||||
|
||||
### Requirements
|
||||
|
||||
- Ruby 3.3.4
|
||||
- See `.ruby-version` file for required Ruby version
|
||||
- PostgreSQL >9.3 (ideally, latest stable version)
|
||||
|
||||
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 |
@@ -19,7 +19,8 @@
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
|
||||
.form-field__label, .hw-combobox__label {
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer;
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer focus:outline-gray-500;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
@@ -120,6 +121,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
.combobox {
|
||||
.hw-combobox__main__wrapper, .hw-combobox__input {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.hw-combobox__main__wrapper {
|
||||
@apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;
|
||||
}
|
||||
|
||||
.hw-combobox__listbox {
|
||||
@apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;
|
||||
}
|
||||
|
||||
.hw_combobox__pagination__wrapper {
|
||||
@apply h-px;
|
||||
|
||||
&:only-child {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
--hw-border-color: rgba(0, 0, 0, 0.2);
|
||||
--hw-handle-width: 20px;
|
||||
--hw-handle-height: 20px;
|
||||
--hw-handle-offset-right: 0px;
|
||||
}
|
||||
|
||||
/* Small, single purpose classes that should take precedence over other styles */
|
||||
@layer utilities {
|
||||
.scrollbar::-webkit-scrollbar {
|
||||
|
||||
@@ -4,13 +4,21 @@ class Account::EntriesController < ApplicationController
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: %i[edit update show destroy]
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
@pagy, @entries = pagy(@account.entries.search(@q).reverse_chronological, limit: params[:per_page] || "10")
|
||||
end
|
||||
|
||||
def edit
|
||||
render entryable_view_path(:edit)
|
||||
end
|
||||
|
||||
def update
|
||||
prev_amount = @entry.amount
|
||||
prev_date = @entry.date
|
||||
|
||||
@entry.update!(entry_params)
|
||||
@entry.sync_account_later
|
||||
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
@@ -43,6 +51,11 @@ class Account::EntriesController < ApplicationController
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency, :notes)
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:search)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -17,10 +17,10 @@ class Account::TradesController < ApplicationController
|
||||
|
||||
if entry = @builder.save
|
||||
entry.sync_account_later
|
||||
redirect_to account_path(@account), notice: t(".success")
|
||||
redirect_to @account, notice: t(".success")
|
||||
else
|
||||
flash[:alert] = t(".failure")
|
||||
redirect_back_or_to account_path(@account)
|
||||
redirect_back_or_to @account
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,6 +33,13 @@ class Account::TradesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def securities
|
||||
query = params[:q]
|
||||
return render json: [] if query.blank? || query.length < 2 || query.length > 100
|
||||
|
||||
@securities = Security::SynthComboboxOption.find_in_synth(query)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
|
||||
@@ -12,16 +12,25 @@ class Account::TransactionsController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
@entry.update!(entry_params)
|
||||
prev_amount = @entry.amount
|
||||
prev_date = @entry.date
|
||||
|
||||
@entry.update!(entry_params.except(:origin))
|
||||
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
@entry,
|
||||
partial: "account/entries/entry",
|
||||
locals: entry_locals.merge(entry: @entry)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
@@ -30,10 +39,18 @@ class Account::TransactionsController < ApplicationController
|
||||
@entry = @account.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def entry_locals
|
||||
{
|
||||
selectable: entry_params[:origin].present?,
|
||||
show_balance: entry_params[:origin] == "account",
|
||||
origin: entry_params[:origin]
|
||||
}
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature,
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature, :origin,
|
||||
entryable_attributes: [
|
||||
:id,
|
||||
:category_id,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
class Account::TransfersController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: :destroy
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
|
||||
def new
|
||||
@transfer = Account::Transfer.new
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
@@ -14,8 +17,7 @@ class Account::TransfersController < ApplicationController
|
||||
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d,
|
||||
currency: transfer_params[:currency],
|
||||
name: transfer_params[:name]
|
||||
currency: transfer_params[:currency]
|
||||
|
||||
if @transfer.save
|
||||
@transfer.entries.each(&:sync_account_later)
|
||||
@@ -28,18 +30,33 @@ class Account::TransfersController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@transfer.update_entries!(transfer_update_params)
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy_and_remove_marks!
|
||||
@transfer.destroy!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transfer
|
||||
@transfer = Account::Transfer.find(params[:id])
|
||||
record = Account::Transfer.find(params[:id])
|
||||
|
||||
unless record.entries.all? { |entry| Current.family.accounts.include?(entry.account) }
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
|
||||
@transfer = record
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name)
|
||||
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
|
||||
end
|
||||
|
||||
def transfer_update_params
|
||||
params.require(:account_transfer).permit(:excluded, :notes)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ class Account::ValuationsController < ApplicationController
|
||||
redirect_back_or_to account_valuations_path(@account), notice: t(".success")
|
||||
else
|
||||
flash[:alert] = @entry.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
redirect_to @account
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class AccountsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
include Filterable
|
||||
before_action :set_account, only: %i[edit show destroy sync update]
|
||||
before_action :set_account, only: %i[sync]
|
||||
|
||||
def index
|
||||
@institutions = Current.family.institutions
|
||||
@@ -10,6 +9,7 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def summary
|
||||
@period = Period.from_param(params[:period])
|
||||
snapshot = Current.family.snapshot(@period)
|
||||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@@ -19,52 +19,10 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def list
|
||||
@period = Period.from_param(params[:period])
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def new
|
||||
@account = Account.new(
|
||||
accountable: Accountable.from_type(params[:type])&.new,
|
||||
currency: Current.family.currency
|
||||
)
|
||||
|
||||
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
|
||||
|
||||
if params[:institution_id]
|
||||
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@series = @account.series(period: @period)
|
||||
@trend = @series.trend
|
||||
end
|
||||
|
||||
def edit
|
||||
@account.accountable.build_address if @account.accountable.is_a?(Property) && @account.accountable.address.blank?
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params)
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
def create
|
||||
@account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
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
|
||||
|
||||
def destroy
|
||||
@account.destroy!
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def sync
|
||||
unless @account.syncing?
|
||||
@account.sync_later
|
||||
@@ -77,12 +35,7 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :accountable_type, :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])
|
||||
|
||||
60
app/controllers/concerns/accountable_resource.rb
Normal file
60
app/controllers/concerns/accountable_resource.rb
Normal file
@@ -0,0 +1,60 @@
|
||||
module AccountableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
layout :with_sidebar
|
||||
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def permitted_accountable_attributes(*attrs)
|
||||
@permitted_accountable_attributes = attrs if attrs.any?
|
||||
@permitted_accountable_attributes ||= [ :id ]
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
@account = Current.family.accounts.build(
|
||||
currency: Current.family.currency,
|
||||
accountable: accountable_type.new,
|
||||
institution_id: params[:institution_id]
|
||||
)
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def create
|
||||
@account = Current.family.accounts.create_and_sync(account_params.except(:return_to))
|
||||
redirect_to account_params[:return_to].presence || @account, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params.except(:return_to))
|
||||
redirect_back_or_to @account, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@account.destroy!
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def accountable_type
|
||||
controller_name.classify.constantize
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(
|
||||
:name, :is_active, :balance, :subtype, :currency, :institution_id, :accountable_type, :return_to,
|
||||
accountable_attributes: self.class.permitted_accountable_attributes
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
module Filterable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_period
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_period
|
||||
@period = Period.find_by_name(params[:period])
|
||||
if @period.nil?
|
||||
start_date = params[:start_date].presence&.to_date
|
||||
end_date = params[:end_date].presence&.to_date
|
||||
if start_date.is_a?(Date) && end_date.is_a?(Date) && start_date <= end_date
|
||||
@period = Period.new(name: "custom", date_range: start_date..end_date)
|
||||
else
|
||||
params[:period] = "last_30_days"
|
||||
@period = Period.find_by_name(params[:period])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
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
|
||||
@@ -7,6 +7,7 @@ module Invitable
|
||||
|
||||
private
|
||||
def invite_code_required?
|
||||
return false if @invitation.present?
|
||||
self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true"
|
||||
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
|
||||
@@ -1,41 +1,12 @@
|
||||
class CreditCardsController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
include AccountableResource
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params)
|
||||
redirect_to @account, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:available_credit,
|
||||
:minimum_payment,
|
||||
:apr,
|
||||
:annual_fee,
|
||||
:expiration_date
|
||||
]
|
||||
)
|
||||
end
|
||||
permitted_accountable_attributes(
|
||||
:id,
|
||||
:available_credit,
|
||||
:minimum_payment,
|
||||
:apr,
|
||||
:annual_fee,
|
||||
:expiration_date
|
||||
)
|
||||
end
|
||||
|
||||
3
app/controllers/cryptos_controller.rb
Normal file
3
app/controllers/cryptos_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class CryptosController < ApplicationController
|
||||
include AccountableResource
|
||||
end
|
||||
3
app/controllers/depositories_controller.rb
Normal file
3
app/controllers/depositories_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class DepositoriesController < ApplicationController
|
||||
include AccountableResource
|
||||
end
|
||||
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
|
||||
|
||||
3
app/controllers/investments_controller.rb
Normal file
3
app/controllers/investments_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class InvestmentsController < ApplicationController
|
||||
include AccountableResource
|
||||
end
|
||||
42
app/controllers/invitations_controller.rb
Normal file
42
app/controllers/invitations_controller.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class InvitationsController < ApplicationController
|
||||
skip_authentication only: :accept
|
||||
def new
|
||||
@invitation = Invitation.new
|
||||
end
|
||||
|
||||
def create
|
||||
unless Current.user.admin?
|
||||
flash[:alert] = t(".failure")
|
||||
redirect_to settings_profile_path
|
||||
return
|
||||
end
|
||||
|
||||
@invitation = Current.family.invitations.build(invitation_params)
|
||||
@invitation.inviter = Current.user
|
||||
|
||||
if @invitation.save
|
||||
InvitationMailer.invite_email(@invitation).deliver_later unless self_hosted?
|
||||
flash[:notice] = t(".success")
|
||||
else
|
||||
flash[:alert] = t(".failure")
|
||||
end
|
||||
|
||||
redirect_to settings_profile_path
|
||||
end
|
||||
|
||||
def accept
|
||||
@invitation = Invitation.find_by!(token: params[:id])
|
||||
|
||||
if @invitation.pending?
|
||||
redirect_to new_registration_path(invitation: @invitation.token)
|
||||
else
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invitation_params
|
||||
params.require(:invitation).permit(:email, :role)
|
||||
end
|
||||
end
|
||||
@@ -3,8 +3,9 @@ class Issue::ExchangeRateProviderMissingsController < ApplicationController
|
||||
|
||||
def update
|
||||
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
|
||||
@issue.issuable.sync_later
|
||||
redirect_back_or_to account_path(@issue.issuable)
|
||||
account = @issue.issuable
|
||||
account.sync_later
|
||||
redirect_back_or_to account
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,39 +1,7 @@
|
||||
class LoansController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
include AccountableResource
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params)
|
||||
redirect_to @account, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:rate_type,
|
||||
:interest_rate,
|
||||
:term_months
|
||||
]
|
||||
)
|
||||
end
|
||||
permitted_accountable_attributes(
|
||||
:id, :rate_type, :interest_rate, :term_months
|
||||
)
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
24
app/controllers/onboardings_controller.rb
Normal file
24
app/controllers/onboardings_controller.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class OnboardingsController < ApplicationController
|
||||
layout "application"
|
||||
before_action :set_user
|
||||
before_action :load_invitation
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def profile
|
||||
end
|
||||
|
||||
def preferences
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
def load_invitation
|
||||
@invitation = Current.family.invitations.accepted.find_by(email: Current.user.email)
|
||||
end
|
||||
end
|
||||
3
app/controllers/other_assets_controller.rb
Normal file
3
app/controllers/other_assets_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class OtherAssetsController < ApplicationController
|
||||
include AccountableResource
|
||||
end
|
||||
3
app/controllers/other_liabilities_controller.rb
Normal file
3
app/controllers/other_liabilities_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class OtherLiabilitiesController < ApplicationController
|
||||
include AccountableResource
|
||||
end
|
||||
@@ -2,9 +2,8 @@ class PagesController < ApplicationController
|
||||
skip_before_action :authenticate_user!, only: %i[early_access]
|
||||
layout :with_sidebar, except: %i[early_access]
|
||||
|
||||
include Filterable
|
||||
|
||||
def dashboard
|
||||
@period = Period.from_param(params[:period])
|
||||
snapshot = Current.family.snapshot(@period)
|
||||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@@ -20,7 +19,7 @@ class PagesController < ApplicationController
|
||||
@top_earners = snapshot_account_transactions[:top_earners]
|
||||
@top_savers = snapshot_account_transactions[:top_savers]
|
||||
|
||||
@accounts = Current.family.accounts
|
||||
@accounts = Current.family.accounts.active
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class PasswordResetsController < ApplicationController
|
||||
).password_reset.deliver_later
|
||||
end
|
||||
|
||||
redirect_to root_path, notice: t(".requested")
|
||||
redirect_to new_password_reset_path(step: "pending")
|
||||
end
|
||||
|
||||
def edit
|
||||
|
||||
@@ -1,40 +1,22 @@
|
||||
class PropertiesController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
include AccountableResource
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
permitted_accountable_attributes(
|
||||
:id, :year_built, :area_unit, :area_value,
|
||||
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
|
||||
)
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
def new
|
||||
@account = Current.family.accounts.build(
|
||||
currency: Current.family.currency,
|
||||
accountable: Property.new(
|
||||
address: Address.new
|
||||
),
|
||||
institution_id: params[:institution_id]
|
||||
)
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params)
|
||||
redirect_to @account, notice: t(".success")
|
||||
def edit
|
||||
@account.accountable.address ||= Address.new
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:year_built,
|
||||
:area_unit,
|
||||
:area_value,
|
||||
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,36 +4,49 @@ class RegistrationsController < ApplicationController
|
||||
layout "auth"
|
||||
|
||||
before_action :set_user, only: :create
|
||||
before_action :set_invitation
|
||||
before_action :claim_invite_code, only: :create, if: :invite_code_required?
|
||||
|
||||
def new
|
||||
@user = User.new
|
||||
@user = User.new(email: @invitation&.email)
|
||||
end
|
||||
|
||||
def create
|
||||
family = Family.new
|
||||
@user.family = family
|
||||
@user.role = :admin
|
||||
if @invitation
|
||||
@user.family = @invitation.family
|
||||
@user.role = @invitation.role
|
||||
@user.email = @invitation.email
|
||||
else
|
||||
family = Family.new
|
||||
@user.family = family
|
||||
@user.role = :admin
|
||||
end
|
||||
|
||||
if @user.save
|
||||
Category.create_default_categories(@user.family)
|
||||
@invitation&.update!(accepted_at: Time.current)
|
||||
Category.create_default_categories(@user.family) unless @invitation
|
||||
@session = create_session_for(@user)
|
||||
flash[:notice] = t(".success")
|
||||
redirect_to root_path
|
||||
redirect_to root_path, notice: t(".success")
|
||||
else
|
||||
flash[:alert] = t(".failure")
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = User.new user_params.except(:invite_code)
|
||||
def set_invitation
|
||||
token = params[:invitation]
|
||||
token ||= params[:user][:invitation] if params[:user].present?
|
||||
@invitation = Invitation.pending.find_by(token: token)
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
|
||||
def set_user
|
||||
@user = User.new user_params.except(:invite_code, :invitation)
|
||||
end
|
||||
|
||||
def user_params(specific_param = nil)
|
||||
params = self.params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code, :invitation)
|
||||
specific_param ? params[specific_param] : params
|
||||
end
|
||||
|
||||
def claim_invite_code
|
||||
|
||||
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,7 @@
|
||||
class Settings::ProfilesController < SettingsController
|
||||
def show
|
||||
@user = Current.user
|
||||
@users = Current.family.users.order(:created_at)
|
||||
@pending_invitations = Current.family.invitations.pending
|
||||
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
|
||||
|
||||
@@ -32,11 +32,12 @@ class TransactionsController < ApplicationController
|
||||
.create!(transaction_entry_params.merge(amount: amount))
|
||||
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to account_path(@entry.account), notice: t(".success")
|
||||
redirect_back_or_to @entry.account, notice: t(".success")
|
||||
end
|
||||
|
||||
def bulk_delete
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
destroyed.map(&:account).uniq.each(&:sync_later)
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
|
||||
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
|
||||
@@ -1,41 +1,7 @@
|
||||
class VehiclesController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
include AccountableResource
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params)
|
||||
redirect_to @account, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:make,
|
||||
:model,
|
||||
:year,
|
||||
:mileage_value,
|
||||
:mileage_unit
|
||||
]
|
||||
)
|
||||
end
|
||||
permitted_accountable_attributes(
|
||||
:id, :make, :model, :year, :mileage_value, :mileage_unit
|
||||
)
|
||||
end
|
||||
|
||||
@@ -12,43 +12,18 @@ module Account::EntriesHelper
|
||||
transfers.map(&:transfer).uniq
|
||||
end
|
||||
|
||||
def entry_icon(entry, is_oldest: false)
|
||||
if is_oldest
|
||||
"keyboard"
|
||||
elsif entry.trend.direction.up?
|
||||
"arrow-up"
|
||||
elsif entry.trend.direction.down?
|
||||
"arrow-down"
|
||||
else
|
||||
"minus"
|
||||
end
|
||||
end
|
||||
|
||||
def entry_style(entry, is_oldest: false)
|
||||
color = is_oldest ? "#D444F1" : entry.trend.color
|
||||
|
||||
mixed_hex_styles(color)
|
||||
end
|
||||
|
||||
def entry_name(entry)
|
||||
if entry.account_trade?
|
||||
trade = entry.account_trade
|
||||
prefix = trade.sell? ? "Sell " : "Buy "
|
||||
generated = prefix + "#{trade.qty.abs} shares of #{trade.security.ticker}"
|
||||
name = entry.name || generated
|
||||
name
|
||||
else
|
||||
entry.name || "Transaction"
|
||||
end
|
||||
end
|
||||
|
||||
def entries_by_date(entries, selectable: true)
|
||||
def entries_by_date(entries, selectable: true, totals: false)
|
||||
entries.group_by(&:date).map do |date, grouped_entries|
|
||||
content = capture do
|
||||
yield grouped_entries
|
||||
# Valuations always go first, then sort by created_at desc
|
||||
sorted_entries = grouped_entries.sort_by do |entry|
|
||||
[ entry.account_valuation? ? 0 : 1, -entry.created_at.to_i ]
|
||||
end
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable: }
|
||||
content = capture do
|
||||
yield sorted_entries
|
||||
end
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: sorted_entries, content:, selectable:, totals: }
|
||||
end.join.html_safe
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
module AccountsHelper
|
||||
def period_label(period)
|
||||
return "since account creation" if period.date_range.begin.nil?
|
||||
start_date, end_date = period.date_range.first, period.date_range.last
|
||||
|
||||
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
|
||||
return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
|
||||
|
||||
days_apart = (end_date - start_date).to_i
|
||||
|
||||
case days_apart
|
||||
when 1
|
||||
"vs. yesterday"
|
||||
when 7
|
||||
"vs. last week"
|
||||
when 30, 31
|
||||
"vs. last month"
|
||||
when 365, 366
|
||||
"vs. last year"
|
||||
else
|
||||
"from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
|
||||
end
|
||||
end
|
||||
|
||||
def summary_card(title:, &block)
|
||||
content = capture(&block)
|
||||
render "accounts/summary_card", title: title, content: content
|
||||
@@ -28,65 +51,9 @@ module AccountsHelper
|
||||
class_mapping(accountable_type)[:hex]
|
||||
end
|
||||
|
||||
# Eventually, we'll have an accountable form for each type of accountable, so
|
||||
# this helper is a convenience for now to reuse common logic in the accounts controller
|
||||
def new_account_form_url(account)
|
||||
case account.accountable_type
|
||||
when "Property"
|
||||
properties_path
|
||||
when "Vehicle"
|
||||
vehicles_path
|
||||
when "Loan"
|
||||
loans_path
|
||||
when "CreditCard"
|
||||
credit_cards_path
|
||||
else
|
||||
accounts_path
|
||||
end
|
||||
end
|
||||
|
||||
def edit_account_form_url(account)
|
||||
case account.accountable_type
|
||||
when "Property"
|
||||
property_path(account)
|
||||
when "Vehicle"
|
||||
vehicle_path(account)
|
||||
when "Loan"
|
||||
loan_path(account)
|
||||
when "CreditCard"
|
||||
credit_card_path(account)
|
||||
else
|
||||
account_path(account)
|
||||
end
|
||||
end
|
||||
|
||||
def account_tabs(account)
|
||||
overview_tab = { key: "overview", label: t("accounts.show.overview"), path: account_path(account, tab: "overview"), partial_path: "accounts/overview" }
|
||||
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), route: account_holdings_path(account) }
|
||||
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), route: account_cashes_path(account) }
|
||||
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), route: account_valuations_path(account) }
|
||||
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), route: account_transactions_path(account) }
|
||||
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), route: account_trades_path(account) }
|
||||
|
||||
return [ value_tab ] if account.other_asset? || account.other_liability?
|
||||
return [ overview_tab, value_tab ] if account.property? || account.vehicle?
|
||||
return [ holdings_tab, cash_tab, trades_tab, value_tab ] if account.investment?
|
||||
return [ overview_tab, value_tab, transactions_tab ] if account.loan? || account.credit_card?
|
||||
|
||||
[ value_tab, transactions_tab ]
|
||||
end
|
||||
|
||||
def selected_account_tab(account)
|
||||
available_tabs = account_tabs(account)
|
||||
|
||||
tab = available_tabs.find { |tab| tab[:key] == params[:tab] }
|
||||
|
||||
tab || available_tabs.first
|
||||
end
|
||||
|
||||
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, liabilities = Current.family.accounts.active.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
|
||||
[ 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
|
||||
@@ -113,26 +122,16 @@ module ApplicationHelper
|
||||
{ bg_class: bg_class, text_class: text_class, symbol: symbol, icon: icon }
|
||||
end
|
||||
|
||||
def period_label(period)
|
||||
return "since account creation" if period.date_range.begin.nil?
|
||||
start_date, end_date = period.date_range.first, period.date_range.last
|
||||
# Wrapper around I18n.l to support custom date formats
|
||||
def format_date(object, format = :default, options = {})
|
||||
date = object.to_date
|
||||
|
||||
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
|
||||
return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
|
||||
format_code = options[:format_code] || Current.family&.date_format
|
||||
|
||||
days_apart = (end_date - start_date).to_i
|
||||
|
||||
case days_apart
|
||||
when 1
|
||||
"vs. yesterday"
|
||||
when 7
|
||||
"vs. last week"
|
||||
when 30, 31
|
||||
"vs. last month"
|
||||
when 365, 366
|
||||
"vs. last year"
|
||||
if format_code.present?
|
||||
date.strftime(format_code)
|
||||
else
|
||||
"from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
|
||||
I18n.l(date, format: format, **options)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
2
app/helpers/invitations_helper.rb
Normal file
2
app/helpers/invitations_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module InvitationsHelper
|
||||
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,158 @@
|
||||
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: [] }
|
||||
}
|
||||
singularLabel: String,
|
||||
pluralLabel: String,
|
||||
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.classList.toggle("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() {
|
||||
if (this.selectedIdsValue.length === 1) {
|
||||
return this.singularLabelValue;
|
||||
}
|
||||
|
||||
return this.pluralLabelValue;
|
||||
}
|
||||
|
||||
#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.3 : 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
app/jobs/fetch_security_info_job.rb
Normal file
18
app/jobs/fetch_security_info_job.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class FetchSecurityInfoJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(security_id)
|
||||
return unless Security.security_info_provider.present?
|
||||
|
||||
security = Security.find(security_id)
|
||||
|
||||
security_info_response = Security.security_info_provider.fetch_security_info(
|
||||
ticker: security.ticker,
|
||||
mic_code: security.exchange_mic
|
||||
)
|
||||
|
||||
security.update(
|
||||
name: security_info_response.info.dig("name")
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: ENV["EMAIL_SENDER"] if ENV["EMAIL_SENDER"].present?
|
||||
default from: email_address_with_name(ENV.fetch("EMAIL_SENDER", "sender@maybe.local"), "Maybe Finance")
|
||||
layout "mailer"
|
||||
end
|
||||
|
||||
11
app/mailers/invitation_mailer.rb
Normal file
11
app/mailers/invitation_mailer.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class InvitationMailer < ApplicationMailer
|
||||
def invite_email(invitation)
|
||||
@invitation = invitation
|
||||
@accept_url = accept_invitation_url(@invitation.token)
|
||||
|
||||
mail(
|
||||
to: @invitation.email,
|
||||
subject: t(".subject", inviter: @invitation.inviter.display_name)
|
||||
)
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user