mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
commit
58ae4cf2ae
78 changed files with 4460 additions and 5025 deletions
|
|
@ -1 +1 @@
|
||||||
0.34.2
|
0.35.0
|
||||||
|
|
|
||||||
2
.github/workflows/build_and_push.yml
vendored
2
.github/workflows/build_and_push.yml
vendored
|
|
@ -96,7 +96,7 @@ jobs:
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/Dockerfile.dev
|
file: ./docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -84,3 +84,4 @@ node_modules/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
/e2e/temp/
|
||||||
|
|
|
||||||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
|
||||||
|
# [0.35.0]
|
||||||
|
|
||||||
|
⚠️ Important ⚠️
|
||||||
|
|
||||||
|
The default `docker-compose.yml` file has been updated to provide sensible defaults for self-hosted production environments. This should not break existing setups, but it's recommended to review your `docker-compose.yml` file and update it accordingly.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- Selection tool on the map now can select points that user can delete in bulk. #433
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Taiwan flag is now shown on its own instead of in combination with China flag.
|
||||||
|
- On the registration page and other user forms, if something goes wrong, error messages are now shown to the user.
|
||||||
|
- Leaving family, deleting family and cancelling invitations now prompt confirmation dialog to prevent accidental actions.
|
||||||
|
- Each pending family invitation now also contains a link to share with the invitee.
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Removed useless system tests and cover map functionality with Playwright e2e tests instead.
|
||||||
|
- S3 storage now can be used in self-hosted instances as well. Set STORAGE_BACKEND environment variable to `s3` and provide `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `AWS_BUCKET` and `AWS_ENDPOINT_URL` environment variables to configure it.
|
||||||
|
- Number of family members on self-hosted instances is no longer limited. #1918
|
||||||
|
- Export to GPX now adds speed and course to each point if they are available.
|
||||||
|
- `docker-compose.yml` file updated to provide sensible defaults for self-hosted production environment.
|
||||||
|
- `.env.example` file added with default environment variables.
|
||||||
|
- Single Dockerfile introduced so Dawarich could be run in self-hosted mode in production environment.
|
||||||
|
|
||||||
# [0.34.2] - 2025-10-31
|
# [0.34.2] - 2025-10-31
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
|
||||||
8
Gemfile
8
Gemfile
|
|
@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
|
||||||
|
|
||||||
ruby File.read('.ruby-version').strip
|
ruby File.read('.ruby-version').strip
|
||||||
|
|
||||||
gem 'activerecord-postgis-adapter'
|
gem 'activerecord-postgis-adapter', '~> 11.0'
|
||||||
# https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40
|
# https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40
|
||||||
gem 'aws-sdk-core', '~> 3.215.1', require: false
|
gem 'aws-sdk-core', '~> 3.215.1', require: false
|
||||||
gem 'aws-sdk-kms', '~> 1.96.0', require: false
|
gem 'aws-sdk-kms', '~> 1.96.0', require: false
|
||||||
|
|
@ -29,12 +29,12 @@ gem 'pg'
|
||||||
gem 'prometheus_exporter'
|
gem 'prometheus_exporter'
|
||||||
gem 'puma'
|
gem 'puma'
|
||||||
gem 'pundit', '>= 2.5.1'
|
gem 'pundit', '>= 2.5.1'
|
||||||
gem 'rails', '~> 8.0', '>= 8.0.3'
|
gem 'rails', '~> 8.0'
|
||||||
gem 'rails_icons'
|
gem 'rails_icons'
|
||||||
gem 'redis'
|
gem 'redis'
|
||||||
gem 'rexml'
|
gem 'rexml'
|
||||||
gem 'rgeo'
|
gem 'rgeo'
|
||||||
gem 'rgeo-activerecord'
|
gem 'rgeo-activerecord', '~> 8.0.0'
|
||||||
gem 'rgeo-geojson'
|
gem 'rgeo-geojson'
|
||||||
gem 'rqrcode', '~> 3.0'
|
gem 'rqrcode', '~> 3.0'
|
||||||
gem 'rswag-api'
|
gem 'rswag-api'
|
||||||
|
|
@ -48,7 +48,6 @@ gem 'sidekiq-limit_fetch'
|
||||||
gem 'sprockets-rails'
|
gem 'sprockets-rails'
|
||||||
gem 'stackprof'
|
gem 'stackprof'
|
||||||
gem 'stimulus-rails'
|
gem 'stimulus-rails'
|
||||||
gem 'strong_migrations', '>= 2.4.0'
|
|
||||||
gem 'tailwindcss-rails', '= 3.3.2'
|
gem 'tailwindcss-rails', '= 3.3.2'
|
||||||
gem 'turbo-rails', '>= 2.0.17'
|
gem 'turbo-rails', '>= 2.0.17'
|
||||||
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
|
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
|
||||||
|
|
@ -80,4 +79,5 @@ group :development do
|
||||||
gem 'database_consistency', '>= 2.0.5', require: false
|
gem 'database_consistency', '>= 2.0.5', require: false
|
||||||
gem 'foreman'
|
gem 'foreman'
|
||||||
gem 'rubocop-rails', '>= 2.33.4', require: false
|
gem 'rubocop-rails', '>= 2.33.4', require: false
|
||||||
|
gem 'strong_migrations', '>= 2.4.0'
|
||||||
end
|
end
|
||||||
|
|
|
||||||
61
Gemfile.lock
61
Gemfile.lock
|
|
@ -107,10 +107,10 @@ GEM
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
benchmark (0.4.1)
|
benchmark (0.4.1)
|
||||||
bigdecimal (3.2.3)
|
bigdecimal (3.3.1)
|
||||||
bootsnap (1.18.6)
|
bootsnap (1.18.6)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.0.2)
|
brakeman (7.1.0)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.2)
|
bundler-audit (0.9.2)
|
||||||
|
|
@ -139,12 +139,12 @@ GEM
|
||||||
tzinfo
|
tzinfo
|
||||||
unicode (>= 0.4.4.5)
|
unicode (>= 0.4.4.5)
|
||||||
csv (3.3.4)
|
csv (3.3.4)
|
||||||
data_migrate (11.3.0)
|
data_migrate (11.3.1)
|
||||||
activerecord (>= 6.1)
|
activerecord (>= 6.1)
|
||||||
railties (>= 6.1)
|
railties (>= 6.1)
|
||||||
database_consistency (2.0.6)
|
database_consistency (2.0.6)
|
||||||
activerecord (>= 3.2)
|
activerecord (>= 3.2)
|
||||||
date (3.4.1)
|
date (3.5.0)
|
||||||
debug (1.11.0)
|
debug (1.11.0)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
|
|
@ -161,7 +161,7 @@ GEM
|
||||||
dotenv (= 3.1.8)
|
dotenv (= 3.1.8)
|
||||||
railties (>= 6.1)
|
railties (>= 6.1)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
erb (5.0.2)
|
erb (5.1.3)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
|
|
@ -251,7 +251,7 @@ GEM
|
||||||
method_source (1.1.0)
|
method_source (1.1.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.25.5)
|
minitest (5.26.0)
|
||||||
msgpack (1.7.3)
|
msgpack (1.7.3)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.7.1)
|
multi_xml (0.7.1)
|
||||||
|
|
@ -296,7 +296,7 @@ GEM
|
||||||
pg (1.6.2-arm64-darwin)
|
pg (1.6.2-arm64-darwin)
|
||||||
pg (1.6.2-x86_64-darwin)
|
pg (1.6.2-x86_64-darwin)
|
||||||
pg (1.6.2-x86_64-linux)
|
pg (1.6.2-x86_64-linux)
|
||||||
pp (0.6.2)
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.5.1)
|
prism (1.5.1)
|
||||||
|
|
@ -320,7 +320,7 @@ GEM
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.2)
|
rack (3.2.3)
|
||||||
rack-session (2.1.1)
|
rack-session (2.1.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
|
|
@ -362,10 +362,11 @@ GEM
|
||||||
tsort (>= 0.2)
|
tsort (>= 0.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.0)
|
rake (13.3.1)
|
||||||
rdoc (6.14.2)
|
rdoc (6.15.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
|
tsort
|
||||||
redis (5.4.0)
|
redis (5.4.0)
|
||||||
redis-client (>= 0.22.0)
|
redis-client (>= 0.22.0)
|
||||||
redis-client (0.24.0)
|
redis-client (0.24.0)
|
||||||
|
|
@ -407,17 +408,17 @@ GEM
|
||||||
rspec-mocks (~> 3.13)
|
rspec-mocks (~> 3.13)
|
||||||
rspec-support (~> 3.13)
|
rspec-support (~> 3.13)
|
||||||
rspec-support (3.13.3)
|
rspec-support (3.13.3)
|
||||||
rswag-api (2.16.0)
|
rswag-api (2.17.0)
|
||||||
activesupport (>= 5.2, < 8.1)
|
activesupport (>= 5.2, < 8.2)
|
||||||
railties (>= 5.2, < 8.1)
|
railties (>= 5.2, < 8.2)
|
||||||
rswag-specs (2.16.0)
|
rswag-specs (2.17.0)
|
||||||
activesupport (>= 5.2, < 8.1)
|
activesupport (>= 5.2, < 8.2)
|
||||||
json-schema (>= 2.2, < 6.0)
|
json-schema (>= 2.2, < 7.0)
|
||||||
railties (>= 5.2, < 8.1)
|
railties (>= 5.2, < 8.2)
|
||||||
rspec-core (>= 2.14)
|
rspec-core (>= 2.14)
|
||||||
rswag-ui (2.16.0)
|
rswag-ui (2.17.0)
|
||||||
actionpack (>= 5.2, < 8.1)
|
actionpack (>= 5.2, < 8.2)
|
||||||
railties (>= 5.2, < 8.1)
|
railties (>= 5.2, < 8.2)
|
||||||
rubocop (1.81.1)
|
rubocop (1.81.1)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
|
|
@ -447,10 +448,10 @@ GEM
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
sentry-rails (5.28.0)
|
sentry-rails (6.0.0)
|
||||||
railties (>= 5.0)
|
railties (>= 5.2.0)
|
||||||
sentry-ruby (~> 5.28.0)
|
sentry-ruby (~> 6.0.0)
|
||||||
sentry-ruby (5.28.0)
|
sentry-ruby (6.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
shoulda-matchers (6.5.0)
|
shoulda-matchers (6.5.0)
|
||||||
|
|
@ -487,7 +488,7 @@ GEM
|
||||||
stringio (3.1.7)
|
stringio (3.1.7)
|
||||||
strong_migrations (2.5.1)
|
strong_migrations (2.5.1)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
super_diff (0.16.0)
|
super_diff (0.17.0)
|
||||||
attr_extras (>= 6.2.4)
|
attr_extras (>= 6.2.4)
|
||||||
diff-lcs
|
diff-lcs
|
||||||
patience_diff
|
patience_diff
|
||||||
|
|
@ -501,7 +502,7 @@ GEM
|
||||||
tailwindcss-ruby (3.4.17-x86_64-darwin)
|
tailwindcss-ruby (3.4.17-x86_64-darwin)
|
||||||
tailwindcss-ruby (3.4.17-x86_64-linux)
|
tailwindcss-ruby (3.4.17-x86_64-linux)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
timeout (0.4.3)
|
timeout (0.4.4)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.17)
|
turbo-rails (2.0.17)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
|
|
@ -512,7 +513,7 @@ GEM
|
||||||
unicode-display_width (3.2.0)
|
unicode-display_width (3.2.0)
|
||||||
unicode-emoji (~> 4.1)
|
unicode-emoji (~> 4.1)
|
||||||
unicode-emoji (4.1.0)
|
unicode-emoji (4.1.0)
|
||||||
uri (1.0.3)
|
uri (1.0.4)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
warden (1.2.9)
|
warden (1.2.9)
|
||||||
rack (>= 2.0.9)
|
rack (>= 2.0.9)
|
||||||
|
|
@ -539,7 +540,7 @@ PLATFORMS
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
activerecord-postgis-adapter
|
activerecord-postgis-adapter (~> 11.0)
|
||||||
aws-sdk-core (~> 3.215.1)
|
aws-sdk-core (~> 3.215.1)
|
||||||
aws-sdk-kms (~> 1.96.0)
|
aws-sdk-kms (~> 1.96.0)
|
||||||
aws-sdk-s3 (~> 1.177.0)
|
aws-sdk-s3 (~> 1.177.0)
|
||||||
|
|
@ -574,12 +575,12 @@ DEPENDENCIES
|
||||||
pry-rails
|
pry-rails
|
||||||
puma
|
puma
|
||||||
pundit (>= 2.5.1)
|
pundit (>= 2.5.1)
|
||||||
rails (~> 8.0, >= 8.0.3)
|
rails (~> 8.0)
|
||||||
rails_icons
|
rails_icons
|
||||||
redis
|
redis
|
||||||
rexml
|
rexml
|
||||||
rgeo
|
rgeo
|
||||||
rgeo-activerecord
|
rgeo-activerecord (~> 8.0.0)
|
||||||
rgeo-geojson
|
rgeo-geojson
|
||||||
rqrcode (~> 3.0)
|
rqrcode (~> 3.0)
|
||||||
rspec-rails (>= 8.0.1)
|
rspec-rails (>= 8.0.1)
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ Simply install one of the supported apps on your device and configure it to send
|
||||||
|
|
||||||
⏹️ **To stop the app**, press `Ctrl+C`.
|
⏹️ **To stop the app**, press `Ctrl+C`.
|
||||||
|
|
||||||
|
You can use default values or create a `.env` file based on `.env.example` to customize your setup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 How to Install Dawarich
|
## 🔧 How to Install Dawarich
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -76,33 +76,46 @@
|
||||||
/* Drawer Panel Styles */
|
/* Drawer Panel Styles */
|
||||||
.leaflet-drawer {
|
.leaflet-drawer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 10px;
|
||||||
right: 0;
|
right: 70px; /* Position to the left of the control buttons with margin */
|
||||||
width: 338px;
|
width: 24rem;
|
||||||
height: 100%;
|
max-height: calc(100% - 20px);
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: rgba(255, 255, 255, 0.5);
|
||||||
transform: translateX(100%);
|
border-radius: 8px;
|
||||||
transition: transform 0.3s ease-in-out;
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out, visibility 0.2s;
|
||||||
z-index: 450;
|
z-index: 450;
|
||||||
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
height: auto; /* Make height fit content */
|
||||||
|
cursor: default; /* Override map cursor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-drawer * {
|
||||||
|
cursor: default; /* Ensure all children have default cursor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-drawer a,
|
||||||
|
.leaflet-drawer button,
|
||||||
|
.leaflet-drawer .btn,
|
||||||
|
.leaflet-drawer input[type="checkbox"] {
|
||||||
|
cursor: pointer; /* Interactive elements get pointer cursor */
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-drawer.open {
|
.leaflet-drawer.open {
|
||||||
transform: translateX(0);
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Controls transition */
|
/* Controls remain in place - no transition needed */
|
||||||
.leaflet-control-layers,
|
.leaflet-control-layers,
|
||||||
.leaflet-control-button,
|
.leaflet-control-button,
|
||||||
.toggle-panel-button {
|
.toggle-panel-button {
|
||||||
transition: right 0.3s ease-in-out;
|
|
||||||
z-index: 500;
|
z-index: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-shifted {
|
|
||||||
right: 338px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection Tool Styles */
|
/* Selection Tool Styles */
|
||||||
.leaflet-control-custom {
|
.leaflet-control-custom {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
|
@ -127,6 +140,5 @@
|
||||||
|
|
||||||
/* Cancel Selection Button */
|
/* Cancel Selection Button */
|
||||||
#cancel-selection-button {
|
#cancel-selection-button {
|
||||||
margin-bottom: 1rem;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::PointsController < ApiController
|
class Api::V1::PointsController < ApiController
|
||||||
before_action :authenticate_active_api_user!, only: %i[create update destroy]
|
before_action :authenticate_active_api_user!, only: %i[create update destroy bulk_destroy]
|
||||||
before_action :validate_points_limit, only: %i[create]
|
before_action :validate_points_limit, only: %i[create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
|
@ -45,6 +45,16 @@ class Api::V1::PointsController < ApiController
|
||||||
render json: { message: 'Point deleted successfully' }
|
render json: { message: 'Point deleted successfully' }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bulk_destroy
|
||||||
|
point_ids = bulk_destroy_params[:point_ids]
|
||||||
|
|
||||||
|
render json: { error: 'No points selected' }, status: :unprocessable_entity and return if point_ids.blank?
|
||||||
|
|
||||||
|
deleted_count = current_api_user.points.where(id: point_ids).destroy_all.count
|
||||||
|
|
||||||
|
render json: { message: 'Points were successfully destroyed', count: deleted_count }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def point_params
|
def point_params
|
||||||
|
|
@ -55,6 +65,10 @@ class Api::V1::PointsController < ApiController
|
||||||
params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {})
|
params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bulk_destroy_params
|
||||||
|
params.permit(point_ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
def point_serializer
|
def point_serializer
|
||||||
params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer
|
params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_invitation
|
def set_invitation
|
||||||
return unless invitation_token.present?
|
return if invitation_token.blank?
|
||||||
|
|
||||||
@invitation = Family::Invitation.find_by(token: invitation_token)
|
@invitation = Family::Invitation.find_by(token: invitation_token)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@
|
||||||
module CountryFlagHelper
|
module CountryFlagHelper
|
||||||
def country_flag(country_name)
|
def country_flag(country_name)
|
||||||
country_code = country_to_code(country_name)
|
country_code = country_to_code(country_name)
|
||||||
return "" unless country_code
|
return '' unless country_code
|
||||||
|
|
||||||
|
country_code = 'TW' if country_code == 'CN-TW'
|
||||||
|
|
||||||
# Convert country code to regional indicator symbols (flag emoji)
|
# Convert country code to regional indicator symbols (flag emoji)
|
||||||
country_code.upcase.each_char.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join
|
country_code.upcase.each_char.map { |c| (c.ord + 127_397).chr(Encoding::UTF_8) }.join
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def country_to_code(country_name)
|
def country_to_code(country_name)
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,10 @@ export default class extends Controller {
|
||||||
if (this.currentPopup) {
|
if (this.currentPopup) {
|
||||||
this.map.closePopup(this.currentPopup);
|
this.map.closePopup(this.currentPopup);
|
||||||
this.currentPopup = null;
|
this.currentPopup = null;
|
||||||
|
} else {
|
||||||
|
console.warn('No currentPopup reference found');
|
||||||
|
// Fallback: try to close any open popup
|
||||||
|
this.map.closePopup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,7 +267,10 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelButton) {
|
if (cancelButton) {
|
||||||
cancelButton.addEventListener('click', () => {
|
cancelButton.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
this.exitAddVisitMode(this.addVisitButton);
|
this.exitAddVisitMode(this.addVisitButton);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -346,8 +353,6 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
addCreatedVisitToMap(visitData, latitude, longitude) {
|
addCreatedVisitToMap(visitData, latitude, longitude) {
|
||||||
console.log('Adding newly created visit to map immediately', { latitude, longitude, visitData });
|
|
||||||
|
|
||||||
const mapsController = document.querySelector('[data-controller*="maps"]');
|
const mapsController = document.querySelector('[data-controller*="maps"]');
|
||||||
if (!mapsController) {
|
if (!mapsController) {
|
||||||
console.log('Could not find maps controller element');
|
console.log('Could not find maps controller element');
|
||||||
|
|
@ -357,6 +362,7 @@ export default class extends Controller {
|
||||||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
||||||
if (!stimulusController || !stimulusController.visitsManager) {
|
if (!stimulusController || !stimulusController.visitsManager) {
|
||||||
console.log('Could not find maps controller or visits manager');
|
console.log('Could not find maps controller or visits manager');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,16 +382,10 @@ export default class extends Controller {
|
||||||
|
|
||||||
// Add the circle to the confirmed visits layer
|
// Add the circle to the confirmed visits layer
|
||||||
visitsManager.confirmedVisitCircles.addLayer(circle);
|
visitsManager.confirmedVisitCircles.addLayer(circle);
|
||||||
console.log('✅ Added newly created confirmed visit circle to layer');
|
|
||||||
console.log('Confirmed visits layer info:', {
|
|
||||||
layerCount: visitsManager.confirmedVisitCircles.getLayers().length,
|
|
||||||
isOnMap: this.map.hasLayer(visitsManager.confirmedVisitCircles)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make sure the layer is visible on the map
|
// Make sure the layer is visible on the map
|
||||||
if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) {
|
if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) {
|
||||||
this.map.addLayer(visitsManager.confirmedVisitCircles);
|
this.map.addLayer(visitsManager.confirmedVisitCircles);
|
||||||
console.log('✅ Added confirmed visits layer to map');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the layer control has the confirmed visits layer enabled
|
// Check if the layer control has the confirmed visits layer enabled
|
||||||
|
|
@ -411,9 +411,7 @@ export default class extends Controller {
|
||||||
inputs.forEach(input => {
|
inputs.forEach(input => {
|
||||||
const label = input.nextElementSibling;
|
const label = input.nextElementSibling;
|
||||||
if (label && label.textContent.trim().includes('Confirmed Visits')) {
|
if (label && label.textContent.trim().includes('Confirmed Visits')) {
|
||||||
console.log('Found Confirmed Visits checkbox, current state:', input.checked);
|
|
||||||
if (!input.checked) {
|
if (!input.checked) {
|
||||||
console.log('Enabling Confirmed Visits layer via checkbox');
|
|
||||||
input.checked = true;
|
input.checked = true;
|
||||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export default class extends Controller {
|
||||||
if (this.isUploading) {
|
if (this.isUploading) {
|
||||||
// If still uploading, prevent submission
|
// If still uploading, prevent submission
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
console.log("Form submission prevented during upload")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ export default class extends Controller {
|
||||||
const signedIds = this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]')
|
const signedIds = this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]')
|
||||||
if (signedIds.length === 0) {
|
if (signedIds.length === 0) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
console.log("No files uploaded yet")
|
|
||||||
alert("Please select and upload files first")
|
alert("Please select and upload files first")
|
||||||
} else {
|
} else {
|
||||||
console.log(`Submitting form with ${signedIds.length} uploaded files`)
|
console.log(`Submitting form with ${signedIds.length} uploaded files`)
|
||||||
|
|
@ -78,7 +78,6 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Uploading ${files.length} files`)
|
|
||||||
this.isUploading = true
|
this.isUploading = true
|
||||||
|
|
||||||
// Disable submit button during upload
|
// Disable submit button during upload
|
||||||
|
|
@ -124,8 +123,6 @@ export default class extends Controller {
|
||||||
// Add the progress wrapper AFTER the file input field but BEFORE the submit button
|
// Add the progress wrapper AFTER the file input field but BEFORE the submit button
|
||||||
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
|
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
|
||||||
|
|
||||||
console.log("Progress bar created and inserted before submit button")
|
|
||||||
|
|
||||||
let uploadCount = 0
|
let uploadCount = 0
|
||||||
const totalFiles = files.length
|
const totalFiles = files.length
|
||||||
|
|
||||||
|
|
@ -137,17 +134,13 @@ export default class extends Controller {
|
||||||
});
|
});
|
||||||
|
|
||||||
Array.from(files).forEach(file => {
|
Array.from(files).forEach(file => {
|
||||||
console.log(`Starting upload for ${file.name}`)
|
|
||||||
const upload = new DirectUpload(file, this.urlValue, this)
|
const upload = new DirectUpload(file, this.urlValue, this)
|
||||||
upload.create((error, blob) => {
|
upload.create((error, blob) => {
|
||||||
uploadCount++
|
uploadCount++
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Error uploading file:", error)
|
|
||||||
// Show error to user using flash
|
|
||||||
showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`)
|
showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`)
|
||||||
} else {
|
} else {
|
||||||
console.log(`Successfully uploaded ${file.name} with ID: ${blob.signed_id}`)
|
|
||||||
|
|
||||||
// Create a hidden field with the correct name
|
// Create a hidden field with the correct name
|
||||||
const hiddenField = document.createElement("input")
|
const hiddenField = document.createElement("input")
|
||||||
|
|
@ -155,8 +148,6 @@ export default class extends Controller {
|
||||||
hiddenField.setAttribute("name", "import[files][]")
|
hiddenField.setAttribute("name", "import[files][]")
|
||||||
hiddenField.setAttribute("value", blob.signed_id)
|
hiddenField.setAttribute("value", blob.signed_id)
|
||||||
this.element.appendChild(hiddenField)
|
this.element.appendChild(hiddenField)
|
||||||
|
|
||||||
console.log("Added hidden field with signed ID:", blob.signed_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable submit button when all uploads are complete
|
// Enable submit button when all uploads are complete
|
||||||
|
|
@ -186,8 +177,6 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.isUploading = false
|
this.isUploading = false
|
||||||
console.log("All uploads completed")
|
|
||||||
console.log(`Ready to submit with ${successfulUploads} files`)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ export default class extends BaseController {
|
||||||
this.addInfoToggleButton();
|
this.addInfoToggleButton();
|
||||||
|
|
||||||
// Initialize the visits manager
|
// Initialize the visits manager
|
||||||
this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme);
|
this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme, this);
|
||||||
|
|
||||||
// Expose visits manager globally for location search integration
|
// Expose visits manager globally for location search integration
|
||||||
window.visitsManager = this.visitsManager;
|
window.visitsManager = this.visitsManager;
|
||||||
|
|
@ -712,6 +712,9 @@ export default class extends BaseController {
|
||||||
if (this.map.hasLayer(this.fogOverlay)) {
|
if (this.map.hasLayer(this.fogOverlay)) {
|
||||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold);
|
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showFlashMessage('notice', 'Point deleted successfully');
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('There was a problem with the delete request:', error);
|
console.error('There was a problem with the delete request:', error);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
import { showFlashMessage } from "./helpers";
|
import { showFlashMessage } from "./helpers";
|
||||||
|
import { createPolylinesLayer } from "./polylines";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages visits functionality including displaying, fetching, and interacting with visits
|
* Manages visits functionality including displaying, fetching, and interacting with visits
|
||||||
*/
|
*/
|
||||||
export class VisitsManager {
|
export class VisitsManager {
|
||||||
constructor(map, apiKey, userTheme = 'dark') {
|
constructor(map, apiKey, userTheme = 'dark', mapsController = null) {
|
||||||
this.map = map;
|
this.map = map;
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
this.userTheme = userTheme;
|
this.userTheme = userTheme;
|
||||||
|
this.mapsController = mapsController;
|
||||||
|
|
||||||
// Create custom panes for different visit types
|
// Create custom panes for different visit types
|
||||||
// Leaflet default panes: tilePane=200, overlayPane=400, shadowPane=500, markerPane=600, tooltipPane=650, popupPane=700
|
// Leaflet default panes: tilePane=200, overlayPane=400, shadowPane=500, markerPane=600, tooltipPane=650, popupPane=700
|
||||||
|
|
@ -218,15 +220,20 @@ export class VisitsManager {
|
||||||
// Set selection as active to ensure date summary is displayed
|
// Set selection as active to ensure date summary is displayed
|
||||||
this.isSelectionActive = true;
|
this.isSelectionActive = true;
|
||||||
|
|
||||||
this.displayVisits(visits);
|
// Make sure the drawer is open FIRST, before displaying visits
|
||||||
|
|
||||||
// Make sure the drawer is open
|
|
||||||
if (!this.drawerOpen) {
|
if (!this.drawerOpen) {
|
||||||
this.toggleDrawer();
|
this.toggleDrawer();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add cancel selection button to the drawer
|
// Now display visits in the drawer
|
||||||
this.addSelectionCancelButton();
|
this.displayVisits(visits);
|
||||||
|
|
||||||
|
// Add cancel selection button to the drawer AFTER displayVisits
|
||||||
|
// This needs to be after because displayVisits sets innerHTML which would wipe out the buttons
|
||||||
|
// Use setTimeout to ensure DOM has fully updated
|
||||||
|
setTimeout(() => {
|
||||||
|
this.addSelectionCancelButton();
|
||||||
|
}, 0);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching visits in selection:', error);
|
console.error('Error fetching visits in selection:', error);
|
||||||
|
|
@ -362,7 +369,7 @@ export class VisitsManager {
|
||||||
const visitsCount = dateGroups[dateStr].count || 0;
|
const visitsCount = dateGroups[dateStr].count || 0;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="flex justify-between items-center py-1 border-b border-base-300 last:border-0 my-2 hover:bg-accent hover:text-accent-content transition-colors">
|
<div class="flex justify-between items-center py-1 border-b border-base-300 last:border-0 my-2 hover:bg-accent hover:text-accent-content transition-colors border-radius-md">
|
||||||
<div class="font-medium">${dateStr}</div>
|
<div class="font-medium">${dateStr}</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
${pointsCount > 0 ? `<div class="badge badge-secondary">${pointsCount} pts</div>` : ''}
|
${pointsCount > 0 ? `<div class="badge badge-secondary">${pointsCount} pts</div>` : ''}
|
||||||
|
|
@ -372,14 +379,18 @@ export class VisitsManager {
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Create the whole panel
|
// Create the whole panel with collapsible content
|
||||||
return `
|
return `
|
||||||
<div class="bg-base-100 rounded-lg p-3 mb-4 shadow-sm">
|
<details id="data-section-collapse" class="collapse collapse-arrow bg-base-100 rounded-lg mb-4 shadow-sm">
|
||||||
<h3 class="text-lg font-bold mb-2">Data in Selected Area</h3>
|
<summary class="collapse-title text-lg font-bold">
|
||||||
<div class="divide-y divide-base-300">
|
Data in Selected Area
|
||||||
${dateItems}
|
</summary>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<div class="divide-y divide-base-300">
|
||||||
|
${dateItems}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,18 +399,207 @@ export class VisitsManager {
|
||||||
*/
|
*/
|
||||||
addSelectionCancelButton() {
|
addSelectionCancelButton() {
|
||||||
const container = document.getElementById('visits-list');
|
const container = document.getElementById('visits-list');
|
||||||
if (!container) return;
|
if (!container) {
|
||||||
|
console.error('addSelectionCancelButton: visits-list container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add cancel button at the top of the drawer if it doesn't exist
|
// Remove any existing button container first to avoid duplicates
|
||||||
if (!document.getElementById('cancel-selection-button')) {
|
const existingButtonContainer = document.getElementById('selection-button-container');
|
||||||
const cancelButton = document.createElement('button');
|
if (existingButtonContainer) {
|
||||||
cancelButton.id = 'cancel-selection-button';
|
existingButtonContainer.remove();
|
||||||
cancelButton.className = 'btn btn-sm btn-warning mb-4 w-full';
|
}
|
||||||
cancelButton.textContent = 'Cancel Area Selection';
|
|
||||||
cancelButton.onclick = () => this.clearSelection();
|
|
||||||
|
|
||||||
// Insert at the beginning of the container
|
// Create a button container
|
||||||
container.insertBefore(cancelButton, container.firstChild);
|
const buttonContainer = document.createElement('div');
|
||||||
|
buttonContainer.className = 'flex flex-col gap-2 mb-4';
|
||||||
|
buttonContainer.id = 'selection-button-container';
|
||||||
|
|
||||||
|
// Cancel button
|
||||||
|
const cancelButton = document.createElement('button');
|
||||||
|
cancelButton.id = 'cancel-selection-button';
|
||||||
|
cancelButton.className = 'btn btn-sm btn-warning w-full';
|
||||||
|
cancelButton.textContent = 'Cancel Selection';
|
||||||
|
cancelButton.onclick = () => this.clearSelection();
|
||||||
|
|
||||||
|
// Delete all selected points button
|
||||||
|
const deleteButton = document.createElement('button');
|
||||||
|
deleteButton.id = 'delete-selection-button';
|
||||||
|
deleteButton.className = 'btn btn-sm btn-error w-full';
|
||||||
|
deleteButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline mr-1"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>Delete Points';
|
||||||
|
deleteButton.onclick = () => this.deleteSelectedPoints();
|
||||||
|
|
||||||
|
// Add count badge if we have selected points
|
||||||
|
if (this.selectedPoints && this.selectedPoints.length > 0) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'badge badge-sm ml-1';
|
||||||
|
badge.textContent = this.selectedPoints.length;
|
||||||
|
deleteButton.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonContainer.appendChild(cancelButton);
|
||||||
|
buttonContainer.appendChild(deleteButton);
|
||||||
|
|
||||||
|
// Insert at the beginning of the container
|
||||||
|
container.insertBefore(buttonContainer, container.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all points in the current selection
|
||||||
|
*/
|
||||||
|
async deleteSelectedPoints() {
|
||||||
|
if (!this.selectedPoints || this.selectedPoints.length === 0) {
|
||||||
|
showFlashMessage('warning', 'No points selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointCount = this.selectedPoints.length;
|
||||||
|
const confirmed = confirm(
|
||||||
|
`⚠️ WARNING: This will permanently delete ${pointCount} point${pointCount > 1 ? 's' : ''} from your location history.\n\n` +
|
||||||
|
`This action cannot be undone!\n\n` +
|
||||||
|
`Are you sure you want to continue?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get point IDs from the selected points
|
||||||
|
// Debug: log the structure of selected points
|
||||||
|
console.log('Selected points sample:', this.selectedPoints[0]);
|
||||||
|
|
||||||
|
// Points format: [lat, lng, ?, ?, timestamp, ?, id, country, ?]
|
||||||
|
// ID is at index 6 based on the marker array structure
|
||||||
|
const pointIds = this.selectedPoints
|
||||||
|
.map(point => point[6]) // ID is at index 6
|
||||||
|
.filter(id => id != null && id !== '');
|
||||||
|
|
||||||
|
console.log('Point IDs to delete:', pointIds);
|
||||||
|
|
||||||
|
if (pointIds.length === 0) {
|
||||||
|
showFlashMessage('error', 'No valid point IDs found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the bulk delete API
|
||||||
|
const response = await fetch('/api/v1/points/bulk_destroy', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`,
|
||||||
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ point_ids: pointIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Response error:', response.status, errorText);
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Delete result:', result);
|
||||||
|
|
||||||
|
// Check if any points were actually deleted
|
||||||
|
if (result.count === 0) {
|
||||||
|
showFlashMessage('warning', 'No points were deleted. They may have already been removed.');
|
||||||
|
this.clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showFlashMessage('notice', `Successfully deleted ${result.count} point${result.count > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
// Remove deleted points from the map
|
||||||
|
pointIds.forEach(id => {
|
||||||
|
this.mapsController.removeMarker(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the polylines layer
|
||||||
|
this.updatePolylinesAfterDeletion();
|
||||||
|
|
||||||
|
// Update heatmap with remaining markers
|
||||||
|
if (this.mapsController.heatmapLayer) {
|
||||||
|
this.mapsController.heatmapLayer.setLatLngs(
|
||||||
|
this.mapsController.markers.map(marker => [marker[0], marker[1], 0.2])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fog if enabled
|
||||||
|
if (this.mapsController.fogOverlay && this.mapsController.map.hasLayer(this.mapsController.fogOverlay)) {
|
||||||
|
this.mapsController.updateFog(
|
||||||
|
this.mapsController.markers,
|
||||||
|
this.mapsController.clearFogRadius,
|
||||||
|
this.mapsController.fogLineThreshold
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
this.clearSelection();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting points:', error);
|
||||||
|
showFlashMessage('error', 'Failed to delete points. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates polylines layer after deletion (similar to single point deletion)
|
||||||
|
*/
|
||||||
|
updatePolylinesAfterDeletion() {
|
||||||
|
let wasPolyLayerVisible = false;
|
||||||
|
|
||||||
|
// Check if polylines layer was visible
|
||||||
|
if (this.mapsController.polylinesLayer) {
|
||||||
|
if (this.mapsController.map.hasLayer(this.mapsController.polylinesLayer)) {
|
||||||
|
wasPolyLayerVisible = true;
|
||||||
|
}
|
||||||
|
this.mapsController.map.removeLayer(this.mapsController.polylinesLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new polylines layer with updated markers
|
||||||
|
this.mapsController.polylinesLayer = createPolylinesLayer(
|
||||||
|
this.mapsController.markers,
|
||||||
|
this.mapsController.map,
|
||||||
|
this.mapsController.timezone,
|
||||||
|
this.mapsController.routeOpacity,
|
||||||
|
this.mapsController.userSettings,
|
||||||
|
this.mapsController.distanceUnit
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-add to map if it was visible, otherwise ensure it's removed
|
||||||
|
if (wasPolyLayerVisible) {
|
||||||
|
this.mapsController.polylinesLayer.addTo(this.mapsController.map);
|
||||||
|
} else {
|
||||||
|
this.mapsController.map.removeLayer(this.mapsController.polylinesLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update layer control
|
||||||
|
if (this.mapsController.layerControl) {
|
||||||
|
this.mapsController.map.removeControl(this.mapsController.layerControl);
|
||||||
|
const controlsLayer = {
|
||||||
|
Points: this.mapsController.markersLayer || L.layerGroup(),
|
||||||
|
Routes: this.mapsController.polylinesLayer || L.layerGroup(),
|
||||||
|
Tracks: this.mapsController.tracksLayer || L.layerGroup(),
|
||||||
|
Heatmap: this.mapsController.heatmapLayer || L.layerGroup(),
|
||||||
|
"Fog of War": this.mapsController.fogOverlay,
|
||||||
|
"Scratch map": this.mapsController.scratchLayerManager?.getLayer() || L.layerGroup(),
|
||||||
|
Areas: this.mapsController.areasLayer || L.layerGroup(),
|
||||||
|
Photos: this.mapsController.photoMarkers || L.layerGroup(),
|
||||||
|
"Suggested Visits": this.getVisitCirclesLayer(),
|
||||||
|
"Confirmed Visits": this.getConfirmedVisitCirclesLayer()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include Family Members layer if available
|
||||||
|
if (window.familyMembersController?.familyMarkersLayer) {
|
||||||
|
controlsLayer['Family Members'] = window.familyMembersController.familyMarkersLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mapsController.layerControl = L.control.layers(
|
||||||
|
this.mapsController.baseMaps(),
|
||||||
|
controlsLayer
|
||||||
|
).addTo(this.mapsController.map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,13 +624,9 @@ export class VisitsManager {
|
||||||
drawerButton.innerHTML = this.drawerOpen ? '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right-close-icon lucide-panel-right-close"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>' : '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right-open-icon lucide-panel-right-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>';
|
drawerButton.innerHTML = this.drawerOpen ? '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right-close-icon lucide-panel-right-close"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>' : '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right-open-icon lucide-panel-right-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>';
|
||||||
}
|
}
|
||||||
|
|
||||||
const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel, .drawer-button, #selection-tool-button');
|
|
||||||
controls.forEach(control => {
|
|
||||||
control.classList.toggle('controls-shifted');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the drawer content if it's being opened - but don't fetch visits automatically
|
// Update the drawer content if it's being opened - but don't fetch visits automatically
|
||||||
if (this.drawerOpen) {
|
// Only show the "no data" message if there's no selection active
|
||||||
|
if (this.drawerOpen && !this.isSelectionActive) {
|
||||||
const container = document.getElementById('visits-list');
|
const container = document.getElementById('visits-list');
|
||||||
if (container) {
|
if (container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
|
|
@ -451,16 +647,18 @@ export class VisitsManager {
|
||||||
createDrawer() {
|
createDrawer() {
|
||||||
const drawer = document.createElement('div');
|
const drawer = document.createElement('div');
|
||||||
drawer.id = 'visits-drawer';
|
drawer.id = 'visits-drawer';
|
||||||
drawer.className = 'fixed top-0 right-0 h-full w-64 bg-base-100 shadow-lg transform translate-x-full transition-transform duration-300 ease-in-out z-39 overflow-y-auto leaflet-drawer';
|
drawer.className = 'bg-base-100 shadow-lg z-39 overflow-y-auto leaflet-drawer';
|
||||||
|
|
||||||
// Add styles to make the drawer scrollable
|
// Add styles to make the drawer scrollable
|
||||||
drawer.style.overflowY = 'auto';
|
drawer.style.overflowY = 'auto';
|
||||||
drawer.style.maxHeight = '100vh';
|
|
||||||
|
|
||||||
drawer.innerHTML = `
|
drawer.innerHTML = `
|
||||||
<div class="p-3 drawer">
|
<div class="p-3 my-2 drawer flex flex-col items-center relative">
|
||||||
<h2 class="text-xl font-bold mb-4 text-accent-content">Recent Visits</h2>
|
<button id="close-visits-drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" title="Close panel">
|
||||||
<div id="visits-list" class="space-y-2">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
|
||||||
|
</button>
|
||||||
|
<h2 class="text-xl font-bold mb-4 text-accent-content w-full text-center">Recent Visits</h2>
|
||||||
|
<div id="visits-list" class="space-y-2 w-full">
|
||||||
<p class="text-gray-500">Loading visits...</p>
|
<p class="text-gray-500">Loading visits...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -472,6 +670,15 @@ export class VisitsManager {
|
||||||
L.DomEvent.disableClickPropagation(drawer);
|
L.DomEvent.disableClickPropagation(drawer);
|
||||||
|
|
||||||
this.map.getContainer().appendChild(drawer);
|
this.map.getContainer().appendChild(drawer);
|
||||||
|
|
||||||
|
// Add close button event listener
|
||||||
|
const closeButton = drawer.querySelector('#close-visits-drawer');
|
||||||
|
if (closeButton) {
|
||||||
|
closeButton.addEventListener('click', () => {
|
||||||
|
this.toggleDrawer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return drawer;
|
return drawer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -630,6 +837,10 @@ export class VisitsManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save the current state of collapsible sections before updating
|
||||||
|
const dataSectionOpen = document.querySelector('#data-section-collapse')?.open || false;
|
||||||
|
const visitsSectionOpen = document.querySelector('#visits-section-collapse')?.open || false;
|
||||||
|
|
||||||
// Update the drawer title if selection is active
|
// Update the drawer title if selection is active
|
||||||
if (this.isSelectionActive && this.selectionRect) {
|
if (this.isSelectionActive && this.selectionRect) {
|
||||||
const visitsCount = visits ? visits.filter(visit => visit.status !== 'declined').length : 0;
|
const visitsCount = visits ? visits.filter(visit => visit.status !== 'declined').length : 0;
|
||||||
|
|
@ -693,7 +904,7 @@ export class VisitsManager {
|
||||||
const visitStyle = visit.status === 'suggested' ? 'border: 2px dashed #60a5fa;' : '';
|
const visitStyle = visit.status === 'suggested' ? 'border: 2px dashed #60a5fa;' : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="w-full p-3 m-2 rounded-lg hover:bg-base-300 transition-colors visit-item relative ${bgClass}"
|
<div class="w-full p-3 mt-2 rounded-lg hover:bg-base-300 transition-colors visit-item relative ${bgClass}"
|
||||||
style="${visitStyle}"
|
style="${visitStyle}"
|
||||||
data-lat="${visit.place?.latitude || ''}"
|
data-lat="${visit.place?.latitude || ''}"
|
||||||
data-lng="${visit.place?.longitude || ''}"
|
data-lng="${visit.place?.longitude || ''}"
|
||||||
|
|
@ -721,8 +932,31 @@ export class VisitsManager {
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// Wrap visits in a collapsible section
|
||||||
|
const visitsSection = visits && visits.length > 0 ? `
|
||||||
|
<details id="visits-section-collapse" class="collapse collapse-arrow bg-base-100 rounded-lg mb-4 shadow-sm">
|
||||||
|
<summary class="collapse-title text-lg font-bold">
|
||||||
|
Visits (${visits.filter(v => v.status !== 'declined').length})
|
||||||
|
</summary>
|
||||||
|
<div class="collapse-content">
|
||||||
|
${visitsHtml}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
` : '';
|
||||||
|
|
||||||
// Combine date summary and visits HTML
|
// Combine date summary and visits HTML
|
||||||
container.innerHTML = dateGroupsHtml + visitsHtml;
|
container.innerHTML = dateGroupsHtml + visitsSection;
|
||||||
|
|
||||||
|
// Restore the state of collapsible sections
|
||||||
|
const dataSection = document.querySelector('#data-section-collapse');
|
||||||
|
const visitsSection2 = document.querySelector('#visits-section-collapse');
|
||||||
|
|
||||||
|
if (dataSection && dataSectionOpen) {
|
||||||
|
dataSection.open = true;
|
||||||
|
}
|
||||||
|
if (visitsSection2 && visitsSectionOpen) {
|
||||||
|
visitsSection2.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Add the circles layer to the map
|
// Add the circles layer to the map
|
||||||
this.visitCircles.addTo(this.map);
|
this.visitCircles.addTo(this.map);
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,10 @@ class Family::Invitations::CleanupJob < ApplicationJob
|
||||||
Rails.logger.info "Updated #{expired_count} expired family invitations"
|
Rails.logger.info "Updated #{expired_count} expired family invitations"
|
||||||
|
|
||||||
cleanup_threshold = 30.days.ago
|
cleanup_threshold = 30.days.ago
|
||||||
deleted_count = Family::Invitation.where(status: [:expired, :cancelled])
|
deleted_count =
|
||||||
.where('updated_at < ?', cleanup_threshold)
|
Family::Invitation.where(status: %i[expired cancelled])
|
||||||
.delete_all
|
.where('updated_at < ?', cleanup_threshold)
|
||||||
|
.delete_all
|
||||||
|
|
||||||
Rails.logger.info "Deleted #{deleted_count} old family invitations"
|
Rails.logger.info "Deleted #{deleted_count} old family invitations"
|
||||||
|
|
||||||
|
|
|
||||||
13
app/jobs/family/invitations/sending_job.rb
Normal file
13
app/jobs/family/invitations/sending_job.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Family::Invitations::SendingJob < ApplicationJob
|
||||||
|
queue_as :families
|
||||||
|
|
||||||
|
def perform(invitation_id)
|
||||||
|
invitation = Family::Invitation.find_by(id: invitation_id)
|
||||||
|
|
||||||
|
return unless invitation&.pending?
|
||||||
|
|
||||||
|
FamilyMailer.invitation(invitation).deliver_now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -11,6 +11,8 @@ class Family < ApplicationRecord
|
||||||
MAX_MEMBERS = 5
|
MAX_MEMBERS = 5
|
||||||
|
|
||||||
def can_add_members?
|
def can_add_members?
|
||||||
|
return true if DawarichSettings.self_hosted?
|
||||||
|
|
||||||
(member_count + pending_invitations_count) < MAX_MEMBERS
|
(member_count + pending_invitations_count) < MAX_MEMBERS
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -32,6 +34,8 @@ class Family < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def full?
|
def full?
|
||||||
|
return false if DawarichSettings.self_hosted?
|
||||||
|
|
||||||
(member_count + pending_invitations_count) >= MAX_MEMBERS
|
(member_count + pending_invitations_count) >= MAX_MEMBERS
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class FamilyInvitationPolicy < ApplicationPolicy
|
|
||||||
def show?
|
|
||||||
# Public endpoint for invitation acceptance - no authentication required
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
def create?
|
|
||||||
user.family == record.family && user.family_owner?
|
|
||||||
end
|
|
||||||
|
|
||||||
def accept?
|
|
||||||
# Users can accept invitations sent to their email
|
|
||||||
user.email == record.email
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy?
|
|
||||||
# Only family owners can cancel invitations
|
|
||||||
user.family == record.family && user.family_owner?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class FamilyMembershipPolicy < ApplicationPolicy
|
|
||||||
def show?
|
|
||||||
user.family == record.family
|
|
||||||
end
|
|
||||||
|
|
||||||
def update?
|
|
||||||
# Users can update their own settings
|
|
||||||
return true if user == record.user
|
|
||||||
|
|
||||||
# Family owners can update any member's settings
|
|
||||||
user.family == record.family && user.family_owner?
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy?
|
|
||||||
# Users can remove themselves (handled by family leave logic)
|
|
||||||
return true if user == record.user
|
|
||||||
|
|
||||||
# Family owners can remove other members
|
|
||||||
user.family == record.family && user.family_owner?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,5 +1,17 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Simple wrapper class that acts like GPX::GPXFile but preserves enhanced XML
|
||||||
|
class EnhancedGpxFile < GPX::GPXFile
|
||||||
|
def initialize(name, xml_string)
|
||||||
|
super(name: name)
|
||||||
|
@enhanced_xml = xml_string
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
@enhanced_xml
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class Points::GpxSerializer
|
class Points::GpxSerializer
|
||||||
def initialize(points, name)
|
def initialize(points, name)
|
||||||
@points = points
|
@points = points
|
||||||
|
|
@ -7,30 +19,92 @@ class Points::GpxSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
gpx_file = GPX::GPXFile.new(name: "dawarich_#{name}")
|
gpx_file = create_base_gpx_file
|
||||||
track = GPX::Track.new(name: "dawarich_#{name}")
|
add_track_points_to_gpx(gpx_file)
|
||||||
|
xml_string = enhance_gpx_with_speed_and_course(gpx_file.to_s)
|
||||||
|
|
||||||
gpx_file.tracks << track
|
EnhancedGpxFile.new("dawarich_#{name}", xml_string)
|
||||||
|
|
||||||
track_segment = GPX::Segment.new
|
|
||||||
track.segments << track_segment
|
|
||||||
|
|
||||||
points.each do |point|
|
|
||||||
track_segment.points << GPX::TrackPoint.new(
|
|
||||||
lat: point.lat,
|
|
||||||
lon: point.lon,
|
|
||||||
elevation: point.altitude.to_f,
|
|
||||||
time: point.recorded_at
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
GPX::GPXFile.new(
|
|
||||||
name: "dawarich_#{name}",
|
|
||||||
gpx_data: gpx_file.to_s.sub('<gpx', '<gpx xmlns="http://www.topografix.com/GPX/1/1"')
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :points, :name
|
attr_reader :points, :name
|
||||||
|
|
||||||
|
def create_base_gpx_file
|
||||||
|
gpx_file = GPX::GPXFile.new(name: "dawarich_#{name}")
|
||||||
|
track = GPX::Track.new(name: "dawarich_#{name}")
|
||||||
|
gpx_file.tracks << track
|
||||||
|
|
||||||
|
track_segment = GPX::Segment.new
|
||||||
|
track.segments << track_segment
|
||||||
|
|
||||||
|
gpx_file
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_track_points_to_gpx(gpx_file)
|
||||||
|
track_segment = gpx_file.tracks.first.segments.first
|
||||||
|
|
||||||
|
points.each do |point|
|
||||||
|
track_point = create_track_point(point)
|
||||||
|
track_segment.points << track_point
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_track_point(point)
|
||||||
|
track_point_attrs = build_track_point_attributes(point)
|
||||||
|
GPX::TrackPoint.new(**track_point_attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_track_point_attributes(point)
|
||||||
|
{
|
||||||
|
lat: point.lat,
|
||||||
|
lon: point.lon,
|
||||||
|
elevation: point.altitude.to_f,
|
||||||
|
time: point.recorded_at
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def enhance_gpx_with_speed_and_course(gpx_xml)
|
||||||
|
xml_string = add_gpx_namespace(gpx_xml)
|
||||||
|
enhance_trackpoints_with_speed_and_course(xml_string)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_gpx_namespace(gpx_xml)
|
||||||
|
gpx_xml.sub('<gpx', '<gpx xmlns="http://www.topografix.com/GPX/1/1"')
|
||||||
|
end
|
||||||
|
|
||||||
|
def enhance_trackpoints_with_speed_and_course(xml_string)
|
||||||
|
trkpt_count = 0
|
||||||
|
xml_string.gsub(/(<trkpt[^>]*>.*?<\/trkpt>)/m) do |trkpt_xml|
|
||||||
|
point = points[trkpt_count]
|
||||||
|
trkpt_count += 1
|
||||||
|
enhance_single_trackpoint(trkpt_xml, point)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def enhance_single_trackpoint(trkpt_xml, point)
|
||||||
|
enhanced_trkpt = add_speed_to_trackpoint(trkpt_xml, point)
|
||||||
|
add_course_to_trackpoint(enhanced_trkpt, point)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_speed_to_trackpoint(trkpt_xml, point)
|
||||||
|
return trkpt_xml unless should_include_speed?(point)
|
||||||
|
|
||||||
|
trkpt_xml.sub(/(<ele>[^<]*<\/ele>)/, "\\1\n <speed>#{point.velocity.to_f}</speed>")
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_course_to_trackpoint(trkpt_xml, point)
|
||||||
|
return trkpt_xml unless should_include_course?(point)
|
||||||
|
|
||||||
|
extensions_xml = "\n <extensions>\n <course>#{point.course.to_f}</course>\n </extensions>"
|
||||||
|
trkpt_xml.sub(/\n <\/trkpt>/, "#{extensions_xml}\n </trkpt>")
|
||||||
|
end
|
||||||
|
|
||||||
|
def should_include_speed?(point)
|
||||||
|
point.velocity.present? && point.velocity.to_f > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def should_include_course?(point)
|
||||||
|
point.course.present?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ module Families
|
||||||
return false unless invite_sendable?
|
return false unless invite_sendable?
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
create_invitation
|
invitation = create_invitation
|
||||||
send_invitation_email
|
send_invitation_email(invitation)
|
||||||
send_notification
|
send_notification
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -80,16 +80,18 @@ module Families
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_invitation_email
|
def send_invitation_email(invitation)
|
||||||
# Send email in background with retry logic
|
Family::Invitations::SendingJob.perform_later(invitation.id)
|
||||||
FamilyMailer.invitation(@invitation).deliver_later(
|
|
||||||
queue: :mailer,
|
|
||||||
retry: 3,
|
|
||||||
wait: 30.seconds
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_notification
|
def send_notification
|
||||||
|
message =
|
||||||
|
if DawarichSettings.self_hosted?
|
||||||
|
"Family invitation sent to #{email} if SMTP is configured properly. If you're not using SMTP, copy the invitation link from the family page and share it manually."
|
||||||
|
else
|
||||||
|
"Family invitation sent to #{email}"
|
||||||
|
end
|
||||||
|
|
||||||
Notification.create!(
|
Notification.create!(
|
||||||
user: invited_by,
|
user: invited_by,
|
||||||
kind: :info,
|
kind: :info,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||||
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', method: :put, data: { turbo_method: :put, turbo: false }) do |f| %>
|
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', method: :put, data: { turbo_method: :put, turbo: false }) do |f| %>
|
||||||
|
<%= render "devise/shared/error_messages", resource: resource %>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= f.label :email, class: 'label' do %>
|
<%= f.label :email, class: 'label' do %>
|
||||||
<span class="label-text">Email</span>
|
<span class="label-text">Email</span>
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,23 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<h1 class="text-5xl font-bold text-base-content">Register now!</h1>
|
<h1 class="text-5xl font-bold text-base-content">Almost there!</h1>
|
||||||
<p class="py-6 text-base-content opacity-70">and take control over your location data.</p>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<p class="py-6 text-base-content opacity-70">
|
||||||
|
Only a few steps left until you get control over your location data!
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li class="mb-2">1. Create your account</li>
|
||||||
|
<li class="mb-2">2. Configure your mobile app</li>
|
||||||
|
<li class="mb-2">3. Start tracking your location data securely</li>
|
||||||
|
<li class="mb-2">4. ...</li>
|
||||||
|
<li class="mb-2">5. You're beautiful!</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||||
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
|
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
|
||||||
|
<%= render "devise/shared/error_messages", resource: resource %>
|
||||||
<% if @invitation %>
|
<% if @invitation %>
|
||||||
<%= f.hidden_field :invitation_token, value: params[:invitation_token] %>
|
<%= f.hidden_field :invitation_token, value: params[:invitation_token] %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -32,7 +43,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
||||||
readonly: @invitation.present?,
|
readonly: @invitation.present?,
|
||||||
class: "input input-bordered #{@invitation ? 'input-disabled' : ''}" %>
|
class: "input input-bordered w-full #{@invitation ? 'input-disabled' : ''}" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
|
|
@ -42,7 +53,7 @@
|
||||||
<% if @minimum_password_length %>
|
<% if @minimum_password_length %>
|
||||||
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
|
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
|
||||||
<% end %><br />
|
<% end %><br />
|
||||||
<%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered' %>
|
<%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered w-full' %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
|
|
@ -52,7 +63,7 @@
|
||||||
<% if @minimum_password_length %>
|
<% if @minimum_password_length %>
|
||||||
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
|
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
|
||||||
<% end %><br />
|
<% end %><br />
|
||||||
<%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered' %>
|
<%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered w-full' %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if !DawarichSettings.self_hosted? %>
|
<% if !DawarichSettings.self_hosted? %>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||||
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
|
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
|
||||||
|
<%= render "devise/shared/error_messages", resource: resource %>
|
||||||
|
|
||||||
<% if @invitation %>
|
<% if @invitation %>
|
||||||
<%= hidden_field_tag :invitation_token, params[:invitation_token] %>
|
<%= hidden_field_tag :invitation_token, params[:invitation_token] %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
<% if resource.errors.any? %>
|
<% if resource.errors.any? %>
|
||||||
<div id="error_explanation" data-turbo-cache="false">
|
<div id="error_explanation" class="alert alert-error mb-4" data-turbo-cache="false">
|
||||||
<h2>
|
<%= icon 'circle-x' %>
|
||||||
<%= I18n.t("errors.messages.not_saved",
|
<div class="font-bold mb-4 flex items-center gap-2">
|
||||||
count: resource.errors.count,
|
<div>
|
||||||
resource: resource.class.model_name.human.downcase)
|
<h3 class="font-bold">
|
||||||
%>
|
<%= I18n.t("errors.messages.not_saved",
|
||||||
</h2>
|
count: resource.errors.count,
|
||||||
<ul>
|
resource: resource.class.model_name.human.downcase)
|
||||||
<% resource.errors.full_messages.each do |message| %>
|
%>
|
||||||
<li><%= message %></li>
|
</h3>
|
||||||
<% end %>
|
<ul class="text-sm mt-1">
|
||||||
</ul>
|
<% resource.errors.full_messages.each do |message| %>
|
||||||
|
<li><%= message %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
<% if policy(@family).destroy? %>
|
<% if policy(@family).destroy? %>
|
||||||
<%= link_to family_path,
|
<%= link_to family_path,
|
||||||
method: :delete,
|
method: :delete,
|
||||||
data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' },
|
data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_method: :delete },
|
||||||
class: "btn btn-outline btn-error" do %>
|
class: "btn btn-outline btn-error" do %>
|
||||||
<%= icon 'trash-2', class: "inline-block w-4" %>
|
<%= icon 'trash-2', class: "inline-block w-4" %>
|
||||||
Delete Family
|
Delete Family
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
<% if !current_user.family_owner? && current_user.family_membership %>
|
<% if !current_user.family_owner? && current_user.family_membership %>
|
||||||
<%= link_to family_member_path(current_user.family_membership),
|
<%= link_to family_member_path(current_user.family_membership),
|
||||||
method: :delete,
|
method: :delete,
|
||||||
data: { turbo_confirm: 'Are you sure you want to leave this family?' },
|
data: { turbo_confirm: 'Are you sure you want to leave this family?', turbo_method: :delete },
|
||||||
class: "btn btn-outline btm-sm btn-warning" do %>
|
class: "btn btn-outline btm-sm btn-warning" do %>
|
||||||
Leave Family
|
Leave Family
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
<% if policy(@family).destroy? %>
|
<% if policy(@family).destroy? %>
|
||||||
<%= link_to family_path,
|
<%= link_to family_path,
|
||||||
method: :delete,
|
method: :delete,
|
||||||
data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' },
|
data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_method: :delete },
|
||||||
class: "btn btn-outline btm-sm btn-error" do %>
|
class: "btn btn-outline btm-sm btn-error" do %>
|
||||||
<%= icon 'trash-2', class: "inline-block w-4" %>
|
<%= icon 'trash-2', class: "inline-block w-4" %>
|
||||||
Delete
|
Delete
|
||||||
|
|
@ -175,38 +175,46 @@
|
||||||
<% if @pending_invitations.any? %>
|
<% if @pending_invitations.any? %>
|
||||||
<div class="space-y-3 mb-4">
|
<div class="space-y-3 mb-4">
|
||||||
<% @pending_invitations.each do |invitation| %>
|
<% @pending_invitations.each do |invitation| %>
|
||||||
<div class="flex items-center justify-between p-3 bg-base-100 rounded-lg">
|
<div class="p-3 bg-base-100 rounded-lg">
|
||||||
<div class="flex-grow">
|
<div class="flex items-center justify-between">
|
||||||
<div class="font-medium text-base-content"><%= invitation.email %></div>
|
<div class="flex-grow">
|
||||||
<div class="text-sm text-base-content opacity-60">
|
<div class="font-medium text-base-content"><%= invitation.email %></div>
|
||||||
<%= t('families.show.invited_on', default: 'Invited') %>
|
<div class="text-sm text-base-content opacity-60">
|
||||||
<%= invitation.created_at.strftime('%b %d, %Y') %>
|
<%= t('families.show.invited_on', default: 'Invited') %>
|
||||||
</div>
|
<%= invitation.created_at.strftime('%b %d, %Y') %>
|
||||||
<div class="text-xs text-base-content opacity-50">
|
</div>
|
||||||
<%= t('families.show.expires_on', default: 'Expires') %>
|
<div class="text-xs text-base-content opacity-50">
|
||||||
<%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %>
|
<%= t('families.show.expires_on', default: 'Expires') %>
|
||||||
</div>
|
<%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %>
|
||||||
<div class="mt-2">
|
</div>
|
||||||
<button data-controller="clipboard"
|
|
||||||
data-clipboard-text-value="<%= public_invitation_url(invitation.token) %>"
|
|
||||||
data-action="click->clipboard#copy"
|
|
||||||
class="btn btn-outline btn-info btn-xs"
|
|
||||||
title="Copy invitation link">
|
|
||||||
<%= icon 'copy', class: "inline-block w-3" %>
|
|
||||||
Copy Invitation Link
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<% if policy(@family).manage_invitations? %>
|
||||||
|
<div class="ml-3">
|
||||||
|
<%= button_to family_invitation_path(invitation.token),
|
||||||
|
method: :delete,
|
||||||
|
form: { data: { turbo_confirm: 'Are you sure you want to cancel this invitation?', turbo_method: :delete } },
|
||||||
|
class: "btn btn-outline btn-warning btn-sm" do %>
|
||||||
|
Cancel
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-3">
|
||||||
|
<input type="text"
|
||||||
|
readonly
|
||||||
|
value="<%= public_invitation_url(invitation.token) %>"
|
||||||
|
class="input input-bordered input-sm flex-grow"
|
||||||
|
onclick="this.select();"
|
||||||
|
/>
|
||||||
|
<button data-controller="clipboard"
|
||||||
|
data-clipboard-text-value="<%= public_invitation_url(invitation.token) %>"
|
||||||
|
data-action="click->clipboard#copy"
|
||||||
|
class="btn btn-outline btn-info btn-sm ml-auto"
|
||||||
|
title="Copy invitation link">
|
||||||
|
<%= icon 'copy', class: "inline-block w-3" %>
|
||||||
|
Copy Invitation Link
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<% if policy(@family).manage_invitations? %>
|
|
||||||
<div class="ml-3">
|
|
||||||
<%= link_to family_invitation_path(invitation.token),
|
|
||||||
method: :delete,
|
|
||||||
data: { turbo_confirm: 'Are you sure you want to cancel this invitation?' },
|
|
||||||
class: "btn btn-outline btn-warning btn-sm" do %>
|
|
||||||
Cancel
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -156,8 +156,7 @@
|
||||||
<li>
|
<li>
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
<span class="hidden xl:inline"><%= current_user.email %></span>
|
<span class="inline"><%= icon 'user' %></span>
|
||||||
<span class="inline xl:hidden"><%= icon 'user' %></span>
|
|
||||||
<% if onboarding_modal_showable?(current_user) %>
|
<% if onboarding_modal_showable?(current_user) %>
|
||||||
<span class="indicator-item badge badge-secondary badge-xs"></span>
|
<span class="indicator-item badge badge-secondary badge-xs"></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
default: &default
|
default: &default
|
||||||
adapter: redis
|
adapter: redis
|
||||||
url: <%= "#{ENV.fetch("REDIS_URL")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %>
|
url: <%= "#{ENV.fetch("REDIS_URL", "redis://localhost:6379")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %>
|
||||||
|
|
||||||
development:
|
development:
|
||||||
<<: *default
|
<<: *default
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ Rails.application.configure do
|
||||||
# Raise error when a before_action's only/except options reference missing actions
|
# Raise error when a before_action's only/except options reference missing actions
|
||||||
config.action_controller.raise_on_missing_callback_actions = true
|
config.action_controller.raise_on_missing_callback_actions = true
|
||||||
|
|
||||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',').map(&:strip)
|
||||||
|
|
||||||
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] || hosts.first, port: ENV.fetch('PORT', 3000) }
|
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] || hosts.first, port: ENV.fetch('PORT', 3000) }
|
||||||
|
|
||||||
|
|
@ -99,5 +99,5 @@ Rails.application.configure do
|
||||||
config.lograge.enabled = true
|
config.lograge.enabled = true
|
||||||
config.lograge.formatter = Lograge::Formatters::Json.new
|
config.lograge.formatter = Lograge::Formatters::Json.new
|
||||||
|
|
||||||
config.active_storage.service = ENV['SELF_HOSTED'] == 'true' ? :local : :s3
|
config.active_storage.service = ENV.fetch('STORAGE_BACKEND', :local)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ Rails.application.configure do
|
||||||
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
|
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
|
||||||
|
|
||||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||||
config.active_storage.service = ENV['SELF_HOSTED'] == 'true' ? :local : :s3
|
config.active_storage.service = ENV.fetch('STORAGE_BACKEND', :local)
|
||||||
|
|
||||||
config.silence_healthcheck_path = '/api/v1/health'
|
config.silence_healthcheck_path = '/api/v1/health'
|
||||||
|
|
||||||
|
|
@ -101,7 +101,7 @@ Rails.application.configure do
|
||||||
# ]
|
# ]
|
||||||
# Skip DNS rebinding protection for the health check endpoint.
|
# Skip DNS rebinding protection for the health check endpoint.
|
||||||
config.host_authorization = { exclude: ->(request) { request.path == "/api/v1/health" } }
|
config.host_authorization = { exclude: ->(request) { request.path == "/api/v1/health" } }
|
||||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',').map(&:strip)
|
||||||
|
|
||||||
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] }
|
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] }
|
||||||
config.hosts.concat(hosts) if hosts.present?
|
config.hosts.concat(hosts) if hosts.present?
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ Rails.application.configure do
|
||||||
# ]
|
# ]
|
||||||
# Skip DNS rebinding protection for the health check endpoint.
|
# Skip DNS rebinding protection for the health check endpoint.
|
||||||
config.host_authorization = { exclude: ->(request) { request.path == '/api/v1/health' } }
|
config.host_authorization = { exclude: ->(request) { request.path == '/api/v1/health' } }
|
||||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',').map(&:strip)
|
||||||
|
|
||||||
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] }
|
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] }
|
||||||
config.hosts.concat(hosts) if hosts.present?
|
config.hosts.concat(hosts) if hosts.present?
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
return unless Rails.env.development?
|
||||||
|
|
||||||
# Mark existing migrations as safe
|
# Mark existing migrations as safe
|
||||||
StrongMigrations.start_after = 20_250_122_150_500
|
StrongMigrations.start_after = 20_250_122_150_500
|
||||||
|
|
||||||
# Set timeouts for migrations
|
# Set timeouts for migrations
|
||||||
# If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user
|
# PgBouncer in transaction mode doesn't support SET commands
|
||||||
StrongMigrations.lock_timeout = 10.seconds
|
# Timeouts should be set on the database user instead
|
||||||
StrongMigrations.statement_timeout = 1.hour
|
# StrongMigrations.lock_timeout = 10.seconds
|
||||||
|
# StrongMigrations.statement_timeout = 1.hour
|
||||||
|
|
||||||
# Analyze tables after indexes are added
|
# Analyze tables after indexes are added
|
||||||
# Outdated statistics can sometimes hurt performance
|
# Outdated statistics can sometimes hurt performance
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,11 @@ Rails.application.routes.draw do
|
||||||
get 'suggestions'
|
get 'suggestions'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :points, only: %i[index create update destroy]
|
resources :points, only: %i[index create update destroy] do
|
||||||
|
collection do
|
||||||
|
delete :bulk_destroy
|
||||||
|
end
|
||||||
|
end
|
||||||
resources :visits, only: %i[index create update destroy] do
|
resources :visits, only: %i[index create update destroy] do
|
||||||
get 'possible_places', to: 'visits/possible_places#index', on: :member
|
get 'possible_places', to: 'visits/possible_places#index', on: :member
|
||||||
collection do
|
collection do
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,14 @@ local:
|
||||||
root: <%= Rails.root.join("storage") %>
|
root: <%= Rails.root.join("storage") %>
|
||||||
|
|
||||||
# Only load S3 config if not in test environment
|
# Only load S3 config if not in test environment
|
||||||
<% if !Rails.env.test? && ENV['AWS_ACCESS_KEY_ID'] && ENV['AWS_SECRET_ACCESS_KEY'] && ENV['AWS_REGION'] && ENV['AWS_BUCKET'] %>
|
<% if !Rails.env.test? && ENV['AWS_ACCESS_KEY_ID'] && ENV['AWS_SECRET_ACCESS_KEY'] && ENV['AWS_REGION'] && ENV['AWS_BUCKET'] && ENV['AWS_ENDPOINT_URL'] %>
|
||||||
s3:
|
s3:
|
||||||
service: S3
|
service: S3
|
||||||
access_key_id: <%= ENV.fetch("AWS_ACCESS_KEY_ID") %>
|
access_key_id: <%= ENV.fetch("AWS_ACCESS_KEY_ID") %>
|
||||||
secret_access_key: <%= ENV.fetch("AWS_SECRET_ACCESS_KEY") %>
|
secret_access_key: <%= ENV.fetch("AWS_SECRET_ACCESS_KEY") %>
|
||||||
region: <%= ENV.fetch("AWS_REGION") %>
|
region: <%= ENV.fetch("AWS_REGION") %>
|
||||||
bucket: <%= ENV.fetch("AWS_BUCKET") %>
|
bucket: <%= ENV.fetch("AWS_BUCKET") %>
|
||||||
|
endpoint: <%= ENV.fetch("AWS_ENDPOINT_URL") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
# Remember not to checkin your GCS keyfile to a repository
|
# Remember not to checkin your GCS keyfile to a repository
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ class CreateFamilies < ActiveRecord::Migration[8.0]
|
||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_foreign_key :families, :users, column: :creator_id, validate: false
|
add_foreign_key :families, :users, column: :creator_id
|
||||||
add_index :families, :creator_id
|
add_index :families, :creator_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ class CreateFamilyMemberships < ActiveRecord::Migration[8.0]
|
||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_foreign_key :family_memberships, :families, validate: false
|
add_foreign_key :family_memberships, :families
|
||||||
add_foreign_key :family_memberships, :users, validate: false
|
add_foreign_key :family_memberships, :users
|
||||||
add_index :family_memberships, :user_id, unique: true # One family per user
|
add_index :family_memberships, :user_id, unique: true # One family per user
|
||||||
add_index :family_memberships, %i[family_id role], name: 'index_family_memberships_on_family_and_role'
|
add_index :family_memberships, %i[family_id role], name: 'index_family_memberships_on_family_and_role'
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ class CreateFamilyInvitations < ActiveRecord::Migration[8.0]
|
||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_foreign_key :family_invitations, :families, validate: false
|
add_foreign_key :family_invitations, :families
|
||||||
add_foreign_key :family_invitations, :users, column: :invited_by_id, validate: false
|
add_foreign_key :family_invitations, :users, column: :invited_by_id
|
||||||
add_index :family_invitations, :token, unique: true
|
add_index :family_invitations, :token, unique: true
|
||||||
add_index :family_invitations, %i[family_id email], name: 'index_family_invitations_on_family_id_and_email'
|
add_index :family_invitations, %i[family_id email], name: 'index_family_invitations_on_family_id_and_email'
|
||||||
add_index :family_invitations, %i[family_id status expires_at],
|
add_index :family_invitations, %i[family_id status expires_at],
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
class ValidateFamilyForeignKeys < ActiveRecord::Migration[8.0]
|
class ValidateFamilyForeignKeys < ActiveRecord::Migration[8.0]
|
||||||
def change
|
def change
|
||||||
validate_foreign_key :families, :users
|
# No longer needed - foreign keys are now validated immediately in their creation migrations
|
||||||
validate_foreign_key :family_memberships, :families
|
# validate_foreign_key :families, :users
|
||||||
validate_foreign_key :family_memberships, :users
|
# validate_foreign_key :family_memberships, :families
|
||||||
validate_foreign_key :family_invitations, :families
|
# validate_foreign_key :family_memberships, :users
|
||||||
validate_foreign_key :family_invitations, :users
|
# validate_foreign_key :family_invitations, :families
|
||||||
|
# validate_foreign_key :family_invitations, :users
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
141
docker/.env.example
Normal file
141
docker/.env.example
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# Dawarich Docker Compose Configuration
|
||||||
|
# Copy this file to .env and customize for your environment
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENVIRONMENT CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Rails environment: development, staging, or production
|
||||||
|
RAILS_ENV=development
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# PostgreSQL credentials
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=password
|
||||||
|
|
||||||
|
# Database name
|
||||||
|
POSTGRES_DB=dawarich_development
|
||||||
|
|
||||||
|
# Database connection settings (used by Rails app)
|
||||||
|
DATABASE_HOST=dawarich_db
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USERNAME=postgres
|
||||||
|
DATABASE_PASSWORD=password
|
||||||
|
DATABASE_NAME=dawarich_development
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REDIS CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Redis connection URL
|
||||||
|
REDIS_URL=redis://dawarich_redis:6379
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# APPLICATION SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Port to expose the application on
|
||||||
|
DAWARICH_APP_PORT=3000
|
||||||
|
|
||||||
|
# Application hosts (comma-separated)
|
||||||
|
# Development: localhost
|
||||||
|
# Production: your-domain.com,www.your-domain.com
|
||||||
|
APPLICATION_HOSTS=localhost,::1,127.0.0.1
|
||||||
|
|
||||||
|
# Application protocol (http or https)
|
||||||
|
APPLICATION_PROTOCOL=http
|
||||||
|
|
||||||
|
# Time zone
|
||||||
|
TIME_ZONE=Europe/London
|
||||||
|
|
||||||
|
# Minimum minutes spent in city for statistics
|
||||||
|
MIN_MINUTES_SPENT_IN_CITY=60
|
||||||
|
|
||||||
|
# Self-hosted flag (true for docker deployments)
|
||||||
|
SELF_HOSTED=true
|
||||||
|
|
||||||
|
# Store geodata (reverse geocoding results)
|
||||||
|
STORE_GEODATA=true
|
||||||
|
|
||||||
|
# Storage backend (local or s3)
|
||||||
|
STORAGE_BACKEND=local
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SECURITY
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Secret key base for production/staging
|
||||||
|
# Generate with: openssl rand -hex 64
|
||||||
|
# Leave empty for development
|
||||||
|
# REQUIRED for production and staging environments
|
||||||
|
SECRET_KEY_BASE=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BACKGROUND JOBS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Sidekiq concurrency (number of threads)
|
||||||
|
BACKGROUND_PROCESSING_CONCURRENCY=10
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MONITORING & LOGGING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Prometheus exporter settings
|
||||||
|
PROMETHEUS_EXPORTER_ENABLED=false
|
||||||
|
PROMETHEUS_EXPORTER_HOST=0.0.0.0
|
||||||
|
PROMETHEUS_EXPORTER_PORT=9394
|
||||||
|
PROMETHEUS_EXPORTER_HOST_SIDEKIQ=dawarich_app
|
||||||
|
|
||||||
|
# Uncomment to expose Prometheus port
|
||||||
|
# PROMETHEUS_PORT=9394
|
||||||
|
|
||||||
|
# Rails logging
|
||||||
|
RAILS_LOG_TO_STDOUT=true
|
||||||
|
|
||||||
|
# Docker logging settings
|
||||||
|
LOG_MAX_SIZE=100m
|
||||||
|
LOG_MAX_FILE=5
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RESOURCE LIMITS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# CPU and memory limits for the app container
|
||||||
|
APP_CPU_LIMIT=0.50
|
||||||
|
APP_MEMORY_LIMIT=4G
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EXAMPLE CONFIGURATIONS BY ENVIRONMENT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- DEVELOPMENT ---
|
||||||
|
# RAILS_ENV=development
|
||||||
|
# POSTGRES_DB=dawarich_development
|
||||||
|
# DATABASE_NAME=dawarich_development
|
||||||
|
# APPLICATION_HOSTS=localhost,::1,127.0.0.1
|
||||||
|
# APPLICATION_PROTOCOL=http
|
||||||
|
# SECRET_KEY_BASE=
|
||||||
|
# SELF_HOSTED=true
|
||||||
|
|
||||||
|
# --- STAGING ---
|
||||||
|
# RAILS_ENV=staging
|
||||||
|
# POSTGRES_DB=dawarich_staging
|
||||||
|
# DATABASE_NAME=dawarich_staging
|
||||||
|
# APPLICATION_HOSTS=staging.example.com
|
||||||
|
# APPLICATION_PROTOCOL=https
|
||||||
|
# SECRET_KEY_BASE=your-generated-secret-key
|
||||||
|
# SELF_HOSTED=true
|
||||||
|
|
||||||
|
# --- PRODUCTION ---
|
||||||
|
# RAILS_ENV=production
|
||||||
|
# POSTGRES_DB=dawarich_production
|
||||||
|
# DATABASE_NAME=dawarich_production
|
||||||
|
# APPLICATION_HOSTS=dawarich.example.com,www.dawarich.example.com
|
||||||
|
# APPLICATION_PROTOCOL=https
|
||||||
|
# SECRET_KEY_BASE=your-generated-secret-key
|
||||||
|
# SELF_HOSTED=true
|
||||||
|
# PROMETHEUS_EXPORTER_ENABLED=true
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
FROM ruby:3.4.6-slim
|
FROM ruby:3.4.6-slim
|
||||||
|
|
||||||
|
ARG RAILS_ENV=production
|
||||||
|
|
||||||
ENV APP_PATH=/var/app
|
ENV APP_PATH=/var/app
|
||||||
ENV BUNDLE_VERSION=2.5.21
|
ENV BUNDLE_VERSION=2.5.21
|
||||||
ENV BUNDLE_PATH=/usr/local/bundle/gems
|
ENV BUNDLE_PATH=/usr/local/bundle/gems
|
||||||
ENV RAILS_LOG_TO_STDOUT=true
|
ENV RAILS_LOG_TO_STDOUT=true
|
||||||
ENV RAILS_PORT=3000
|
ENV RAILS_PORT=3000
|
||||||
ENV RAILS_ENV=production
|
|
||||||
|
|
||||||
RUN apt-get update -qq \
|
RUN apt-get update -qq \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \
|
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \
|
||||||
|
|
@ -25,10 +26,14 @@ RUN apt-get update -qq \
|
||||||
less \
|
less \
|
||||||
libjemalloc2 libjemalloc-dev \
|
libjemalloc2 libjemalloc-dev \
|
||||||
cmake \
|
cmake \
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
ca-certificates \
|
||||||
|
&& mkdir -p $APP_PATH \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js LTS for production/staging
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||||
&& apt-get install -y nodejs \
|
&& apt-get install -y nodejs \
|
||||||
&& npm install -g yarn \
|
&& npm install -g yarn \
|
||||||
&& mkdir -p $APP_PATH \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Use jemalloc with check for architecture
|
# Use jemalloc with check for architecture
|
||||||
|
|
@ -41,7 +46,7 @@ RUN if [ "$(uname -m)" = "x86_64" ]; then \
|
||||||
# Enable YJIT
|
# Enable YJIT
|
||||||
ENV RUBY_YJIT_ENABLE=1
|
ENV RUBY_YJIT_ENABLE=1
|
||||||
|
|
||||||
# Update gem system and install bundler
|
# Update RubyGems and install Bundler
|
||||||
RUN gem update --system 3.6.9 \
|
RUN gem update --system 3.6.9 \
|
||||||
&& gem install bundler --version "$BUNDLE_VERSION" \
|
&& gem install bundler --version "$BUNDLE_VERSION" \
|
||||||
&& rm -rf $GEM_HOME/cache/*
|
&& rm -rf $GEM_HOME/cache/*
|
||||||
|
|
@ -58,7 +63,7 @@ RUN bundle config set --local path 'vendor/bundle' \
|
||||||
|
|
||||||
COPY ../. ./
|
COPY ../. ./
|
||||||
|
|
||||||
# Precompile assets for production
|
# Precompile assets
|
||||||
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rake assets:precompile \
|
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rake assets:precompile \
|
||||||
&& rm -rf node_modules tmp/cache
|
&& rm -rf node_modules tmp/cache
|
||||||
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
FROM ruby:3.4.6-slim
|
|
||||||
|
|
||||||
ENV APP_PATH=/var/app
|
|
||||||
ENV BUNDLE_VERSION=2.5.21
|
|
||||||
ENV BUNDLE_PATH=/usr/local/bundle/gems
|
|
||||||
ENV RAILS_LOG_TO_STDOUT=true
|
|
||||||
ENV RAILS_PORT=3000
|
|
||||||
ENV RAILS_ENV=development
|
|
||||||
ENV SELF_HOSTED=true
|
|
||||||
ENV SIDEKIQ_USERNAME=sidekiq
|
|
||||||
ENV SIDEKIQ_PASSWORD=password
|
|
||||||
# Resolving sqlite3 error
|
|
||||||
ENV PGSSENCMODE=disable
|
|
||||||
|
|
||||||
RUN apt-get update -qq \
|
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \
|
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|
||||||
curl \
|
|
||||||
wget \
|
|
||||||
build-essential \
|
|
||||||
git \
|
|
||||||
postgresql-client \
|
|
||||||
libpq-dev \
|
|
||||||
libxml2-dev \
|
|
||||||
libxslt-dev \
|
|
||||||
libyaml-dev \
|
|
||||||
libgeos-dev libgeos++-dev \
|
|
||||||
imagemagick \
|
|
||||||
tzdata \
|
|
||||||
less \
|
|
||||||
libjemalloc2 libjemalloc-dev \
|
|
||||||
cmake \
|
|
||||||
ca-certificates \
|
|
||||||
&& mkdir -p $APP_PATH \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Node.js using official NodeSource script
|
|
||||||
# NodeSource supports: amd64, arm64, armhf (arm/v7)
|
|
||||||
# For unsupported architectures, fall back to Debian's nodejs package
|
|
||||||
RUN ARCH=$(dpkg --print-architecture) && \
|
|
||||||
if [ "$ARCH" = "amd64" ] || [ "$ARCH" = "arm64" ] || [ "$ARCH" = "armhf" ]; then \
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
|
||||||
apt-get install -y nodejs; \
|
|
||||||
else \
|
|
||||||
apt-get update && \
|
|
||||||
apt-get install -y nodejs npm; \
|
|
||||||
fi && \
|
|
||||||
npm install -g yarn && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Use jemalloc with check for architecture
|
|
||||||
RUN if [ "$(uname -m)" = "x86_64" ]; then \
|
|
||||||
echo "/usr/lib/x86_64-linux-gnu/libjemalloc.so.2" > /etc/ld.so.preload; \
|
|
||||||
else \
|
|
||||||
echo "/usr/lib/aarch64-linux-gnu/libjemalloc.so.2" > /etc/ld.so.preload; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Optional: Set YJIT explicitly (enabled by default in 3.4.1 MRI builds)
|
|
||||||
ENV RUBY_YJIT_ENABLE=1
|
|
||||||
|
|
||||||
# Update RubyGems and install Bundler
|
|
||||||
RUN gem update --system 3.6.9 \
|
|
||||||
&& gem install bundler --version "$BUNDLE_VERSION" \
|
|
||||||
&& rm -rf $GEM_HOME/cache/*
|
|
||||||
|
|
||||||
WORKDIR $APP_PATH
|
|
||||||
|
|
||||||
COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./
|
|
||||||
|
|
||||||
RUN bundle config set --local path 'vendor/bundle' \
|
|
||||||
&& bundle install --jobs 4 --retry 3 \
|
|
||||||
&& rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem
|
|
||||||
|
|
||||||
COPY ../. ./
|
|
||||||
|
|
||||||
# Create caching-dev.txt file to enable Rails caching in development
|
|
||||||
RUN mkdir -p $APP_PATH/tmp && touch $APP_PATH/tmp/caching-dev.txt
|
|
||||||
|
|
||||||
COPY ./docker/web-entrypoint.sh /usr/local/bin/web-entrypoint.sh
|
|
||||||
RUN chmod +x /usr/local/bin/web-entrypoint.sh
|
|
||||||
|
|
||||||
COPY ./docker/sidekiq-entrypoint.sh /usr/local/bin/sidekiq-entrypoint.sh
|
|
||||||
RUN chmod +x /usr/local/bin/sidekiq-entrypoint.sh
|
|
||||||
|
|
||||||
EXPOSE $RAILS_PORT
|
|
||||||
|
|
||||||
ENTRYPOINT ["bundle", "exec"]
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
networks:
|
|
||||||
dawarich:
|
|
||||||
services:
|
|
||||||
dawarich_redis:
|
|
||||||
image: redis:7.4-alpine
|
|
||||||
container_name: dawarich_redis
|
|
||||||
command: redis-server
|
|
||||||
networks:
|
|
||||||
- dawarich
|
|
||||||
volumes:
|
|
||||||
- dawarich_redis_data:/data
|
|
||||||
restart: always
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
|
|
||||||
interval: 10s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
timeout: 10s
|
|
||||||
dawarich_db:
|
|
||||||
image: postgis/postgis:17-3.5-alpine
|
|
||||||
shm_size: 1G
|
|
||||||
container_name: dawarich_db
|
|
||||||
volumes:
|
|
||||||
- dawarich_db_data:/var/lib/postgresql/data
|
|
||||||
networks:
|
|
||||||
- dawarich
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: password
|
|
||||||
POSTGRES_DB: dawarich_production
|
|
||||||
restart: always
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "pg_isready", "-U", "postgres" ]
|
|
||||||
interval: 10s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
timeout: 10s
|
|
||||||
dawarich_app:
|
|
||||||
image: dawarich:prod
|
|
||||||
container_name: dawarich_app
|
|
||||||
volumes:
|
|
||||||
- dawarich_public:/var/app/public
|
|
||||||
- dawarich_watched:/var/app/tmp/imports/watched
|
|
||||||
- dawarich_storage:/var/app/storage
|
|
||||||
- dawarich_db_data:/dawarich_db_data
|
|
||||||
networks:
|
|
||||||
- dawarich
|
|
||||||
ports:
|
|
||||||
- 3000:3000
|
|
||||||
# - 9394:9394 # Prometheus exporter, uncomment if needed
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
entrypoint: web-entrypoint.sh
|
|
||||||
command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
|
|
||||||
restart: on-failure
|
|
||||||
environment:
|
|
||||||
RAILS_ENV: production
|
|
||||||
REDIS_URL: redis://dawarich_redis:6379
|
|
||||||
DATABASE_HOST: dawarich_db
|
|
||||||
DATABASE_PORT: 5432
|
|
||||||
DATABASE_USERNAME: postgres
|
|
||||||
DATABASE_PASSWORD: password
|
|
||||||
DATABASE_NAME: dawarich_production
|
|
||||||
MIN_MINUTES_SPENT_IN_CITY: 60
|
|
||||||
APPLICATION_HOSTS: localhost,::1,127.0.0.1
|
|
||||||
TIME_ZONE: Europe/London
|
|
||||||
APPLICATION_PROTOCOL: http
|
|
||||||
PROMETHEUS_EXPORTER_ENABLED: false
|
|
||||||
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
|
|
||||||
PROMETHEUS_EXPORTER_PORT: 9394
|
|
||||||
SECRET_KEY_BASE: 1234567890
|
|
||||||
RAILS_LOG_TO_STDOUT: "true"
|
|
||||||
STORE_GEODATA: "true"
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "100m"
|
|
||||||
max-file: "5"
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD-SHELL", "wget -qO - http://127.0.0.1:3000/api/v1/health | grep -q '\"status\"\\s*:\\s*\"ok\"'" ]
|
|
||||||
interval: 10s
|
|
||||||
retries: 30
|
|
||||||
start_period: 30s
|
|
||||||
timeout: 10s
|
|
||||||
depends_on:
|
|
||||||
dawarich_db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
dawarich_redis:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '0.50' # Limit CPU usage to 50% of one core
|
|
||||||
memory: '4G' # Limit memory usage to 2GB
|
|
||||||
dawarich_sidekiq:
|
|
||||||
image: dawarich:prod
|
|
||||||
container_name: dawarich_sidekiq
|
|
||||||
volumes:
|
|
||||||
- dawarich_public:/var/app/public
|
|
||||||
- dawarich_watched:/var/app/tmp/imports/watched
|
|
||||||
- dawarich_storage:/var/app/storage
|
|
||||||
networks:
|
|
||||||
- dawarich
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
entrypoint: sidekiq-entrypoint.sh
|
|
||||||
command: ['bundle', 'exec', 'sidekiq']
|
|
||||||
restart: on-failure
|
|
||||||
environment:
|
|
||||||
RAILS_ENV: production
|
|
||||||
REDIS_URL: redis://dawarich_redis:6379
|
|
||||||
DATABASE_HOST: dawarich_db
|
|
||||||
DATABASE_PORT: 5432
|
|
||||||
DATABASE_USERNAME: postgres
|
|
||||||
DATABASE_PASSWORD: password
|
|
||||||
DATABASE_NAME: dawarich_production
|
|
||||||
APPLICATION_HOSTS: localhost,::1,127.0.0.1
|
|
||||||
BACKGROUND_PROCESSING_CONCURRENCY: 10
|
|
||||||
APPLICATION_PROTOCOL: http
|
|
||||||
PROMETHEUS_EXPORTER_ENABLED: false
|
|
||||||
PROMETHEUS_EXPORTER_HOST: dawarich_app
|
|
||||||
PROMETHEUS_EXPORTER_PORT: 9394
|
|
||||||
SECRET_KEY_BASE: 1234567890
|
|
||||||
RAILS_LOG_TO_STDOUT: "true"
|
|
||||||
STORE_GEODATA: "true"
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "100m"
|
|
||||||
max-file: "5"
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD-SHELL", "pgrep -f sidekiq" ]
|
|
||||||
interval: 10s
|
|
||||||
retries: 30
|
|
||||||
start_period: 30s
|
|
||||||
timeout: 10s
|
|
||||||
depends_on:
|
|
||||||
dawarich_db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
dawarich_redis:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
dawarich_app:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
volumes:
|
|
||||||
dawarich_db_data:
|
|
||||||
dawarich_redis_data:
|
|
||||||
dawarich_public:
|
|
||||||
dawarich_watched:
|
|
||||||
dawarich_storage:
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
networks:
|
networks:
|
||||||
dawarich:
|
dawarich:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
dawarich_redis:
|
dawarich_redis:
|
||||||
image: redis:7.4-alpine
|
image: redis:7.4-alpine
|
||||||
|
|
@ -16,6 +17,7 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
|
|
||||||
dawarich_db:
|
dawarich_db:
|
||||||
image: postgis/postgis:17-3.5-alpine
|
image: postgis/postgis:17-3.5-alpine
|
||||||
shm_size: 1G
|
shm_size: 1G
|
||||||
|
|
@ -27,17 +29,18 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- dawarich
|
- dawarich
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: password
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
|
||||||
POSTGRES_DB: dawarich_development
|
POSTGRES_DB: ${POSTGRES_DB:-dawarich_development}
|
||||||
restart: always
|
restart: always
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ]
|
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-dawarich_development}" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
# command: postgres -c config_file=/etc/postgresql/postgresql.conf # Use custom config, uncomment if you want to use a custom config
|
# command: postgres -c config_file=/etc/postgresql/postgresql.conf # Use custom config, uncomment if you want to use a custom config
|
||||||
|
|
||||||
dawarich_app:
|
dawarich_app:
|
||||||
image: freikin/dawarich:latest
|
image: freikin/dawarich:latest
|
||||||
container_name: dawarich_app
|
container_name: dawarich_app
|
||||||
|
|
@ -49,34 +52,37 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- dawarich
|
- dawarich
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- "${DAWARICH_APP_PORT:-3000}:3000"
|
||||||
# - 9394:9394 # Prometheus exporter, uncomment if needed
|
# - "${PROMETHEUS_PORT:-9394}:9394" # Prometheus exporter, uncomment if needed
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
entrypoint: web-entrypoint.sh
|
entrypoint: web-entrypoint.sh
|
||||||
command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
|
command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
RAILS_ENV: development
|
RAILS_ENV: ${RAILS_ENV:-development}
|
||||||
REDIS_URL: redis://dawarich_redis:6379
|
REDIS_URL: ${REDIS_URL:-redis://dawarich_redis:6379}
|
||||||
DATABASE_HOST: dawarich_db
|
DATABASE_HOST: ${DATABASE_HOST:-dawarich_db}
|
||||||
DATABASE_USERNAME: postgres
|
DATABASE_PORT: ${DATABASE_PORT:-5432}
|
||||||
DATABASE_PASSWORD: password
|
DATABASE_USERNAME: ${DATABASE_USERNAME:-postgres}
|
||||||
DATABASE_NAME: dawarich_development
|
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
|
||||||
MIN_MINUTES_SPENT_IN_CITY: 60
|
DATABASE_NAME: ${DATABASE_NAME:-dawarich_development}
|
||||||
APPLICATION_HOSTS: localhost
|
MIN_MINUTES_SPENT_IN_CITY: ${MIN_MINUTES_SPENT_IN_CITY:-60}
|
||||||
TIME_ZONE: Europe/London
|
APPLICATION_HOSTS: ${APPLICATION_HOSTS:-localhost,::1,127.0.0.1}
|
||||||
APPLICATION_PROTOCOL: http
|
TIME_ZONE: ${TIME_ZONE:-Europe/London}
|
||||||
PROMETHEUS_EXPORTER_ENABLED: "false"
|
APPLICATION_PROTOCOL: ${APPLICATION_PROTOCOL:-http}
|
||||||
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
|
PROMETHEUS_EXPORTER_ENABLED: ${PROMETHEUS_EXPORTER_ENABLED:-"false"}
|
||||||
PROMETHEUS_EXPORTER_PORT: 9394
|
PROMETHEUS_EXPORTER_HOST: ${PROMETHEUS_EXPORTER_HOST:-0.0.0.0}
|
||||||
SELF_HOSTED: "true"
|
PROMETHEUS_EXPORTER_PORT: ${PROMETHEUS_EXPORTER_PORT:-9394}
|
||||||
STORE_GEODATA: "true"
|
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-}
|
||||||
|
RAILS_LOG_TO_STDOUT: ${RAILS_LOG_TO_STDOUT:-"true"}
|
||||||
|
SELF_HOSTED: ${SELF_HOSTED:-"true"}
|
||||||
|
STORE_GEODATA: ${STORE_GEODATA:-"true"}
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "100m"
|
max-size: ${LOG_MAX_SIZE:-100m}
|
||||||
max-file: "5"
|
max-file: ${LOG_MAX_FILE:-5}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "wget -qO - http://127.0.0.1:3000/api/v1/health | grep -q '\"status\"\\s*:\\s*\"ok\"'" ]
|
test: [ "CMD-SHELL", "wget -qO - http://127.0.0.1:3000/api/v1/health | grep -q '\"status\"\\s*:\\s*\"ok\"'" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|
@ -93,8 +99,9 @@ services:
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: '0.50' # Limit CPU usage to 50% of one core
|
cpus: ${APP_CPU_LIMIT:-0.50}
|
||||||
memory: '4G' # Limit memory usage to 4GB
|
memory: ${APP_MEMORY_LIMIT:-4G}
|
||||||
|
|
||||||
dawarich_sidekiq:
|
dawarich_sidekiq:
|
||||||
image: freikin/dawarich:latest
|
image: freikin/dawarich:latest
|
||||||
container_name: dawarich_sidekiq
|
container_name: dawarich_sidekiq
|
||||||
|
|
@ -110,25 +117,28 @@ services:
|
||||||
command: ['sidekiq']
|
command: ['sidekiq']
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
RAILS_ENV: development
|
RAILS_ENV: ${RAILS_ENV:-development}
|
||||||
REDIS_URL: redis://dawarich_redis:6379
|
REDIS_URL: ${REDIS_URL:-redis://dawarich_redis:6379}
|
||||||
DATABASE_HOST: dawarich_db
|
DATABASE_HOST: ${DATABASE_HOST:-dawarich_db}
|
||||||
DATABASE_USERNAME: postgres
|
DATABASE_PORT: ${DATABASE_PORT:-5432}
|
||||||
DATABASE_PASSWORD: password
|
DATABASE_USERNAME: ${DATABASE_USERNAME:-postgres}
|
||||||
DATABASE_NAME: dawarich_development
|
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
|
||||||
APPLICATION_HOSTS: localhost
|
DATABASE_NAME: ${DATABASE_NAME:-dawarich_development}
|
||||||
BACKGROUND_PROCESSING_CONCURRENCY: 10
|
APPLICATION_HOSTS: ${APPLICATION_HOSTS:-localhost,::1,127.0.0.1}
|
||||||
APPLICATION_PROTOCOL: http
|
BACKGROUND_PROCESSING_CONCURRENCY: ${BACKGROUND_PROCESSING_CONCURRENCY:-10}
|
||||||
PROMETHEUS_EXPORTER_ENABLED: "false"
|
APPLICATION_PROTOCOL: ${APPLICATION_PROTOCOL:-http}
|
||||||
PROMETHEUS_EXPORTER_HOST: dawarich_app
|
PROMETHEUS_EXPORTER_ENABLED: ${PROMETHEUS_EXPORTER_ENABLED:-"false"}
|
||||||
PROMETHEUS_EXPORTER_PORT: 9394
|
PROMETHEUS_EXPORTER_HOST: ${PROMETHEUS_EXPORTER_HOST_SIDEKIQ:-dawarich_app}
|
||||||
SELF_HOSTED: "true"
|
PROMETHEUS_EXPORTER_PORT: ${PROMETHEUS_EXPORTER_PORT:-9394}
|
||||||
STORE_GEODATA: "true"
|
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-}
|
||||||
|
RAILS_LOG_TO_STDOUT: ${RAILS_LOG_TO_STDOUT:-"true"}
|
||||||
|
SELF_HOSTED: ${SELF_HOSTED:-"true"}
|
||||||
|
STORE_GEODATA: ${STORE_GEODATA:-"true"}
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "100m"
|
max-size: ${LOG_MAX_SIZE:-100m}
|
||||||
max-file: "5"
|
max-file: ${LOG_MAX_FILE:-5}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pgrep -f sidekiq" ]
|
test: [ "CMD-SHELL", "pgrep -f sidekiq" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|
|
||||||
115
e2e/README.md
Normal file
115
e2e/README.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# E2E Tests
|
||||||
|
|
||||||
|
End-to-end tests for Dawarich using Playwright.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
npx playwright test
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
npx playwright test e2e/map/map-controls.spec.js
|
||||||
|
|
||||||
|
# Run tests in headed mode (watch browser)
|
||||||
|
npx playwright test --headed
|
||||||
|
|
||||||
|
# Run tests in debug mode
|
||||||
|
npx playwright test --debug
|
||||||
|
|
||||||
|
# Run tests sequentially (avoid parallel issues)
|
||||||
|
npx playwright test --workers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
e2e/
|
||||||
|
├── setup/ # Test setup and authentication
|
||||||
|
├── helpers/ # Shared helper functions
|
||||||
|
├── map/ # Map-related tests (40 tests total)
|
||||||
|
└── temp/ # Playwright artifacts (screenshots, videos)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Files
|
||||||
|
|
||||||
|
**Map Tests (62 tests)**
|
||||||
|
- `map-controls.spec.js` - Basic map controls, zoom, tile layers (5 tests)
|
||||||
|
- `map-layers.spec.js` - Layer toggles: Routes, Heatmap, Fog, etc. (8 tests)
|
||||||
|
- `map-points.spec.js` - Point interactions and deletion (4 tests)
|
||||||
|
- `map-visits.spec.js` - Confirmed visit interactions and management (5 tests)
|
||||||
|
- `map-suggested-visits.spec.js` - Suggested visit interactions (confirm/decline) (6 tests)
|
||||||
|
- `map-add-visit.spec.js` - Add visit control and form (8 tests)
|
||||||
|
- `map-selection-tool.spec.js` - Selection tool functionality (4 tests)
|
||||||
|
- `map-calendar-panel.spec.js` - Calendar panel navigation (9 tests)
|
||||||
|
- `map-side-panel.spec.js` - Side panel (visits drawer) functionality (13 tests)*
|
||||||
|
- `map-bulk-delete.spec.js` - Bulk point deletion (12 tests)
|
||||||
|
|
||||||
|
\* Some side panel tests may be skipped if demo data doesn't contain visits
|
||||||
|
|
||||||
|
## Helper Functions
|
||||||
|
|
||||||
|
### Map Helpers (`helpers/map.js`)
|
||||||
|
- `waitForMap(page)` - Wait for Leaflet map initialization
|
||||||
|
- `enableLayer(page, layerName)` - Enable a map layer by name
|
||||||
|
- `clickConfirmedVisit(page)` - Click first confirmed visit circle
|
||||||
|
- `clickSuggestedVisit(page)` - Click first suggested visit circle
|
||||||
|
- `getMapZoom(page)` - Get current map zoom level
|
||||||
|
|
||||||
|
### Navigation Helpers (`helpers/navigation.js`)
|
||||||
|
- `closeOnboardingModal(page)` - Close getting started modal
|
||||||
|
- `navigateToDate(page, startDate, endDate)` - Navigate to specific date range
|
||||||
|
- `navigateToMap(page)` - Navigate to map page with setup
|
||||||
|
|
||||||
|
### Selection Helpers (`helpers/selection.js`)
|
||||||
|
- `drawSelectionRectangle(page, options)` - Draw selection on map
|
||||||
|
- `enableSelectionMode(page)` - Enable area selection tool
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Basic Test Template
|
||||||
|
```javascript
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { navigateToMap } from '../helpers/navigation.js';
|
||||||
|
import { waitForMap } from '../helpers/map.js';
|
||||||
|
|
||||||
|
test('my test', async ({ page }) => {
|
||||||
|
await navigateToMap(page);
|
||||||
|
await waitForMap(page);
|
||||||
|
// Your test logic
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Map Layers
|
||||||
|
```javascript
|
||||||
|
import { enableLayer } from '../helpers/map.js';
|
||||||
|
|
||||||
|
await enableLayer(page, 'Routes');
|
||||||
|
await enableLayer(page, 'Heatmap');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### View Test Artifacts
|
||||||
|
```bash
|
||||||
|
# Open HTML report
|
||||||
|
npx playwright show-report
|
||||||
|
|
||||||
|
# Screenshots and videos are in:
|
||||||
|
test-results/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
- **Flaky tests**: Run with `--workers=1` to avoid parallel interference
|
||||||
|
- **Timeout errors**: Increase timeout in test or use `page.waitForTimeout()`
|
||||||
|
- **Map not loading**: Ensure `waitForMap()` is called after navigation
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Tests run with:
|
||||||
|
- 1 worker (sequential)
|
||||||
|
- 2 retries on failure
|
||||||
|
- Screenshots/videos on failure
|
||||||
|
- JUnit XML reports
|
||||||
|
|
||||||
|
See `playwright.config.js` for full configuration.
|
||||||
84
e2e/helpers/map.js
Normal file
84
e2e/helpers/map.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* Map helper functions for Playwright tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for Leaflet map to be fully initialized
|
||||||
|
* @param {Page} page - Playwright page object
|
||||||
|
*/
|
||||||
|
export async function waitForMap(page) {
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const container = document.querySelector('#map [data-maps-target="container"]');
|
||||||
|
return container && container._leaflet_id !== undefined;
|
||||||
|
}, { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable a map layer by name
|
||||||
|
* @param {Page} page - Playwright page object
|
||||||
|
* @param {string} layerName - Name of the layer to enable (e.g., "Routes", "Heatmap")
|
||||||
|
*/
|
||||||
|
export async function enableLayer(page, layerName) {
|
||||||
|
await page.locator('.leaflet-control-layers').hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const checkbox = page.locator(`.leaflet-control-layers-overlays label:has-text("${layerName}") input[type="checkbox"]`);
|
||||||
|
const isChecked = await checkbox.isChecked();
|
||||||
|
|
||||||
|
if (!isChecked) {
|
||||||
|
await checkbox.check();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click on the first confirmed visit circle on the map
|
||||||
|
* @param {Page} page - Playwright page object
|
||||||
|
* @returns {Promise<boolean>} - True if a visit was clicked, false otherwise
|
||||||
|
*/
|
||||||
|
export async function clickConfirmedVisit(page) {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
|
||||||
|
const layers = controller.visitsManager.confirmedVisitCircles._layers;
|
||||||
|
const firstVisit = Object.values(layers)[0];
|
||||||
|
if (firstVisit) {
|
||||||
|
firstVisit.fire('click');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click on the first suggested visit circle on the map
|
||||||
|
* @param {Page} page - Playwright page object
|
||||||
|
* @returns {Promise<boolean>} - True if a visit was clicked, false otherwise
|
||||||
|
*/
|
||||||
|
export async function clickSuggestedVisit(page) {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
|
||||||
|
const layers = controller.visitsManager.suggestedVisitCircles._layers;
|
||||||
|
const firstVisit = Object.values(layers)[0];
|
||||||
|
if (firstVisit) {
|
||||||
|
firstVisit.fire('click');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current map zoom level
|
||||||
|
* @param {Page} page - Playwright page object
|
||||||
|
* @returns {Promise<number|null>} - Current zoom level or null
|
||||||
|
*/
|
||||||
|
export async function getMapZoom(page) {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.map?.getZoom() || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
45
e2e/helpers/navigation.js
Normal file
45
e2e/helpers/navigation.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Navigation and UI helper functions for Playwright tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the onboarding modal if it's open
|
||||||
|
* @param {Page} page - Playwright page object
|
||||||
|
*/
|
||||||
|
export async function closeOnboardingModal(page) {
|
||||||
|
const onboardingModal = page.locator('#getting_started');
|
||||||
|
const isModalOpen = await onboardingModal.evaluate((dialog) => dialog.open).catch(() => false);
|
||||||
|
if (isModalOpen) {
|
||||||
|
await page.locator('#getting_started button.btn-primary').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the map page and close onboarding modal
|
||||||
|
* @param {Page} page - Playwright page object
|
||||||
|
*/
|
||||||
|
export async function navigateToMap(page) {
|
||||||
|
await page.goto('/map');
|
||||||
|
await closeOnboardingModal(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a specific date range on the map
|
||||||
|
* @param {Page} page - Playwright page object
|
||||||
|
* @param {string} startDate - Start date in format 'YYYY-MM-DDTHH:mm'
|
||||||
|
* @param {string} endDate - End date in format 'YYYY-MM-DDTHH:mm'
|
||||||
|
*/
|
||||||
|
export async function navigateToDate(page, startDate, endDate) {
|
||||||
|
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
|
||||||
|
await startInput.clear();
|
||||||
|
await startInput.fill(startDate);
|
||||||
|
|
||||||
|
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
|
||||||
|
await endInput.clear();
|
||||||
|
await endInput.fill(endDate);
|
||||||
|
|
||||||
|
await page.click('input[type="submit"][value="Search"]');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
64
e2e/helpers/selection.js
Normal file
64
e2e/helpers/selection.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Selection and drawing helper functions for Playwright tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable selection mode by clicking the selection tool button
|
||||||
|
* @param {Page} page - Playwright page object
|
||||||
|
*/
|
||||||
|
export async function enableSelectionMode(page) {
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a selection rectangle on the map
|
||||||
|
* @param {Page} page - Playwright page object
|
||||||
|
* @param {Object} options - Drawing options
|
||||||
|
* @param {number} options.startX - Start X position (0-1 as fraction of width, default: 0.2)
|
||||||
|
* @param {number} options.startY - Start Y position (0-1 as fraction of height, default: 0.2)
|
||||||
|
* @param {number} options.endX - End X position (0-1 as fraction of width, default: 0.8)
|
||||||
|
* @param {number} options.endY - End Y position (0-1 as fraction of height, default: 0.8)
|
||||||
|
* @param {number} options.steps - Number of steps for smooth drag (default: 10)
|
||||||
|
*/
|
||||||
|
export async function drawSelectionRectangle(page, options = {}) {
|
||||||
|
const {
|
||||||
|
startX = 0.2,
|
||||||
|
startY = 0.2,
|
||||||
|
endX = 0.8,
|
||||||
|
endY = 0.8,
|
||||||
|
steps = 10
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Click area selection tool
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Get map container bounding box
|
||||||
|
const mapContainer = page.locator('#map [data-maps-target="container"]');
|
||||||
|
const bbox = await mapContainer.boundingBox();
|
||||||
|
|
||||||
|
// Calculate absolute positions
|
||||||
|
const absStartX = bbox.x + bbox.width * startX;
|
||||||
|
const absStartY = bbox.y + bbox.height * startY;
|
||||||
|
const absEndX = bbox.x + bbox.width * endX;
|
||||||
|
const absEndY = bbox.y + bbox.height * endY;
|
||||||
|
|
||||||
|
// Draw rectangle
|
||||||
|
await page.mouse.move(absStartX, absStartY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(absEndX, absEndY, { steps });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
// Wait for API calls and drawer animations
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Wait for drawer to open (it should open automatically after selection)
|
||||||
|
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
|
||||||
|
|
||||||
|
// Wait for delete button to appear in the drawer (indicates selection is complete)
|
||||||
|
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
|
||||||
|
await page.waitForTimeout(500); // Brief wait for UI to stabilize
|
||||||
|
}
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test to verify the refactored LiveMapHandler class works correctly
|
|
||||||
*/
|
|
||||||
|
|
||||||
test.describe('LiveMapHandler Refactoring', () => {
|
|
||||||
let page;
|
|
||||||
let context;
|
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
context = await browser.newContext();
|
|
||||||
page = await context.newPage();
|
|
||||||
|
|
||||||
// Sign in
|
|
||||||
await page.goto('/users/sign_in');
|
|
||||||
await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 });
|
|
||||||
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
|
|
||||||
await page.fill('input[name="user[password]"]', 'password');
|
|
||||||
await page.click('input[type="submit"][value="Log in"]');
|
|
||||||
await page.waitForURL('/map', { timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await page.close();
|
|
||||||
await context.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should have LiveMapHandler class imported and available', async () => {
|
|
||||||
// Navigate to map
|
|
||||||
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
|
|
||||||
await page.waitForSelector('#map', { timeout: 10000 });
|
|
||||||
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
|
|
||||||
|
|
||||||
// Check if LiveMapHandler is available in the code
|
|
||||||
const hasLiveMapHandler = await page.evaluate(() => {
|
|
||||||
// Check if the LiveMapHandler class exists in the bundled JavaScript
|
|
||||||
const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML);
|
|
||||||
const allJavaScript = scripts.join(' ');
|
|
||||||
|
|
||||||
const hasLiveMapHandlerClass = allJavaScript.includes('LiveMapHandler') ||
|
|
||||||
allJavaScript.includes('live_map_handler');
|
|
||||||
const hasAppendPointDelegation = allJavaScript.includes('liveMapHandler.appendPoint') ||
|
|
||||||
allJavaScript.includes('this.liveMapHandler');
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasLiveMapHandlerClass,
|
|
||||||
hasAppendPointDelegation,
|
|
||||||
totalJSSize: allJavaScript.length,
|
|
||||||
scriptCount: scripts.length
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('LiveMapHandler availability:', hasLiveMapHandler);
|
|
||||||
|
|
||||||
// The test is informational - we verify the refactoring is present in source
|
|
||||||
expect(hasLiveMapHandler.scriptCount).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should have proper delegation in maps controller', async () => {
|
|
||||||
// Navigate to map
|
|
||||||
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
|
|
||||||
await page.waitForSelector('#map', { timeout: 10000 });
|
|
||||||
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
|
|
||||||
|
|
||||||
// Verify the controller structure
|
|
||||||
const controllerAnalysis = await page.evaluate(() => {
|
|
||||||
const mapElement = document.querySelector('#map');
|
|
||||||
const controllers = mapElement?._stimulus_controllers;
|
|
||||||
const mapController = controllers?.find(c => c.identifier === 'maps');
|
|
||||||
|
|
||||||
if (mapController) {
|
|
||||||
const hasAppendPoint = typeof mapController.appendPoint === 'function';
|
|
||||||
const methodSource = hasAppendPoint ? mapController.appendPoint.toString() : '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasController: true,
|
|
||||||
hasAppendPoint,
|
|
||||||
// Check if appendPoint delegates to LiveMapHandler
|
|
||||||
usesDelegation: methodSource.includes('liveMapHandler') || methodSource.includes('LiveMapHandler'),
|
|
||||||
methodLength: methodSource.length,
|
|
||||||
isSimpleMethod: methodSource.length < 500 // Should be much smaller now
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasController: false,
|
|
||||||
message: 'Controller not found in test environment'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Controller delegation analysis:', controllerAnalysis);
|
|
||||||
|
|
||||||
// Test passes either way since we've implemented the refactoring
|
|
||||||
if (controllerAnalysis.hasController) {
|
|
||||||
// If controller exists, verify it's using delegation
|
|
||||||
expect(controllerAnalysis.hasAppendPoint).toBe(true);
|
|
||||||
// The new appendPoint method should be much smaller (delegation only)
|
|
||||||
expect(controllerAnalysis.isSimpleMethod).toBe(true);
|
|
||||||
} else {
|
|
||||||
// Controller not found - this is the current test environment limitation
|
|
||||||
console.log('Controller not accessible in test, but refactoring implemented in source');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(true).toBe(true); // Test always passes as verification
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should maintain backward compatibility', async () => {
|
|
||||||
// Navigate to map
|
|
||||||
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
|
|
||||||
await page.waitForSelector('#map', { timeout: 10000 });
|
|
||||||
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
|
|
||||||
|
|
||||||
// Verify basic map functionality still works
|
|
||||||
const mapFunctionality = await page.evaluate(() => {
|
|
||||||
return {
|
|
||||||
hasLeafletContainer: !!document.querySelector('.leaflet-container'),
|
|
||||||
hasMapElement: !!document.querySelector('#map'),
|
|
||||||
hasApiKey: !!document.querySelector('#map')?.dataset?.api_key,
|
|
||||||
leafletElementCount: document.querySelectorAll('[class*="leaflet"]').length,
|
|
||||||
hasDataController: document.querySelector('#map')?.hasAttribute('data-controller')
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Map functionality check:', mapFunctionality);
|
|
||||||
|
|
||||||
// Verify all core functionality remains intact
|
|
||||||
expect(mapFunctionality.hasLeafletContainer).toBe(true);
|
|
||||||
expect(mapFunctionality.hasMapElement).toBe(true);
|
|
||||||
expect(mapFunctionality.hasApiKey).toBe(true);
|
|
||||||
expect(mapFunctionality.hasDataController).toBe(true);
|
|
||||||
expect(mapFunctionality.leafletElementCount).toBeGreaterThan(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load diff
1670
e2e/map.spec.js
1670
e2e/map.spec.js
File diff suppressed because it is too large
Load diff
260
e2e/map/map-add-visit.spec.js
Normal file
260
e2e/map/map-add-visit.spec.js
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { navigateToMap } from '../helpers/navigation.js';
|
||||||
|
import { waitForMap } from '../helpers/map.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to wait for add visit controller to be fully initialized
|
||||||
|
*/
|
||||||
|
async function waitForAddVisitController(page) {
|
||||||
|
await page.waitForTimeout(2000); // Wait for controller to connect and attach handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Add Visit Control', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await navigateToMap(page);
|
||||||
|
await waitForMap(page);
|
||||||
|
await waitForAddVisitController(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show add visit button control', async ({ page }) => {
|
||||||
|
const addVisitButton = page.locator('.add-visit-button');
|
||||||
|
await expect(addVisitButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enable add visit mode when clicked', async ({ page }) => {
|
||||||
|
const addVisitButton = page.locator('.add-visit-button');
|
||||||
|
await addVisitButton.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify flash message appears
|
||||||
|
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Click on the map")');
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify cursor changed to crosshair
|
||||||
|
const cursor = await page.evaluate(() => {
|
||||||
|
const container = document.querySelector('#map [data-maps-target="container"]');
|
||||||
|
return container?.style.cursor;
|
||||||
|
});
|
||||||
|
expect(cursor).toBe('crosshair');
|
||||||
|
|
||||||
|
// Verify button has active state (background color applied)
|
||||||
|
const hasActiveStyle = await addVisitButton.evaluate((el) => {
|
||||||
|
return el.style.backgroundColor !== '';
|
||||||
|
});
|
||||||
|
expect(hasActiveStyle).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should open popup form when map is clicked', async ({ page }) => {
|
||||||
|
const addVisitButton = page.locator('.add-visit-button');
|
||||||
|
await addVisitButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Click on map - use bottom left corner which is less likely to have points
|
||||||
|
const mapContainer = page.locator('#map [data-maps-target="container"]');
|
||||||
|
const bbox = await mapContainer.boundingBox();
|
||||||
|
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify popup is visible
|
||||||
|
const popup = page.locator('.leaflet-popup');
|
||||||
|
await expect(popup).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify popup contains the add visit form
|
||||||
|
await expect(popup.locator('h3:has-text("Add New Visit")')).toBeVisible();
|
||||||
|
|
||||||
|
// Verify marker appears (📍 emoji with class add-visit-marker)
|
||||||
|
const marker = page.locator('.add-visit-marker');
|
||||||
|
await expect(marker).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display correct form content in popup', async ({ page }) => {
|
||||||
|
// Enable mode and click map
|
||||||
|
await page.locator('.add-visit-button').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map [data-maps-target="container"]');
|
||||||
|
const bbox = await mapContainer.boundingBox();
|
||||||
|
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify popup content has all required elements
|
||||||
|
const popupContent = page.locator('.leaflet-popup-content');
|
||||||
|
await expect(popupContent.locator('h3:has-text("Add New Visit")')).toBeVisible();
|
||||||
|
await expect(popupContent.locator('input#visit-name')).toBeVisible();
|
||||||
|
await expect(popupContent.locator('input#visit-start')).toBeVisible();
|
||||||
|
await expect(popupContent.locator('input#visit-end')).toBeVisible();
|
||||||
|
await expect(popupContent.locator('button:has-text("Create Visit")')).toBeVisible();
|
||||||
|
await expect(popupContent.locator('button:has-text("Cancel")')).toBeVisible();
|
||||||
|
|
||||||
|
// Verify name field has focus
|
||||||
|
const nameFieldFocused = await page.evaluate(() => {
|
||||||
|
return document.activeElement?.id === 'visit-name';
|
||||||
|
});
|
||||||
|
expect(nameFieldFocused).toBe(true);
|
||||||
|
|
||||||
|
// Verify start and end time have default values
|
||||||
|
const startValue = await page.locator('input#visit-start').inputValue();
|
||||||
|
const endValue = await page.locator('input#visit-end').inputValue();
|
||||||
|
expect(startValue).toBeTruthy();
|
||||||
|
expect(endValue).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should hide popup and remove marker when cancel is clicked', async ({ page }) => {
|
||||||
|
// Enable mode and click map
|
||||||
|
await page.locator('.add-visit-button').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map [data-maps-target="container"]');
|
||||||
|
const bbox = await mapContainer.boundingBox();
|
||||||
|
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify popup and marker exist
|
||||||
|
await expect(page.locator('.leaflet-popup')).toBeVisible();
|
||||||
|
await expect(page.locator('.add-visit-marker')).toBeVisible();
|
||||||
|
|
||||||
|
// Click cancel button
|
||||||
|
await page.locator('#cancel-visit').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify popup is hidden
|
||||||
|
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
|
||||||
|
expect(popupVisible).toBe(false);
|
||||||
|
|
||||||
|
// Verify marker is removed
|
||||||
|
const markerCount = await page.locator('.add-visit-marker').count();
|
||||||
|
expect(markerCount).toBe(0);
|
||||||
|
|
||||||
|
// Verify cursor is reset to default
|
||||||
|
const cursor = await page.evaluate(() => {
|
||||||
|
const container = document.querySelector('#map [data-maps-target="container"]');
|
||||||
|
return container?.style.cursor;
|
||||||
|
});
|
||||||
|
expect(cursor).toBe('');
|
||||||
|
|
||||||
|
// Verify mode was exited (cursor should be reset)
|
||||||
|
const cursorReset = await page.evaluate(() => {
|
||||||
|
const container = document.querySelector('#map [data-maps-target="container"]');
|
||||||
|
return container?.style.cursor === '';
|
||||||
|
});
|
||||||
|
expect(cursorReset).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create visit and show marker on map when submitted', async ({ page }) => {
|
||||||
|
// Get initial confirmed visit count
|
||||||
|
const initialCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
|
||||||
|
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable mode and click map
|
||||||
|
await page.locator('.add-visit-button').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map [data-maps-target="container"]');
|
||||||
|
const bbox = await mapContainer.boundingBox();
|
||||||
|
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Fill form with unique visit name
|
||||||
|
const visitName = `E2E Test Visit ${Date.now()}`;
|
||||||
|
await page.locator('#visit-name').fill(visitName);
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await page.locator('button:has-text("Create Visit")').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify success message
|
||||||
|
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("created successfully")');
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify popup is closed
|
||||||
|
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
|
||||||
|
expect(popupVisible).toBe(false);
|
||||||
|
|
||||||
|
// Verify confirmed visit marker count increased
|
||||||
|
const finalCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
|
||||||
|
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(finalCount).toBeGreaterThan(initialCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should disable add visit mode when clicked second time', async ({ page }) => {
|
||||||
|
const addVisitButton = page.locator('.add-visit-button');
|
||||||
|
|
||||||
|
// First click - enable mode
|
||||||
|
await addVisitButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify mode is enabled
|
||||||
|
const cursorEnabled = await page.evaluate(() => {
|
||||||
|
const container = document.querySelector('#map [data-maps-target="container"]');
|
||||||
|
return container?.style.cursor === 'crosshair';
|
||||||
|
});
|
||||||
|
expect(cursorEnabled).toBe(true);
|
||||||
|
|
||||||
|
// Second click - disable mode
|
||||||
|
await addVisitButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify cursor is reset
|
||||||
|
const cursorDisabled = await page.evaluate(() => {
|
||||||
|
const container = document.querySelector('#map [data-maps-target="container"]');
|
||||||
|
return container?.style.cursor;
|
||||||
|
});
|
||||||
|
expect(cursorDisabled).toBe('');
|
||||||
|
|
||||||
|
// Verify mode was exited by checking if we can click map without creating marker
|
||||||
|
const isAddingVisit = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'add-visit');
|
||||||
|
return controller?.isAddingVisit === true;
|
||||||
|
});
|
||||||
|
expect(isAddingVisit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should ensure only one visit popup is open at a time', async ({ page }) => {
|
||||||
|
const addVisitButton = page.locator('.add-visit-button');
|
||||||
|
await addVisitButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map [data-maps-target="container"]');
|
||||||
|
const bbox = await mapContainer.boundingBox();
|
||||||
|
|
||||||
|
// Click first location on map
|
||||||
|
await page.mouse.click(bbox.x + bbox.width * 0.3, bbox.y + bbox.height * 0.3);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify first popup exists
|
||||||
|
let popupCount = await page.locator('.leaflet-popup').count();
|
||||||
|
expect(popupCount).toBe(1);
|
||||||
|
|
||||||
|
// Get the content of first popup to verify it exists
|
||||||
|
const firstPopupContent = await page.locator('.leaflet-popup-content input#visit-name').count();
|
||||||
|
expect(firstPopupContent).toBe(1);
|
||||||
|
|
||||||
|
// Click second location on map
|
||||||
|
await page.mouse.click(bbox.x + bbox.width * 0.7, bbox.y + bbox.height * 0.7);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify still only one popup exists (old one was closed, new one opened)
|
||||||
|
popupCount = await page.locator('.leaflet-popup').count();
|
||||||
|
expect(popupCount).toBe(1);
|
||||||
|
|
||||||
|
// Verify the popup contains the add visit form (not some other popup)
|
||||||
|
const popupContent = page.locator('.leaflet-popup-content');
|
||||||
|
await expect(popupContent.locator('h3:has-text("Add New Visit")')).toBeVisible();
|
||||||
|
await expect(popupContent.locator('input#visit-name')).toBeVisible();
|
||||||
|
|
||||||
|
// Verify only one marker exists
|
||||||
|
const markerCount = await page.locator('.add-visit-marker').count();
|
||||||
|
expect(markerCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
380
e2e/map/map-bulk-delete.spec.js
Normal file
380
e2e/map/map-bulk-delete.spec.js
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { drawSelectionRectangle } from '../helpers/selection.js';
|
||||||
|
import { navigateToDate, closeOnboardingModal } from '../helpers/navigation.js';
|
||||||
|
import { waitForMap, enableLayer } from '../helpers/map.js';
|
||||||
|
|
||||||
|
test.describe('Bulk Delete Points', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Navigate to map page
|
||||||
|
await page.goto('/map', {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for map to be initialized
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
// Close onboarding modal if present
|
||||||
|
await closeOnboardingModal(page);
|
||||||
|
|
||||||
|
// Navigate to a date with points (October 13, 2024)
|
||||||
|
await navigateToDate(page, '2024-10-13T00:00', '2024-10-13T23:59');
|
||||||
|
|
||||||
|
// Enable Points layer
|
||||||
|
await enableLayer(page, 'Points');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show area selection tool button', async ({ page }) => {
|
||||||
|
// Check that area selection button exists
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await expect(selectionButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enable selection mode when area tool is clicked', async ({ page }) => {
|
||||||
|
// Click area selection button
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify selection mode is active
|
||||||
|
const isSelectionActive = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.visitsManager?.selectionMode === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isSelectionActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should select points in drawn area and show delete button', async ({ page }) => {
|
||||||
|
await drawSelectionRectangle(page);
|
||||||
|
|
||||||
|
// Check that delete button appears
|
||||||
|
const deleteButton = page.locator('#delete-selection-button');
|
||||||
|
await expect(deleteButton).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Check button has text "Delete Points"
|
||||||
|
await expect(deleteButton).toContainText('Delete Points');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show point count badge on delete button', async ({ page }) => {
|
||||||
|
await drawSelectionRectangle(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Check for badge with count
|
||||||
|
const badge = page.locator('#delete-selection-button .badge');
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
|
||||||
|
// Badge should contain a number
|
||||||
|
const badgeText = await badge.textContent();
|
||||||
|
expect(parseInt(badgeText)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show cancel button alongside delete button', async ({ page }) => {
|
||||||
|
await drawSelectionRectangle(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Check both buttons exist
|
||||||
|
const cancelButton = page.locator('#cancel-selection-button');
|
||||||
|
const deleteButton = page.locator('#delete-selection-button');
|
||||||
|
|
||||||
|
await expect(cancelButton).toBeVisible();
|
||||||
|
await expect(deleteButton).toBeVisible();
|
||||||
|
await expect(cancelButton).toContainText('Cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should cancel selection when cancel button is clicked', async ({ page }) => {
|
||||||
|
await drawSelectionRectangle(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Click cancel button
|
||||||
|
const cancelButton = page.locator('#cancel-selection-button');
|
||||||
|
await cancelButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify buttons are gone
|
||||||
|
await expect(cancelButton).not.toBeVisible();
|
||||||
|
await expect(page.locator('#delete-selection-button')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify selection is cleared
|
||||||
|
const isSelectionActive = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.visitsManager?.isSelectionActive === false;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isSelectionActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show confirmation dialog when delete button is clicked', async ({ page }) => {
|
||||||
|
// Set up dialog handler
|
||||||
|
let dialogMessage = '';
|
||||||
|
page.on('dialog', async dialog => {
|
||||||
|
dialogMessage = dialog.message();
|
||||||
|
await dialog.dismiss(); // Dismiss to prevent actual deletion
|
||||||
|
});
|
||||||
|
|
||||||
|
await drawSelectionRectangle(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Click delete button
|
||||||
|
const deleteButton = page.locator('#delete-selection-button');
|
||||||
|
await deleteButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify confirmation dialog appeared with warning
|
||||||
|
expect(dialogMessage).toContain('WARNING');
|
||||||
|
expect(dialogMessage).toContain('permanently delete');
|
||||||
|
expect(dialogMessage).toContain('cannot be undone');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete points and show success message when confirmed', async ({ page }) => {
|
||||||
|
// Set up dialog handler to accept deletion
|
||||||
|
page.on('dialog', async dialog => {
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get initial point count
|
||||||
|
const initialPointCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.markers?.length || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
await drawSelectionRectangle(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Click delete button
|
||||||
|
const deleteButton = page.locator('#delete-selection-button');
|
||||||
|
await deleteButton.click();
|
||||||
|
await page.waitForTimeout(2000); // Wait for deletion to complete
|
||||||
|
|
||||||
|
// Check for success flash message with specific text
|
||||||
|
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Successfully deleted")');
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
const messageText = await flashMessage.textContent();
|
||||||
|
expect(messageText).toMatch(/Successfully deleted \d+ point/);
|
||||||
|
|
||||||
|
// Verify point count decreased
|
||||||
|
const finalPointCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.markers?.length || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(finalPointCount).toBeLessThan(initialPointCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should preserve Routes layer disabled state after deletion', async ({ page }) => {
|
||||||
|
// Ensure Routes layer is disabled
|
||||||
|
await page.locator('.leaflet-control-layers').hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]');
|
||||||
|
const isRoutesChecked = await routesCheckbox.isChecked();
|
||||||
|
if (isRoutesChecked) {
|
||||||
|
await routesCheckbox.uncheck();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up dialog handler to accept deletion
|
||||||
|
page.on('dialog', async dialog => {
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Perform deletion using same selection logic as helper
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map [data-maps-target="container"]');
|
||||||
|
const bbox = await mapContainer.boundingBox();
|
||||||
|
|
||||||
|
// Use larger selection area to ensure we select points
|
||||||
|
const startX = bbox.x + bbox.width * 0.2;
|
||||||
|
const startY = bbox.y + bbox.height * 0.2;
|
||||||
|
const endX = bbox.x + bbox.width * 0.8;
|
||||||
|
const endY = bbox.y + bbox.height * 0.8;
|
||||||
|
|
||||||
|
await page.mouse.move(startX, startY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(endX, endY, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Wait for drawer and button to appear
|
||||||
|
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
|
||||||
|
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
|
||||||
|
|
||||||
|
const deleteButton = page.locator('#delete-selection-button');
|
||||||
|
await deleteButton.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify Routes layer is still disabled
|
||||||
|
const isRoutesLayerVisible = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.map?.hasLayer(controller?.polylinesLayer);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isRoutesLayerVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should preserve Routes layer enabled state after deletion', async ({ page }) => {
|
||||||
|
// Enable Routes layer
|
||||||
|
await page.locator('.leaflet-control-layers').hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]');
|
||||||
|
const isRoutesChecked = await routesCheckbox.isChecked();
|
||||||
|
if (!isRoutesChecked) {
|
||||||
|
await routesCheckbox.check();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up dialog handler to accept deletion
|
||||||
|
page.on('dialog', async dialog => {
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Perform deletion using same selection logic as helper
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map [data-maps-target="container"]');
|
||||||
|
const bbox = await mapContainer.boundingBox();
|
||||||
|
|
||||||
|
// Use larger selection area to ensure we select points
|
||||||
|
const startX = bbox.x + bbox.width * 0.2;
|
||||||
|
const startY = bbox.y + bbox.height * 0.2;
|
||||||
|
const endX = bbox.x + bbox.width * 0.8;
|
||||||
|
const endY = bbox.y + bbox.height * 0.8;
|
||||||
|
|
||||||
|
await page.mouse.move(startX, startY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(endX, endY, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Wait for drawer and button to appear
|
||||||
|
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
|
||||||
|
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
|
||||||
|
|
||||||
|
const deleteButton = page.locator('#delete-selection-button');
|
||||||
|
await deleteButton.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify Routes layer is still enabled
|
||||||
|
const isRoutesLayerVisible = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.map?.hasLayer(controller?.polylinesLayer);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isRoutesLayerVisible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update heatmap after bulk deletion', async ({ page }) => {
|
||||||
|
// Enable Heatmap layer
|
||||||
|
await page.locator('.leaflet-control-layers').hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const heatmapCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Heatmap") input[type="checkbox"]');
|
||||||
|
const isHeatmapChecked = await heatmapCheckbox.isChecked();
|
||||||
|
if (!isHeatmapChecked) {
|
||||||
|
await heatmapCheckbox.check();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial heatmap data count
|
||||||
|
const initialHeatmapCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.heatmapLayer?._latlngs?.length || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up dialog handler to accept deletion
|
||||||
|
page.on('dialog', async dialog => {
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Perform deletion using same selection logic as helper
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map [data-maps-target="container"]');
|
||||||
|
const bbox = await mapContainer.boundingBox();
|
||||||
|
|
||||||
|
// Use larger selection area to ensure we select points
|
||||||
|
const startX = bbox.x + bbox.width * 0.2;
|
||||||
|
const startY = bbox.y + bbox.height * 0.2;
|
||||||
|
const endX = bbox.x + bbox.width * 0.8;
|
||||||
|
const endY = bbox.y + bbox.height * 0.8;
|
||||||
|
|
||||||
|
await page.mouse.move(startX, startY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(endX, endY, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Wait for drawer and button to appear
|
||||||
|
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
|
||||||
|
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
|
||||||
|
|
||||||
|
const deleteButton = page.locator('#delete-selection-button');
|
||||||
|
await deleteButton.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify heatmap was updated
|
||||||
|
const finalHeatmapCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.heatmapLayer?._latlngs?.length || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(finalHeatmapCount).toBeLessThan(initialHeatmapCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should clear selection after successful deletion', async ({ page }) => {
|
||||||
|
// Set up dialog handler to accept deletion
|
||||||
|
page.on('dialog', async dialog => {
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Perform deletion using same selection logic as helper
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map [data-maps-target="container"]');
|
||||||
|
const bbox = await mapContainer.boundingBox();
|
||||||
|
|
||||||
|
// Use larger selection area to ensure we select points
|
||||||
|
const startX = bbox.x + bbox.width * 0.2;
|
||||||
|
const startY = bbox.y + bbox.height * 0.2;
|
||||||
|
const endX = bbox.x + bbox.width * 0.8;
|
||||||
|
const endY = bbox.y + bbox.height * 0.8;
|
||||||
|
|
||||||
|
await page.mouse.move(startX, startY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(endX, endY, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Wait for drawer and button to appear
|
||||||
|
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
|
||||||
|
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
|
||||||
|
|
||||||
|
const deleteButton = page.locator('#delete-selection-button');
|
||||||
|
await deleteButton.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify selection is cleared
|
||||||
|
const isSelectionActive = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.visitsManager?.isSelectionActive === false &&
|
||||||
|
controller?.visitsManager?.selectedPoints?.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isSelectionActive).toBe(true);
|
||||||
|
|
||||||
|
// Verify buttons are removed
|
||||||
|
await expect(page.locator('#cancel-selection-button')).not.toBeVisible();
|
||||||
|
await expect(page.locator('#delete-selection-button')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
308
e2e/map/map-calendar-panel.spec.js
Normal file
308
e2e/map/map-calendar-panel.spec.js
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { closeOnboardingModal } from '../helpers/navigation.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar Panel Tests
|
||||||
|
*
|
||||||
|
* Tests for the calendar panel control that allows users to navigate between
|
||||||
|
* different years and months. The panel is opened via the "Toggle Panel" button
|
||||||
|
* in the top-right corner of the map.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe('Calendar Panel', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/map');
|
||||||
|
await closeOnboardingModal(page);
|
||||||
|
|
||||||
|
// Wait for map to be fully loaded
|
||||||
|
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
|
||||||
|
await page.waitForTimeout(2000); // Wait for all controls to be initialized
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to find and click the calendar toggle button
|
||||||
|
*/
|
||||||
|
async function clickCalendarButton(page) {
|
||||||
|
// The calendar button is the "Toggle Panel" button with a calendar icon
|
||||||
|
// It's the third button in the top-right control stack (after Select Area and Add Visit)
|
||||||
|
const calendarButton = await page.locator('button.toggle-panel-button').first();
|
||||||
|
await expect(calendarButton).toBeVisible({ timeout: 5000 });
|
||||||
|
await calendarButton.click();
|
||||||
|
await page.waitForTimeout(500); // Wait for panel animation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if panel is visible
|
||||||
|
*/
|
||||||
|
async function isPanelVisible(page) {
|
||||||
|
const panel = page.locator('.leaflet-right-panel');
|
||||||
|
const isVisible = await panel.isVisible().catch(() => false);
|
||||||
|
if (!isVisible) return false;
|
||||||
|
|
||||||
|
const displayStyle = await panel.evaluate(el => el.style.display);
|
||||||
|
return displayStyle !== 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
test('should open calendar panel on first click', async ({ page }) => {
|
||||||
|
// Verify panel is not visible initially
|
||||||
|
const initiallyVisible = await isPanelVisible(page);
|
||||||
|
expect(initiallyVisible).toBe(false);
|
||||||
|
|
||||||
|
// Click calendar button
|
||||||
|
await clickCalendarButton(page);
|
||||||
|
|
||||||
|
// Verify panel is now visible
|
||||||
|
const panelVisible = await isPanelVisible(page);
|
||||||
|
expect(panelVisible).toBe(true);
|
||||||
|
|
||||||
|
// Verify panel contains expected elements
|
||||||
|
const yearSelect = page.locator('#year-select');
|
||||||
|
await expect(yearSelect).toBeVisible();
|
||||||
|
|
||||||
|
const monthsGrid = page.locator('#months-grid');
|
||||||
|
await expect(monthsGrid).toBeVisible();
|
||||||
|
|
||||||
|
// Verify "Whole year" link is present
|
||||||
|
const wholeYearLink = page.locator('#whole-year-link');
|
||||||
|
await expect(wholeYearLink).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should close calendar panel on second click', async ({ page }) => {
|
||||||
|
// Open panel
|
||||||
|
await clickCalendarButton(page);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Verify panel is visible
|
||||||
|
let panelVisible = await isPanelVisible(page);
|
||||||
|
expect(panelVisible).toBe(true);
|
||||||
|
|
||||||
|
// Click button again to close
|
||||||
|
await clickCalendarButton(page);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Verify panel is hidden
|
||||||
|
panelVisible = await isPanelVisible(page);
|
||||||
|
expect(panelVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow year selection', async ({ page }) => {
|
||||||
|
// Open panel
|
||||||
|
await clickCalendarButton(page);
|
||||||
|
|
||||||
|
// Wait for year select to be populated (it loads from API)
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const yearSelect = page.locator('#year-select');
|
||||||
|
await expect(yearSelect).toBeVisible();
|
||||||
|
|
||||||
|
// Get available years
|
||||||
|
const options = await yearSelect.locator('option:not([disabled])').all();
|
||||||
|
|
||||||
|
// Should have at least one year available
|
||||||
|
expect(options.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Select the first available year
|
||||||
|
const firstYearOption = options[0];
|
||||||
|
const yearValue = await firstYearOption.getAttribute('value');
|
||||||
|
|
||||||
|
await yearSelect.selectOption(yearValue);
|
||||||
|
|
||||||
|
// Verify year was selected
|
||||||
|
const selectedValue = await yearSelect.inputValue();
|
||||||
|
expect(selectedValue).toBe(yearValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to month when clicking month button', async ({ page }) => {
|
||||||
|
// Open panel
|
||||||
|
await clickCalendarButton(page);
|
||||||
|
|
||||||
|
// Wait for months to load
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Select year 2024 (which has October data in demo)
|
||||||
|
const yearSelect = page.locator('#year-select');
|
||||||
|
await yearSelect.selectOption('2024');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Find October button (demo data has October 2024)
|
||||||
|
const octoberButton = page.locator('#months-grid a[data-month-name="Oct"]');
|
||||||
|
await expect(octoberButton).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify October is enabled (not disabled)
|
||||||
|
const isDisabled = await octoberButton.evaluate(el => el.classList.contains('disabled'));
|
||||||
|
expect(isDisabled).toBe(false);
|
||||||
|
|
||||||
|
// Verify button is clickable
|
||||||
|
const pointerEvents = await octoberButton.evaluate(el => el.style.pointerEvents);
|
||||||
|
expect(pointerEvents).not.toBe('none');
|
||||||
|
|
||||||
|
// Get the expected href before clicking
|
||||||
|
const expectedHref = await octoberButton.getAttribute('href');
|
||||||
|
expect(expectedHref).toBeTruthy();
|
||||||
|
const decodedHref = decodeURIComponent(expectedHref);
|
||||||
|
|
||||||
|
expect(decodedHref).toContain('map?');
|
||||||
|
expect(decodedHref).toContain('start_at=2024-10-01T00:00');
|
||||||
|
expect(decodedHref).toContain('end_at=2024-10-31T23:59');
|
||||||
|
|
||||||
|
// Click the month button and wait for navigation
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/map**', { timeout: 10000 }),
|
||||||
|
octoberButton.click()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wait for page to settle
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify we navigated to the map page
|
||||||
|
expect(page.url()).toContain('/map');
|
||||||
|
|
||||||
|
// Verify map loaded with data
|
||||||
|
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to whole year when clicking "Whole year" button', async ({ page }) => {
|
||||||
|
// Open panel
|
||||||
|
await clickCalendarButton(page);
|
||||||
|
|
||||||
|
// Wait for panel to load
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const wholeYearLink = page.locator('#whole-year-link');
|
||||||
|
await expect(wholeYearLink).toBeVisible();
|
||||||
|
|
||||||
|
// Get the href and decode it
|
||||||
|
const href = await wholeYearLink.getAttribute('href');
|
||||||
|
expect(href).toBeTruthy();
|
||||||
|
const decodedHref = decodeURIComponent(href);
|
||||||
|
|
||||||
|
expect(decodedHref).toContain('map?');
|
||||||
|
expect(decodedHref).toContain('start_at=');
|
||||||
|
expect(decodedHref).toContain('end_at=');
|
||||||
|
|
||||||
|
// Href should contain full year dates (01-01 to 12-31)
|
||||||
|
expect(decodedHref).toContain('-01-01T00:00');
|
||||||
|
expect(decodedHref).toContain('-12-31T23:59');
|
||||||
|
|
||||||
|
// Store the expected year from the href
|
||||||
|
const yearMatch = decodedHref.match(/(\d{4})-01-01/);
|
||||||
|
expect(yearMatch).toBeTruthy();
|
||||||
|
const expectedYear = yearMatch[1];
|
||||||
|
|
||||||
|
// Click the link and wait for navigation
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/map**', { timeout: 10000 }),
|
||||||
|
wholeYearLink.click()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wait for page to settle
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify we navigated to the map page
|
||||||
|
expect(page.url()).toContain('/map');
|
||||||
|
|
||||||
|
// The URL parameters might be processed differently (e.g., stripped by Turbo or redirected)
|
||||||
|
// Instead of checking URL, verify the panel updates to show the whole year is selected
|
||||||
|
// by checking the year in the select dropdown
|
||||||
|
const panelVisible = await isPanelVisible(page);
|
||||||
|
if (!panelVisible) {
|
||||||
|
// Panel might have closed on navigation, reopen it
|
||||||
|
await clickCalendarButton(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearSelect = page.locator('#year-select');
|
||||||
|
const selectedYear = await yearSelect.inputValue();
|
||||||
|
expect(selectedYear).toBe(expectedYear);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update month buttons when year is changed', async ({ page }) => {
|
||||||
|
// Open panel
|
||||||
|
await clickCalendarButton(page);
|
||||||
|
|
||||||
|
// Wait for data to load
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const yearSelect = page.locator('#year-select');
|
||||||
|
|
||||||
|
// Get available years
|
||||||
|
const options = await yearSelect.locator('option:not([disabled])').all();
|
||||||
|
|
||||||
|
if (options.length < 2) {
|
||||||
|
console.log('Test skipped: Less than 2 years available');
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select first year and capture month states
|
||||||
|
const firstYearOption = options[0];
|
||||||
|
const firstYear = await firstYearOption.getAttribute('value');
|
||||||
|
await yearSelect.selectOption(firstYear);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Get enabled months for first year
|
||||||
|
const firstYearMonths = await page.locator('#months-grid a:not(.disabled)').count();
|
||||||
|
|
||||||
|
// Select second year
|
||||||
|
const secondYearOption = options[1];
|
||||||
|
const secondYear = await secondYearOption.getAttribute('value');
|
||||||
|
await yearSelect.selectOption(secondYear);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Get enabled months for second year
|
||||||
|
const secondYearMonths = await page.locator('#months-grid a:not(.disabled)').count();
|
||||||
|
|
||||||
|
// Months should be different (unless both years have same tracked months)
|
||||||
|
// At minimum, verify that month buttons are updated (content changed from loading dots)
|
||||||
|
const monthButtons = await page.locator('#months-grid a').all();
|
||||||
|
|
||||||
|
for (const button of monthButtons) {
|
||||||
|
const buttonText = await button.textContent();
|
||||||
|
// Should not contain loading dots anymore
|
||||||
|
expect(buttonText).not.toContain('loading');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should highlight active month based on current URL parameters', async ({ page }) => {
|
||||||
|
// Navigate to a specific month first
|
||||||
|
await page.goto('/map?start_at=2024-10-01T00:00&end_at=2024-10-31T23:59');
|
||||||
|
await closeOnboardingModal(page);
|
||||||
|
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Open calendar panel
|
||||||
|
await clickCalendarButton(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Find October button (month index 9, displayed as "Oct")
|
||||||
|
const octoberButton = page.locator('#months-grid a[data-month-name="Oct"]');
|
||||||
|
await expect(octoberButton).toBeVisible();
|
||||||
|
|
||||||
|
// Verify October is marked as active
|
||||||
|
const hasActiveClass = await octoberButton.evaluate(el =>
|
||||||
|
el.classList.contains('btn-active')
|
||||||
|
);
|
||||||
|
expect(hasActiveClass).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show visited cities section in panel', async ({ page }) => {
|
||||||
|
// Open panel
|
||||||
|
await clickCalendarButton(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify visited cities section is present
|
||||||
|
const visitedCitiesContainer = page.locator('#visited-cities-container');
|
||||||
|
await expect(visitedCitiesContainer).toBeVisible();
|
||||||
|
|
||||||
|
const visitedCitiesTitle = visitedCitiesContainer.locator('h3');
|
||||||
|
await expect(visitedCitiesTitle).toHaveText('Visited cities');
|
||||||
|
|
||||||
|
const visitedCitiesList = page.locator('#visited-cities-list');
|
||||||
|
await expect(visitedCitiesList).toBeVisible();
|
||||||
|
|
||||||
|
// List should eventually load (either with cities or "No places visited")
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const listContent = await visitedCitiesList.textContent();
|
||||||
|
expect(listContent.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
157
e2e/map/map-controls.spec.js
Normal file
157
e2e/map/map-controls.spec.js
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { navigateToMap, closeOnboardingModal, navigateToDate } from '../helpers/navigation.js';
|
||||||
|
import { waitForMap, getMapZoom } from '../helpers/map.js';
|
||||||
|
|
||||||
|
test.describe('Map Page', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await navigateToMap(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load map container and display map with controls', async ({ page }) => {
|
||||||
|
await expect(page.locator('#map')).toBeVisible();
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
// Verify zoom controls are present
|
||||||
|
await expect(page.locator('.leaflet-control-zoom')).toBeVisible();
|
||||||
|
|
||||||
|
// Verify custom map controls are present (from map_controls.js)
|
||||||
|
await expect(page.locator('.add-visit-button')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.toggle-panel-button')).toBeVisible();
|
||||||
|
await expect(page.locator('.drawer-button')).toBeVisible();
|
||||||
|
await expect(page.locator('#selection-tool-button')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should zoom in when clicking zoom in button', async ({ page }) => {
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
const initialZoom = await getMapZoom(page);
|
||||||
|
await page.locator('.leaflet-control-zoom-in').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const newZoom = await getMapZoom(page);
|
||||||
|
|
||||||
|
expect(newZoom).toBeGreaterThan(initialZoom);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should zoom out when clicking zoom out button', async ({ page }) => {
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
const initialZoom = await getMapZoom(page);
|
||||||
|
await page.locator('.leaflet-control-zoom-out').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const newZoom = await getMapZoom(page);
|
||||||
|
|
||||||
|
expect(newZoom).toBeLessThan(initialZoom);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should switch between map tile layers', async ({ page }) => {
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
await page.locator('.leaflet-control-layers').hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const getSelectedLayer = () => page.evaluate(() => {
|
||||||
|
const radio = document.querySelector('.leaflet-control-layers-base input[type="radio"]:checked');
|
||||||
|
return radio ? radio.nextSibling.textContent.trim() : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialLayer = await getSelectedLayer();
|
||||||
|
await page.locator('.leaflet-control-layers-base input[type="radio"]:not(:checked)').first().click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const newLayer = await getSelectedLayer();
|
||||||
|
|
||||||
|
expect(newLayer).not.toBe(initialLayer);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to specific date and display points layer', async ({ page }) => {
|
||||||
|
// Wait for map to be ready
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const container = document.querySelector('#map [data-maps-target="container"]');
|
||||||
|
return container && container._leaflet_id !== undefined;
|
||||||
|
}, { timeout: 10000 });
|
||||||
|
|
||||||
|
// Navigate to date 13.10.2024
|
||||||
|
// First, need to expand the date controls on mobile (if collapsed)
|
||||||
|
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
|
||||||
|
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
|
||||||
|
|
||||||
|
if (!isPanelVisible) {
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear and fill in the start date/time input (midnight)
|
||||||
|
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
|
||||||
|
await startInput.clear();
|
||||||
|
await startInput.fill('2024-10-13T00:00');
|
||||||
|
|
||||||
|
// Clear and fill in the end date/time input (end of day)
|
||||||
|
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
|
||||||
|
await endInput.clear();
|
||||||
|
await endInput.fill('2024-10-13T23:59');
|
||||||
|
|
||||||
|
// Click the Search button to submit
|
||||||
|
await page.click('input[type="submit"][value="Search"]');
|
||||||
|
|
||||||
|
// Wait for page navigation and map reload
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(1000); // Wait for map to reinitialize
|
||||||
|
|
||||||
|
// Close onboarding modal if it appears after navigation
|
||||||
|
await closeOnboardingModal(page);
|
||||||
|
|
||||||
|
// Open layer control to enable points
|
||||||
|
await page.locator('.leaflet-control-layers').hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Enable points layer if not already enabled
|
||||||
|
const pointsCheckbox = page.locator('.leaflet-control-layers-overlays input[type="checkbox"]').first();
|
||||||
|
const isChecked = await pointsCheckbox.isChecked();
|
||||||
|
|
||||||
|
if (!isChecked) {
|
||||||
|
await pointsCheckbox.check();
|
||||||
|
await page.waitForTimeout(1000); // Wait for points to render
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify points are visible on the map
|
||||||
|
const layerInfo = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
|
||||||
|
if (!controller) {
|
||||||
|
return { error: 'Controller not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
hasMarkersLayer: !!controller.markersLayer,
|
||||||
|
markersCount: 0,
|
||||||
|
hasPolylinesLayer: !!controller.polylinesLayer,
|
||||||
|
polylinesCount: 0,
|
||||||
|
hasTracksLayer: !!controller.tracksLayer,
|
||||||
|
tracksCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check markers layer
|
||||||
|
if (controller.markersLayer && controller.markersLayer._layers) {
|
||||||
|
result.markersCount = Object.keys(controller.markersLayer._layers).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check polylines layer
|
||||||
|
if (controller.polylinesLayer && controller.polylinesLayer._layers) {
|
||||||
|
result.polylinesCount = Object.keys(controller.polylinesLayer._layers).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tracks layer
|
||||||
|
if (controller.tracksLayer && controller.tracksLayer._layers) {
|
||||||
|
result.tracksCount = Object.keys(controller.tracksLayer._layers).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that at least one layer has data
|
||||||
|
const hasData = layerInfo.markersCount > 0 ||
|
||||||
|
layerInfo.polylinesCount > 0 ||
|
||||||
|
layerInfo.tracksCount > 0;
|
||||||
|
|
||||||
|
expect(hasData).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
184
e2e/map/map-layers.spec.js
Normal file
184
e2e/map/map-layers.spec.js
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js';
|
||||||
|
import { waitForMap, enableLayer } from '../helpers/map.js';
|
||||||
|
|
||||||
|
test.describe('Map Layers', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await navigateToMap(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enable Routes layer and display routes', async ({ page }) => {
|
||||||
|
// Wait for map to be ready
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const container = document.querySelector('#map [data-maps-target="container"]');
|
||||||
|
return container && container._leaflet_id !== undefined;
|
||||||
|
}, { timeout: 10000 });
|
||||||
|
|
||||||
|
// Navigate to date with data
|
||||||
|
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
|
||||||
|
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
|
||||||
|
|
||||||
|
if (!isPanelVisible) {
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
|
||||||
|
await startInput.clear();
|
||||||
|
await startInput.fill('2024-10-13T00:00');
|
||||||
|
|
||||||
|
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
|
||||||
|
await endInput.clear();
|
||||||
|
await endInput.fill('2024-10-13T23:59');
|
||||||
|
|
||||||
|
await page.click('input[type="submit"][value="Search"]');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Close onboarding modal if present
|
||||||
|
await closeOnboardingModal(page);
|
||||||
|
|
||||||
|
// Open layer control and enable Routes
|
||||||
|
await page.locator('.leaflet-control-layers').hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]');
|
||||||
|
const isChecked = await routesCheckbox.isChecked();
|
||||||
|
|
||||||
|
if (!isChecked) {
|
||||||
|
await routesCheckbox.check();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify routes are visible
|
||||||
|
const hasRoutes = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.polylinesLayer && controller.polylinesLayer._layers) {
|
||||||
|
return Object.keys(controller.polylinesLayer._layers).length > 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasRoutes).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enable Heatmap layer and display heatmap', async ({ page }) => {
|
||||||
|
await waitForMap(page);
|
||||||
|
await enableLayer(page, 'Heatmap');
|
||||||
|
|
||||||
|
const hasHeatmap = await page.locator('.leaflet-heatmap-layer').isVisible();
|
||||||
|
expect(hasHeatmap).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enable Fog of War layer and display fog', async ({ page }) => {
|
||||||
|
await waitForMap(page);
|
||||||
|
await enableLayer(page, 'Fog of War');
|
||||||
|
|
||||||
|
const hasFog = await page.evaluate(() => {
|
||||||
|
const fogCanvas = document.getElementById('fog');
|
||||||
|
return fogCanvas && fogCanvas instanceof HTMLCanvasElement;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasFog).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enable Areas layer and display areas', async ({ page }) => {
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
const hasAreasLayer = await page.evaluate(() => {
|
||||||
|
const mapElement = document.querySelector('#map');
|
||||||
|
const app = window.Stimulus;
|
||||||
|
const controller = app?.getControllerForElementAndIdentifier(mapElement, 'maps');
|
||||||
|
return controller?.areasLayer !== null && controller?.areasLayer !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasAreasLayer).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enable Suggested Visits layer', async ({ page }) => {
|
||||||
|
await waitForMap(page);
|
||||||
|
await enableLayer(page, 'Suggested Visits');
|
||||||
|
|
||||||
|
const hasSuggestedVisits = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.visitsManager?.visitCircles !== null &&
|
||||||
|
controller?.visitsManager?.visitCircles !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasSuggestedVisits).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enable Confirmed Visits layer', async ({ page }) => {
|
||||||
|
await waitForMap(page);
|
||||||
|
await enableLayer(page, 'Confirmed Visits');
|
||||||
|
|
||||||
|
const hasConfirmedVisits = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.visitsManager?.confirmedVisitCircles !== null &&
|
||||||
|
controller?.visitsManager?.confirmedVisitCircles !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasConfirmedVisits).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enable Scratch Map layer and display visited countries', async ({ page }) => {
|
||||||
|
await waitForMap(page);
|
||||||
|
await enableLayer(page, 'Scratch Map');
|
||||||
|
|
||||||
|
// Wait a bit for the layer to load country borders
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify scratch layer exists and has been initialized
|
||||||
|
const hasScratchLayer = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
|
||||||
|
// Check if scratchLayerManager exists
|
||||||
|
if (!controller?.scratchLayerManager) return false;
|
||||||
|
|
||||||
|
// Check if scratch layer was created
|
||||||
|
const scratchLayer = controller.scratchLayerManager.getLayer();
|
||||||
|
return scratchLayer !== null && scratchLayer !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasScratchLayer).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remember enabled layers across page reloads', async ({ page }) => {
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
// Enable multiple layers
|
||||||
|
await enableLayer(page, 'Points');
|
||||||
|
await enableLayer(page, 'Routes');
|
||||||
|
await enableLayer(page, 'Heatmap');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Get current layer states
|
||||||
|
const getLayerStates = () => page.evaluate(() => {
|
||||||
|
const layers = {};
|
||||||
|
document.querySelectorAll('.leaflet-control-layers-overlays input[type="checkbox"]').forEach(checkbox => {
|
||||||
|
const label = checkbox.parentElement.textContent.trim();
|
||||||
|
layers[label] = checkbox.checked;
|
||||||
|
});
|
||||||
|
return layers;
|
||||||
|
});
|
||||||
|
|
||||||
|
const layersBeforeReload = await getLayerStates();
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await page.reload();
|
||||||
|
await closeOnboardingModal(page);
|
||||||
|
await waitForMap(page);
|
||||||
|
await page.waitForTimeout(1000); // Wait for layers to restore
|
||||||
|
|
||||||
|
// Get layer states after reload
|
||||||
|
const layersAfterReload = await getLayerStates();
|
||||||
|
|
||||||
|
// Verify Points, Routes, and Heatmap are still enabled
|
||||||
|
expect(layersAfterReload['Points']).toBe(true);
|
||||||
|
expect(layersAfterReload['Routes']).toBe(true);
|
||||||
|
expect(layersAfterReload['Heatmap']).toBe(true);
|
||||||
|
|
||||||
|
// Verify layer states match before and after
|
||||||
|
expect(layersAfterReload).toEqual(layersBeforeReload);
|
||||||
|
});
|
||||||
|
});
|
||||||
141
e2e/map/map-points.spec.js
Normal file
141
e2e/map/map-points.spec.js
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { navigateToMap } from '../helpers/navigation.js';
|
||||||
|
import { waitForMap, enableLayer } from '../helpers/map.js';
|
||||||
|
|
||||||
|
test.describe('Point Interactions', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await navigateToMap(page);
|
||||||
|
await waitForMap(page);
|
||||||
|
await enableLayer(page, 'Points');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Pan map to ensure a marker is in viewport
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.markers && controller.markers.length > 0) {
|
||||||
|
const firstMarker = controller.markers[0];
|
||||||
|
controller.map.setView([firstMarker[0], firstMarker[1]], 14);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have draggable markers on the map', async ({ page }) => {
|
||||||
|
// Verify markers have draggable class
|
||||||
|
const marker = page.locator('.leaflet-marker-icon').first();
|
||||||
|
await expect(marker).toBeVisible();
|
||||||
|
|
||||||
|
// Check if marker has draggable class
|
||||||
|
const isDraggable = await marker.evaluate((el) => {
|
||||||
|
return el.classList.contains('leaflet-marker-draggable');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isDraggable).toBe(true);
|
||||||
|
|
||||||
|
// Verify marker position can be retrieved (required for drag operations)
|
||||||
|
const box = await marker.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box.x).toBeGreaterThan(0);
|
||||||
|
expect(box.y).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should open popup when clicking a point', async ({ page }) => {
|
||||||
|
// Click on a marker with force to ensure interaction
|
||||||
|
const marker = page.locator('.leaflet-marker-icon').first();
|
||||||
|
await marker.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify popup is visible
|
||||||
|
const popup = page.locator('.leaflet-popup');
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display correct popup content with point data', async ({ page }) => {
|
||||||
|
// Click on a marker
|
||||||
|
const marker = page.locator('.leaflet-marker-icon').first();
|
||||||
|
await marker.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Get popup content
|
||||||
|
const popupContent = page.locator('.leaflet-popup-content');
|
||||||
|
await expect(popupContent).toBeVisible();
|
||||||
|
|
||||||
|
const content = await popupContent.textContent();
|
||||||
|
|
||||||
|
// Verify all required fields are present
|
||||||
|
expect(content).toContain('Timestamp:');
|
||||||
|
expect(content).toContain('Latitude:');
|
||||||
|
expect(content).toContain('Longitude:');
|
||||||
|
expect(content).toContain('Altitude:');
|
||||||
|
expect(content).toContain('Speed:');
|
||||||
|
expect(content).toContain('Battery:');
|
||||||
|
expect(content).toContain('Id:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete a point and redraw route', async ({ page }) => {
|
||||||
|
// Enable Routes layer to verify route redraw
|
||||||
|
await enableLayer(page, 'Routes');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Count initial markers and get point ID
|
||||||
|
const initialData = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
const markerCount = controller?.markersLayer ? Object.keys(controller.markersLayer._layers).length : 0;
|
||||||
|
const polylineCount = controller?.polylinesLayer ? Object.keys(controller.polylinesLayer._layers).length : 0;
|
||||||
|
return { markerCount, polylineCount };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on a marker to open popup
|
||||||
|
const marker = page.locator('.leaflet-marker-icon').first();
|
||||||
|
await marker.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify popup opened
|
||||||
|
await expect(page.locator('.leaflet-popup')).toBeVisible();
|
||||||
|
|
||||||
|
// Get the point ID from popup before deleting
|
||||||
|
const pointId = await page.locator('.leaflet-popup-content').evaluate((content) => {
|
||||||
|
const match = content.textContent.match(/Id:\s*(\d+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pointId).not.toBeNull();
|
||||||
|
|
||||||
|
// Find delete button (might be a link or button with "Delete" text)
|
||||||
|
const deleteButton = page.locator('.leaflet-popup-content a:has-text("Delete"), .leaflet-popup-content button:has-text("Delete")').first();
|
||||||
|
|
||||||
|
const hasDeleteButton = await deleteButton.count() > 0;
|
||||||
|
|
||||||
|
if (hasDeleteButton) {
|
||||||
|
// Handle confirmation dialog
|
||||||
|
page.once('dialog', dialog => {
|
||||||
|
expect(dialog.message()).toContain('delete');
|
||||||
|
dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteButton.click();
|
||||||
|
await page.waitForTimeout(2000); // Wait for deletion to complete
|
||||||
|
|
||||||
|
// Verify marker count decreased
|
||||||
|
const finalData = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
const markerCount = controller?.markersLayer ? Object.keys(controller.markersLayer._layers).length : 0;
|
||||||
|
const polylineCount = controller?.polylinesLayer ? Object.keys(controller.polylinesLayer._layers).length : 0;
|
||||||
|
return { markerCount, polylineCount };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify at least one marker was removed
|
||||||
|
expect(finalData.markerCount).toBeLessThan(initialData.markerCount);
|
||||||
|
|
||||||
|
// Verify routes still exist (they should be redrawn)
|
||||||
|
expect(finalData.polylineCount).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// Verify success flash message appears
|
||||||
|
const flashMessage = page.locator('#flash-messages [role="alert"]').filter({ hasText: /deleted successfully/i });
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 5000 });
|
||||||
|
} else {
|
||||||
|
// If no delete button, just verify the test setup worked
|
||||||
|
console.log('No delete button found in popup - this might be expected based on permissions');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
166
e2e/map/map-selection-tool.spec.js
Normal file
166
e2e/map/map-selection-tool.spec.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { navigateToMap } from '../helpers/navigation.js';
|
||||||
|
import { waitForMap } from '../helpers/map.js';
|
||||||
|
|
||||||
|
test.describe('Selection Tool', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await navigateToMap(page);
|
||||||
|
await waitForMap(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enable selection mode when clicked', async ({ page }) => {
|
||||||
|
// Click selection tool button
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await expect(selectionButton).toBeVisible();
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify selection mode is enabled (flash message appears)
|
||||||
|
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Selection mode enabled")');
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify selection mode is active in controller
|
||||||
|
const isSelectionActive = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.visitsManager?.isSelectionActive === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isSelectionActive).toBe(true);
|
||||||
|
|
||||||
|
// Verify button has active class
|
||||||
|
const hasActiveClass = await selectionButton.evaluate((el) => {
|
||||||
|
return el.classList.contains('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasActiveClass).toBe(true);
|
||||||
|
|
||||||
|
// Verify map dragging is disabled (required for selection to work)
|
||||||
|
const isDraggingDisabled = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return !controller?.map?.dragging?.enabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isDraggingDisabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should disable selection mode when clicked second time', async ({ page }) => {
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
|
||||||
|
// First click - enable selection mode
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify selection mode is enabled
|
||||||
|
const isEnabledAfterFirstClick = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.visitsManager?.isSelectionActive === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isEnabledAfterFirstClick).toBe(true);
|
||||||
|
|
||||||
|
// Second click - disable selection mode
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify selection mode is disabled
|
||||||
|
const isDisabledAfterSecondClick = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.visitsManager?.isSelectionActive === false;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isDisabledAfterSecondClick).toBe(true);
|
||||||
|
|
||||||
|
// Verify no selection rectangle exists
|
||||||
|
const hasSelectionRect = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.visitsManager?.selectionRect !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasSelectionRect).toBe(false);
|
||||||
|
|
||||||
|
// Verify button no longer has active class
|
||||||
|
const hasActiveClass = await selectionButton.evaluate((el) => {
|
||||||
|
return el.classList.contains('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasActiveClass).toBe(false);
|
||||||
|
|
||||||
|
// Verify map dragging is re-enabled
|
||||||
|
const isDraggingEnabled = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return controller?.map?.dragging?.enabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isDraggingEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show info message about dragging to select area', async ({ page }) => {
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify informational flash message about dragging
|
||||||
|
const flashMessage = page.locator('#flash-messages [role="alert"]');
|
||||||
|
const messageText = await flashMessage.textContent();
|
||||||
|
|
||||||
|
expect(messageText).toContain('Click and drag');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should open side panel when selection is complete', async ({ page }) => {
|
||||||
|
// Navigate to a date with known data (October 13, 2024 - same as bulk delete tests)
|
||||||
|
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
|
||||||
|
await startInput.clear();
|
||||||
|
await startInput.fill('2024-10-13T00:00');
|
||||||
|
|
||||||
|
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
|
||||||
|
await endInput.clear();
|
||||||
|
await endInput.fill('2024-10-13T23:59');
|
||||||
|
|
||||||
|
await page.click('input[type="submit"][value="Search"]');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify drawer is initially closed
|
||||||
|
const drawerInitiallyClosed = await page.evaluate(() => {
|
||||||
|
const drawer = document.getElementById('visits-drawer');
|
||||||
|
return !drawer?.classList.contains('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(drawerInitiallyClosed).toBe(true);
|
||||||
|
|
||||||
|
// Enable selection mode
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Draw a selection rectangle on the map
|
||||||
|
const mapContainer = page.locator('#map [data-maps-target="container"]');
|
||||||
|
const bbox = await mapContainer.boundingBox();
|
||||||
|
|
||||||
|
// Draw rectangle covering most of the map to ensure we select points
|
||||||
|
const startX = bbox.x + bbox.width * 0.2;
|
||||||
|
const startY = bbox.y + bbox.height * 0.2;
|
||||||
|
const endX = bbox.x + bbox.width * 0.8;
|
||||||
|
const endY = bbox.y + bbox.height * 0.8;
|
||||||
|
|
||||||
|
await page.mouse.move(startX, startY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(endX, endY, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
// Wait for drawer to open
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify drawer is now open
|
||||||
|
const drawerOpen = await page.evaluate(() => {
|
||||||
|
const drawer = document.getElementById('visits-drawer');
|
||||||
|
return drawer?.classList.contains('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(drawerOpen).toBe(true);
|
||||||
|
|
||||||
|
// Verify drawer shows either selection data or cancel button (indicates selection is active)
|
||||||
|
const hasCancelButton = await page.locator('#cancel-selection-button').isVisible();
|
||||||
|
expect(hasCancelButton).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
644
e2e/map/map-side-panel.spec.js
Normal file
644
e2e/map/map-side-panel.spec.js
Normal file
|
|
@ -0,0 +1,644 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { closeOnboardingModal, navigateToDate } from '../helpers/navigation.js';
|
||||||
|
import { drawSelectionRectangle } from '../helpers/selection.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side Panel (Visits Drawer) Tests
|
||||||
|
*
|
||||||
|
* Tests for the side panel that displays visits when selection tool is used.
|
||||||
|
* The panel can be toggled via the drawer button and shows suggested/confirmed visits
|
||||||
|
* with options to confirm, decline, or merge them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe('Side Panel', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/map');
|
||||||
|
await closeOnboardingModal(page);
|
||||||
|
|
||||||
|
// Wait for map to be fully loaded
|
||||||
|
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Navigate to October 2024 (has demo data)
|
||||||
|
await navigateToDate(page, '2024-10-01T00:00', '2024-10-31T23:59');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to click the drawer button
|
||||||
|
*/
|
||||||
|
async function clickDrawerButton(page) {
|
||||||
|
const drawerButton = page.locator('.drawer-button');
|
||||||
|
await expect(drawerButton).toBeVisible({ timeout: 5000 });
|
||||||
|
await drawerButton.click();
|
||||||
|
await page.waitForTimeout(500); // Wait for drawer animation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if drawer is open
|
||||||
|
*/
|
||||||
|
async function isDrawerOpen(page) {
|
||||||
|
const drawer = page.locator('#visits-drawer');
|
||||||
|
const exists = await drawer.count() > 0;
|
||||||
|
if (!exists) return false;
|
||||||
|
|
||||||
|
const hasOpenClass = await drawer.evaluate(el => el.classList.contains('open'));
|
||||||
|
return hasOpenClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to perform selection and wait for visits to load
|
||||||
|
* This is a simplified version that doesn't use the shared helper
|
||||||
|
* because we need custom waiting logic for the drawer
|
||||||
|
*/
|
||||||
|
async function selectAreaWithVisits(page) {
|
||||||
|
// First, enable Suggested Visits layer to ensure visits are loaded
|
||||||
|
const layersButton = page.locator('.leaflet-control-layers-toggle');
|
||||||
|
await layersButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Enable "Suggested Visits" layer
|
||||||
|
const suggestedVisitsCheckbox = page.locator('input[type="checkbox"]').filter({
|
||||||
|
has: page.locator(':scope ~ span', { hasText: 'Suggested Visits' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const isChecked = await suggestedVisitsCheckbox.isChecked();
|
||||||
|
if (!isChecked) {
|
||||||
|
await suggestedVisitsCheckbox.check();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close layers control
|
||||||
|
await layersButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Enable selection mode
|
||||||
|
const selectionButton = page.locator('#selection-tool-button');
|
||||||
|
await selectionButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Get map bounds for drawing selection
|
||||||
|
const map = page.locator('.leaflet-container');
|
||||||
|
const mapBox = await map.boundingBox();
|
||||||
|
|
||||||
|
// Calculate coordinates for drawing a large selection area
|
||||||
|
// Make it much wider to catch visits - use most of the map area
|
||||||
|
const startX = mapBox.x + 100;
|
||||||
|
const startY = mapBox.y + 100;
|
||||||
|
const endX = mapBox.x + mapBox.width - 400; // Leave room for drawer on right
|
||||||
|
const endY = mapBox.y + mapBox.height - 100;
|
||||||
|
|
||||||
|
// Draw selection rectangle
|
||||||
|
await page.mouse.move(startX, startY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(endX, endY, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
// Wait for drawer to be created and opened
|
||||||
|
await page.waitForSelector('#visits-drawer.open', { timeout: 10000 });
|
||||||
|
await page.waitForTimeout(3000); // Wait longer for visits API response
|
||||||
|
}
|
||||||
|
|
||||||
|
test('should open and close drawer panel via button click', async ({ page }) => {
|
||||||
|
// Verify drawer is initially closed
|
||||||
|
const initiallyOpen = await isDrawerOpen(page);
|
||||||
|
expect(initiallyOpen).toBe(false);
|
||||||
|
|
||||||
|
// Click to open
|
||||||
|
await clickDrawerButton(page);
|
||||||
|
|
||||||
|
// Verify drawer is now open
|
||||||
|
let drawerOpen = await isDrawerOpen(page);
|
||||||
|
expect(drawerOpen).toBe(true);
|
||||||
|
|
||||||
|
// Verify drawer content is visible
|
||||||
|
const drawerContent = page.locator('#visits-drawer .drawer');
|
||||||
|
await expect(drawerContent).toBeVisible();
|
||||||
|
|
||||||
|
// Click to close
|
||||||
|
await clickDrawerButton(page);
|
||||||
|
|
||||||
|
// Verify drawer is now closed
|
||||||
|
drawerOpen = await isDrawerOpen(page);
|
||||||
|
expect(drawerOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show visits in panel after selection', async ({ page }) => {
|
||||||
|
await selectAreaWithVisits(page);
|
||||||
|
|
||||||
|
// Verify drawer is open
|
||||||
|
const drawerOpen = await isDrawerOpen(page);
|
||||||
|
expect(drawerOpen).toBe(true);
|
||||||
|
|
||||||
|
// Verify visits list container exists
|
||||||
|
const visitsList = page.locator('#visits-list');
|
||||||
|
await expect(visitsList).toBeVisible();
|
||||||
|
|
||||||
|
// Wait for API response - check if we have visit items or "no visits" message
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Check what content is actually shown
|
||||||
|
const visitItems = page.locator('.visit-item');
|
||||||
|
const visitCount = await visitItems.count();
|
||||||
|
|
||||||
|
const noVisitsMessage = page.locator('#visits-list p.text-gray-500');
|
||||||
|
|
||||||
|
// Either we have visits OR we have a "no visits" message (not "Loading...")
|
||||||
|
if (visitCount > 0) {
|
||||||
|
// We have visits - verify the title shows count
|
||||||
|
const drawerTitle = page.locator('#visits-drawer .drawer h2');
|
||||||
|
const titleText = await drawerTitle.textContent();
|
||||||
|
expect(titleText).toMatch(/\d+ visits? found/);
|
||||||
|
} else {
|
||||||
|
// No visits found - verify we show the appropriate message
|
||||||
|
// Should NOT still be showing "Loading visits..."
|
||||||
|
const messageText = await noVisitsMessage.textContent();
|
||||||
|
expect(messageText).not.toContain('Loading visits');
|
||||||
|
expect(messageText).toContain('No visits');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display visit details in panel', async ({ page }) => {
|
||||||
|
await selectAreaWithVisits(page);
|
||||||
|
|
||||||
|
// Open the visits collapsible section
|
||||||
|
const visitsSection = page.locator('#visits-section-collapse');
|
||||||
|
await expect(visitsSection).toBeVisible();
|
||||||
|
|
||||||
|
const visitsSummary = visitsSection.locator('summary');
|
||||||
|
await visitsSummary.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Check if we have any visits
|
||||||
|
const visitCount = await page.locator('.visit-item').count();
|
||||||
|
|
||||||
|
if (visitCount === 0) {
|
||||||
|
console.log('Test skipped: No visits available in test data');
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first visit item
|
||||||
|
const firstVisit = page.locator('.visit-item').first();
|
||||||
|
await expect(firstVisit).toBeVisible();
|
||||||
|
|
||||||
|
// Verify visit has required information
|
||||||
|
const visitName = firstVisit.locator('.font-semibold');
|
||||||
|
await expect(visitName).toBeVisible();
|
||||||
|
const nameText = await visitName.textContent();
|
||||||
|
expect(nameText.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify time information is present
|
||||||
|
const timeInfo = firstVisit.locator('.text-sm.text-gray-600');
|
||||||
|
await expect(timeInfo).toBeVisible();
|
||||||
|
|
||||||
|
// Check if this is a suggested visit (has confirm/decline buttons)
|
||||||
|
const hasSuggestedButtons = (await firstVisit.locator('.confirm-visit').count()) > 0;
|
||||||
|
|
||||||
|
if (hasSuggestedButtons) {
|
||||||
|
// For suggested visits, verify action buttons are present
|
||||||
|
const confirmButton = firstVisit.locator('.confirm-visit');
|
||||||
|
const declineButton = firstVisit.locator('.decline-visit');
|
||||||
|
|
||||||
|
await expect(confirmButton).toBeVisible();
|
||||||
|
await expect(declineButton).toBeVisible();
|
||||||
|
expect(await confirmButton.textContent()).toBe('Confirm');
|
||||||
|
expect(await declineButton.textContent()).toBe('Decline');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should confirm individual suggested visit from panel', async ({ page }) => {
|
||||||
|
await selectAreaWithVisits(page);
|
||||||
|
|
||||||
|
// Open the visits collapsible section
|
||||||
|
const visitsSection = page.locator('#visits-section-collapse');
|
||||||
|
await expect(visitsSection).toBeVisible();
|
||||||
|
|
||||||
|
const visitsSummary = visitsSection.locator('summary');
|
||||||
|
await visitsSummary.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Find a suggested visit (one with confirm/decline buttons)
|
||||||
|
const suggestedVisit = page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') }).first();
|
||||||
|
|
||||||
|
// Check if any suggested visits exist
|
||||||
|
const suggestedCount = await page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') }).count();
|
||||||
|
|
||||||
|
if (suggestedCount === 0) {
|
||||||
|
console.log('Test skipped: No suggested visits available');
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(suggestedVisit).toBeVisible();
|
||||||
|
|
||||||
|
// Verify it has the suggested visit styling (dashed border)
|
||||||
|
const hasDashedBorder = await suggestedVisit.evaluate(el =>
|
||||||
|
el.classList.contains('border-dashed')
|
||||||
|
);
|
||||||
|
expect(hasDashedBorder).toBe(true);
|
||||||
|
|
||||||
|
// Get initial count of visits
|
||||||
|
const initialVisitCount = await page.locator('.visit-item').count();
|
||||||
|
|
||||||
|
// Click confirm button
|
||||||
|
const confirmButton = suggestedVisit.locator('.confirm-visit');
|
||||||
|
await confirmButton.click();
|
||||||
|
|
||||||
|
// Wait for API call and UI update
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify flash message appears
|
||||||
|
const flashMessage = page.locator('.flash-message');
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// The visit should still be in the list but without confirm/decline buttons
|
||||||
|
// Or the count might decrease if it was removed from suggested visits
|
||||||
|
const finalVisitCount = await page.locator('.visit-item').count();
|
||||||
|
expect(finalVisitCount).toBeLessThanOrEqual(initialVisitCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should decline individual suggested visit from panel', async ({ page }) => {
|
||||||
|
await selectAreaWithVisits(page);
|
||||||
|
|
||||||
|
// Open the visits collapsible section
|
||||||
|
const visitsSection = page.locator('#visits-section-collapse');
|
||||||
|
await expect(visitsSection).toBeVisible();
|
||||||
|
|
||||||
|
const visitsSummary = visitsSection.locator('summary');
|
||||||
|
await visitsSummary.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Find a suggested visit
|
||||||
|
const suggestedVisit = page.locator('.visit-item').filter({ has: page.locator('.decline-visit') }).first();
|
||||||
|
|
||||||
|
const suggestedCount = await page.locator('.visit-item').filter({ has: page.locator('.decline-visit') }).count();
|
||||||
|
|
||||||
|
if (suggestedCount === 0) {
|
||||||
|
console.log('Test skipped: No suggested visits available');
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(suggestedVisit).toBeVisible();
|
||||||
|
|
||||||
|
// Get initial count
|
||||||
|
const initialVisitCount = await page.locator('.visit-item').count();
|
||||||
|
|
||||||
|
// Click decline button
|
||||||
|
const declineButton = suggestedVisit.locator('.decline-visit');
|
||||||
|
await declineButton.click();
|
||||||
|
|
||||||
|
// Wait for API call and UI update
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify flash message
|
||||||
|
const flashMessage = page.locator('.flash-message');
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Visit should be removed from the list
|
||||||
|
const finalVisitCount = await page.locator('.visit-item').count();
|
||||||
|
expect(finalVisitCount).toBeLessThan(initialVisitCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show checkboxes on hover for mass selection', async ({ page }) => {
|
||||||
|
await selectAreaWithVisits(page);
|
||||||
|
|
||||||
|
// Open the visits collapsible section
|
||||||
|
const visitsSection = page.locator('#visits-section-collapse');
|
||||||
|
await expect(visitsSection).toBeVisible();
|
||||||
|
|
||||||
|
const visitsSummary = visitsSection.locator('summary');
|
||||||
|
await visitsSummary.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Check if we have any visits
|
||||||
|
const visitCount = await page.locator('.visit-item').count();
|
||||||
|
|
||||||
|
if (visitCount === 0) {
|
||||||
|
console.log('Test skipped: No visits available in test data');
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstVisit = page.locator('.visit-item').first();
|
||||||
|
await expect(firstVisit).toBeVisible();
|
||||||
|
|
||||||
|
// Initially, checkbox should be hidden
|
||||||
|
const checkboxContainer = firstVisit.locator('.visit-checkbox-container');
|
||||||
|
let opacity = await checkboxContainer.evaluate(el => el.style.opacity);
|
||||||
|
expect(opacity === '0' || opacity === '').toBe(true);
|
||||||
|
|
||||||
|
// Hover over the visit item
|
||||||
|
await firstVisit.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Checkbox should now be visible
|
||||||
|
opacity = await checkboxContainer.evaluate(el => el.style.opacity);
|
||||||
|
expect(opacity).toBe('1');
|
||||||
|
|
||||||
|
// Checkbox should be clickable
|
||||||
|
const pointerEvents = await checkboxContainer.evaluate(el => el.style.pointerEvents);
|
||||||
|
expect(pointerEvents).toBe('auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should select multiple visits and show bulk action buttons', async ({ page }) => {
|
||||||
|
await selectAreaWithVisits(page);
|
||||||
|
|
||||||
|
// Open the visits collapsible section
|
||||||
|
const visitsSection = page.locator('#visits-section-collapse');
|
||||||
|
await expect(visitsSection).toBeVisible();
|
||||||
|
|
||||||
|
const visitsSummary = visitsSection.locator('summary');
|
||||||
|
await visitsSummary.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify we have at least 2 visits
|
||||||
|
const visitCount = await page.locator('.visit-item').count();
|
||||||
|
if (visitCount < 2) {
|
||||||
|
console.log('Test skipped: Need at least 2 visits');
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select first visit by hovering and clicking checkbox
|
||||||
|
const firstVisit = page.locator('.visit-item').first();
|
||||||
|
await firstVisit.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const firstCheckbox = firstVisit.locator('.visit-checkbox');
|
||||||
|
await firstCheckbox.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Select second visit
|
||||||
|
const secondVisit = page.locator('.visit-item').nth(1);
|
||||||
|
await secondVisit.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const secondCheckbox = secondVisit.locator('.visit-checkbox');
|
||||||
|
await secondCheckbox.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify bulk action buttons appear
|
||||||
|
const bulkActionsContainer = page.locator('.visit-bulk-actions');
|
||||||
|
await expect(bulkActionsContainer).toBeVisible();
|
||||||
|
|
||||||
|
// Verify all three action buttons are present
|
||||||
|
const mergeButton = bulkActionsContainer.locator('button').filter({ hasText: 'Merge' });
|
||||||
|
const confirmButton = bulkActionsContainer.locator('button').filter({ hasText: 'Confirm' });
|
||||||
|
const declineButton = bulkActionsContainer.locator('button').filter({ hasText: 'Decline' });
|
||||||
|
|
||||||
|
await expect(mergeButton).toBeVisible();
|
||||||
|
await expect(confirmButton).toBeVisible();
|
||||||
|
await expect(declineButton).toBeVisible();
|
||||||
|
|
||||||
|
// Verify selection count text
|
||||||
|
const selectionText = bulkActionsContainer.locator('.text-sm.text-center');
|
||||||
|
const selectionTextContent = await selectionText.textContent();
|
||||||
|
expect(selectionTextContent).toContain('2 visits selected');
|
||||||
|
|
||||||
|
// Verify cancel button exists
|
||||||
|
const cancelButton = bulkActionsContainer.locator('button').filter({ hasText: 'Cancel Selection' });
|
||||||
|
await expect(cancelButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should cancel mass selection', async ({ page }) => {
|
||||||
|
await selectAreaWithVisits(page);
|
||||||
|
|
||||||
|
// Open the visits collapsible section
|
||||||
|
const visitsSection = page.locator('#visits-section-collapse');
|
||||||
|
await expect(visitsSection).toBeVisible();
|
||||||
|
|
||||||
|
const visitsSummary = visitsSection.locator('summary');
|
||||||
|
await visitsSummary.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const visitCount = await page.locator('.visit-item').count();
|
||||||
|
if (visitCount < 2) {
|
||||||
|
console.log('Test skipped: Need at least 2 visits');
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select two visits
|
||||||
|
const firstVisit = page.locator('.visit-item').first();
|
||||||
|
await firstVisit.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await firstVisit.locator('.visit-checkbox').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const secondVisit = page.locator('.visit-item').nth(1);
|
||||||
|
await secondVisit.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await secondVisit.locator('.visit-checkbox').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify bulk actions are visible
|
||||||
|
const bulkActions = page.locator('.visit-bulk-actions');
|
||||||
|
await expect(bulkActions).toBeVisible();
|
||||||
|
|
||||||
|
// Click cancel button
|
||||||
|
const cancelButton = bulkActions.locator('button').filter({ hasText: 'Cancel Selection' });
|
||||||
|
await cancelButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify bulk actions are removed
|
||||||
|
await expect(bulkActions).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify checkboxes are unchecked
|
||||||
|
const checkedCheckboxes = await page.locator('.visit-checkbox:checked').count();
|
||||||
|
expect(checkedCheckboxes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should mass confirm multiple visits', async ({ page }) => {
|
||||||
|
await selectAreaWithVisits(page);
|
||||||
|
|
||||||
|
// Open the visits collapsible section
|
||||||
|
const visitsSection = page.locator('#visits-section-collapse');
|
||||||
|
await expect(visitsSection).toBeVisible();
|
||||||
|
|
||||||
|
const visitsSummary = visitsSection.locator('summary');
|
||||||
|
await visitsSummary.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Find suggested visits (those with confirm buttons)
|
||||||
|
const suggestedVisits = page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') });
|
||||||
|
const suggestedCount = await suggestedVisits.count();
|
||||||
|
|
||||||
|
if (suggestedCount < 2) {
|
||||||
|
console.log('Test skipped: Need at least 2 suggested visits');
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial count
|
||||||
|
const initialVisitCount = await page.locator('.visit-item').count();
|
||||||
|
|
||||||
|
// Select first two suggested visits
|
||||||
|
const firstSuggested = suggestedVisits.first();
|
||||||
|
await firstSuggested.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await firstSuggested.locator('.visit-checkbox').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const secondSuggested = suggestedVisits.nth(1);
|
||||||
|
await secondSuggested.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await secondSuggested.locator('.visit-checkbox').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Click mass confirm button
|
||||||
|
const bulkActions = page.locator('.visit-bulk-actions');
|
||||||
|
const confirmButton = bulkActions.locator('button').filter({ hasText: 'Confirm' });
|
||||||
|
await confirmButton.click();
|
||||||
|
|
||||||
|
// Wait for API call
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify flash message
|
||||||
|
const flashMessage = page.locator('.flash-message');
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// The visits might be removed or updated in the list
|
||||||
|
// At minimum, bulk actions should be removed
|
||||||
|
const bulkActionsVisible = await bulkActions.isVisible().catch(() => false);
|
||||||
|
expect(bulkActionsVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should mass decline multiple visits', async ({ page }) => {
|
||||||
|
await selectAreaWithVisits(page);
|
||||||
|
|
||||||
|
// Open the visits collapsible section
|
||||||
|
const visitsSection = page.locator('#visits-section-collapse');
|
||||||
|
await expect(visitsSection).toBeVisible();
|
||||||
|
|
||||||
|
const visitsSummary = visitsSection.locator('summary');
|
||||||
|
await visitsSummary.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const suggestedVisits = page.locator('.visit-item').filter({ has: page.locator('.decline-visit') });
|
||||||
|
const suggestedCount = await suggestedVisits.count();
|
||||||
|
|
||||||
|
if (suggestedCount < 2) {
|
||||||
|
console.log('Test skipped: Need at least 2 suggested visits');
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial count
|
||||||
|
const initialVisitCount = await page.locator('.visit-item').count();
|
||||||
|
|
||||||
|
// Select two visits
|
||||||
|
const firstSuggested = suggestedVisits.first();
|
||||||
|
await firstSuggested.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await firstSuggested.locator('.visit-checkbox').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const secondSuggested = suggestedVisits.nth(1);
|
||||||
|
await secondSuggested.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await secondSuggested.locator('.visit-checkbox').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Click mass decline button
|
||||||
|
const bulkActions = page.locator('.visit-bulk-actions');
|
||||||
|
const declineButton = bulkActions.locator('button').filter({ hasText: 'Decline' });
|
||||||
|
await declineButton.click();
|
||||||
|
|
||||||
|
// Wait for API call
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify flash message
|
||||||
|
const flashMessage = page.locator('.flash-message');
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Visits should be removed from the list
|
||||||
|
const finalVisitCount = await page.locator('.visit-item').count();
|
||||||
|
expect(finalVisitCount).toBeLessThan(initialVisitCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should mass merge multiple visits', async ({ page }) => {
|
||||||
|
await selectAreaWithVisits(page);
|
||||||
|
|
||||||
|
// Open the visits collapsible section
|
||||||
|
const visitsSection = page.locator('#visits-section-collapse');
|
||||||
|
await expect(visitsSection).toBeVisible();
|
||||||
|
|
||||||
|
const visitsSummary = visitsSection.locator('summary');
|
||||||
|
await visitsSummary.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const visitCount = await page.locator('.visit-item').count();
|
||||||
|
if (visitCount < 2) {
|
||||||
|
console.log('Test skipped: Need at least 2 visits');
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select two visits
|
||||||
|
const firstVisit = page.locator('.visit-item').first();
|
||||||
|
await firstVisit.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await firstVisit.locator('.visit-checkbox').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const secondVisit = page.locator('.visit-item').nth(1);
|
||||||
|
await secondVisit.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await secondVisit.locator('.visit-checkbox').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Click merge button
|
||||||
|
const bulkActions = page.locator('.visit-bulk-actions');
|
||||||
|
const mergeButton = bulkActions.locator('button').filter({ hasText: 'Merge' });
|
||||||
|
await mergeButton.click();
|
||||||
|
|
||||||
|
// Wait for API call
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify flash message appears
|
||||||
|
const flashMessage = page.locator('.flash-message');
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// After merge, the visits should be combined into one
|
||||||
|
// So final count should be less than initial
|
||||||
|
const finalVisitCount = await page.locator('.visit-item').count();
|
||||||
|
expect(finalVisitCount).toBeLessThan(visitCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should open and close panel without shifting controls', async ({ page }) => {
|
||||||
|
// Get the layer control element
|
||||||
|
const layerControl = page.locator('.leaflet-control-layers');
|
||||||
|
await expect(layerControl).toBeVisible();
|
||||||
|
|
||||||
|
// Get initial position of the control
|
||||||
|
const initialBox = await layerControl.boundingBox();
|
||||||
|
|
||||||
|
// Open the drawer
|
||||||
|
await clickDrawerButton(page);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify drawer is open
|
||||||
|
const drawerOpen = await isDrawerOpen(page);
|
||||||
|
expect(drawerOpen).toBe(true);
|
||||||
|
|
||||||
|
// Get position after opening - should be the same (no shifting)
|
||||||
|
const afterOpenBox = await layerControl.boundingBox();
|
||||||
|
expect(afterOpenBox.x).toBe(initialBox.x);
|
||||||
|
expect(afterOpenBox.y).toBe(initialBox.y);
|
||||||
|
|
||||||
|
// Close the drawer
|
||||||
|
await clickDrawerButton(page);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify drawer is closed
|
||||||
|
const drawerClosed = await isDrawerOpen(page);
|
||||||
|
expect(drawerClosed).toBe(false);
|
||||||
|
|
||||||
|
// Get final position - should still be the same
|
||||||
|
const afterCloseBox = await layerControl.boundingBox();
|
||||||
|
expect(afterCloseBox.x).toBe(initialBox.x);
|
||||||
|
expect(afterCloseBox.y).toBe(initialBox.y);
|
||||||
|
});
|
||||||
|
});
|
||||||
296
e2e/map/map-suggested-visits.spec.js
Normal file
296
e2e/map/map-suggested-visits.spec.js
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js';
|
||||||
|
import { waitForMap, enableLayer, clickSuggestedVisit } from '../helpers/map.js';
|
||||||
|
|
||||||
|
test.describe('Suggested Visit Interactions', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await navigateToMap(page);
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
// Navigate to a date range that includes visits (last month to now)
|
||||||
|
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
|
||||||
|
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
|
||||||
|
|
||||||
|
if (!isPanelVisible) {
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set date range to last month
|
||||||
|
await page.click('a:has-text("Last month")');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
await closeOnboardingModal(page);
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
await enableLayer(page, 'Suggested Visits');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Pan map to ensure a visit marker is in viewport
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.suggestedVisitCircles) {
|
||||||
|
const layers = controller.visitsManager.suggestedVisitCircles._layers;
|
||||||
|
const firstVisit = Object.values(layers)[0];
|
||||||
|
if (firstVisit && firstVisit._latlng) {
|
||||||
|
controller.map.setView(firstVisit._latlng, 14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should click on a suggested visit and open popup', async ({ page }) => {
|
||||||
|
// Debug: Check what visit circles exist
|
||||||
|
const allCircles = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
|
||||||
|
const layers = controller.visitsManager.suggestedVisitCircles._layers;
|
||||||
|
return {
|
||||||
|
count: Object.keys(layers).length,
|
||||||
|
hasLayers: Object.keys(layers).length > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { count: 0, hasLayers: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we have visits in the layer but can't find DOM elements, use coordinates
|
||||||
|
if (!allCircles.hasLayers) {
|
||||||
|
console.log('No suggested visits found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on the visit using map coordinates
|
||||||
|
const visitClicked = await clickSuggestedVisit(page);
|
||||||
|
|
||||||
|
if (!visitClicked) {
|
||||||
|
console.log('Could not click suggested visit - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify popup is visible
|
||||||
|
const popup = page.locator('.leaflet-popup');
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display correct content in suggested visit popup', async ({ page }) => {
|
||||||
|
// Click visit programmatically
|
||||||
|
const visitClicked = await clickSuggestedVisit(page);
|
||||||
|
|
||||||
|
if (!visitClicked) {
|
||||||
|
console.log('No suggested visits found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Get popup content
|
||||||
|
const popupContent = page.locator('.leaflet-popup-content');
|
||||||
|
await expect(popupContent).toBeVisible();
|
||||||
|
|
||||||
|
const content = await popupContent.textContent();
|
||||||
|
|
||||||
|
// Verify visit information is present
|
||||||
|
expect(content).toMatch(/Visit|Place|Duration|Started|Ended|Suggested/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should confirm suggested visit', async ({ page }) => {
|
||||||
|
// Click visit programmatically
|
||||||
|
const visitClicked = await clickSuggestedVisit(page);
|
||||||
|
|
||||||
|
if (!visitClicked) {
|
||||||
|
console.log('No suggested visits found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Look for confirm button in popup
|
||||||
|
const confirmButton = page.locator('.leaflet-popup-content button:has-text("Confirm")').first();
|
||||||
|
const hasConfirmButton = await confirmButton.count() > 0;
|
||||||
|
|
||||||
|
if (!hasConfirmButton) {
|
||||||
|
console.log('No confirm button found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial counts for both suggested and confirmed visits
|
||||||
|
const initialCounts = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return {
|
||||||
|
suggested: controller?.visitsManager?.suggestedVisitCircles?._layers
|
||||||
|
? Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length
|
||||||
|
: 0,
|
||||||
|
confirmed: controller?.visitsManager?.confirmedVisitCircles?._layers
|
||||||
|
? Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length
|
||||||
|
: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click confirm button
|
||||||
|
await confirmButton.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Verify the marker changed from yellow to green (suggested to confirmed)
|
||||||
|
const finalCounts = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
return {
|
||||||
|
suggested: controller?.visitsManager?.suggestedVisitCircles?._layers
|
||||||
|
? Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length
|
||||||
|
: 0,
|
||||||
|
confirmed: controller?.visitsManager?.confirmedVisitCircles?._layers
|
||||||
|
? Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length
|
||||||
|
: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify suggested visit count decreased
|
||||||
|
expect(finalCounts.suggested).toBeLessThan(initialCounts.suggested);
|
||||||
|
|
||||||
|
// Verify confirmed visit count increased (marker changed from yellow to green)
|
||||||
|
expect(finalCounts.confirmed).toBeGreaterThan(initialCounts.confirmed);
|
||||||
|
|
||||||
|
// Verify popup is closed after confirmation
|
||||||
|
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
|
||||||
|
expect(popupVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should decline suggested visit', async ({ page }) => {
|
||||||
|
// Click visit programmatically
|
||||||
|
const visitClicked = await clickSuggestedVisit(page);
|
||||||
|
|
||||||
|
if (!visitClicked) {
|
||||||
|
console.log('No suggested visits found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Look for decline button in popup
|
||||||
|
const declineButton = page.locator('.leaflet-popup-content button:has-text("Decline")').first();
|
||||||
|
const hasDeclineButton = await declineButton.count() > 0;
|
||||||
|
|
||||||
|
if (!hasDeclineButton) {
|
||||||
|
console.log('No decline button found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial suggested visit count
|
||||||
|
const initialCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
|
||||||
|
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify popup is visible before decline
|
||||||
|
await expect(page.locator('.leaflet-popup')).toBeVisible();
|
||||||
|
|
||||||
|
// Click decline button
|
||||||
|
await declineButton.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Verify popup is removed from map
|
||||||
|
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
|
||||||
|
expect(popupVisible).toBe(false);
|
||||||
|
|
||||||
|
// Verify marker is removed from map (suggested visit count decreased)
|
||||||
|
const finalCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
|
||||||
|
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(finalCount).toBeLessThan(initialCount);
|
||||||
|
|
||||||
|
// Verify the yellow marker is no longer visible on the map
|
||||||
|
const yellowMarkerCount = await page.locator('.leaflet-interactive[stroke="#f59e0b"]').count();
|
||||||
|
expect(yellowMarkerCount).toBeLessThan(initialCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should change place in dropdown for suggested visit', async ({ page }) => {
|
||||||
|
const visitCircle = page.locator('.leaflet-interactive[stroke="#f59e0b"]').first();
|
||||||
|
const hasVisits = await visitCircle.count() > 0;
|
||||||
|
|
||||||
|
if (!hasVisits) {
|
||||||
|
console.log('No suggested visits found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await visitCircle.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Look for place dropdown/select in popup
|
||||||
|
const placeSelect = page.locator('.leaflet-popup-content select, .leaflet-popup-content [role="combobox"]').first();
|
||||||
|
const hasPlaceDropdown = await placeSelect.count() > 0;
|
||||||
|
|
||||||
|
if (!hasPlaceDropdown) {
|
||||||
|
console.log('No place dropdown found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select a different option
|
||||||
|
await placeSelect.selectOption({ index: 1 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Verify the selection changed
|
||||||
|
const newValue = await placeSelect.inputValue();
|
||||||
|
expect(newValue).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete suggested visit from map', async ({ page }) => {
|
||||||
|
const visitCircle = page.locator('.leaflet-interactive[stroke="#f59e0b"]').first();
|
||||||
|
const hasVisits = await visitCircle.count() > 0;
|
||||||
|
|
||||||
|
if (!hasVisits) {
|
||||||
|
console.log('No suggested visits found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count initial visits
|
||||||
|
const initialVisitCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
|
||||||
|
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
await visitCircle.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Find delete button
|
||||||
|
const deleteButton = page.locator('.leaflet-popup-content button:has-text("Delete"), .leaflet-popup-content a:has-text("Delete")').first();
|
||||||
|
const hasDeleteButton = await deleteButton.count() > 0;
|
||||||
|
|
||||||
|
if (!hasDeleteButton) {
|
||||||
|
console.log('No delete button found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle confirmation dialog
|
||||||
|
page.once('dialog', dialog => {
|
||||||
|
expect(dialog.message()).toMatch(/delete|remove/i);
|
||||||
|
dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteButton.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify visit count decreased
|
||||||
|
const finalVisitCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
|
||||||
|
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(finalVisitCount).toBeLessThan(initialVisitCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
243
e2e/map/map-visits.spec.js
Normal file
243
e2e/map/map-visits.spec.js
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js';
|
||||||
|
import { waitForMap, enableLayer, clickConfirmedVisit } from '../helpers/map.js';
|
||||||
|
|
||||||
|
test.describe('Visit Interactions', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await navigateToMap(page);
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
// Navigate to a date range that includes visits (last month to now)
|
||||||
|
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
|
||||||
|
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
|
||||||
|
|
||||||
|
if (!isPanelVisible) {
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set date range to last month
|
||||||
|
await page.click('a:has-text("Last month")');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
await closeOnboardingModal(page);
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
await enableLayer(page, 'Confirmed Visits');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Pan map to ensure a visit marker is in viewport
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.confirmedVisitCircles) {
|
||||||
|
const layers = controller.visitsManager.confirmedVisitCircles._layers;
|
||||||
|
const firstVisit = Object.values(layers)[0];
|
||||||
|
if (firstVisit && firstVisit._latlng) {
|
||||||
|
controller.map.setView(firstVisit._latlng, 14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should click on a confirmed visit and open popup', async ({ page }) => {
|
||||||
|
// Debug: Check what visit circles exist
|
||||||
|
const allCircles = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
|
||||||
|
const layers = controller.visitsManager.confirmedVisitCircles._layers;
|
||||||
|
return {
|
||||||
|
count: Object.keys(layers).length,
|
||||||
|
hasLayers: Object.keys(layers).length > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { count: 0, hasLayers: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we have visits in the layer but can't find DOM elements, use coordinates
|
||||||
|
if (!allCircles.hasLayers) {
|
||||||
|
console.log('No confirmed visits found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on the visit using map coordinates
|
||||||
|
const visitClicked = await clickConfirmedVisit(page);
|
||||||
|
|
||||||
|
if (!visitClicked) {
|
||||||
|
console.log('Could not click visit - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify popup is visible
|
||||||
|
const popup = page.locator('.leaflet-popup');
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display correct content in confirmed visit popup', async ({ page }) => {
|
||||||
|
// Click visit programmatically
|
||||||
|
const visitClicked = await clickConfirmedVisit(page);
|
||||||
|
|
||||||
|
if (!visitClicked) {
|
||||||
|
console.log('No confirmed visits found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Get popup content
|
||||||
|
const popupContent = page.locator('.leaflet-popup-content');
|
||||||
|
await expect(popupContent).toBeVisible();
|
||||||
|
|
||||||
|
const content = await popupContent.textContent();
|
||||||
|
|
||||||
|
// Verify visit information is present
|
||||||
|
expect(content).toMatch(/Visit|Place|Duration|Started|Ended/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should change place in dropdown and save', async ({ page }) => {
|
||||||
|
const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first();
|
||||||
|
const hasVisits = await visitCircle.count() > 0;
|
||||||
|
|
||||||
|
if (!hasVisits) {
|
||||||
|
console.log('No confirmed visits found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await visitCircle.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Look for place dropdown/select in popup
|
||||||
|
const placeSelect = page.locator('.leaflet-popup-content select, .leaflet-popup-content [role="combobox"]').first();
|
||||||
|
const hasPlaceDropdown = await placeSelect.count() > 0;
|
||||||
|
|
||||||
|
if (!hasPlaceDropdown) {
|
||||||
|
console.log('No place dropdown found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current value
|
||||||
|
const initialValue = await placeSelect.inputValue().catch(() => null);
|
||||||
|
|
||||||
|
// Select a different option
|
||||||
|
await placeSelect.selectOption({ index: 1 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Find and click save button
|
||||||
|
const saveButton = page.locator('.leaflet-popup-content button:has-text("Save"), .leaflet-popup-content input[type="submit"]').first();
|
||||||
|
const hasSaveButton = await saveButton.count() > 0;
|
||||||
|
|
||||||
|
if (hasSaveButton) {
|
||||||
|
await saveButton.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify popup closes after successful save
|
||||||
|
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
|
||||||
|
expect(popupVisible).toBe(false);
|
||||||
|
|
||||||
|
// Verify success flash message appears
|
||||||
|
const flashMessage = page.locator('#flash-messages [role="alert"]');
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 2000 });
|
||||||
|
const messageText = await flashMessage.textContent();
|
||||||
|
expect(messageText).toContain('Visit updated successfully');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should change visit name and save', async ({ page }) => {
|
||||||
|
const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first();
|
||||||
|
const hasVisits = await visitCircle.count() > 0;
|
||||||
|
|
||||||
|
if (!hasVisits) {
|
||||||
|
console.log('No confirmed visits found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await visitCircle.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Look for name input field
|
||||||
|
const nameInput = page.locator('.leaflet-popup-content input[type="text"]').first();
|
||||||
|
const hasNameInput = await nameInput.count() > 0;
|
||||||
|
|
||||||
|
if (!hasNameInput) {
|
||||||
|
console.log('No name input found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the name
|
||||||
|
const newName = `Test Visit ${Date.now()}`;
|
||||||
|
await nameInput.fill(newName);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Find and click save button
|
||||||
|
const saveButton = page.locator('.leaflet-popup-content button:has-text("Save"), .leaflet-popup-content input[type="submit"]').first();
|
||||||
|
const hasSaveButton = await saveButton.count() > 0;
|
||||||
|
|
||||||
|
if (hasSaveButton) {
|
||||||
|
await saveButton.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify popup closes after successful save
|
||||||
|
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
|
||||||
|
expect(popupVisible).toBe(false);
|
||||||
|
|
||||||
|
// Verify success flash message appears
|
||||||
|
const flashMessage = page.locator('#flash-messages [role="alert"]');
|
||||||
|
await expect(flashMessage).toBeVisible({ timeout: 2000 });
|
||||||
|
const messageText = await flashMessage.textContent();
|
||||||
|
expect(messageText).toContain('Visit updated successfully');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete confirmed visit from map', async ({ page }) => {
|
||||||
|
const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first();
|
||||||
|
const hasVisits = await visitCircle.count() > 0;
|
||||||
|
|
||||||
|
if (!hasVisits) {
|
||||||
|
console.log('No confirmed visits found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count initial visits
|
||||||
|
const initialVisitCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
|
||||||
|
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
await visitCircle.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Find delete button
|
||||||
|
const deleteButton = page.locator('.leaflet-popup-content button:has-text("Delete"), .leaflet-popup-content a:has-text("Delete")').first();
|
||||||
|
const hasDeleteButton = await deleteButton.count() > 0;
|
||||||
|
|
||||||
|
if (!hasDeleteButton) {
|
||||||
|
console.log('No delete button found - skipping test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle confirmation dialog
|
||||||
|
page.once('dialog', dialog => {
|
||||||
|
expect(dialog.message()).toMatch(/delete|remove/i);
|
||||||
|
dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteButton.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Verify visit count decreased
|
||||||
|
const finalVisitCount = await page.evaluate(() => {
|
||||||
|
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
|
||||||
|
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
|
||||||
|
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(finalVisitCount).toBeLessThan(initialVisitCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test to verify the marker factory refactoring is memory-safe
|
|
||||||
* and maintains consistent marker creation across different use cases
|
|
||||||
*/
|
|
||||||
|
|
||||||
test.describe('Marker Factory Refactoring', () => {
|
|
||||||
let page;
|
|
||||||
let context;
|
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
context = await browser.newContext();
|
|
||||||
page = await context.newPage();
|
|
||||||
|
|
||||||
// Sign in
|
|
||||||
await page.goto('/users/sign_in');
|
|
||||||
await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 });
|
|
||||||
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
|
|
||||||
await page.fill('input[name="user[password]"]', 'password');
|
|
||||||
await page.click('input[type="submit"][value="Log in"]');
|
|
||||||
await page.waitForURL('/map', { timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await page.close();
|
|
||||||
await context.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should have marker factory available in bundled code', async () => {
|
|
||||||
// Navigate to map
|
|
||||||
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
|
|
||||||
await page.waitForSelector('#map', { timeout: 10000 });
|
|
||||||
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
|
|
||||||
|
|
||||||
// Check if marker factory functions are available in the bundled code
|
|
||||||
const factoryAnalysis = await page.evaluate(() => {
|
|
||||||
const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML);
|
|
||||||
const allJavaScript = scripts.join(' ');
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasMarkerFactory: allJavaScript.includes('marker_factory') || allJavaScript.includes('MarkerFactory'),
|
|
||||||
hasCreateLiveMarker: allJavaScript.includes('createLiveMarker'),
|
|
||||||
hasCreateInteractiveMarker: allJavaScript.includes('createInteractiveMarker'),
|
|
||||||
hasCreateStandardIcon: allJavaScript.includes('createStandardIcon'),
|
|
||||||
totalJSSize: allJavaScript.length,
|
|
||||||
scriptCount: scripts.length
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Marker factory analysis:', factoryAnalysis);
|
|
||||||
|
|
||||||
// The refactoring should be present (though may not be detectable in bundled JS)
|
|
||||||
expect(factoryAnalysis.scriptCount).toBeGreaterThan(0);
|
|
||||||
expect(factoryAnalysis.totalJSSize).toBeGreaterThan(1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should maintain consistent marker styling across use cases', async () => {
|
|
||||||
// Navigate to map
|
|
||||||
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
|
|
||||||
await page.waitForSelector('#map', { timeout: 10000 });
|
|
||||||
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
|
|
||||||
|
|
||||||
// Check for consistent marker styling in the DOM
|
|
||||||
const markerConsistency = await page.evaluate(() => {
|
|
||||||
// Look for custom-div-icon markers (our standard marker style)
|
|
||||||
const customMarkers = document.querySelectorAll('.custom-div-icon');
|
|
||||||
const markerStyles = Array.from(customMarkers).map(marker => {
|
|
||||||
const innerDiv = marker.querySelector('div');
|
|
||||||
return {
|
|
||||||
hasInnerDiv: !!innerDiv,
|
|
||||||
backgroundColor: innerDiv?.style.backgroundColor || 'none',
|
|
||||||
borderRadius: innerDiv?.style.borderRadius || 'none',
|
|
||||||
width: innerDiv?.style.width || 'none',
|
|
||||||
height: innerDiv?.style.height || 'none'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if all markers have consistent styling
|
|
||||||
const hasConsistentStyling = markerStyles.every(style =>
|
|
||||||
style.hasInnerDiv &&
|
|
||||||
style.borderRadius === '50%' &&
|
|
||||||
(style.backgroundColor === 'blue' || style.backgroundColor === 'orange') &&
|
|
||||||
style.width === style.height // Should be square
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalCustomMarkers: customMarkers.length,
|
|
||||||
markerStyles: markerStyles.slice(0, 3), // Show first 3 for debugging
|
|
||||||
hasConsistentStyling,
|
|
||||||
allMarkersCount: document.querySelectorAll('.leaflet-marker-icon').length
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Marker consistency analysis:', markerConsistency);
|
|
||||||
|
|
||||||
// Verify consistent styling if markers are present
|
|
||||||
if (markerConsistency.totalCustomMarkers > 0) {
|
|
||||||
expect(markerConsistency.hasConsistentStyling).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test always passes as we've verified implementation
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should have memory-safe marker creation patterns', async () => {
|
|
||||||
// Navigate to map
|
|
||||||
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
|
|
||||||
await page.waitForSelector('#map', { timeout: 10000 });
|
|
||||||
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
|
|
||||||
|
|
||||||
// Monitor basic memory patterns
|
|
||||||
const memoryInfo = await page.evaluate(() => {
|
|
||||||
const memory = window.performance.memory;
|
|
||||||
return {
|
|
||||||
usedJSHeapSize: memory?.usedJSHeapSize || 0,
|
|
||||||
totalJSHeapSize: memory?.totalJSHeapSize || 0,
|
|
||||||
jsHeapSizeLimit: memory?.jsHeapSizeLimit || 0,
|
|
||||||
memoryAvailable: !!memory
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Memory info:', memoryInfo);
|
|
||||||
|
|
||||||
// Verify memory monitoring is available and reasonable
|
|
||||||
if (memoryInfo.memoryAvailable) {
|
|
||||||
expect(memoryInfo.usedJSHeapSize).toBeGreaterThan(0);
|
|
||||||
expect(memoryInfo.usedJSHeapSize).toBeLessThan(memoryInfo.totalJSHeapSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for memory-safe patterns in the code structure
|
|
||||||
const codeSafetyAnalysis = await page.evaluate(() => {
|
|
||||||
return {
|
|
||||||
hasLeafletContainer: !!document.querySelector('.leaflet-container'),
|
|
||||||
hasMapElement: !!document.querySelector('#map'),
|
|
||||||
leafletLayerCount: document.querySelectorAll('.leaflet-layer').length,
|
|
||||||
markerPaneElements: document.querySelectorAll('.leaflet-marker-pane').length,
|
|
||||||
totalLeafletElements: document.querySelectorAll('[class*="leaflet"]').length
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Code safety analysis:', codeSafetyAnalysis);
|
|
||||||
|
|
||||||
// Verify basic structure is sound
|
|
||||||
expect(codeSafetyAnalysis.hasLeafletContainer).toBe(true);
|
|
||||||
expect(codeSafetyAnalysis.hasMapElement).toBe(true);
|
|
||||||
expect(codeSafetyAnalysis.totalLeafletElements).toBeGreaterThan(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should demonstrate marker factory benefits', async () => {
|
|
||||||
// This test documents the benefits of the marker factory refactoring
|
|
||||||
|
|
||||||
console.log('=== MARKER FACTORY REFACTORING BENEFITS ===');
|
|
||||||
console.log('');
|
|
||||||
console.log('1. ✅ CODE REUSE:');
|
|
||||||
console.log(' - Single source of truth for marker styling');
|
|
||||||
console.log(' - Consistent divIcon creation across all use cases');
|
|
||||||
console.log(' - Reduced code duplication between markers.js and live_map_handler.js');
|
|
||||||
console.log('');
|
|
||||||
console.log('2. ✅ MEMORY SAFETY:');
|
|
||||||
console.log(' - createLiveMarker(): Lightweight markers for live streaming');
|
|
||||||
console.log(' - createInteractiveMarker(): Full-featured markers for static display');
|
|
||||||
console.log(' - createStandardIcon(): Shared icon factory prevents object duplication');
|
|
||||||
console.log('');
|
|
||||||
console.log('3. ✅ MAINTENANCE:');
|
|
||||||
console.log(' - Centralized marker logic in marker_factory.js');
|
|
||||||
console.log(' - Easy to update styling across entire application');
|
|
||||||
console.log(' - Clear separation between live and interactive marker features');
|
|
||||||
console.log('');
|
|
||||||
console.log('4. ✅ PERFORMANCE:');
|
|
||||||
console.log(' - Live markers skip expensive drag handlers and popups');
|
|
||||||
console.log(' - Interactive markers include full feature set only when needed');
|
|
||||||
console.log(' - No shared object references that could cause memory leaks');
|
|
||||||
console.log('');
|
|
||||||
console.log('=== REFACTORING COMPLETE ===');
|
|
||||||
|
|
||||||
// Test always passes - this is documentation
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test to verify the Live Mode memory leak fix
|
|
||||||
* This test focuses on verifying the fix works by checking DOM elements
|
|
||||||
* and memory patterns rather than requiring full controller integration
|
|
||||||
*/
|
|
||||||
|
|
||||||
test.describe('Memory Leak Fix Verification', () => {
|
|
||||||
let page;
|
|
||||||
let context;
|
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
context = await browser.newContext();
|
|
||||||
page = await context.newPage();
|
|
||||||
|
|
||||||
// Sign in
|
|
||||||
await page.goto('/users/sign_in');
|
|
||||||
await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 });
|
|
||||||
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
|
|
||||||
await page.fill('input[name="user[password]"]', 'password');
|
|
||||||
await page.click('input[type="submit"][value="Log in"]');
|
|
||||||
await page.waitForURL('/map', { timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await page.close();
|
|
||||||
await context.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should load map page with memory leak fix implemented', async () => {
|
|
||||||
// Navigate to map with test data
|
|
||||||
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
|
|
||||||
await page.waitForSelector('#map', { timeout: 10000 });
|
|
||||||
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
|
|
||||||
|
|
||||||
// Verify the updated appendPoint method exists and has the fix
|
|
||||||
const codeAnalysis = await page.evaluate(() => {
|
|
||||||
// Check if the maps controller exists and analyze its appendPoint method
|
|
||||||
const mapElement = document.querySelector('#map');
|
|
||||||
const controllers = mapElement?._stimulus_controllers;
|
|
||||||
const mapController = controllers?.find(c => c.identifier === 'maps');
|
|
||||||
|
|
||||||
if (mapController && mapController.appendPoint) {
|
|
||||||
const methodString = mapController.appendPoint.toString();
|
|
||||||
return {
|
|
||||||
hasController: true,
|
|
||||||
hasAppendPoint: true,
|
|
||||||
// Check for fixed patterns (absence of problematic code)
|
|
||||||
hasOldClearLayersPattern: methodString.includes('clearLayers()') && methodString.includes('L.layerGroup(this.markersArray)'),
|
|
||||||
hasOldPolylineRecreation: methodString.includes('createPolylinesLayer'),
|
|
||||||
// Check for new efficient patterns
|
|
||||||
hasIncrementalMarkerAdd: methodString.includes('this.markersLayer.addLayer(newMarker)'),
|
|
||||||
hasBoundedData: methodString.includes('> 1000'),
|
|
||||||
hasLastMarkerTracking: methodString.includes('this.lastMarkerRef'),
|
|
||||||
methodLength: methodString.length
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasController: !!mapController,
|
|
||||||
hasAppendPoint: false,
|
|
||||||
controllerCount: controllers?.length || 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Code analysis:', codeAnalysis);
|
|
||||||
|
|
||||||
// The test passes if either:
|
|
||||||
// 1. Controller is found and shows the fix is implemented
|
|
||||||
// 2. Controller is not found (which is the current issue) but the code exists in the file
|
|
||||||
if (codeAnalysis.hasController && codeAnalysis.hasAppendPoint) {
|
|
||||||
// If controller is found, verify the fix
|
|
||||||
expect(codeAnalysis.hasOldClearLayersPattern).toBe(false); // Old inefficient pattern should be gone
|
|
||||||
expect(codeAnalysis.hasIncrementalMarkerAdd).toBe(true); // New efficient pattern should exist
|
|
||||||
expect(codeAnalysis.hasBoundedData).toBe(true); // Should have bounded data structures
|
|
||||||
} else {
|
|
||||||
// Controller not found (expected based on previous tests), but we've implemented the fix
|
|
||||||
console.log('Controller not found in test environment, but fix has been implemented in code');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify basic map functionality
|
|
||||||
const mapState = await page.evaluate(() => {
|
|
||||||
return {
|
|
||||||
hasLeafletContainer: !!document.querySelector('.leaflet-container'),
|
|
||||||
leafletElementCount: document.querySelectorAll('[class*="leaflet"]').length,
|
|
||||||
hasMapElement: !!document.querySelector('#map'),
|
|
||||||
mapHasDataController: document.querySelector('#map')?.hasAttribute('data-controller')
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mapState.hasLeafletContainer).toBe(true);
|
|
||||||
expect(mapState.hasMapElement).toBe(true);
|
|
||||||
expect(mapState.mapHasDataController).toBe(true);
|
|
||||||
expect(mapState.leafletElementCount).toBeGreaterThan(10); // Should have substantial Leaflet elements
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should have memory-efficient appendPoint implementation in source code', async () => {
|
|
||||||
// This test verifies the fix exists in the actual source file
|
|
||||||
// by checking the current page's loaded JavaScript
|
|
||||||
|
|
||||||
const hasEfficientImplementation = await page.evaluate(() => {
|
|
||||||
// Try to access the source code through various means
|
|
||||||
const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML);
|
|
||||||
const allJavaScript = scripts.join(' ');
|
|
||||||
|
|
||||||
// Check for key improvements (these should exist in the bundled JS)
|
|
||||||
const hasIncrementalAdd = allJavaScript.includes('addLayer(newMarker)');
|
|
||||||
const hasBoundedArrays = allJavaScript.includes('length > 1000');
|
|
||||||
const hasEfficientTracking = allJavaScript.includes('lastMarkerRef');
|
|
||||||
|
|
||||||
// Check that old inefficient patterns are not present together
|
|
||||||
const hasOldPattern = allJavaScript.includes('clearLayers()') &&
|
|
||||||
allJavaScript.includes('addLayer(L.layerGroup(this.markersArray))');
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasIncrementalAdd,
|
|
||||||
hasBoundedArrays,
|
|
||||||
hasEfficientTracking,
|
|
||||||
hasOldPattern,
|
|
||||||
scriptCount: scripts.length,
|
|
||||||
totalJSSize: allJavaScript.length
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Source code analysis:', hasEfficientImplementation);
|
|
||||||
|
|
||||||
// We expect the fix to be present in the bundled JavaScript
|
|
||||||
// Note: These might not be detected if the JS is minified/bundled differently
|
|
||||||
console.log('Memory leak fix has been implemented in maps_controller.js');
|
|
||||||
console.log('Key improvements:');
|
|
||||||
console.log('- Incremental marker addition instead of layer recreation');
|
|
||||||
console.log('- Bounded data structures (1000 point limit)');
|
|
||||||
console.log('- Efficient last marker tracking');
|
|
||||||
console.log('- Incremental polyline updates');
|
|
||||||
|
|
||||||
// Test passes regardless as we've verified the fix is in the source code
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
24
e2e/setup/auth.setup.js
Normal file
24
e2e/setup/auth.setup.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { test as setup, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const authFile = 'e2e/temp/.auth/user.json';
|
||||||
|
|
||||||
|
setup('authenticate', async ({ page }) => {
|
||||||
|
// Navigate to login page with more lenient waiting
|
||||||
|
await page.goto('/users/sign_in', {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fill in credentials
|
||||||
|
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
|
||||||
|
await page.fill('input[name="user[password]"]', 'password');
|
||||||
|
|
||||||
|
// Click login button
|
||||||
|
await page.click('input[type="submit"][value="Log in"]');
|
||||||
|
|
||||||
|
// Wait for successful navigation
|
||||||
|
await page.waitForURL('/map', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Save authentication state
|
||||||
|
await page.context().storageState({ path: authFile });
|
||||||
|
});
|
||||||
|
|
@ -23,27 +23,42 @@ export default defineConfig({
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||||
|
|
||||||
|
/* Use European locale and timezone */
|
||||||
|
locale: 'en-GB',
|
||||||
|
timezoneId: 'Europe/Berlin',
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
|
||||||
/* Take screenshot on failure */
|
/* Take screenshot on failure */
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
|
|
||||||
/* Record video on failure */
|
/* Record video on failure */
|
||||||
video: 'retain-on-failure',
|
video: 'retain-on-failure',
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
projects: [
|
projects: [
|
||||||
|
// Setup project - runs authentication before all tests
|
||||||
|
{
|
||||||
|
name: 'setup',
|
||||||
|
testMatch: /.*\/setup\/auth\.setup\.js/
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
// Use saved authentication state
|
||||||
|
storageState: 'e2e/temp/.auth/user.json'
|
||||||
|
},
|
||||||
|
dependencies: ['setup'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'RAILS_ENV=test rails server -p 3000',
|
command: 'OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES RAILS_ENV=test rails server -p 3000',
|
||||||
url: 'http://localhost:3000',
|
url: 'http://localhost:3000',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120 * 1000,
|
timeout: 120 * 1000,
|
||||||
|
|
|
||||||
78
spec/jobs/family/invitations/sending_job_spec.rb
Normal file
78
spec/jobs/family/invitations/sending_job_spec.rb
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Family::Invitations::SendingJob, type: :job do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:family) { create(:family, creator: user) }
|
||||||
|
let(:invitation) { create(:family_invitation, family: family, invited_by: user, status: :pending) }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
context 'when invitation exists and is pending' do
|
||||||
|
it 'sends the invitation email' do
|
||||||
|
mailer_double = double('mailer')
|
||||||
|
expect(FamilyMailer).to receive(:invitation).with(invitation).and_return(mailer_double)
|
||||||
|
expect(mailer_double).to receive(:deliver_now)
|
||||||
|
|
||||||
|
described_class.perform_now(invitation.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when invitation does not exist' do
|
||||||
|
it 'does not raise an error' do
|
||||||
|
expect do
|
||||||
|
described_class.perform_now(999_999)
|
||||||
|
end.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not send any email' do
|
||||||
|
expect(FamilyMailer).not_to receive(:invitation)
|
||||||
|
|
||||||
|
described_class.perform_now(999_999)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when invitation is not pending' do
|
||||||
|
let(:accepted_invitation) do
|
||||||
|
create(:family_invitation, family: family, invited_by: user, status: :accepted)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not send the invitation email' do
|
||||||
|
expect(FamilyMailer).not_to receive(:invitation)
|
||||||
|
|
||||||
|
described_class.perform_now(accepted_invitation.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when invitation is cancelled' do
|
||||||
|
let(:cancelled_invitation) do
|
||||||
|
create(:family_invitation, family: family, invited_by: user, status: :cancelled)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not send the invitation email' do
|
||||||
|
expect(FamilyMailer).not_to receive(:invitation)
|
||||||
|
|
||||||
|
described_class.perform_now(cancelled_invitation.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'integration test' do
|
||||||
|
before do
|
||||||
|
ActionMailer::Base.deliveries.clear
|
||||||
|
# Set a from address for the mailer to avoid SMTP errors
|
||||||
|
allow(ActionMailer::Base).to receive(:default).and_return(from: 'noreply@dawarich.app')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'actually calls the mailer' do
|
||||||
|
mailer = instance_double(ActionMailer::MessageDelivery)
|
||||||
|
allow(FamilyMailer).to receive(:invitation).and_return(mailer)
|
||||||
|
allow(mailer).to receive(:deliver_now)
|
||||||
|
|
||||||
|
described_class.perform_now(invitation.id)
|
||||||
|
|
||||||
|
expect(FamilyMailer).to have_received(:invitation).with(invitation)
|
||||||
|
expect(mailer).to have_received(:deliver_now)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -26,31 +26,100 @@ RSpec.describe Family, type: :model do
|
||||||
describe '#can_add_members?' do
|
describe '#can_add_members?' do
|
||||||
let(:family) { create(:family, creator: user) }
|
let(:family) { create(:family, creator: user) }
|
||||||
|
|
||||||
context 'when family has fewer than max members' do
|
context 'when not in self-hosted mode' do
|
||||||
before do
|
before do
|
||||||
create(:family_membership, family: family, user: user, role: :owner)
|
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
|
||||||
create_list(:family_membership, 3, family: family, role: :member)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true' do
|
context 'when family has fewer than max members' do
|
||||||
expect(family.can_add_members?).to be true
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 3, family: family, role: :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
expect(family.can_add_members?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has max members' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 4, family: family, role: :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(family.can_add_members?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has pending invitations that would reach max' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 3, family: family, role: :member)
|
||||||
|
create(:family_invitation, family: family, invited_by: user, status: :pending)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(family.can_add_members?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has no members' do
|
||||||
|
it 'returns true' do
|
||||||
|
expect(family.can_add_members?).to be true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when family has max members' do
|
context 'when in self-hosted mode' do
|
||||||
before do
|
before do
|
||||||
create(:family_membership, family: family, user: user, role: :owner)
|
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
|
||||||
create_list(:family_membership, 4, family: family, role: :member)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false' do
|
context 'when family has fewer than max members' do
|
||||||
expect(family.can_add_members?).to be false
|
before do
|
||||||
end
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
end
|
create_list(:family_membership, 3, family: family, role: :member)
|
||||||
|
end
|
||||||
|
|
||||||
context 'when family has no members' do
|
it 'returns true' do
|
||||||
it 'returns true' do
|
expect(family.can_add_members?).to be true
|
||||||
expect(family.can_add_members?).to be true
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has max members' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 4, family: family, role: :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true (no limit in self-hosted mode)' do
|
||||||
|
expect(family.can_add_members?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has more than max members' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 10, family: family, role: :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true (no limit in self-hosted mode)' do
|
||||||
|
expect(family.can_add_members?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has pending invitations that would exceed max' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 4, family: family, role: :member)
|
||||||
|
create_list(:family_invitation, 5, family: family, invited_by: user, status: :pending)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true (no limit in self-hosted mode)' do
|
||||||
|
expect(family.can_add_members?).to be true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -122,4 +191,99 @@ RSpec.describe Family, type: :model do
|
||||||
expect(Family::Membership.find_by(id: membership.id)).to be_nil
|
expect(Family::Membership.find_by(id: membership.id)).to be_nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#full?' do
|
||||||
|
let(:family) { create(:family, creator: user) }
|
||||||
|
|
||||||
|
context 'when not in self-hosted mode' do
|
||||||
|
before do
|
||||||
|
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has fewer than max members' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 3, family: family, role: :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(family.full?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has exactly max members' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 4, family: family, role: :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
expect(family.full?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has pending invitations that would reach max' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 3, family: family, role: :member)
|
||||||
|
create(:family_invitation, family: family, invited_by: user, status: :pending)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
expect(family.full?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when in self-hosted mode' do
|
||||||
|
before do
|
||||||
|
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has fewer than max members' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 3, family: family, role: :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(family.full?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has exactly max members' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 4, family: family, role: :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false (no limit in self-hosted mode)' do
|
||||||
|
expect(family.full?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has more than max members' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 10, family: family, role: :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false (no limit in self-hosted mode)' do
|
||||||
|
expect(family.full?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family has pending invitations that would exceed max' do
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: family, user: user, role: :owner)
|
||||||
|
create_list(:family_membership, 4, family: family, role: :member)
|
||||||
|
create_list(:family_invitation, 5, family: family, invited_by: user, status: :pending)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false (no limit in self-hosted mode)' do
|
||||||
|
expect(family.full?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -198,4 +198,113 @@ RSpec.describe 'Api::V1::Points', type: :request do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'DELETE /bulk_destroy' do
|
||||||
|
let(:point_ids) { points.first(5).map(&:id) }
|
||||||
|
|
||||||
|
it 'returns a successful response' do
|
||||||
|
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
|
||||||
|
params: { point_ids: }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'deletes multiple points' do
|
||||||
|
expect do
|
||||||
|
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
|
||||||
|
params: { point_ids: }
|
||||||
|
end.to change { user.points.count }.by(-5)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the count of deleted points' do
|
||||||
|
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
|
||||||
|
params: { point_ids: }
|
||||||
|
|
||||||
|
json_response = JSON.parse(response.body)
|
||||||
|
|
||||||
|
expect(json_response['message']).to eq('Points were successfully destroyed')
|
||||||
|
expect(json_response['count']).to eq(5)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'only deletes points belonging to the current user' do
|
||||||
|
other_user = create(:user)
|
||||||
|
other_points = create_list(:point, 3, user: other_user)
|
||||||
|
all_point_ids = point_ids + other_points.map(&:id)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
|
||||||
|
params: { point_ids: all_point_ids }
|
||||||
|
end.to change { user.points.count }.by(-5)
|
||||||
|
.and change { other_user.points.count }.by(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when no point_ids are provided' do
|
||||||
|
it 'returns success with zero count' do
|
||||||
|
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
|
||||||
|
params: { point_ids: [] }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
json_response = JSON.parse(response.body)
|
||||||
|
expect(json_response['count']).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when point_ids parameter is missing' do
|
||||||
|
it 'returns an error' do
|
||||||
|
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
|
||||||
|
json_response = JSON.parse(response.body)
|
||||||
|
expect(json_response['error']).to eq('No points selected')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is inactive' do
|
||||||
|
before do
|
||||||
|
user.update(status: :inactive, active_until: 1.day.ago)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns an unauthorized response' do
|
||||||
|
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
|
||||||
|
params: { point_ids: }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not delete any points' do
|
||||||
|
expect do
|
||||||
|
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
|
||||||
|
params: { point_ids: }
|
||||||
|
end.not_to(change { user.points.count })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when deleting all user points' do
|
||||||
|
it 'successfully deletes all points' do
|
||||||
|
all_point_ids = points.map(&:id)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
|
||||||
|
params: { point_ids: all_point_ids }
|
||||||
|
end.to change { user.points.count }.from(15).to(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when some point_ids do not exist' do
|
||||||
|
it 'deletes only existing points' do
|
||||||
|
non_existent_ids = [999_999, 888_888]
|
||||||
|
mixed_ids = point_ids + non_existent_ids
|
||||||
|
|
||||||
|
expect do
|
||||||
|
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
|
||||||
|
params: { point_ids: mixed_ids }
|
||||||
|
end.to change { user.points.count }.by(-5)
|
||||||
|
|
||||||
|
json_response = JSON.parse(response.body)
|
||||||
|
expect(json_response['count']).to eq(5)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ RSpec.describe 'Users::Registrations', type: :request do
|
||||||
get new_user_registration_path
|
get new_user_registration_path
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include('Register now!')
|
expect(response.body).to include('Almost there!')
|
||||||
expect(response.body).to include('take control over your location data')
|
expect(response.body).to include('control over your location data')
|
||||||
expect(response.body).not_to include('Join')
|
expect(response.body).not_to include('Join')
|
||||||
expect(response.body).to include('Sign up')
|
expect(response.body).to include('Sign up')
|
||||||
end
|
end
|
||||||
|
|
@ -227,7 +227,7 @@ RSpec.describe 'Users::Registrations', type: :request do
|
||||||
get new_user_registration_path
|
get new_user_registration_path
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include('Register now!')
|
expect(response.body).to include('Almost there!')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'allows account creation' do
|
it 'allows account creation' do
|
||||||
|
|
@ -326,6 +326,70 @@ RSpec.describe 'Users::Registrations', type: :request do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'Validation Error Handling' do
|
||||||
|
context 'when trying to register with an existing email' do
|
||||||
|
let!(:existing_user) { create(:user, email: 'existing@example.com') }
|
||||||
|
|
||||||
|
it 'renders the registration form with error message' do
|
||||||
|
post user_registration_path, params: {
|
||||||
|
user: {
|
||||||
|
email: existing_user.email,
|
||||||
|
password: 'password123',
|
||||||
|
password_confirmation: 'password123'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
expect(response.body).to include('Email has already been taken')
|
||||||
|
expect(response.body).to include('error_explanation')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not create a new user' do
|
||||||
|
expect do
|
||||||
|
post user_registration_path, params: {
|
||||||
|
user: {
|
||||||
|
email: existing_user.email,
|
||||||
|
password: 'password123',
|
||||||
|
password_confirmation: 'password123'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end.not_to change(User, :count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when password is too short' do
|
||||||
|
it 'renders the registration form with error message' do
|
||||||
|
post user_registration_path, params: {
|
||||||
|
user: {
|
||||||
|
email: 'newuser@example.com',
|
||||||
|
password: 'short',
|
||||||
|
password_confirmation: 'short'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
expect(response.body).to include('Password is too short')
|
||||||
|
expect(response.body).to include('error_explanation')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when passwords do not match' do
|
||||||
|
it 'renders the registration form with error message' do
|
||||||
|
post user_registration_path, params: {
|
||||||
|
user: {
|
||||||
|
email: 'newuser@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
password_confirmation: 'different123'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
expect(response.body).to include("Password confirmation doesn")
|
||||||
|
expect(response.body).to include('error_explanation')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'UTM Parameter Tracking' do
|
describe 'UTM Parameter Tracking' do
|
||||||
let(:utm_params) do
|
let(:utm_params) do
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ RSpec.describe Points::GpxSerializer do
|
||||||
|
|
||||||
let(:points) do
|
let(:points) do
|
||||||
(1..3).map do |i|
|
(1..3).map do |i|
|
||||||
create(:point, timestamp: 1.day.ago + i.minutes)
|
create(:point, timestamp: 1.day.ago + i.minutes, velocity: i * 10.5, course: i * 45.2)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -16,17 +16,55 @@ RSpec.describe Points::GpxSerializer do
|
||||||
expect(serializer).to be_a(GPX::GPXFile)
|
expect(serializer).to be_a(GPX::GPXFile)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'includes waypoints' do
|
it 'includes waypoints in XML output' do
|
||||||
expect(serializer.tracks[0].points.size).to eq(3)
|
gpx_xml = serializer.to_s
|
||||||
|
|
||||||
|
# Check that all 3 points are included in XML
|
||||||
|
expect(gpx_xml.scan(/<trkpt/).size).to eq(3)
|
||||||
|
|
||||||
|
# Check that basic point data is included
|
||||||
|
points.each do |point|
|
||||||
|
expect(gpx_xml).to include("lat=\"#{point.lat}\"")
|
||||||
|
expect(gpx_xml).to include("lon=\"#{point.lon}\"")
|
||||||
|
expect(gpx_xml).to include("<ele>#{point.altitude.to_f}</ele>")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes speed and course data in the GPX XML output' do
|
||||||
|
gpx_xml = serializer.to_s
|
||||||
|
|
||||||
|
# Check that speed is included in XML for points with velocity
|
||||||
|
expect(gpx_xml).to include('<speed>10.5</speed>')
|
||||||
|
expect(gpx_xml).to include('<speed>21.0</speed>')
|
||||||
|
expect(gpx_xml).to include('<speed>31.5</speed>')
|
||||||
|
|
||||||
|
# Check that course is included in extensions for points with course data
|
||||||
|
expect(gpx_xml).to include('<course>45.2</course>')
|
||||||
|
expect(gpx_xml).to include('<course>90.4</course>')
|
||||||
|
expect(gpx_xml).to include('<course>135.6</course>')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'includes waypoints with correct attributes' do
|
context 'when points have nil velocity or course' do
|
||||||
serializer.tracks[0].points.each_with_index do |track_point, index|
|
let(:points) do
|
||||||
point = points[index]
|
[
|
||||||
|
create(:point, timestamp: 1.day.ago, velocity: nil, course: nil),
|
||||||
|
create(:point, timestamp: 1.day.ago + 1.minute, velocity: 15.5, course: nil),
|
||||||
|
create(:point, timestamp: 1.day.ago + 2.minutes, velocity: nil, course: 90.0)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
expect(track_point.lat.to_s).to eq(point.lat.to_s)
|
it 'handles nil values gracefully in XML output' do
|
||||||
expect(track_point.lon.to_s).to eq(point.lon.to_s)
|
gpx_xml = serializer.to_s
|
||||||
expect(track_point.time).to eq(point.recorded_at)
|
|
||||||
|
# Should only include speed for the point with velocity
|
||||||
|
expect(gpx_xml).to include('<speed>15.5</speed>')
|
||||||
|
expect(gpx_xml).not_to include('<speed>0</speed>') # Should not include zero/nil speeds
|
||||||
|
|
||||||
|
# Should only include course for the point with course data
|
||||||
|
expect(gpx_xml).to include('<course>90.0</course>')
|
||||||
|
|
||||||
|
# Should have 3 track points total
|
||||||
|
expect(gpx_xml.scan(/<trkpt/).size).to eq(3)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ RSpec.describe Families::AcceptInvitation do
|
||||||
|
|
||||||
context 'when family is at max capacity' do
|
context 'when family is at max capacity' do
|
||||||
before do
|
before do
|
||||||
|
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
|
||||||
# Fill family to max capacity
|
# Fill family to max capacity
|
||||||
create_list(:family_membership, Family::MAX_MEMBERS, family: family, role: :member)
|
create_list(:family_membership, Family::MAX_MEMBERS, family: family, role: :member)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,13 @@ RSpec.describe Families::Invite do
|
||||||
expect(invitation.invited_by).to eq(owner)
|
expect(invitation.invited_by).to eq(owner)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'enqueues invitation sending job' do
|
||||||
|
expect(Family::Invitations::SendingJob).to receive(:perform_later).with(an_instance_of(Integer))
|
||||||
|
service.call
|
||||||
|
end
|
||||||
|
|
||||||
it 'sends invitation email' do
|
it 'sends invitation email' do
|
||||||
expect(FamilyMailer).to receive(:invitation).and_call_original
|
expect(Family::Invitations::SendingJob).to receive(:perform_later).and_call_original
|
||||||
expect_any_instance_of(ActionMailer::MessageDelivery).to receive(:deliver_later)
|
|
||||||
service.call
|
service.call
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -57,6 +61,7 @@ RSpec.describe Families::Invite do
|
||||||
|
|
||||||
context 'when family is at max capacity' do
|
context 'when family is at max capacity' do
|
||||||
before do
|
before do
|
||||||
|
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
|
||||||
# Create max members (5 total including owner)
|
# Create max members (5 total including owner)
|
||||||
create_list(:family_membership, Family::MAX_MEMBERS - 1, family: family, role: :member)
|
create_list(:family_membership, Family::MAX_MEMBERS - 1, family: family, role: :member)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
# System Tests Documentation
|
|
||||||
|
|
||||||
## Map Interaction Tests
|
|
||||||
|
|
||||||
This directory contains comprehensive system tests for the map interaction functionality in Dawarich.
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
|
|
||||||
The tests have been refactored to follow RSpec best practices using:
|
|
||||||
|
|
||||||
- **Helper modules** for reusable functionality
|
|
||||||
- **Shared examples** for common test patterns
|
|
||||||
- **Support files** for organization and maintainability
|
|
||||||
|
|
||||||
### Files Overview
|
|
||||||
|
|
||||||
#### Main Test File
|
|
||||||
- `map_interaction_spec.rb` - Main system test file covering all map functionality
|
|
||||||
|
|
||||||
#### Support Files
|
|
||||||
- `spec/support/system_helpers.rb` - Authentication and navigation helpers
|
|
||||||
- `spec/support/shared_examples/map_examples.rb` - Shared examples for common map functionality
|
|
||||||
- `spec/support/map_layer_helpers.rb` - Specialized helpers for layer testing
|
|
||||||
- `spec/support/polyline_popup_helpers.rb` - Helpers for testing polyline popup interactions
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
|
|
||||||
The system tests cover the following functionality:
|
|
||||||
|
|
||||||
#### Basic Map Functionality
|
|
||||||
- User authentication and map page access
|
|
||||||
- Leaflet map initialization and basic elements
|
|
||||||
- Map data loading and route display
|
|
||||||
|
|
||||||
#### Map Controls
|
|
||||||
- Zoom controls (zoom in/out functionality)
|
|
||||||
- Layer controls (base layer switching, overlay toggles)
|
|
||||||
- Settings panel (cog button open/close)
|
|
||||||
- Calendar panel (date navigation)
|
|
||||||
- Map statistics and scale display
|
|
||||||
- Map attributions
|
|
||||||
|
|
||||||
#### Polyline Popup Content
|
|
||||||
- **Route popup data validation** for both km and miles distance units
|
|
||||||
- Tests verify popup contains:
|
|
||||||
- **Start time** - formatted timestamp of route beginning
|
|
||||||
- **End time** - formatted timestamp of route end
|
|
||||||
- **Duration** - calculated time span of the route
|
|
||||||
- **Total Distance** - route distance in user's preferred unit (km/mi)
|
|
||||||
- **Current Speed** - speed data (always in km/h as per application logic)
|
|
||||||
|
|
||||||
#### Distance Unit Testing
|
|
||||||
- **Kilometers (km)** - Default distance unit testing
|
|
||||||
- **Miles (mi)** - Alternative distance unit testing
|
|
||||||
- Proper user settings configuration and validation
|
|
||||||
- Correct data attribute structure verification
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
|
|
||||||
#### Refactored Structure
|
|
||||||
- **DRY Principle**: Eliminated repetitive login code using shared helpers
|
|
||||||
- **Modular Design**: Separated concerns into focused helper modules
|
|
||||||
- **Reusable Components**: Shared examples for common test patterns
|
|
||||||
- **Maintainable Code**: Clear organization and documentation
|
|
||||||
|
|
||||||
#### Robust Testing Approach
|
|
||||||
- **DOM-based assertions** instead of brittle JavaScript interactions
|
|
||||||
- **Fallback strategies** for complex JavaScript interactions
|
|
||||||
- **Comprehensive validation** of user settings and data structures
|
|
||||||
- **Realistic test data** with proper GPS coordinates and timestamps
|
|
||||||
|
|
||||||
#### Performance Optimizations
|
|
||||||
- **Efficient database cleanup** without transactional fixtures
|
|
||||||
- **Targeted user creation** to avoid database conflicts
|
|
||||||
- **Optimized wait conditions** for dynamic content loading
|
|
||||||
|
|
||||||
### Test Results
|
|
||||||
|
|
||||||
- **Total Tests**: 19 examples
|
|
||||||
- **Success Rate**: 100% (19/19 passing, 0 failures)
|
|
||||||
- **Coverage**: 69.34% line coverage
|
|
||||||
- **Runtime**: ~2.5 minutes for full suite
|
|
||||||
|
|
||||||
### Technical Implementation
|
|
||||||
|
|
||||||
#### User Settings Structure
|
|
||||||
The tests properly handle the nested user settings structure:
|
|
||||||
```ruby
|
|
||||||
user_settings.dig('maps', 'distance_unit') # => 'km' or 'mi'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Polyline Popup Testing Strategy
|
|
||||||
Due to the complexity of triggering JavaScript hover events on canvas elements in headless browsers, the tests use a multi-layered approach:
|
|
||||||
|
|
||||||
1. **Primary**: JavaScript-based canvas hover simulation
|
|
||||||
2. **Secondary**: Direct polyline element interaction
|
|
||||||
3. **Fallback**: Map click interaction
|
|
||||||
4. **Validation**: Settings and data structure verification
|
|
||||||
|
|
||||||
Even when popup interaction cannot be triggered in the test environment, the tests still validate:
|
|
||||||
- User settings are correctly configured
|
|
||||||
- Map loads with proper data attributes
|
|
||||||
- Polylines are present and properly structured
|
|
||||||
- Distance units are correctly set for both km and miles
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
Run all map interaction tests:
|
|
||||||
```bash
|
|
||||||
bundle exec rspec spec/system/map_interaction_spec.rb
|
|
||||||
```
|
|
||||||
|
|
||||||
Run specific test groups:
|
|
||||||
```bash
|
|
||||||
# Polyline popup tests only
|
|
||||||
bundle exec rspec spec/system/map_interaction_spec.rb -e "polyline popup content"
|
|
||||||
|
|
||||||
# Layer control tests only
|
|
||||||
bundle exec rspec spec/system/map_interaction_spec.rb -e "layer controls"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Future Enhancements
|
|
||||||
|
|
||||||
The test suite is designed to be easily extensible for:
|
|
||||||
- Additional map interaction features
|
|
||||||
- New distance units or measurement systems
|
|
||||||
- Enhanced popup content validation
|
|
||||||
- More complex user interaction scenarios
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe 'Authentication UI', type: :system do
|
|
||||||
let(:user) { create(:user, password: 'password123') }
|
|
||||||
|
|
||||||
before do
|
|
||||||
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
|
|
||||||
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
|
|
||||||
|
|
||||||
# Configure email for testing
|
|
||||||
ActionMailer::Base.default_options = { from: 'test@example.com' }
|
|
||||||
ActionMailer::Base.delivery_method = :test
|
|
||||||
ActionMailer::Base.perform_deliveries = true
|
|
||||||
ActionMailer::Base.deliveries.clear
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'Account UI' do
|
|
||||||
it 'shows the user email in the UI when signed in' do
|
|
||||||
sign_in_user(user)
|
|
||||||
|
|
||||||
expect(page).to have_current_path(map_path)
|
|
||||||
expect(page).to have_css('summary', text: user.email)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'Self-hosted UI' do
|
|
||||||
context 'when self-hosted mode is enabled' do
|
|
||||||
before do
|
|
||||||
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
|
|
||||||
stub_const('SELF_HOSTED', true)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not show registration links in the login UI' do
|
|
||||||
visit new_user_session_path
|
|
||||||
|
|
||||||
expect(page).not_to have_link('Register')
|
|
||||||
expect(page).not_to have_link('Sign up')
|
|
||||||
expect(page).not_to have_content('Register a new account')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,923 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe 'Map Interaction', type: :system do
|
|
||||||
let(:user) { create(:user, password: 'password123') }
|
|
||||||
|
|
||||||
before do
|
|
||||||
# Stub the GitHub API call to avoid external dependencies
|
|
||||||
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
|
|
||||||
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:points) do
|
|
||||||
# Create a series of points that form a route
|
|
||||||
[
|
|
||||||
create(:point, user: user,
|
|
||||||
lonlat: 'POINT(13.404954 52.520008)',
|
|
||||||
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
|
|
||||||
create(:point, user: user,
|
|
||||||
lonlat: 'POINT(13.405954 52.521008)',
|
|
||||||
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
|
|
||||||
create(:point, user: user,
|
|
||||||
lonlat: 'POINT(13.406954 52.522008)',
|
|
||||||
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
|
|
||||||
create(:point, user: user,
|
|
||||||
lonlat: 'POINT(13.407954 52.523008)',
|
|
||||||
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'Map page interaction' do
|
|
||||||
context 'when user is signed in' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
include_examples 'map basic functionality'
|
|
||||||
include_examples 'map controls'
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'zoom functionality' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
|
|
||||||
it 'allows zoom in and zoom out functionality' do
|
|
||||||
# Test zoom controls are clickable and functional
|
|
||||||
zoom_in_button = find('.leaflet-control-zoom-in')
|
|
||||||
zoom_out_button = find('.leaflet-control-zoom-out')
|
|
||||||
|
|
||||||
# Verify buttons are enabled and clickable
|
|
||||||
expect(zoom_in_button).to be_visible
|
|
||||||
expect(zoom_out_button).to be_visible
|
|
||||||
|
|
||||||
# Click zoom in button multiple times and verify it works
|
|
||||||
3.times do
|
|
||||||
zoom_in_button.click
|
|
||||||
sleep 0.5
|
|
||||||
end
|
|
||||||
|
|
||||||
# Click zoom out button multiple times and verify it works
|
|
||||||
3.times do
|
|
||||||
zoom_out_button.click
|
|
||||||
sleep 0.5
|
|
||||||
end
|
|
||||||
|
|
||||||
# Verify zoom controls are still present and functional
|
|
||||||
expect(page).to have_css('.leaflet-control-zoom-in')
|
|
||||||
expect(page).to have_css('.leaflet-control-zoom-out')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'settings panel' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
|
|
||||||
it 'opens and closes settings panel with cog button' do
|
|
||||||
# Find and click the settings (cog) button - it's created dynamically by the controller
|
|
||||||
settings_button = find('.map-settings-button', wait: 10)
|
|
||||||
settings_button.click
|
|
||||||
|
|
||||||
# Verify settings panel opens
|
|
||||||
expect(page).to have_css('.leaflet-settings-panel', visible: true)
|
|
||||||
|
|
||||||
# Click settings button again to close
|
|
||||||
settings_button.click
|
|
||||||
|
|
||||||
# Verify settings panel closes
|
|
||||||
expect(page).not_to have_css('.leaflet-settings-panel', visible: true)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'layer controls' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
include_examples 'expandable layer control'
|
|
||||||
|
|
||||||
it 'allows changing map layers between OpenStreetMap and OpenTopo' do
|
|
||||||
expand_layer_control
|
|
||||||
test_base_layer_switching
|
|
||||||
collapse_layer_control
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'allows enabling and disabling map layers' do
|
|
||||||
expand_layer_control
|
|
||||||
|
|
||||||
MapLayerHelpers::OVERLAY_LAYERS.each do |layer_name|
|
|
||||||
test_layer_toggle(layer_name)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'calendar panel' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
|
|
||||||
it 'has functional calendar button' do
|
|
||||||
# Find the calendar button (📅 emoji button)
|
|
||||||
calendar_button = find('.toggle-panel-button', wait: 10)
|
|
||||||
|
|
||||||
# Verify button exists and has correct content
|
|
||||||
expect(calendar_button).to be_present
|
|
||||||
expect(calendar_button.text).to eq('📅')
|
|
||||||
|
|
||||||
# Verify button is clickable (doesn't raise errors)
|
|
||||||
expect { calendar_button.click }.not_to raise_error
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# Try clicking again to test toggle functionality
|
|
||||||
expect { calendar_button.click }.not_to raise_error
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# The calendar panel JavaScript interaction is complex and may not work
|
|
||||||
# reliably in headless test environment, but the button should be functional
|
|
||||||
puts 'Note: Calendar button is functional. Panel interaction may require manual testing.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'map information display' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
|
|
||||||
it 'displays map statistics and scale' do
|
|
||||||
# Check for stats control (distance and points count)
|
|
||||||
expect(page).to have_css('.leaflet-control-stats', wait: 10)
|
|
||||||
stats_text = find('.leaflet-control-stats').text
|
|
||||||
|
|
||||||
# Verify it contains distance and points information
|
|
||||||
expect(stats_text).to match(/\d+\.?\d*\s*(km|mi)/)
|
|
||||||
expect(stats_text).to match(/\d+\s*points/)
|
|
||||||
|
|
||||||
# Check for map scale control
|
|
||||||
expect(page).to have_css('.leaflet-control-scale')
|
|
||||||
expect(page).to have_css('.leaflet-control-scale-line')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'displays map attributions' do
|
|
||||||
# Check for attribution control
|
|
||||||
expect(page).to have_css('.leaflet-control-attribution')
|
|
||||||
|
|
||||||
# Verify attribution text is present
|
|
||||||
attribution_text = find('.leaflet-control-attribution').text
|
|
||||||
expect(attribution_text).not_to be_empty
|
|
||||||
|
|
||||||
# Common attribution text patterns
|
|
||||||
expect(attribution_text).to match(/©|©|OpenStreetMap|contributors/i)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'polyline popup content' do
|
|
||||||
context 'with km distance unit' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
|
|
||||||
it 'displays route popup with correct data in kilometers' do
|
|
||||||
# Verify the user has km as distance unit (default)
|
|
||||||
expect(user.safe_settings.distance_unit).to eq('km')
|
|
||||||
|
|
||||||
# Wait for polylines to load
|
|
||||||
expect(page).to have_css('.leaflet-overlay-pane', wait: 10)
|
|
||||||
sleep 2 # Allow polylines to fully render
|
|
||||||
|
|
||||||
# Verify that polylines are present and interactive
|
|
||||||
expect(page).to have_css('[data-maps-target="container"]')
|
|
||||||
|
|
||||||
# Check that the map has the correct user settings
|
|
||||||
map_element = find('#map')
|
|
||||||
user_settings = JSON.parse(map_element['data-user_settings'])
|
|
||||||
# The raw settings structure has distance_unit nested under maps
|
|
||||||
expect(user_settings.dig('maps', 'distance_unit')).to eq('km')
|
|
||||||
|
|
||||||
# Try to trigger polyline interaction and verify popup structure
|
|
||||||
popup_content = trigger_polyline_hover_and_get_popup
|
|
||||||
|
|
||||||
if popup_content
|
|
||||||
# Verify popup contains all required fields
|
|
||||||
expect(verify_popup_content_structure(popup_content, 'km')).to be true
|
|
||||||
|
|
||||||
# Extract and verify specific data
|
|
||||||
popup_data = extract_popup_data(popup_content)
|
|
||||||
|
|
||||||
# Verify start and end times are present and formatted
|
|
||||||
expect(popup_data[:start]).to be_present
|
|
||||||
expect(popup_data[:end]).to be_present
|
|
||||||
|
|
||||||
# Verify duration is present
|
|
||||||
expect(popup_data[:duration]).to be_present
|
|
||||||
|
|
||||||
# Verify total distance includes km unit
|
|
||||||
expect(popup_data[:total_distance]).to include('km')
|
|
||||||
|
|
||||||
# Verify current speed includes km/h unit
|
|
||||||
expect(popup_data[:current_speed]).to include('km/h')
|
|
||||||
else
|
|
||||||
# If we can't trigger the popup, at least verify the setup is correct
|
|
||||||
expect(user_settings.dig('maps', 'distance_unit')).to eq('km')
|
|
||||||
puts 'Note: Polyline popup interaction could not be triggered in test environment'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with miles distance unit' do
|
|
||||||
let(:user_with_miles) do
|
|
||||||
create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123')
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:points_for_miles_user) do
|
|
||||||
# Create a series of points that form a route for the miles user
|
|
||||||
[
|
|
||||||
create(:point, user: user_with_miles,
|
|
||||||
lonlat: 'POINT(13.404954 52.520008)',
|
|
||||||
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
|
|
||||||
create(:point, user: user_with_miles,
|
|
||||||
lonlat: 'POINT(13.405954 52.521008)',
|
|
||||||
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
|
|
||||||
create(:point, user: user_with_miles,
|
|
||||||
lonlat: 'POINT(13.406954 52.522008)',
|
|
||||||
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
|
|
||||||
create(:point, user: user_with_miles,
|
|
||||||
lonlat: 'POINT(13.407954 52.523008)',
|
|
||||||
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
# Reset session and sign in with the miles user
|
|
||||||
Capybara.reset_sessions!
|
|
||||||
sign_in_and_visit_map(user_with_miles)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'displays route popup with correct data in miles' do
|
|
||||||
# Verify the user has miles as distance unit
|
|
||||||
expect(user_with_miles.safe_settings.distance_unit).to eq('mi')
|
|
||||||
|
|
||||||
# Wait for polylines to load
|
|
||||||
expect(page).to have_css('.leaflet-overlay-pane', wait: 10)
|
|
||||||
sleep 2 # Allow polylines to fully render
|
|
||||||
|
|
||||||
# Verify that polylines are present and interactive
|
|
||||||
expect(page).to have_css('[data-maps-target="container"]')
|
|
||||||
|
|
||||||
# Check that the map has the correct user settings
|
|
||||||
map_element = find('#map')
|
|
||||||
user_settings = JSON.parse(map_element['data-user_settings'])
|
|
||||||
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi')
|
|
||||||
|
|
||||||
# Try to trigger polyline interaction and verify popup structure
|
|
||||||
popup_content = trigger_polyline_hover_and_get_popup
|
|
||||||
|
|
||||||
if popup_content
|
|
||||||
# Verify popup contains all required fields
|
|
||||||
expect(verify_popup_content_structure(popup_content, 'mi')).to be true
|
|
||||||
|
|
||||||
# Extract and verify specific data
|
|
||||||
popup_data = extract_popup_data(popup_content)
|
|
||||||
|
|
||||||
# Verify start and end times are present and formatted
|
|
||||||
expect(popup_data[:start]).to be_present
|
|
||||||
expect(popup_data[:end]).to be_present
|
|
||||||
|
|
||||||
# Verify duration is present
|
|
||||||
expect(popup_data[:duration]).to be_present
|
|
||||||
|
|
||||||
# Verify total distance includes miles unit
|
|
||||||
expect(popup_data[:total_distance]).to include('mi')
|
|
||||||
|
|
||||||
# Verify current speed is in mph for miles unit
|
|
||||||
expect(popup_data[:current_speed]).to include('mph')
|
|
||||||
else
|
|
||||||
# If we can't trigger the popup, at least verify the setup is correct
|
|
||||||
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi')
|
|
||||||
puts 'Note: Polyline popup interaction could not be triggered in test environment'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'polyline popup content' do
|
|
||||||
context 'with km distance unit' do
|
|
||||||
let(:user_with_km) do
|
|
||||||
create(:user, settings: { 'maps' => { 'distance_unit' => 'km' } }, password: 'password123')
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:points_for_km_user) do
|
|
||||||
# Create a series of points that form a route for the km user
|
|
||||||
[
|
|
||||||
create(:point, user: user_with_km,
|
|
||||||
lonlat: 'POINT(13.404954 52.520008)',
|
|
||||||
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
|
|
||||||
create(:point, user: user_with_km,
|
|
||||||
lonlat: 'POINT(13.405954 52.521008)',
|
|
||||||
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
|
|
||||||
create(:point, user: user_with_km,
|
|
||||||
lonlat: 'POINT(13.406954 52.522008)',
|
|
||||||
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
|
|
||||||
create(:point, user: user_with_km,
|
|
||||||
lonlat: 'POINT(13.407954 52.523008)',
|
|
||||||
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
# Reset session and sign in with the km user
|
|
||||||
Capybara.reset_sessions!
|
|
||||||
sign_in_and_visit_map(user_with_km)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'displays route popup with correct data in kilometers' do
|
|
||||||
# Verify the user has km as distance unit
|
|
||||||
expect(user_with_km.safe_settings.distance_unit).to eq('km')
|
|
||||||
|
|
||||||
# Wait for polylines to load
|
|
||||||
expect(page).to have_css('.leaflet-overlay-pane', wait: 10)
|
|
||||||
sleep 2 # Allow polylines to fully render
|
|
||||||
|
|
||||||
# Verify that polylines are present and interactive
|
|
||||||
expect(page).to have_css('[data-maps-target="container"]')
|
|
||||||
|
|
||||||
# Check that the map has the correct user settings
|
|
||||||
map_element = find('#map')
|
|
||||||
user_settings = JSON.parse(map_element['data-user_settings'])
|
|
||||||
# The raw settings structure has distance_unit nested under maps
|
|
||||||
expect(user_settings.dig('maps', 'distance_unit')).to eq('km')
|
|
||||||
|
|
||||||
# Try to trigger polyline interaction and verify popup structure
|
|
||||||
popup_content = trigger_polyline_hover_and_get_popup
|
|
||||||
|
|
||||||
if popup_content
|
|
||||||
# Verify popup contains all required fields
|
|
||||||
expect(verify_popup_content_structure(popup_content, 'km')).to be true
|
|
||||||
|
|
||||||
# Extract and verify specific data
|
|
||||||
popup_data = extract_popup_data(popup_content)
|
|
||||||
|
|
||||||
# Verify start and end times are present and formatted
|
|
||||||
expect(popup_data[:start]).to be_present
|
|
||||||
expect(popup_data[:end]).to be_present
|
|
||||||
|
|
||||||
# Verify duration is present
|
|
||||||
expect(popup_data[:duration]).to be_present
|
|
||||||
|
|
||||||
# Verify total distance includes km unit
|
|
||||||
expect(popup_data[:total_distance]).to include('km')
|
|
||||||
|
|
||||||
# Verify current speed includes km/h unit
|
|
||||||
expect(popup_data[:current_speed]).to include('km/h')
|
|
||||||
else
|
|
||||||
# If we can't trigger the popup, at least verify the setup is correct
|
|
||||||
expect(user_settings.dig('maps', 'distance_unit')).to eq('km')
|
|
||||||
puts 'Note: Polyline popup interaction could not be triggered in test environment'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with miles distance unit' do
|
|
||||||
let(:user_with_miles) do
|
|
||||||
create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123')
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:points_for_miles_user) do
|
|
||||||
# Create a series of points that form a route for the miles user
|
|
||||||
[
|
|
||||||
create(:point, user: user_with_miles,
|
|
||||||
lonlat: 'POINT(13.404954 52.520008)',
|
|
||||||
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
|
|
||||||
create(:point, user: user_with_miles,
|
|
||||||
lonlat: 'POINT(13.405954 52.521008)',
|
|
||||||
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
|
|
||||||
create(:point, user: user_with_miles,
|
|
||||||
lonlat: 'POINT(13.406954 52.522008)',
|
|
||||||
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
|
|
||||||
create(:point, user: user_with_miles,
|
|
||||||
lonlat: 'POINT(13.407954 52.523008)',
|
|
||||||
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
# Reset session and sign in with the miles user
|
|
||||||
Capybara.reset_sessions!
|
|
||||||
sign_in_and_visit_map(user_with_miles)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'displays route popup with correct data in miles' do
|
|
||||||
# Verify the user has miles as distance unit
|
|
||||||
expect(user_with_miles.safe_settings.distance_unit).to eq('mi')
|
|
||||||
|
|
||||||
# Wait for polylines to load
|
|
||||||
expect(page).to have_css('.leaflet-overlay-pane', wait: 10)
|
|
||||||
sleep 2 # Allow polylines to fully render
|
|
||||||
|
|
||||||
# Verify that polylines are present and interactive
|
|
||||||
expect(page).to have_css('[data-maps-target="container"]')
|
|
||||||
|
|
||||||
# Check that the map has the correct user settings
|
|
||||||
map_element = find('#map')
|
|
||||||
user_settings = JSON.parse(map_element['data-user_settings'])
|
|
||||||
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi')
|
|
||||||
|
|
||||||
# Try to trigger polyline interaction and verify popup structure
|
|
||||||
popup_content = trigger_polyline_hover_and_get_popup
|
|
||||||
|
|
||||||
if popup_content
|
|
||||||
# Verify popup contains all required fields
|
|
||||||
expect(verify_popup_content_structure(popup_content, 'mi')).to be true
|
|
||||||
|
|
||||||
# Extract and verify specific data
|
|
||||||
popup_data = extract_popup_data(popup_content)
|
|
||||||
|
|
||||||
# Verify start and end times are present and formatted
|
|
||||||
expect(popup_data[:start]).to be_present
|
|
||||||
expect(popup_data[:end]).to be_present
|
|
||||||
|
|
||||||
# Verify duration is present
|
|
||||||
expect(popup_data[:duration]).to be_present
|
|
||||||
|
|
||||||
# Verify total distance includes miles unit
|
|
||||||
expect(popup_data[:total_distance]).to include('mi')
|
|
||||||
|
|
||||||
# Verify current speed is in mph for miles unit
|
|
||||||
expect(popup_data[:current_speed]).to include('mph')
|
|
||||||
else
|
|
||||||
# If we can't trigger the popup, at least verify the setup is correct
|
|
||||||
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi')
|
|
||||||
puts 'Note: Polyline popup interaction could not be triggered in test environment'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
xcontext 'settings panel functionality' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
|
|
||||||
it 'allows updating route opacity settings' do
|
|
||||||
# Open settings panel
|
|
||||||
settings_button = find('.map-settings-button', wait: 10)
|
|
||||||
settings_button.click
|
|
||||||
expect(page).to have_css('.leaflet-settings-panel', visible: true)
|
|
||||||
|
|
||||||
# Find and update route opacity
|
|
||||||
within('.leaflet-settings-panel') do
|
|
||||||
opacity_input = find('#route-opacity')
|
|
||||||
expect(opacity_input.value).to eq('60') # Default value
|
|
||||||
|
|
||||||
# Change opacity to 80%
|
|
||||||
opacity_input.fill_in(with: '80')
|
|
||||||
|
|
||||||
# Submit the form
|
|
||||||
click_button 'Update'
|
|
||||||
end
|
|
||||||
|
|
||||||
# Wait for success flash message
|
|
||||||
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'allows updating fog of war settings' do
|
|
||||||
settings_button = find('.map-settings-button', wait: 10)
|
|
||||||
settings_button.click
|
|
||||||
|
|
||||||
within('.leaflet-settings-panel') do
|
|
||||||
# Update fog of war radius
|
|
||||||
fog_radius = find('#fog_of_war_meters')
|
|
||||||
fog_radius.fill_in(with: '100')
|
|
||||||
|
|
||||||
# Update fog threshold
|
|
||||||
fog_threshold = find('#fog_of_war_threshold')
|
|
||||||
fog_threshold.fill_in(with: '120')
|
|
||||||
|
|
||||||
click_button 'Update'
|
|
||||||
end
|
|
||||||
|
|
||||||
# Wait for success flash message
|
|
||||||
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'allows updating route splitting settings' do
|
|
||||||
settings_button = find('.map-settings-button', wait: 10)
|
|
||||||
settings_button.click
|
|
||||||
|
|
||||||
within('.leaflet-settings-panel') do
|
|
||||||
# Update meters between routes
|
|
||||||
meters_input = find('#meters_between_routes')
|
|
||||||
meters_input.fill_in(with: '750')
|
|
||||||
|
|
||||||
# Update minutes between routes
|
|
||||||
minutes_input = find('#minutes_between_routes')
|
|
||||||
minutes_input.fill_in(with: '45')
|
|
||||||
|
|
||||||
click_button 'Update'
|
|
||||||
end
|
|
||||||
|
|
||||||
# Wait for success flash message
|
|
||||||
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'allows toggling points rendering mode' do
|
|
||||||
settings_button = find('.map-settings-button', wait: 10)
|
|
||||||
settings_button.click
|
|
||||||
|
|
||||||
within('.leaflet-settings-panel') do
|
|
||||||
# Check current mode (should be 'raw' by default)
|
|
||||||
expect(find('#raw')).to be_checked
|
|
||||||
|
|
||||||
# Switch to simplified mode
|
|
||||||
choose('simplified')
|
|
||||||
|
|
||||||
click_button 'Update'
|
|
||||||
end
|
|
||||||
|
|
||||||
# Wait for success flash message
|
|
||||||
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'allows toggling live map functionality' do
|
|
||||||
settings_button = find('.map-settings-button', wait: 10)
|
|
||||||
settings_button.click
|
|
||||||
|
|
||||||
within('.leaflet-settings-panel') do
|
|
||||||
live_map_checkbox = find('#live_map_enabled')
|
|
||||||
initial_state = live_map_checkbox.checked?
|
|
||||||
|
|
||||||
# Toggle the checkbox
|
|
||||||
live_map_checkbox.click
|
|
||||||
|
|
||||||
click_button 'Update'
|
|
||||||
end
|
|
||||||
|
|
||||||
# Wait for success flash message
|
|
||||||
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'allows toggling speed-colored routes' do
|
|
||||||
settings_button = find('.map-settings-button', wait: 10)
|
|
||||||
settings_button.click
|
|
||||||
|
|
||||||
within('.leaflet-settings-panel') do
|
|
||||||
speed_colored_checkbox = find('#speed_colored_routes')
|
|
||||||
initial_state = speed_colored_checkbox.checked?
|
|
||||||
|
|
||||||
# Toggle speed-colored routes
|
|
||||||
speed_colored_checkbox.click
|
|
||||||
|
|
||||||
click_button 'Update'
|
|
||||||
end
|
|
||||||
|
|
||||||
# Wait for success flash message
|
|
||||||
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'allows updating speed color scale' do
|
|
||||||
settings_button = find('.map-settings-button', wait: 10)
|
|
||||||
settings_button.click
|
|
||||||
|
|
||||||
within('.leaflet-settings-panel') do
|
|
||||||
# Update speed color scale
|
|
||||||
scale_input = find('#speed_color_scale')
|
|
||||||
new_scale = '0:#ff0000|25:#ffff00|50:#00ff00|100:#0000ff'
|
|
||||||
scale_input.fill_in(with: new_scale)
|
|
||||||
|
|
||||||
click_button 'Update'
|
|
||||||
end
|
|
||||||
|
|
||||||
# Wait for success flash message
|
|
||||||
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'opens and interacts with gradient editor modal' do
|
|
||||||
settings_button = find('.map-settings-button', wait: 10)
|
|
||||||
settings_button.click
|
|
||||||
|
|
||||||
within('.leaflet-settings-panel') do
|
|
||||||
click_button 'Edit Scale'
|
|
||||||
end
|
|
||||||
|
|
||||||
# Verify modal opens
|
|
||||||
expect(page).to have_css('#gradient-editor-modal', wait: 5)
|
|
||||||
|
|
||||||
within('#gradient-editor-modal') do
|
|
||||||
expect(page).to have_content('Edit Speed Color Scale')
|
|
||||||
|
|
||||||
# Test adding a new row
|
|
||||||
click_button 'Add Row'
|
|
||||||
|
|
||||||
# Test canceling
|
|
||||||
click_button 'Cancel'
|
|
||||||
end
|
|
||||||
|
|
||||||
# Verify modal closes
|
|
||||||
expect(page).not_to have_css('#gradient-editor-modal')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'layer management' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
include_examples 'expandable layer control'
|
|
||||||
|
|
||||||
it 'manages base layer switching' do
|
|
||||||
# Expand layer control
|
|
||||||
expand_layer_control
|
|
||||||
|
|
||||||
# Test switching between base layers
|
|
||||||
within('.leaflet-control-layers') do
|
|
||||||
# Should have OpenStreetMap selected by default
|
|
||||||
expect(page).to have_css('input[type="radio"]:checked')
|
|
||||||
|
|
||||||
# Try to switch to another base layer if available
|
|
||||||
radio_buttons = all('input[type="radio"]')
|
|
||||||
if radio_buttons.length > 1
|
|
||||||
# Click on a different base layer
|
|
||||||
radio_buttons.last.click
|
|
||||||
sleep 1 # Allow layer to load
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
collapse_layer_control
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'manages overlay layer visibility' do
|
|
||||||
expand_layer_control
|
|
||||||
|
|
||||||
within('.leaflet-control-layers') do
|
|
||||||
# Test toggling overlay layers
|
|
||||||
checkboxes = all('input[type="checkbox"]')
|
|
||||||
|
|
||||||
checkboxes.each do |checkbox|
|
|
||||||
# Get the layer name from the label
|
|
||||||
layer_name = checkbox.find(:xpath, './following-sibling::span').text.strip
|
|
||||||
|
|
||||||
# Toggle the layer
|
|
||||||
initial_state = checkbox.checked?
|
|
||||||
checkbox.click
|
|
||||||
sleep 0.5
|
|
||||||
|
|
||||||
# Verify the layer state changed
|
|
||||||
expect(checkbox.checked?).to eq(!initial_state)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
collapse_layer_control
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'preserves layer states after settings updates' do
|
|
||||||
# Enable some layers first
|
|
||||||
expand_layer_control
|
|
||||||
|
|
||||||
# Remember initial layer states
|
|
||||||
layer_states = {}
|
|
||||||
within('.leaflet-control-layers') do
|
|
||||||
all('input[type="checkbox"]').each do |checkbox|
|
|
||||||
layer_name = checkbox.find(:xpath, './following-sibling::span').text.strip
|
|
||||||
layer_states[layer_name] = checkbox.checked?
|
|
||||||
|
|
||||||
# Enable the layer if not already enabled
|
|
||||||
checkbox.click unless checkbox.checked?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
collapse_layer_control
|
|
||||||
|
|
||||||
# Update a setting
|
|
||||||
settings_button = find('.map-settings-button', wait: 10)
|
|
||||||
settings_button.click
|
|
||||||
|
|
||||||
within('.leaflet-settings-panel') do
|
|
||||||
opacity_input = find('#route-opacity')
|
|
||||||
opacity_input.fill_in(with: '70')
|
|
||||||
click_button 'Update'
|
|
||||||
end
|
|
||||||
|
|
||||||
expect(page).to have_content('Settings updated', wait: 10)
|
|
||||||
|
|
||||||
# Verify layer control still works
|
|
||||||
expand_layer_control
|
|
||||||
expect(page).to have_css('.leaflet-control-layers-list')
|
|
||||||
collapse_layer_control
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'calendar panel functionality' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
|
|
||||||
it 'opens and displays calendar navigation' do
|
|
||||||
# Wait for the map controller to fully initialize and create the toggle button
|
|
||||||
expect(page).to have_css('#map', wait: 10)
|
|
||||||
expect(page).to have_css('.leaflet-container', wait: 10)
|
|
||||||
|
|
||||||
# Additional wait for the controller to finish initializing all controls
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# Click calendar button
|
|
||||||
calendar_button = find('.toggle-panel-button', wait: 15)
|
|
||||||
expect(calendar_button).to be_visible
|
|
||||||
|
|
||||||
# Verify button is clickable
|
|
||||||
expect(calendar_button).not_to be_disabled
|
|
||||||
|
|
||||||
# For now, just verify the button exists and is functional
|
|
||||||
# The calendar panel functionality may need JavaScript debugging
|
|
||||||
# that's beyond the scope of system tests
|
|
||||||
expect(calendar_button.text).to eq('📅')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'allows year selection and month navigation' do
|
|
||||||
# This test is skipped due to calendar panel JavaScript interaction issues
|
|
||||||
# The calendar button exists but the panel doesn't open reliably in test environment
|
|
||||||
skip 'Calendar panel JavaScript interaction needs debugging'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'displays visited cities information' do
|
|
||||||
# This test is skipped due to calendar panel JavaScript interaction issues
|
|
||||||
# The calendar button exists but the panel doesn't open reliably in test environment
|
|
||||||
skip 'Calendar panel JavaScript interaction needs debugging'
|
|
||||||
end
|
|
||||||
|
|
||||||
xit 'persists panel state in localStorage' do
|
|
||||||
# Wait for the map controller to fully initialize and create the toggle button
|
|
||||||
# The button is created dynamically by the JavaScript controller
|
|
||||||
expect(page).to have_css('#map', wait: 10)
|
|
||||||
expect(page).to have_css('.leaflet-container', wait: 10)
|
|
||||||
|
|
||||||
# Additional wait for the controller to finish initializing all controls
|
|
||||||
# The toggle-panel-button is created by the addTogglePanelButton() method
|
|
||||||
# which is called after the map and all other controls are set up
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# Now try to find the calendar button
|
|
||||||
calendar_button = nil
|
|
||||||
begin
|
|
||||||
calendar_button = find('.toggle-panel-button', wait: 15)
|
|
||||||
rescue Capybara::ElementNotFound
|
|
||||||
# If button still not found, check if map controller loaded properly
|
|
||||||
map_element = find('#map')
|
|
||||||
controller_data = map_element['data-controller']
|
|
||||||
|
|
||||||
# Log debug info for troubleshooting
|
|
||||||
puts "Map controller data: #{controller_data}"
|
|
||||||
puts "Map element classes: #{map_element[:class]}"
|
|
||||||
|
|
||||||
# Try one more time with extended wait
|
|
||||||
calendar_button = find('.toggle-panel-button', wait: 20)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Verify button exists and is functional
|
|
||||||
expect(calendar_button).to be_present
|
|
||||||
calendar_button.click
|
|
||||||
|
|
||||||
# Wait for panel to appear
|
|
||||||
expect(page).to have_css('.leaflet-right-panel', visible: true, wait: 10)
|
|
||||||
|
|
||||||
# Close panel
|
|
||||||
calendar_button.click
|
|
||||||
|
|
||||||
# Wait for panel to disappear
|
|
||||||
expect(page).not_to have_css('.leaflet-right-panel', visible: true, wait: 10)
|
|
||||||
|
|
||||||
# Refresh page (user should still be signed in due to session)
|
|
||||||
page.refresh
|
|
||||||
expect(page).to have_css('#map', wait: 10)
|
|
||||||
expect(page).to have_css('.leaflet-container', wait: 10)
|
|
||||||
|
|
||||||
# Wait for controller to reinitialize after refresh
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# Panel should remember its state (though this is hard to test reliably in system tests)
|
|
||||||
# At minimum, verify the panel can be toggled after refresh
|
|
||||||
calendar_button = find('.toggle-panel-button', wait: 15)
|
|
||||||
calendar_button.click
|
|
||||||
expect(page).to have_css('.leaflet-right-panel', wait: 10)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'point management' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
|
|
||||||
xit 'displays point popups with delete functionality' do
|
|
||||||
# Wait for points to load
|
|
||||||
expect(page).to have_css('.leaflet-marker-pane', wait: 10)
|
|
||||||
|
|
||||||
# Try to find and click on a point marker
|
|
||||||
if page.has_css?('.leaflet-marker-icon')
|
|
||||||
first('.leaflet-marker-icon').click
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# Should show popup with point information
|
|
||||||
if page.has_css?('.leaflet-popup-content')
|
|
||||||
popup_content = find('.leaflet-popup-content')
|
|
||||||
|
|
||||||
# Verify popup contains expected information
|
|
||||||
expect(popup_content).to have_content('Timestamp:')
|
|
||||||
expect(popup_content).to have_content('Latitude:')
|
|
||||||
expect(popup_content).to have_content('Longitude:')
|
|
||||||
expect(popup_content).to have_content('Speed:')
|
|
||||||
expect(popup_content).to have_content('Battery:')
|
|
||||||
|
|
||||||
# Should have delete link
|
|
||||||
expect(popup_content).to have_css('a.delete-point')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
xit 'handles point deletion with confirmation' do
|
|
||||||
# This test would require mocking the confirmation dialog and API call
|
|
||||||
# For now, we'll just verify the delete link exists and has the right attributes
|
|
||||||
expect(page).to have_css('.leaflet-marker-pane', wait: 10)
|
|
||||||
|
|
||||||
if page.has_css?('.leaflet-marker-icon')
|
|
||||||
first('.leaflet-marker-icon').click
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
if page.has_css?('.leaflet-popup-content')
|
|
||||||
popup_content = find('.leaflet-popup-content')
|
|
||||||
|
|
||||||
if popup_content.has_css?('a.delete-point')
|
|
||||||
delete_link = popup_content.find('a.delete-point')
|
|
||||||
expect(delete_link['data-id']).to be_present
|
|
||||||
expect(delete_link.text).to eq('[Delete]')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'map initialization and error handling' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
|
|
||||||
context 'with user having no points' do
|
|
||||||
let(:user_no_points) { create(:user, password: 'password123') }
|
|
||||||
|
|
||||||
before do
|
|
||||||
# Clear any existing session and sign in the new user
|
|
||||||
Capybara.reset_sessions!
|
|
||||||
sign_in_and_visit_map(user_no_points)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'handles empty markers array gracefully' do
|
|
||||||
# Map should still initialize
|
|
||||||
expect(page).to have_css('#map')
|
|
||||||
expect(page).to have_css('.leaflet-container')
|
|
||||||
|
|
||||||
# Should have default center
|
|
||||||
expect(page).to have_css('.leaflet-map-pane')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with user having minimal settings' do
|
|
||||||
let(:user_minimal) { create(:user, settings: {}, password: 'password123') }
|
|
||||||
|
|
||||||
before do
|
|
||||||
# Clear any existing session and sign in the new user
|
|
||||||
Capybara.reset_sessions!
|
|
||||||
sign_in_and_visit_map(user_minimal)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'handles missing user settings gracefully' do
|
|
||||||
# Map should still work with defaults
|
|
||||||
expect(page).to have_css('#map')
|
|
||||||
expect(page).to have_css('.leaflet-container')
|
|
||||||
|
|
||||||
# Settings panel should work
|
|
||||||
settings_button = find('.map-settings-button', wait: 10)
|
|
||||||
settings_button.click
|
|
||||||
expect(page).to have_css('.leaflet-settings-panel')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'displays appropriate controls and attributions' do
|
|
||||||
# Verify essential map controls are present
|
|
||||||
expect(page).to have_css('.leaflet-control-zoom')
|
|
||||||
expect(page).to have_css('.leaflet-control-layers')
|
|
||||||
expect(page).to have_css('.leaflet-control-attribution')
|
|
||||||
expect(page).to have_css('.leaflet-control-scale')
|
|
||||||
expect(page).to have_css('.leaflet-control-stats')
|
|
||||||
|
|
||||||
# Verify custom controls (these are created dynamically by JavaScript)
|
|
||||||
expect(page).to have_css('.map-settings-button', wait: 10)
|
|
||||||
expect(page).to have_css('.toggle-panel-button', wait: 15)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'performance and memory management' do
|
|
||||||
include_context 'authenticated map user'
|
|
||||||
|
|
||||||
it 'properly cleans up on page navigation' do
|
|
||||||
# Navigate away and back to test cleanup
|
|
||||||
visit '/stats'
|
|
||||||
expect(page).to have_current_path('/stats')
|
|
||||||
|
|
||||||
# Navigate back to map
|
|
||||||
visit '/map'
|
|
||||||
expect(page).to have_css('#map')
|
|
||||||
expect(page).to have_css('.leaflet-container')
|
|
||||||
end
|
|
||||||
|
|
||||||
xit 'handles large datasets without crashing' do
|
|
||||||
# This test verifies the map can handle the existing dataset
|
|
||||||
# without JavaScript errors or timeouts
|
|
||||||
expect(page).to have_css('.leaflet-overlay-pane', wait: 15)
|
|
||||||
expect(page).to have_css('.leaflet-marker-pane', wait: 15)
|
|
||||||
|
|
||||||
# Try zooming and panning to test performance
|
|
||||||
zoom_in_button = find('.leaflet-control-zoom-in')
|
|
||||||
3.times do
|
|
||||||
zoom_in_button.click
|
|
||||||
sleep 0.3
|
|
||||||
end
|
|
||||||
|
|
||||||
# Map should still be responsive
|
|
||||||
expect(page).to have_css('.leaflet-container')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Loading…
Reference in a new issue