mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Merge branch 'dev' into feature/omniauth
This commit is contained in:
commit
fde478e2a4
111 changed files with 5646 additions and 5222 deletions
|
|
@ -1 +1 @@
|
|||
0.34.0
|
||||
0.35.1
|
||||
|
|
|
|||
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
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.dev
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -84,3 +84,4 @@ node_modules/
|
|||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/e2e/temp/
|
||||
|
|
|
|||
74
CHANGELOG.md
74
CHANGELOG.md
|
|
@ -4,7 +4,22 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# [UNRELEASED]
|
||||
## Unreleased
|
||||
|
||||
## Added
|
||||
|
||||
- Support for KML file uploads. #350
|
||||
- Added a commented line in the `docker-compose.yml` file to use an alternative PostGIS image for ARM architecture.
|
||||
|
||||
## Fixed
|
||||
|
||||
- The map settings panel is now scrollable
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
- Internal redis settings updated to implement support for connecting to Redis via unix socket. #1706
|
||||
|
||||
- Implemented authentication via GitHub and Google for Dawarich Cloud.
|
||||
- Implemented OpenID Connect authentication for self-hosted Dawarich instances. #66
|
||||
|
|
@ -16,6 +31,63 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
- [ ] Disable GitHub and Google authentication for self-hosted Dawarich
|
||||
- [ ] In selfhosted env, no registrations are allowed, we need to account OIDC into that
|
||||
|
||||
|
||||
# [0.35.1] - 2025-11-09
|
||||
|
||||
## Fixed
|
||||
|
||||
- StrongMigration issue #1931
|
||||
|
||||
|
||||
# [0.35.0] - 2025-11-09
|
||||
|
||||
⚠️ 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.
|
||||
|
||||
You can now set `RAILS_ENV` environment variable to `production` to run Dawarich in production mode.
|
||||
|
||||
## 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
|
||||
|
||||
## Fixed
|
||||
|
||||
- Fixed a bug in UTM trackable concern. #1909
|
||||
|
||||
# [0.34.1] - 2025-10-30
|
||||
|
||||
## Fixed
|
||||
|
||||
- Broken Stats page for users with no reverse geocoding enabled. #1877
|
||||
|
||||
## Changed
|
||||
|
||||
- Date navigation on the map page is no longer shown as floating panel. It is now part of the top navigation bar to prevent overlapping with other map controls. #1894 #1881
|
||||
|
||||
## Added
|
||||
|
||||
- [Dawarich Cloud] Added support for UTM parameters during user registration. UTM parameters will be stored with the user record for marketing analytics purposes.
|
||||
|
||||
# [0.34.0] - 2025-10-10
|
||||
|
||||
## The Family release
|
||||
|
|
|
|||
8
Gemfile
8
Gemfile
|
|
@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
|
|||
|
||||
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
|
||||
gem 'aws-sdk-core', '~> 3.215.1', require: false
|
||||
gem 'aws-sdk-kms', '~> 1.96.0', require: false
|
||||
|
|
@ -33,12 +33,12 @@ gem 'pg'
|
|||
gem 'prometheus_exporter'
|
||||
gem 'puma'
|
||||
gem 'pundit', '>= 2.5.1'
|
||||
gem 'rails', '~> 8.0', '>= 8.0.3'
|
||||
gem 'rails', '~> 8.0'
|
||||
gem 'rails_icons'
|
||||
gem 'redis'
|
||||
gem 'rexml'
|
||||
gem 'rgeo'
|
||||
gem 'rgeo-activerecord'
|
||||
gem 'rgeo-activerecord', '~> 8.0.0'
|
||||
gem 'rgeo-geojson'
|
||||
gem 'rqrcode', '~> 3.0'
|
||||
gem 'rswag-api'
|
||||
|
|
@ -52,7 +52,6 @@ gem 'sidekiq-limit_fetch'
|
|||
gem 'sprockets-rails'
|
||||
gem 'stackprof'
|
||||
gem 'stimulus-rails'
|
||||
gem 'strong_migrations', '>= 2.4.0'
|
||||
gem 'tailwindcss-rails', '= 3.3.2'
|
||||
gem 'turbo-rails', '>= 2.0.17'
|
||||
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
|
||||
|
|
@ -84,4 +83,5 @@ group :development do
|
|||
gem 'database_consistency', '>= 2.0.5', require: false
|
||||
gem 'foreman'
|
||||
gem 'rubocop-rails', '>= 2.33.4', require: false
|
||||
gem 'strong_migrations', '>= 2.4.0'
|
||||
end
|
||||
|
|
|
|||
80
Gemfile.lock
80
Gemfile.lock
|
|
@ -109,11 +109,10 @@ GEM
|
|||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.3)
|
||||
bindata (2.5.1)
|
||||
bigdecimal (3.3.1)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
brakeman (7.1.0)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.2)
|
||||
|
|
@ -142,12 +141,12 @@ GEM
|
|||
tzinfo
|
||||
unicode (>= 0.4.4.5)
|
||||
csv (3.3.4)
|
||||
data_migrate (11.3.0)
|
||||
data_migrate (11.3.1)
|
||||
activerecord (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
database_consistency (2.0.6)
|
||||
activerecord (>= 3.2)
|
||||
date (3.4.1)
|
||||
date (3.5.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
|
|
@ -164,9 +163,7 @@ GEM
|
|||
dotenv (= 3.1.8)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.3)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (5.0.2)
|
||||
erb (5.1.3)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
|
|
@ -272,7 +269,7 @@ GEM
|
|||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
minitest (5.26.0)
|
||||
msgpack (1.7.3)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.7.1)
|
||||
|
|
@ -362,7 +359,7 @@ GEM
|
|||
pg (1.6.2-arm64-darwin)
|
||||
pg (1.6.2-x86_64-darwin)
|
||||
pg (1.6.2-x86_64-linux)
|
||||
pp (0.6.2)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.5.1)
|
||||
|
|
@ -386,18 +383,7 @@ GEM
|
|||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.2)
|
||||
rack-oauth2 (2.2.1)
|
||||
activesupport
|
||||
attr_required
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
json-jwt (>= 1.11.0)
|
||||
rack (>= 2.1.0)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack (3.2.3)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
|
|
@ -439,10 +425,11 @@ GEM
|
|||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
rdoc (6.14.2)
|
||||
rake (13.3.1)
|
||||
rdoc (6.15.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redis (5.4.0)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.24.0)
|
||||
|
|
@ -484,17 +471,17 @@ GEM
|
|||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.3)
|
||||
rswag-api (2.16.0)
|
||||
activesupport (>= 5.2, < 8.1)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rswag-specs (2.16.0)
|
||||
activesupport (>= 5.2, < 8.1)
|
||||
json-schema (>= 2.2, < 6.0)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rswag-api (2.17.0)
|
||||
activesupport (>= 5.2, < 8.2)
|
||||
railties (>= 5.2, < 8.2)
|
||||
rswag-specs (2.17.0)
|
||||
activesupport (>= 5.2, < 8.2)
|
||||
json-schema (>= 2.2, < 7.0)
|
||||
railties (>= 5.2, < 8.2)
|
||||
rspec-core (>= 2.14)
|
||||
rswag-ui (2.16.0)
|
||||
actionpack (>= 5.2, < 8.1)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rswag-ui (2.17.0)
|
||||
actionpack (>= 5.2, < 8.2)
|
||||
railties (>= 5.2, < 8.2)
|
||||
rubocop (1.81.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
|
|
@ -524,10 +511,10 @@ GEM
|
|||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.28.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.28.0)
|
||||
sentry-ruby (5.28.0)
|
||||
sentry-rails (6.0.0)
|
||||
railties (>= 5.2.0)
|
||||
sentry-ruby (~> 6.0.0)
|
||||
sentry-ruby (6.0.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
shoulda-matchers (6.5.0)
|
||||
|
|
@ -567,15 +554,10 @@ GEM
|
|||
stringio (3.1.7)
|
||||
strong_migrations (2.5.1)
|
||||
activerecord (>= 7.1)
|
||||
super_diff (0.16.0)
|
||||
super_diff (0.17.0)
|
||||
attr_extras (>= 6.2.4)
|
||||
diff-lcs
|
||||
patience_diff
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
tailwindcss-rails (3.3.2)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby (~> 3.0)
|
||||
|
|
@ -586,7 +568,7 @@ GEM
|
|||
tailwindcss-ruby (3.4.17-x86_64-darwin)
|
||||
tailwindcss-ruby (3.4.17-x86_64-linux)
|
||||
thor (1.4.0)
|
||||
timeout (0.4.3)
|
||||
timeout (0.4.4)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.17)
|
||||
actionpack (>= 7.1.0)
|
||||
|
|
@ -597,7 +579,7 @@ GEM
|
|||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.3)
|
||||
uri (1.0.4)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
|
|
@ -632,7 +614,7 @@ PLATFORMS
|
|||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
activerecord-postgis-adapter
|
||||
activerecord-postgis-adapter (~> 11.0)
|
||||
aws-sdk-core (~> 3.215.1)
|
||||
aws-sdk-kms (~> 1.96.0)
|
||||
aws-sdk-s3 (~> 1.177.0)
|
||||
|
|
@ -671,12 +653,12 @@ DEPENDENCIES
|
|||
pry-rails
|
||||
puma
|
||||
pundit (>= 2.5.1)
|
||||
rails (~> 8.0, >= 8.0.3)
|
||||
rails (~> 8.0)
|
||||
rails_icons
|
||||
redis
|
||||
rexml
|
||||
rgeo
|
||||
rgeo-activerecord
|
||||
rgeo-activerecord (~> 8.0.0)
|
||||
rgeo-geojson
|
||||
rqrcode (~> 3.0)
|
||||
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`.
|
||||
|
||||
You can use default values or create a `.env` file based on `.env.example` to customize your setup.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 How to Install Dawarich
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -27,9 +27,13 @@
|
|||
/* Style for the settings panel */
|
||||
.leaflet-settings-panel {
|
||||
background-color: white;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
position: absolute !important;
|
||||
top: 10px !important;
|
||||
left: 60px !important;
|
||||
transform: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.leaflet-settings-panel label {
|
||||
|
|
|
|||
|
|
@ -76,33 +76,46 @@
|
|||
/* Drawer Panel Styles */
|
||||
.leaflet-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 338px;
|
||||
height: 100%;
|
||||
top: 10px;
|
||||
right: 70px; /* Position to the left of the control buttons with margin */
|
||||
width: 24rem;
|
||||
max-height: calc(100% - 20px);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
border-radius: 8px;
|
||||
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;
|
||||
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 {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Controls transition */
|
||||
/* Controls remain in place - no transition needed */
|
||||
.leaflet-control-layers,
|
||||
.leaflet-control-button,
|
||||
.toggle-panel-button {
|
||||
transition: right 0.3s ease-in-out;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.controls-shifted {
|
||||
right: 338px !important;
|
||||
}
|
||||
|
||||
/* Selection Tool Styles */
|
||||
.leaflet-control-custom {
|
||||
background-color: white;
|
||||
|
|
@ -127,6 +140,5 @@
|
|||
|
||||
/* Cancel Selection Button */
|
||||
#cancel-selection-button {
|
||||
margin-bottom: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
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]
|
||||
|
||||
def index
|
||||
|
|
@ -45,6 +45,16 @@ class Api::V1::PointsController < ApiController
|
|||
render json: { message: 'Point deleted successfully' }
|
||||
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
|
||||
|
||||
def point_params
|
||||
|
|
@ -55,6 +65,10 @@ class Api::V1::PointsController < ApiController
|
|||
params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {})
|
||||
end
|
||||
|
||||
def bulk_destroy_params
|
||||
params.permit(point_ids: [])
|
||||
end
|
||||
|
||||
def point_serializer
|
||||
params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer
|
||||
end
|
||||
|
|
|
|||
34
app/controllers/concerns/utm_trackable.rb
Normal file
34
app/controllers/concerns/utm_trackable.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module UtmTrackable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
UTM_PARAMS = %w[utm_source utm_medium utm_campaign utm_term utm_content].freeze
|
||||
|
||||
def store_utm_params
|
||||
UTM_PARAMS.each do |param|
|
||||
session[param] = params[param] if params[param].present?
|
||||
end
|
||||
end
|
||||
|
||||
def assign_utm_params(record)
|
||||
utm_data = extract_utm_data_from_session
|
||||
|
||||
return unless utm_data.any?
|
||||
|
||||
record.update_columns(utm_data)
|
||||
clear_utm_session
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_utm_data_from_session
|
||||
UTM_PARAMS.each_with_object({}) do |param, hash|
|
||||
hash[param] = session[param] if session[param].present?
|
||||
end
|
||||
end
|
||||
|
||||
def clear_utm_session
|
||||
UTM_PARAMS.each { |param| session.delete(param) }
|
||||
end
|
||||
end
|
||||
|
|
@ -77,6 +77,8 @@ class FamiliesController < ApplicationController
|
|||
end
|
||||
|
||||
def update_location_sharing
|
||||
authorize @family, :update_location_sharing?
|
||||
|
||||
result = Families::UpdateLocationSharing.new(
|
||||
user: current_user,
|
||||
enabled: params[:enabled],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::RegistrationsController < Devise::RegistrationsController
|
||||
include UtmTrackable
|
||||
|
||||
before_action :set_invitation, only: %i[new create]
|
||||
before_action :check_registration_allowed, only: %i[new create]
|
||||
before_action :store_utm_params, only: %i[new], unless: -> { DawarichSettings.self_hosted? }
|
||||
|
||||
def new
|
||||
build_resource({})
|
||||
|
|
@ -16,8 +19,9 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
|||
|
||||
def create
|
||||
super do |resource|
|
||||
if resource.persisted? && @invitation
|
||||
accept_invitation_for_user(resource)
|
||||
if resource.persisted?
|
||||
assign_utm_params(resource)
|
||||
accept_invitation_for_user(resource) if @invitation
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -47,7 +51,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
|||
end
|
||||
|
||||
def set_invitation
|
||||
return unless invitation_token.present?
|
||||
return if invitation_token.blank?
|
||||
|
||||
@invitation = Family::Invitation.find_by(token: invitation_token)
|
||||
end
|
||||
|
|
@ -65,8 +69,8 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
|||
|
||||
def invitation_token
|
||||
@invitation_token ||= params[:invitation_token] ||
|
||||
params.dig(:user, :invitation_token) ||
|
||||
session[:invitation_token]
|
||||
params.dig(:user, :invitation_token) ||
|
||||
session[:invitation_token]
|
||||
end
|
||||
|
||||
def accept_invitation_for_user(user)
|
||||
|
|
@ -80,11 +84,13 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
|||
if service.call
|
||||
flash[:notice] = "Welcome to #{@invitation.family.name}! You're now part of the family."
|
||||
else
|
||||
flash[:alert] = "Account created successfully, but there was an issue accepting the invitation: #{service.error_message}"
|
||||
flash[:alert] =
|
||||
"Account created successfully, but there was an issue accepting the invitation: #{service.error_message}"
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error accepting invitation during registration: #{e.message}"
|
||||
flash[:alert] = "Account created successfully, but there was an issue accepting the invitation. Please try accepting it again."
|
||||
flash[:alert] =
|
||||
'Account created successfully, but there was an issue accepting the invitation. Please try accepting it again.'
|
||||
end
|
||||
|
||||
def sign_up_params
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@
|
|||
module CountryFlagHelper
|
||||
def country_flag(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)
|
||||
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
|
||||
|
||||
|
||||
private
|
||||
|
||||
def country_to_code(country_name)
|
||||
|
|
|
|||
|
|
@ -148,6 +148,10 @@ export default class extends Controller {
|
|||
if (this.currentPopup) {
|
||||
this.map.closePopup(this.currentPopup);
|
||||
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) {
|
||||
cancelButton.addEventListener('click', () => {
|
||||
cancelButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.exitAddVisitMode(this.addVisitButton);
|
||||
});
|
||||
}
|
||||
|
|
@ -346,8 +353,6 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
addCreatedVisitToMap(visitData, latitude, longitude) {
|
||||
console.log('Adding newly created visit to map immediately', { latitude, longitude, visitData });
|
||||
|
||||
const mapsController = document.querySelector('[data-controller*="maps"]');
|
||||
if (!mapsController) {
|
||||
console.log('Could not find maps controller element');
|
||||
|
|
@ -357,6 +362,7 @@ export default class extends Controller {
|
|||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
||||
if (!stimulusController || !stimulusController.visitsManager) {
|
||||
console.log('Could not find maps controller or visits manager');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -376,16 +382,10 @@ export default class extends Controller {
|
|||
|
||||
// Add the circle to the confirmed visits layer
|
||||
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
|
||||
if (!this.map.hasLayer(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
|
||||
|
|
@ -411,9 +411,7 @@ export default class extends Controller {
|
|||
inputs.forEach(input => {
|
||||
const label = input.nextElementSibling;
|
||||
if (label && label.textContent.trim().includes('Confirmed Visits')) {
|
||||
console.log('Found Confirmed Visits checkbox, current state:', input.checked);
|
||||
if (!input.checked) {
|
||||
console.log('Enabling Confirmed Visits layer via checkbox');
|
||||
input.checked = true;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default class extends Controller {
|
|||
if (this.isUploading) {
|
||||
// If still uploading, prevent submission
|
||||
event.preventDefault()
|
||||
console.log("Form submission prevented during upload")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ export default class extends Controller {
|
|||
const signedIds = this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]')
|
||||
if (signedIds.length === 0) {
|
||||
event.preventDefault()
|
||||
console.log("No files uploaded yet")
|
||||
|
||||
alert("Please select and upload files first")
|
||||
} else {
|
||||
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
|
||||
|
||||
// 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
|
||||
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
|
||||
|
||||
console.log("Progress bar created and inserted before submit button")
|
||||
|
||||
let uploadCount = 0
|
||||
const totalFiles = files.length
|
||||
|
||||
|
|
@ -137,17 +134,13 @@ export default class extends Controller {
|
|||
});
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
console.log(`Starting upload for ${file.name}`)
|
||||
const upload = new DirectUpload(file, this.urlValue, this)
|
||||
upload.create((error, blob) => {
|
||||
uploadCount++
|
||||
|
||||
if (error) {
|
||||
console.error("Error uploading file:", error)
|
||||
// Show error to user using flash
|
||||
showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`)
|
||||
} else {
|
||||
console.log(`Successfully uploaded ${file.name} with ID: ${blob.signed_id}`)
|
||||
|
||||
// Create a hidden field with the correct name
|
||||
const hiddenField = document.createElement("input")
|
||||
|
|
@ -155,8 +148,6 @@ export default class extends Controller {
|
|||
hiddenField.setAttribute("name", "import[files][]")
|
||||
hiddenField.setAttribute("value", blob.signed_id)
|
||||
this.element.appendChild(hiddenField)
|
||||
|
||||
console.log("Added hidden field with signed ID:", blob.signed_id)
|
||||
}
|
||||
|
||||
// Enable submit button when all uploads are complete
|
||||
|
|
@ -186,8 +177,6 @@ export default class extends Controller {
|
|||
}
|
||||
}
|
||||
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();
|
||||
|
||||
// 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
|
||||
window.visitsManager = this.visitsManager;
|
||||
|
|
@ -712,6 +712,9 @@ export default class extends BaseController {
|
|||
if (this.map.hasLayer(this.fogOverlay)) {
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold);
|
||||
}
|
||||
|
||||
// Show success message
|
||||
showFlashMessage('notice', 'Point deleted successfully');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('There was a problem with the delete request:', error);
|
||||
|
|
@ -952,100 +955,141 @@ export default class extends BaseController {
|
|||
|
||||
// Form HTML
|
||||
div.innerHTML = `
|
||||
<form id="settings-form" style="overflow-y: auto; max-height: 70vh; width: 12rem; padding-right: 5px;">
|
||||
<label for="route-opacity">Route Opacity, %</label>
|
||||
<div class="join">
|
||||
<input type="number" class="input input-ghost join-item focus:input-ghost input-xs input-bordered w-full max-w-xs" id="route-opacity" name="route_opacity" min="10" max="100" step="10" value="${Math.round(this.routeOpacity * 100)}">
|
||||
<label for="route_opacity_info" class="btn-xs join-item ">?</label>
|
||||
|
||||
<form id="settings-form" class="space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs">Route Opacity, %</span>
|
||||
</label>
|
||||
<div class="join join-horizontal w-full">
|
||||
<input type="number" class="input input-bordered input-sm join-item flex-1" id="route-opacity" name="route_opacity" min="10" max="100" step="10" value="${Math.round(this.routeOpacity * 100)}">
|
||||
<label for="route_opacity_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="fog_of_war_meters">Fog of War radius</label>
|
||||
<div class="join">
|
||||
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="fog_of_war_meters" name="fog_of_war_meters" min="5" max="200" step="1" value="${this.clearFogRadius}">
|
||||
<label for="fog_of_war_meters_info" class="btn-xs join-item">?</label>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs">Fog of War radius</span>
|
||||
</label>
|
||||
<div class="join join-horizontal w-full">
|
||||
<input type="number" class="input input-bordered input-sm join-item flex-1" id="fog_of_war_meters" name="fog_of_war_meters" min="5" max="200" step="1" value="${this.clearFogRadius}">
|
||||
<label for="fog_of_war_meters_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="fog_of_war_threshold">Seconds between Fog of War lines</label>
|
||||
<div class="join">
|
||||
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="fog_of_war_threshold" name="fog_of_war_threshold" step="1" value="${this.userSettings.fog_of_war_threshold}">
|
||||
<label for="fog_of_war_threshold_info" class="btn-xs join-item">?</label>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs">Fog of War threshold</span>
|
||||
</label>
|
||||
<div class="join join-horizontal w-full">
|
||||
<input type="number" class="input input-bordered input-sm join-item flex-1" id="fog_of_war_threshold" name="fog_of_war_threshold" step="1" value="${this.userSettings.fog_of_war_threshold}">
|
||||
<label for="fog_of_war_threshold_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<label for="meters_between_routes">Meters between routes</label>
|
||||
<div class="join">
|
||||
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="meters_between_routes" name="meters_between_routes" step="1" value="${this.userSettings.meters_between_routes}">
|
||||
<label for="meters_between_routes_info" class="btn-xs join-item">?</label>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs">Meters between routes</span>
|
||||
</label>
|
||||
<div class="join join-horizontal w-full">
|
||||
<input type="number" class="input input-bordered input-sm join-item flex-1" id="meters_between_routes" name="meters_between_routes" step="1" value="${this.userSettings.meters_between_routes}">
|
||||
<label for="meters_between_routes_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<label for="minutes_between_routes">Minutes between routes</label>
|
||||
<div class="join">
|
||||
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="minutes_between_routes" name="minutes_between_routes" step="1" value="${this.userSettings.minutes_between_routes}">
|
||||
<label for="minutes_between_routes_info" class="btn-xs join-item">?</label>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs">Minutes between routes</span>
|
||||
</label>
|
||||
<div class="join join-horizontal w-full">
|
||||
<input type="number" class="input input-bordered input-sm join-item flex-1" id="minutes_between_routes" name="minutes_between_routes" step="1" value="${this.userSettings.minutes_between_routes}">
|
||||
<label for="minutes_between_routes_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<label for="time_threshold_minutes">Time threshold minutes</label>
|
||||
<div class="join">
|
||||
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="time_threshold_minutes" name="time_threshold_minutes" step="1" value="${this.userSettings.time_threshold_minutes}">
|
||||
<label for="time_threshold_minutes_info" class="btn-xs join-item">?</label>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs">Time threshold minutes</span>
|
||||
</label>
|
||||
<div class="join join-horizontal w-full">
|
||||
<input type="number" class="input input-bordered input-sm join-item flex-1" id="time_threshold_minutes" name="time_threshold_minutes" step="1" value="${this.userSettings.time_threshold_minutes}">
|
||||
<label for="time_threshold_minutes_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<label for="merge_threshold_minutes">Merge threshold minutes</label>
|
||||
<div class="join">
|
||||
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="merge_threshold_minutes" name="merge_threshold_minutes" step="1" value="${this.userSettings.merge_threshold_minutes}">
|
||||
<label for="merge_threshold_minutes_info" class="btn-xs join-item">?</label>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs">Merge threshold minutes</span>
|
||||
</label>
|
||||
<div class="join join-horizontal w-full">
|
||||
<input type="number" class="input input-bordered input-sm join-item flex-1" id="merge_threshold_minutes" name="merge_threshold_minutes" step="1" value="${this.userSettings.merge_threshold_minutes}">
|
||||
<label for="merge_threshold_minutes_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<label for="points_rendering_mode">
|
||||
Points rendering mode
|
||||
<label for="points_rendering_mode_info" class="btn-xs join-item inline">?</label>
|
||||
</label>
|
||||
<label for="raw">
|
||||
<input type="radio" id="raw" name="points_rendering_mode" class='w-4' style="width: 20px;" value="raw" ${this.pointsRenderingModeChecked('raw')} />
|
||||
Raw
|
||||
</label>
|
||||
|
||||
<label for="simplified">
|
||||
<input type="radio" id="simplified" name="points_rendering_mode" class='w-4' style="width: 20px;" value="simplified" ${this.pointsRenderingModeChecked('simplified')}/>
|
||||
Simplified
|
||||
</label>
|
||||
|
||||
<label for="live_map_enabled">
|
||||
Live Map
|
||||
<label for="live_map_enabled_info" class="btn-xs join-item inline">?</label>
|
||||
<input type="checkbox" id="live_map_enabled" name="live_map_enabled" class='w-4' style="width: 20px;" value="false" ${this.liveMapEnabledChecked(true)} />
|
||||
</label>
|
||||
|
||||
<label for="speed_colored_routes">
|
||||
Speed-colored routes
|
||||
<label for="speed_colored_routes_info" class="btn-xs join-item inline">?</label>
|
||||
<input type="checkbox" id="speed_colored_routes" name="speed_colored_routes" class='w-4' style="width: 20px;" ${this.speedColoredRoutesChecked()} />
|
||||
</label>
|
||||
|
||||
<label for="speed_color_scale">Speed color scale</label>
|
||||
<div class="join">
|
||||
<input type="text" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="speed_color_scale" name="speed_color_scale" min="5" max="100" step="1" value="${this.speedColorScale}">
|
||||
<label for="speed_color_scale_info" class="btn-xs join-item">?</label>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs">Points rendering mode</span>
|
||||
<label for="points_rendering_mode_info" class="btn btn-xs btn-ghost cursor-pointer">?</label>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="label cursor-pointer justify-start gap-2 py-1">
|
||||
<input type="radio" id="raw" name="points_rendering_mode" class="radio radio-sm" value="raw" ${this.pointsRenderingModeChecked('raw')} />
|
||||
<span class="label-text text-xs">Raw</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-2 py-1">
|
||||
<input type="radio" id="simplified" name="points_rendering_mode" class="radio radio-sm" value="simplified" ${this.pointsRenderingModeChecked('simplified')} />
|
||||
<span class="label-text text-xs">Simplified</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="edit-gradient-btn" class="btn btn-xs mt-2">Edit Scale</button>
|
||||
|
||||
<hr>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer py-1">
|
||||
<span class="label-text text-xs">Live Map</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<label for="live_map_enabled_info" class="btn btn-xs btn-ghost cursor-pointer">?</label>
|
||||
<input type="checkbox" id="live_map_enabled" name="live_map_enabled" class="checkbox checkbox-sm" ${this.liveMapEnabledChecked(true)} />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-xs mt-2">Update</button>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer py-1">
|
||||
<span class="label-text text-xs">Speed-colored routes</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<label for="speed_colored_routes_info" class="btn btn-xs btn-ghost cursor-pointer">?</label>
|
||||
<input type="checkbox" id="speed_colored_routes" name="speed_colored_routes" class="checkbox checkbox-sm" ${this.speedColoredRoutesChecked()} />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs">Speed color scale</span>
|
||||
</label>
|
||||
<div class="join join-horizontal w-full">
|
||||
<input type="text" class="input input-bordered input-sm join-item flex-1" id="speed_color_scale" name="speed_color_scale" value="${this.speedColorScale}">
|
||||
<label for="speed_color_scale_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
|
||||
</div>
|
||||
<button type="button" id="edit-gradient-btn" class="btn btn-sm mt-2 w-full">Edit Colors</button>
|
||||
</div>
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<button type="submit" class="btn btn-sm btn-primary w-full">Update</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
// Style the panel with theme-aware styling
|
||||
applyThemeToPanel(div, this.userTheme);
|
||||
div.style.padding = '10px';
|
||||
div.style.width = '220px';
|
||||
div.style.maxHeight = 'calc(60vh - 20px)';
|
||||
div.style.overflowY = 'auto';
|
||||
|
||||
// Prevent map interactions when interacting with the form
|
||||
L.DomEvent.disableClickPropagation(div);
|
||||
L.DomEvent.disableScrollPropagation(div);
|
||||
|
||||
// Attach event listener to the "Edit Gradient" button:
|
||||
const editBtn = div.querySelector("#edit-gradient-btn");
|
||||
|
|
|
|||
|
|
@ -122,9 +122,8 @@ export default class extends BaseController {
|
|||
});
|
||||
});
|
||||
|
||||
// Add markers and route
|
||||
// Add route (no markers on trip forms)
|
||||
if (this.coordinates?.length > 0) {
|
||||
this.addMarkers()
|
||||
this.addPolyline()
|
||||
this.fitMapToBounds()
|
||||
}
|
||||
|
|
@ -246,9 +245,8 @@ export default class extends BaseController {
|
|||
this.polylinesLayer.clearLayers()
|
||||
this.photoMarkers.clearLayers()
|
||||
|
||||
// Add new markers and route if coordinates exist
|
||||
// Add only polyline (no markers) when coordinates exist
|
||||
if (this.coordinates?.length > 0) {
|
||||
this.addMarkers()
|
||||
this.addPolyline()
|
||||
this.fitMapToBounds()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import L from "leaflet";
|
||||
import { showFlashMessage } from "./helpers";
|
||||
import { createPolylinesLayer } from "./polylines";
|
||||
|
||||
/**
|
||||
* Manages visits functionality including displaying, fetching, and interacting with visits
|
||||
*/
|
||||
export class VisitsManager {
|
||||
constructor(map, apiKey, userTheme = 'dark') {
|
||||
constructor(map, apiKey, userTheme = 'dark', mapsController = null) {
|
||||
this.map = map;
|
||||
this.apiKey = apiKey;
|
||||
this.userTheme = userTheme;
|
||||
this.mapsController = mapsController;
|
||||
|
||||
// Create custom panes for different visit types
|
||||
// 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
|
||||
this.isSelectionActive = true;
|
||||
|
||||
this.displayVisits(visits);
|
||||
|
||||
// Make sure the drawer is open
|
||||
// Make sure the drawer is open FIRST, before displaying visits
|
||||
if (!this.drawerOpen) {
|
||||
this.toggleDrawer();
|
||||
}
|
||||
|
||||
// Add cancel selection button to the drawer
|
||||
this.addSelectionCancelButton();
|
||||
// Now display visits in the drawer
|
||||
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) {
|
||||
console.error('Error fetching visits in selection:', error);
|
||||
|
|
@ -362,7 +369,7 @@ export class VisitsManager {
|
|||
const visitsCount = dateGroups[dateStr].count || 0;
|
||||
|
||||
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="flex gap-2">
|
||||
${pointsCount > 0 ? `<div class="badge badge-secondary">${pointsCount} pts</div>` : ''}
|
||||
|
|
@ -372,14 +379,18 @@ export class VisitsManager {
|
|||
`;
|
||||
}).join('');
|
||||
|
||||
// Create the whole panel
|
||||
// Create the whole panel with collapsible content
|
||||
return `
|
||||
<div class="bg-base-100 rounded-lg p-3 mb-4 shadow-sm">
|
||||
<h3 class="text-lg font-bold mb-2">Data in Selected Area</h3>
|
||||
<div class="divide-y divide-base-300">
|
||||
${dateItems}
|
||||
<details id="data-section-collapse" class="collapse collapse-arrow bg-base-100 rounded-lg mb-4 shadow-sm">
|
||||
<summary class="collapse-title text-lg font-bold">
|
||||
Data in Selected Area
|
||||
</summary>
|
||||
<div class="collapse-content">
|
||||
<div class="divide-y divide-base-300">
|
||||
${dateItems}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
@ -388,18 +399,207 @@ export class VisitsManager {
|
|||
*/
|
||||
addSelectionCancelButton() {
|
||||
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
|
||||
if (!document.getElementById('cancel-selection-button')) {
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.id = 'cancel-selection-button';
|
||||
cancelButton.className = 'btn btn-sm btn-warning mb-4 w-full';
|
||||
cancelButton.textContent = 'Cancel Area Selection';
|
||||
cancelButton.onclick = () => this.clearSelection();
|
||||
// Remove any existing button container first to avoid duplicates
|
||||
const existingButtonContainer = document.getElementById('selection-button-container');
|
||||
if (existingButtonContainer) {
|
||||
existingButtonContainer.remove();
|
||||
}
|
||||
|
||||
// Insert at the beginning of the container
|
||||
container.insertBefore(cancelButton, container.firstChild);
|
||||
// Create a button container
|
||||
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>';
|
||||
}
|
||||
|
||||
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
|
||||
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');
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
|
|
@ -451,16 +647,18 @@ export class VisitsManager {
|
|||
createDrawer() {
|
||||
const drawer = document.createElement('div');
|
||||
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
|
||||
drawer.style.overflowY = 'auto';
|
||||
drawer.style.maxHeight = '100vh';
|
||||
|
||||
drawer.innerHTML = `
|
||||
<div class="p-3 drawer">
|
||||
<h2 class="text-xl font-bold mb-4 text-accent-content">Recent Visits</h2>
|
||||
<div id="visits-list" class="space-y-2">
|
||||
<div class="p-3 my-2 drawer flex flex-col items-center relative">
|
||||
<button id="close-visits-drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" title="Close panel">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -472,6 +670,15 @@ export class VisitsManager {
|
|||
L.DomEvent.disableClickPropagation(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;
|
||||
}
|
||||
|
||||
|
|
@ -630,6 +837,10 @@ export class VisitsManager {
|
|||
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
|
||||
if (this.isSelectionActive && this.selectionRect) {
|
||||
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;' : '';
|
||||
|
||||
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}"
|
||||
data-lat="${visit.place?.latitude || ''}"
|
||||
data-lng="${visit.place?.longitude || ''}"
|
||||
|
|
@ -721,8 +932,31 @@ export class VisitsManager {
|
|||
`;
|
||||
}).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
|
||||
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
|
||||
this.visitCircles.addTo(this.map);
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ class Family::Invitations::CleanupJob < ApplicationJob
|
|||
Rails.logger.info "Updated #{expired_count} expired family invitations"
|
||||
|
||||
cleanup_threshold = 30.days.ago
|
||||
deleted_count = Family::Invitation.where(status: [:expired, :cancelled])
|
||||
.where('updated_at < ?', cleanup_threshold)
|
||||
.delete_all
|
||||
deleted_count =
|
||||
Family::Invitation.where(status: %i[expired cancelled])
|
||||
.where('updated_at < ?', cleanup_threshold)
|
||||
.delete_all
|
||||
|
||||
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
|
||||
|
|
@ -38,8 +38,8 @@ class Tracks::DailyGenerationJob < ApplicationJob
|
|||
|
||||
Tracks::ParallelGeneratorJob.perform_later(
|
||||
user.id,
|
||||
start_at: start_timestamp,
|
||||
end_at: Time.current.to_i,
|
||||
start_at: Time.zone.at(start_timestamp),
|
||||
end_at: Time.current,
|
||||
mode: 'daily'
|
||||
)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ class Family < ApplicationRecord
|
|||
MAX_MEMBERS = 5
|
||||
|
||||
def can_add_members?
|
||||
return true if DawarichSettings.self_hosted?
|
||||
|
||||
(member_count + pending_invitations_count) < MAX_MEMBERS
|
||||
end
|
||||
|
||||
|
|
@ -32,6 +34,8 @@ class Family < ApplicationRecord
|
|||
end
|
||||
|
||||
def full?
|
||||
return false if DawarichSettings.self_hosted?
|
||||
|
||||
(member_count + pending_invitations_count) >= MAX_MEMBERS
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class Import < ApplicationRecord
|
|||
enum :source, {
|
||||
google_semantic_history: 0, owntracks: 1, google_records: 2,
|
||||
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7,
|
||||
user_data_archive: 8
|
||||
user_data_archive: 8, kml: 9
|
||||
}, allow_nil: true
|
||||
|
||||
def process!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -34,6 +34,10 @@ class FamilyPolicy < ApplicationPolicy
|
|||
user.family == record && user.family_owner?
|
||||
end
|
||||
|
||||
def update_location_sharing?
|
||||
user.family == record && user.family_owner?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def family_owner_with_members?
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
# 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
|
||||
def initialize(points, name)
|
||||
@points = points
|
||||
|
|
@ -7,30 +19,92 @@ class Points::GpxSerializer
|
|||
end
|
||||
|
||||
def call
|
||||
gpx_file = GPX::GPXFile.new(name: "dawarich_#{name}")
|
||||
track = GPX::Track.new(name: "dawarich_#{name}")
|
||||
gpx_file = create_base_gpx_file
|
||||
add_track_points_to_gpx(gpx_file)
|
||||
xml_string = enhance_gpx_with_speed_and_course(gpx_file.to_s)
|
||||
|
||||
gpx_file.tracks << track
|
||||
|
||||
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"')
|
||||
)
|
||||
EnhancedGpxFile.new("dawarich_#{name}", xml_string)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ module Families
|
|||
return false unless invite_sendable?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
create_invitation
|
||||
send_invitation_email
|
||||
invitation = create_invitation
|
||||
send_invitation_email(invitation)
|
||||
send_notification
|
||||
end
|
||||
|
||||
|
|
@ -80,16 +80,18 @@ module Families
|
|||
)
|
||||
end
|
||||
|
||||
def send_invitation_email
|
||||
# Send email in background with retry logic
|
||||
FamilyMailer.invitation(@invitation).deliver_later(
|
||||
queue: :mailer,
|
||||
retry: 3,
|
||||
wait: 30.seconds
|
||||
)
|
||||
def send_invitation_email(invitation)
|
||||
Family::Invitations::SendingJob.perform_later(invitation.id)
|
||||
end
|
||||
|
||||
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!(
|
||||
user: invited_by,
|
||||
kind: :info,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class Imports::Create
|
|||
when 'google_records' then GoogleMaps::RecordsStorageImporter
|
||||
when 'owntracks' then OwnTracks::Importer
|
||||
when 'gpx' then Gpx::TrackImporter
|
||||
when 'kml' then Kml::Importer
|
||||
when 'geojson' then Geojson::Importer
|
||||
when 'immich_api', 'photoprism_api' then Photos::Importer
|
||||
else
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class Imports::SourceDetector
|
|||
|
||||
def detect_source
|
||||
return :gpx if gpx_file?
|
||||
return :kml if kml_file?
|
||||
return :owntracks if owntracks_file?
|
||||
|
||||
json_data = parse_json
|
||||
|
|
@ -116,6 +117,22 @@ class Imports::SourceDetector
|
|||
) && content_to_check.include?('<gpx')
|
||||
end
|
||||
|
||||
def kml_file?
|
||||
return false unless filename&.downcase&.end_with?('.kml', '.kmz')
|
||||
|
||||
content_to_check =
|
||||
if file_path && File.exist?(file_path)
|
||||
# Read first 1KB for KML detection
|
||||
File.open(file_path, 'rb') { |f| f.read(1024) }
|
||||
else
|
||||
file_content
|
||||
end
|
||||
(
|
||||
content_to_check.strip.start_with?('<?xml') ||
|
||||
content_to_check.strip.start_with?('<kml')
|
||||
) && content_to_check.include?('<kml')
|
||||
end
|
||||
|
||||
def owntracks_file?
|
||||
return false unless filename
|
||||
|
||||
|
|
|
|||
234
app/services/kml/importer.rb
Normal file
234
app/services/kml/importer.rb
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rexml/document'
|
||||
|
||||
class Kml::Importer
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
file_content = load_file_content
|
||||
doc = REXML::Document.new(file_content)
|
||||
|
||||
points_data = []
|
||||
|
||||
# Process all Placemarks which can contain various geometry types
|
||||
REXML::XPath.each(doc, '//Placemark') do |placemark|
|
||||
points_data.concat(parse_placemark(placemark))
|
||||
end
|
||||
|
||||
# Process gx:Track elements (Google Earth extensions for GPS tracks)
|
||||
REXML::XPath.each(doc, '//gx:Track') do |track|
|
||||
points_data.concat(parse_gx_track(track))
|
||||
end
|
||||
|
||||
points_data.compact!
|
||||
|
||||
return if points_data.empty?
|
||||
|
||||
# Process in batches to avoid memory issues with large files
|
||||
points_data.each_slice(1000) do |batch|
|
||||
bulk_insert_points(batch)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_placemark(placemark)
|
||||
points = []
|
||||
timestamp = extract_timestamp(placemark)
|
||||
|
||||
# Handle Point geometry
|
||||
point_node = REXML::XPath.first(placemark, './/Point/coordinates')
|
||||
if point_node
|
||||
coords = parse_coordinates(point_node.text)
|
||||
points << build_point(coords.first, timestamp, placemark) if coords.any?
|
||||
end
|
||||
|
||||
# Handle LineString geometry (tracks/routes)
|
||||
linestring_node = REXML::XPath.first(placemark, './/LineString/coordinates')
|
||||
if linestring_node
|
||||
coords = parse_coordinates(linestring_node.text)
|
||||
coords.each do |coord|
|
||||
points << build_point(coord, timestamp, placemark)
|
||||
end
|
||||
end
|
||||
|
||||
# Handle MultiGeometry (can contain multiple Points, LineStrings, etc.)
|
||||
REXML::XPath.each(placemark, './/MultiGeometry//coordinates') do |coords_node|
|
||||
coords = parse_coordinates(coords_node.text)
|
||||
coords.each do |coord|
|
||||
points << build_point(coord, timestamp, placemark)
|
||||
end
|
||||
end
|
||||
|
||||
points.compact
|
||||
end
|
||||
|
||||
def parse_gx_track(track)
|
||||
# Google Earth Track extension with coordinated when/coord pairs
|
||||
points = []
|
||||
|
||||
timestamps = []
|
||||
REXML::XPath.each(track, './/when') do |when_node|
|
||||
timestamps << when_node.text.strip
|
||||
end
|
||||
|
||||
coordinates = []
|
||||
REXML::XPath.each(track, './/gx:coord') do |coord_node|
|
||||
coordinates << coord_node.text.strip
|
||||
end
|
||||
|
||||
# Match timestamps with coordinates
|
||||
[timestamps.size, coordinates.size].min.times do |i|
|
||||
begin
|
||||
time = Time.parse(timestamps[i]).to_i
|
||||
coord_parts = coordinates[i].split(/\s+/)
|
||||
next if coord_parts.size < 2
|
||||
|
||||
lng, lat, alt = coord_parts.map(&:to_f)
|
||||
|
||||
points << {
|
||||
lonlat: "POINT(#{lng} #{lat})",
|
||||
altitude: alt&.to_i || 0,
|
||||
timestamp: time,
|
||||
import_id: import.id,
|
||||
velocity: 0.0,
|
||||
raw_data: { source: 'gx_track', index: i },
|
||||
user_id: user_id,
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
}
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Failed to parse gx:Track point at index #{i}: #{e.message}")
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
points
|
||||
end
|
||||
|
||||
def parse_coordinates(coord_text)
|
||||
# KML coordinates format: "longitude,latitude[,altitude] ..."
|
||||
# Multiple coordinates separated by whitespace
|
||||
return [] if coord_text.blank?
|
||||
|
||||
coord_text.strip.split(/\s+/).map do |coord_str|
|
||||
parts = coord_str.split(',')
|
||||
next if parts.size < 2
|
||||
|
||||
{
|
||||
lng: parts[0].to_f,
|
||||
lat: parts[1].to_f,
|
||||
alt: parts[2]&.to_f || 0.0
|
||||
}
|
||||
end.compact
|
||||
end
|
||||
|
||||
def extract_timestamp(placemark)
|
||||
# Try TimeStamp first
|
||||
timestamp_node = REXML::XPath.first(placemark, './/TimeStamp/when')
|
||||
return Time.parse(timestamp_node.text).to_i if timestamp_node
|
||||
|
||||
# Try TimeSpan begin
|
||||
timespan_begin = REXML::XPath.first(placemark, './/TimeSpan/begin')
|
||||
return Time.parse(timespan_begin.text).to_i if timespan_begin
|
||||
|
||||
# Try TimeSpan end as fallback
|
||||
timespan_end = REXML::XPath.first(placemark, './/TimeSpan/end')
|
||||
return Time.parse(timespan_end.text).to_i if timespan_end
|
||||
|
||||
# Default to import creation time if no timestamp found
|
||||
import.created_at.to_i
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Failed to parse timestamp: #{e.message}")
|
||||
import.created_at.to_i
|
||||
end
|
||||
|
||||
def build_point(coord, timestamp, placemark)
|
||||
return if coord[:lat].blank? || coord[:lng].blank?
|
||||
|
||||
{
|
||||
lonlat: "POINT(#{coord[:lng]} #{coord[:lat]})",
|
||||
altitude: coord[:alt].to_i,
|
||||
timestamp: timestamp,
|
||||
import_id: import.id,
|
||||
velocity: extract_velocity(placemark),
|
||||
raw_data: extract_extended_data(placemark),
|
||||
user_id: user_id,
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
}
|
||||
end
|
||||
|
||||
def extract_velocity(placemark)
|
||||
# Try to extract speed from ExtendedData
|
||||
speed_node = REXML::XPath.first(placemark, ".//Data[@name='speed']/value") ||
|
||||
REXML::XPath.first(placemark, ".//Data[@name='Speed']/value") ||
|
||||
REXML::XPath.first(placemark, ".//Data[@name='velocity']/value")
|
||||
|
||||
return speed_node.text.to_f.round(1) if speed_node
|
||||
|
||||
0.0
|
||||
rescue StandardError
|
||||
0.0
|
||||
end
|
||||
|
||||
def extract_extended_data(placemark)
|
||||
data = {}
|
||||
|
||||
# Extract name if present
|
||||
name_node = REXML::XPath.first(placemark, './/name')
|
||||
data['name'] = name_node.text.strip if name_node
|
||||
|
||||
# Extract description if present
|
||||
desc_node = REXML::XPath.first(placemark, './/description')
|
||||
data['description'] = desc_node.text.strip if desc_node
|
||||
|
||||
# Extract all ExtendedData/Data elements
|
||||
REXML::XPath.each(placemark, './/ExtendedData/Data') do |data_node|
|
||||
name = data_node.attributes['name']
|
||||
value_node = REXML::XPath.first(data_node, './value')
|
||||
data[name] = value_node.text if name && value_node
|
||||
end
|
||||
|
||||
data
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Failed to extract extended data: #{e.message}")
|
||||
{}
|
||||
end
|
||||
|
||||
def bulk_insert_points(batch)
|
||||
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
Point.upsert_all(
|
||||
unique_batch,
|
||||
unique_by: %i[lonlat timestamp user_id],
|
||||
returning: false,
|
||||
on_duplicate: :skip
|
||||
)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
broadcast_import_progress(import, unique_batch.size)
|
||||
rescue StandardError => e
|
||||
create_notification("Failed to process KML file: #{e.message}")
|
||||
end
|
||||
|
||||
def create_notification(message)
|
||||
Notification.create!(
|
||||
user_id: user_id,
|
||||
title: 'KML Import Error',
|
||||
content: message,
|
||||
kind: :error
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -17,6 +17,8 @@
|
|||
</div>
|
||||
<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| %>
|
||||
<%= render "devise/shared/error_messages", resource: resource %>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :email, class: 'label' do %>
|
||||
<span class="label-text">Email</span>
|
||||
|
|
|
|||
|
|
@ -16,12 +16,23 @@
|
|||
</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<h1 class="text-5xl font-bold text-base-content">Register now!</h1>
|
||||
<p class="py-6 text-base-content opacity-70">and take control over your location data.</p>
|
||||
<h1 class="text-5xl font-bold text-base-content">Almost there!</h1>
|
||||
<% 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 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| %>
|
||||
<%= render "devise/shared/error_messages", resource: resource %>
|
||||
<% if @invitation %>
|
||||
<%= f.hidden_field :invitation_token, value: params[:invitation_token] %>
|
||||
<% end %>
|
||||
|
|
@ -32,7 +43,7 @@
|
|||
<% end %>
|
||||
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
||||
readonly: @invitation.present?,
|
||||
class: "input input-bordered #{@invitation ? 'input-disabled' : ''}" %>
|
||||
class: "input input-bordered w-full #{@invitation ? 'input-disabled' : ''}" %>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
|
|
@ -42,7 +53,7 @@
|
|||
<% if @minimum_password_length %>
|
||||
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
|
||||
<% 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 class="form-control">
|
||||
|
|
@ -52,7 +63,7 @@
|
|||
<% if @minimum_password_length %>
|
||||
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
|
||||
<% 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>
|
||||
|
||||
<% if !DawarichSettings.self_hosted? %>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
</div>
|
||||
<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| %>
|
||||
<%= render "devise/shared/error_messages", resource: resource %>
|
||||
|
||||
<% if @invitation %>
|
||||
<%= hidden_field_tag :invitation_token, params[:invitation_token] %>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
<% if resource.errors.any? %>
|
||||
<div id="error_explanation" data-turbo-cache="false">
|
||||
<h2>
|
||||
<%= I18n.t("errors.messages.not_saved",
|
||||
count: resource.errors.count,
|
||||
resource: resource.class.model_name.human.downcase)
|
||||
%>
|
||||
</h2>
|
||||
<ul>
|
||||
<% resource.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<div id="error_explanation" class="alert alert-error mb-4" data-turbo-cache="false">
|
||||
<%= icon 'circle-x' %>
|
||||
<div class="font-bold mb-4 flex items-center gap-2">
|
||||
<div>
|
||||
<h3 class="font-bold">
|
||||
<%= I18n.t("errors.messages.not_saved",
|
||||
count: resource.errors.count,
|
||||
resource: resource.class.model_name.human.downcase)
|
||||
%>
|
||||
</h3>
|
||||
<ul class="text-sm mt-1">
|
||||
<% resource.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@
|
|||
<% if policy(@family).destroy? %>
|
||||
<%= link_to family_path,
|
||||
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 %>
|
||||
<%= icon 'trash-2', class: "inline-block w-4" %>
|
||||
Delete Family
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
<% if !current_user.family_owner? && current_user.family_membership %>
|
||||
<%= link_to family_member_path(current_user.family_membership),
|
||||
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 %>
|
||||
Leave Family
|
||||
<% end %>
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
<% if policy(@family).destroy? %>
|
||||
<%= link_to family_path,
|
||||
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 %>
|
||||
<%= icon 'trash-2', class: "inline-block w-4" %>
|
||||
Delete
|
||||
|
|
@ -175,38 +175,46 @@
|
|||
<% if @pending_invitations.any? %>
|
||||
<div class="space-y-3 mb-4">
|
||||
<% @pending_invitations.each do |invitation| %>
|
||||
<div class="flex items-center justify-between p-3 bg-base-100 rounded-lg">
|
||||
<div class="flex-grow">
|
||||
<div class="font-medium text-base-content"><%= invitation.email %></div>
|
||||
<div class="text-sm text-base-content opacity-60">
|
||||
<%= t('families.show.invited_on', default: 'Invited') %>
|
||||
<%= invitation.created_at.strftime('%b %d, %Y') %>
|
||||
</div>
|
||||
<div class="text-xs text-base-content opacity-50">
|
||||
<%= t('families.show.expires_on', default: 'Expires') %>
|
||||
<%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<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 class="p-3 bg-base-100 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-grow">
|
||||
<div class="font-medium text-base-content"><%= invitation.email %></div>
|
||||
<div class="text-sm text-base-content opacity-60">
|
||||
<%= t('families.show.invited_on', default: 'Invited') %>
|
||||
<%= invitation.created_at.strftime('%b %d, %Y') %>
|
||||
</div>
|
||||
<div class="text-xs text-base-content opacity-50">
|
||||
<%= t('families.show.expires_on', default: 'Expires') %>
|
||||
<%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %>
|
||||
</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>
|
||||
<% 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>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<li><strong>✅ GPX:</strong> Track files (.gpx)</li>
|
||||
<li><strong>✅ GeoJSON:</strong> Feature collections (.json)</li>
|
||||
<li><strong>✅ OwnTracks:</strong> Recorder files (.rec)</li>
|
||||
<li><strong>✅ KML:</strong> KML files (.kml)</li>
|
||||
</ul>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
File format is automatically detected during upload.
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Full Screen Map Container -->
|
||||
<div class='absolute top-16 left-0 right-0 w-full z-20' style='height: calc(100vh - 4rem);'>
|
||||
<div class='absolute top-16 left-0 right-0 bottom-0 w-full z-20 flex flex-col'>
|
||||
<%= yield %>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@
|
|||
Here you can set a custom color scale for speed colored routes. It uses color stops at specified km/h values and creates a gradient from it. The default value is <code>0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300</code>
|
||||
</p>
|
||||
<p class="py-4">
|
||||
You can also use the 'Edit Scale' button to edit it using an UI.
|
||||
You can also use the 'Edit Colors' button to edit it using an UI.
|
||||
</p>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="speed_color_scale_info">Close</label>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
<% content_for :title, 'Map' %>
|
||||
|
||||
<!-- Floating Date Navigation Controls -->
|
||||
<div class="fixed top-20 left-0 right-0 flex justify-center" style="z-index: 9999; margin-left: 80px; margin-right: 80px;">
|
||||
<div style="width: 1500px; max-width: 100%;" data-controller="map-controls">
|
||||
<!-- Date Navigation Controls - Native Page Element -->
|
||||
<div class="w-full px-4 py-3 bg-base-100" data-controller="map-controls">
|
||||
<!-- Mobile: Compact Toggle Button -->
|
||||
<div class="lg:hidden justify-center flex">
|
||||
<div class="lg:hidden flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
data-action="click->map-controls#toggle"
|
||||
|
|
@ -19,7 +18,7 @@
|
|||
<!-- Expandable Panel (hidden on mobile by default, always visible on desktop) -->
|
||||
<div
|
||||
data-map-controls-target="panel"
|
||||
class="hidden lg:!block bg-base-100 bg-opacity-95 rounded-lg shadow-lg p-4 mt-2 lg:mt-0 scale-80">
|
||||
class="hidden lg:!block bg-base-100 rounded-lg shadow-lg p-4 mt-2 lg:mt-0">
|
||||
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
|
||||
<div class="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4 lg:items-end">
|
||||
<div class="w-full lg:w-1/12">
|
||||
|
|
@ -71,29 +70,30 @@
|
|||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full Screen Map -->
|
||||
<div
|
||||
id='map'
|
||||
class="absolute inset-0 w-full h-full z-0"
|
||||
data-controller="maps points add-visit family-members"
|
||||
data-points-target="map"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
|
||||
data-user_theme="<%= current_user&.theme || 'dark' %>"
|
||||
data-coordinates='<%= @coordinates.to_json.html_safe %>'
|
||||
data-tracks='<%= @tracks.to_json.html_safe %>'
|
||||
data-distance="<%= @distance %>"
|
||||
data-points_number="<%= @points_number %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>"
|
||||
data-features='<%= @features.to_json.html_safe %>'
|
||||
data-family-members-features-value='<%= @features.to_json.html_safe %>'
|
||||
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
|
||||
<div data-maps-target="container" class="w-full h-full">
|
||||
<div id="fog" class="fog"></div>
|
||||
<!-- Map Container - Fills remaining space -->
|
||||
<div class="w-full h-full">
|
||||
<div
|
||||
id='map'
|
||||
class="w-full h-full"
|
||||
data-controller="maps points add-visit family-members"
|
||||
data-points-target="map"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
|
||||
data-user_theme="<%= current_user&.theme || 'dark' %>"
|
||||
data-coordinates='<%= @coordinates.to_json.html_safe %>'
|
||||
data-tracks='<%= @tracks.to_json.html_safe %>'
|
||||
data-distance="<%= @distance %>"
|
||||
data-points_number="<%= @points_number %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>"
|
||||
data-features='<%= @features.to_json.html_safe %>'
|
||||
data-family-members-features-value='<%= @features.to_json.html_safe %>'
|
||||
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
|
||||
<div data-maps-target="container" class="w-full h-full">
|
||||
<div id="fog" class="fog"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -156,8 +156,7 @@
|
|||
<li>
|
||||
<details>
|
||||
<summary>
|
||||
<span class="hidden xl:inline"><%= current_user.email %></span>
|
||||
<span class="inline xl:hidden"><%= icon 'user' %></span>
|
||||
<span class="inline"><%= icon 'user' %></span>
|
||||
<% if onboarding_modal_showable?(current_user) %>
|
||||
<span class="indicator-item badge badge-secondary badge-xs"></span>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
|
||||
<% if DawarichSettings.reverse_geocoding_enabled? %>
|
||||
<%= render 'stats/reverse_geocoding_stats' %>
|
||||
<% else %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class='text-xs text-gray-500 text-center mt-5'>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
default: &default
|
||||
adapter: redis
|
||||
url: <%= "#{ENV.fetch("REDIS_URL")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %>
|
||||
url: <%= "#{ENV.fetch("REDIS_URL", "redis://localhost:6379")}" %>
|
||||
db: <%= ENV.fetch('RAILS_WS_DB', 2) %>
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ Rails.application.configure do
|
|||
|
||||
# Enable/disable caching. By default caching is disabled.
|
||||
# Run rails dev:cache to toggle caching.
|
||||
config.cache_store = :redis_cache_store, { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" }
|
||||
config.cache_store = :redis_cache_store, {
|
||||
url: ENV['REDIS_URL'],
|
||||
db: ENV.fetch('RAILS_CACHE_DB', 0)
|
||||
}
|
||||
|
||||
if Rails.root.join('tmp/caching-dev.txt').exist?
|
||||
config.action_controller.perform_caching = true
|
||||
|
|
@ -86,7 +89,7 @@ Rails.application.configure do
|
|||
# Raise error when a before_action's only/except options reference missing actions
|
||||
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) }
|
||||
|
||||
|
|
@ -99,5 +102,5 @@ Rails.application.configure do
|
|||
config.lograge.enabled = true
|
||||
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
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ Rails.application.configure do
|
|||
# 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).
|
||||
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'
|
||||
|
||||
|
|
@ -73,7 +73,10 @@ Rails.application.configure do
|
|||
config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info')
|
||||
|
||||
# Use a different cache store in production.
|
||||
config.cache_store = :redis_cache_store, { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" }
|
||||
config.cache_store = :redis_cache_store, {
|
||||
url: ENV['REDIS_URL'],
|
||||
db: ENV.fetch('RAILS_CACHE_DB', 0)
|
||||
}
|
||||
|
||||
# Use a real queuing backend for Active Job (and separate queues per environment).
|
||||
config.active_job.queue_adapter = :sidekiq
|
||||
|
|
@ -101,7 +104,7 @@ Rails.application.configure do
|
|||
# ]
|
||||
# Skip DNS rebinding protection for the health check endpoint.
|
||||
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.hosts.concat(hosts) if hosts.present?
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ Rails.application.configure do
|
|||
# ]
|
||||
# Skip DNS rebinding protection for the health check endpoint.
|
||||
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.hosts.concat(hosts) if hosts.present?
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ settings = {
|
|||
debug_mode: true,
|
||||
timeout: 5,
|
||||
units: :km,
|
||||
cache: Redis.new(url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}"),
|
||||
cache: Redis.new(url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_CACHE_DB', 0)),
|
||||
always_raise: :all,
|
||||
http_headers: {
|
||||
'User-Agent' => "Dawarich #{APP_VERSION} (https://dawarich.app)"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Sidekiq.configure_server do |config|
|
||||
config.redis = { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_JOB_QUEUE_DB', 1)}" }
|
||||
config.redis = { url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_JOB_QUEUE_DB', 1) }
|
||||
config.logger = Sidekiq::Logger.new($stdout)
|
||||
|
||||
if ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true'
|
||||
|
|
|
|||
|
|
@ -1,26 +1,31 @@
|
|||
# Mark existing migrations as safe
|
||||
StrongMigrations.start_after = 20_250_122_150_500
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Set timeouts for migrations
|
||||
# If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user
|
||||
StrongMigrations.lock_timeout = 10.seconds
|
||||
StrongMigrations.statement_timeout = 1.hour
|
||||
# return unless Rails.env.development?
|
||||
|
||||
# Analyze tables after indexes are added
|
||||
# Outdated statistics can sometimes hurt performance
|
||||
StrongMigrations.auto_analyze = true
|
||||
# # Mark existing migrations as safe
|
||||
# StrongMigrations.start_after = 20_250_122_150_500
|
||||
|
||||
# Set the version of the production database
|
||||
# so the right checks are run in development
|
||||
# StrongMigrations.target_version = 10
|
||||
# # Set timeouts for migrations
|
||||
# # PgBouncer in transaction mode doesn't support SET commands
|
||||
# # Timeouts should be set on the database user instead
|
||||
# # StrongMigrations.lock_timeout = 10.seconds
|
||||
# # StrongMigrations.statement_timeout = 1.hour
|
||||
|
||||
# Add custom checks
|
||||
# StrongMigrations.add_check do |method, args|
|
||||
# if method == :add_index && args[0].to_s == "users"
|
||||
# stop! "No more indexes on the users table"
|
||||
# end
|
||||
# end
|
||||
# # Analyze tables after indexes are added
|
||||
# # Outdated statistics can sometimes hurt performance
|
||||
# StrongMigrations.auto_analyze = true
|
||||
|
||||
# Make some operations safe by default
|
||||
# See https://github.com/ankane/strong_migrations#safe-by-default
|
||||
# StrongMigrations.safe_by_default = true
|
||||
# # Set the version of the production database
|
||||
# # so the right checks are run in development
|
||||
# # StrongMigrations.target_version = 10
|
||||
|
||||
# # Add custom checks
|
||||
# # StrongMigrations.add_check do |method, args|
|
||||
# # if method == :add_index && args[0].to_s == "users"
|
||||
# # stop! "No more indexes on the users table"
|
||||
# # end
|
||||
# # end
|
||||
|
||||
# # Make some operations safe by default
|
||||
# # See https://github.com/ankane/strong_migrations#safe-by-default
|
||||
# # StrongMigrations.safe_by_default = true
|
||||
|
|
|
|||
|
|
@ -126,7 +126,11 @@ Rails.application.routes.draw do
|
|||
get 'suggestions'
|
||||
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
|
||||
get 'possible_places', to: 'visits/possible_places#index', on: :member
|
||||
collection do
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@ local:
|
|||
root: <%= Rails.root.join("storage") %>
|
||||
|
||||
# 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:
|
||||
service: S3
|
||||
access_key_id: <%= ENV.fetch("AWS_ACCESS_KEY_ID") %>
|
||||
secret_access_key: <%= ENV.fetch("AWS_SECRET_ACCESS_KEY") %>
|
||||
region: <%= ENV.fetch("AWS_REGION") %>
|
||||
bucket: <%= ENV.fetch("AWS_BUCKET") %>
|
||||
endpoint: <%= ENV.fetch("AWS_ENDPOINT_URL") %>
|
||||
<% end %>
|
||||
|
||||
# Remember not to checkin your GCS keyfile to a repository
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
class AddVisitedCountriesToTrips < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
safety_assured do
|
||||
# safety_assured do
|
||||
execute <<-SQL
|
||||
ALTER TABLE trips ADD COLUMN visited_countries JSONB DEFAULT '{}'::jsonb NOT NULL;
|
||||
SQL
|
||||
end
|
||||
# end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ class AddH3HexIdsToStats < ActiveRecord::Migration[8.0]
|
|||
|
||||
def change
|
||||
add_column :stats, :h3_hex_ids, :jsonb, default: {}, if_not_exists: true
|
||||
safety_assured do
|
||||
# safety_assured do
|
||||
add_index :stats, :h3_hex_ids, using: :gin,
|
||||
where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)",
|
||||
algorithm: :concurrently, if_not_exists: true
|
||||
end
|
||||
# end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class CreateFamilies < ActiveRecord::Migration[8.0]
|
|||
t.timestamps
|
||||
end
|
||||
|
||||
add_foreign_key :families, :users, column: :creator_id, validate: false
|
||||
add_foreign_key :families, :users, column: :creator_id
|
||||
add_index :families, :creator_id
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ class CreateFamilyMemberships < ActiveRecord::Migration[8.0]
|
|||
t.timestamps
|
||||
end
|
||||
|
||||
add_foreign_key :family_memberships, :families, validate: false
|
||||
add_foreign_key :family_memberships, :users, validate: false
|
||||
add_foreign_key :family_memberships, :families
|
||||
add_foreign_key :family_memberships, :users
|
||||
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'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ class CreateFamilyInvitations < ActiveRecord::Migration[8.0]
|
|||
t.timestamps
|
||||
end
|
||||
|
||||
add_foreign_key :family_invitations, :families, validate: false
|
||||
add_foreign_key :family_invitations, :users, column: :invited_by_id, validate: false
|
||||
add_foreign_key :family_invitations, :families
|
||||
add_foreign_key :family_invitations, :users, column: :invited_by_id
|
||||
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 status expires_at],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
class ValidateFamilyForeignKeys < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
validate_foreign_key :families, :users
|
||||
validate_foreign_key :family_memberships, :families
|
||||
validate_foreign_key :family_memberships, :users
|
||||
validate_foreign_key :family_invitations, :families
|
||||
validate_foreign_key :family_invitations, :users
|
||||
# No longer needed - foreign keys are now validated immediately in their creation migrations
|
||||
# validate_foreign_key :families, :users
|
||||
# validate_foreign_key :family_memberships, :families
|
||||
# validate_foreign_key :family_memberships, :users
|
||||
# validate_foreign_key :family_invitations, :families
|
||||
# validate_foreign_key :family_invitations, :users
|
||||
end
|
||||
end
|
||||
|
|
|
|||
11
db/migrate/20251030190924_add_utm_parameters_to_users.rb
Normal file
11
db/migrate/20251030190924_add_utm_parameters_to_users.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddUtmParametersToUsers < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :users, :utm_source, :string
|
||||
add_column :users, :utm_medium, :string
|
||||
add_column :users, :utm_campaign, :string
|
||||
add_column :users, :utm_term, :string
|
||||
add_column :users, :utm_content, :string
|
||||
end
|
||||
end
|
||||
7
db/schema.rb
generated
7
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_10_28_160950) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_10_30_190924) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
|
|
@ -320,6 +320,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_28_160950) do
|
|||
t.text "patreon_access_token"
|
||||
t.text "patreon_refresh_token"
|
||||
t.datetime "patreon_token_expires_at"
|
||||
t.string "utm_source"
|
||||
t.string "utm_medium"
|
||||
t.string "utm_campaign"
|
||||
t.string "utm_term"
|
||||
t.string "utm_content"
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
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
|
||||
|
||||
ARG RAILS_ENV=production
|
||||
|
||||
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=production
|
||||
|
||||
RUN apt-get update -qq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \
|
||||
|
|
@ -25,12 +26,16 @@ RUN apt-get update -qq \
|
|||
less \
|
||||
libjemalloc2 libjemalloc-dev \
|
||||
cmake \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm install -g yarn \
|
||||
ca-certificates \
|
||||
&& mkdir -p $APP_PATH \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js from Debian repositories (supports all architectures including armv7)
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get install -y nodejs npm \
|
||||
&& 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; \
|
||||
|
|
@ -41,7 +46,7 @@ RUN if [ "$(uname -m)" = "x86_64" ]; then \
|
|||
# Enable YJIT
|
||||
ENV RUBY_YJIT_ENABLE=1
|
||||
|
||||
# Update gem system and install bundler
|
||||
# Update RubyGems and install Bundler
|
||||
RUN gem update --system 3.6.9 \
|
||||
&& gem install bundler --version "$BUNDLE_VERSION" \
|
||||
&& rm -rf $GEM_HOME/cache/*
|
||||
|
|
@ -58,7 +63,7 @@ RUN bundle config set --local path 'vendor/bundle' \
|
|||
|
||||
COPY ../. ./
|
||||
|
||||
# Precompile assets for production
|
||||
# Precompile assets
|
||||
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rake assets:precompile \
|
||||
&& 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:
|
||||
dawarich:
|
||||
|
||||
services:
|
||||
dawarich_redis:
|
||||
image: redis:7.4-alpine
|
||||
|
|
@ -16,28 +17,30 @@ services:
|
|||
retries: 5
|
||||
start_period: 30s
|
||||
timeout: 10s
|
||||
|
||||
dawarich_db:
|
||||
image: postgis/postgis:17-3.5-alpine
|
||||
# image: imresamu/postgis:17-3.5-alpine # If you're on ARM architecture, use this image instead
|
||||
shm_size: 1G
|
||||
container_name: dawarich_db
|
||||
volumes:
|
||||
- dawarich_db_data:/var/lib/postgresql/data
|
||||
- dawarich_shared:/var/shared
|
||||
# - ./postgresql.conf:/etc/postgresql/postgresql.conf # Optional, uncomment if you want to use a custom config
|
||||
networks:
|
||||
- dawarich
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: dawarich_development
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-dawarich_development}
|
||||
restart: always
|
||||
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
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
timeout: 10s
|
||||
# command: postgres -c config_file=/etc/postgresql/postgresql.conf # Use custom config, uncomment if you want to use a custom config
|
||||
|
||||
dawarich_app:
|
||||
image: freikin/dawarich:latest
|
||||
container_name: dawarich_app
|
||||
|
|
@ -49,34 +52,37 @@ services:
|
|||
networks:
|
||||
- dawarich
|
||||
ports:
|
||||
- 3000:3000
|
||||
# - 9394:9394 # Prometheus exporter, uncomment if needed
|
||||
- "${DAWARICH_APP_PORT:-3000}:3000"
|
||||
# - "${PROMETHEUS_PORT:-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: development
|
||||
REDIS_URL: redis://dawarich_redis:6379
|
||||
DATABASE_HOST: dawarich_db
|
||||
DATABASE_USERNAME: postgres
|
||||
DATABASE_PASSWORD: password
|
||||
DATABASE_NAME: dawarich_development
|
||||
MIN_MINUTES_SPENT_IN_CITY: 60
|
||||
APPLICATION_HOSTS: localhost
|
||||
TIME_ZONE: Europe/London
|
||||
APPLICATION_PROTOCOL: http
|
||||
PROMETHEUS_EXPORTER_ENABLED: "false"
|
||||
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
|
||||
PROMETHEUS_EXPORTER_PORT: 9394
|
||||
SELF_HOSTED: "true"
|
||||
STORE_GEODATA: "true"
|
||||
RAILS_ENV: ${RAILS_ENV:-development}
|
||||
REDIS_URL: ${REDIS_URL:-redis://dawarich_redis:6379}
|
||||
DATABASE_HOST: ${DATABASE_HOST:-dawarich_db}
|
||||
DATABASE_PORT: ${DATABASE_PORT:-5432}
|
||||
DATABASE_USERNAME: ${DATABASE_USERNAME:-postgres}
|
||||
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
|
||||
DATABASE_NAME: ${DATABASE_NAME:-dawarich_development}
|
||||
MIN_MINUTES_SPENT_IN_CITY: ${MIN_MINUTES_SPENT_IN_CITY:-60}
|
||||
APPLICATION_HOSTS: ${APPLICATION_HOSTS:-localhost,::1,127.0.0.1}
|
||||
TIME_ZONE: ${TIME_ZONE:-Europe/London}
|
||||
APPLICATION_PROTOCOL: ${APPLICATION_PROTOCOL:-http}
|
||||
PROMETHEUS_EXPORTER_ENABLED: ${PROMETHEUS_EXPORTER_ENABLED:-false}
|
||||
PROMETHEUS_EXPORTER_HOST: ${PROMETHEUS_EXPORTER_HOST:-0.0.0.0}
|
||||
PROMETHEUS_EXPORTER_PORT: ${PROMETHEUS_EXPORTER_PORT:-9394}
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-"CHANGE_ME"}
|
||||
RAILS_LOG_TO_STDOUT: ${RAILS_LOG_TO_STDOUT:-true}
|
||||
SELF_HOSTED: ${SELF_HOSTED:-true}
|
||||
STORE_GEODATA: ${STORE_GEODATA:-true}
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
max-size: ${LOG_MAX_SIZE:-100m}
|
||||
max-file: ${LOG_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
|
||||
|
|
@ -93,8 +99,9 @@ services:
|
|||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.50' # Limit CPU usage to 50% of one core
|
||||
memory: '4G' # Limit memory usage to 4GB
|
||||
cpus: ${APP_CPU_LIMIT:-0.50}
|
||||
memory: ${APP_MEMORY_LIMIT:-4G}
|
||||
|
||||
dawarich_sidekiq:
|
||||
image: freikin/dawarich:latest
|
||||
container_name: dawarich_sidekiq
|
||||
|
|
@ -110,25 +117,28 @@ services:
|
|||
command: ['sidekiq']
|
||||
restart: on-failure
|
||||
environment:
|
||||
RAILS_ENV: development
|
||||
REDIS_URL: redis://dawarich_redis:6379
|
||||
DATABASE_HOST: dawarich_db
|
||||
DATABASE_USERNAME: postgres
|
||||
DATABASE_PASSWORD: password
|
||||
DATABASE_NAME: dawarich_development
|
||||
APPLICATION_HOSTS: localhost
|
||||
BACKGROUND_PROCESSING_CONCURRENCY: 10
|
||||
APPLICATION_PROTOCOL: http
|
||||
PROMETHEUS_EXPORTER_ENABLED: "false"
|
||||
PROMETHEUS_EXPORTER_HOST: dawarich_app
|
||||
PROMETHEUS_EXPORTER_PORT: 9394
|
||||
SELF_HOSTED: "true"
|
||||
STORE_GEODATA: "true"
|
||||
RAILS_ENV: ${RAILS_ENV:-development}
|
||||
REDIS_URL: ${REDIS_URL:-redis://dawarich_redis:6379}
|
||||
DATABASE_HOST: ${DATABASE_HOST:-dawarich_db}
|
||||
DATABASE_PORT: ${DATABASE_PORT:-5432}
|
||||
DATABASE_USERNAME: ${DATABASE_USERNAME:-postgres}
|
||||
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
|
||||
DATABASE_NAME: ${DATABASE_NAME:-dawarich_development}
|
||||
APPLICATION_HOSTS: ${APPLICATION_HOSTS:-localhost,::1,127.0.0.1}
|
||||
BACKGROUND_PROCESSING_CONCURRENCY: ${BACKGROUND_PROCESSING_CONCURRENCY:-10}
|
||||
APPLICATION_PROTOCOL: ${APPLICATION_PROTOCOL:-http}
|
||||
PROMETHEUS_EXPORTER_ENABLED: ${PROMETHEUS_EXPORTER_ENABLED:-false}
|
||||
PROMETHEUS_EXPORTER_HOST: ${PROMETHEUS_EXPORTER_HOST_SIDEKIQ:-dawarich_app}
|
||||
PROMETHEUS_EXPORTER_PORT: ${PROMETHEUS_EXPORTER_PORT:-9394}
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-"CHANGE_ME"}
|
||||
RAILS_LOG_TO_STDOUT: ${RAILS_LOG_TO_STDOUT:-true}
|
||||
SELF_HOSTED: ${SELF_HOSTED:-true}
|
||||
STORE_GEODATA: ${STORE_GEODATA:-true}
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
max-size: ${LOG_MAX_SIZE:-100m}
|
||||
max-file: ${LOG_MAX_FILE:-5}
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pgrep -f sidekiq" ]
|
||||
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('/')`. */
|
||||
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 */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
|
||||
/* Take screenshot on failure */
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
|
||||
/* Record video on failure */
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
// Setup project - runs authentication before all tests
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /.*\/setup\/auth\.setup\.js/
|
||||
},
|
||||
|
||||
{
|
||||
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 */
|
||||
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',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
listen_addresses = '*'
|
||||
max_connections = 50
|
||||
|
||||
shared_buffers = 512MB
|
||||
|
||||
work_mem = 128MB
|
||||
maintenance_work_mem = 128MB
|
||||
|
||||
|
||||
dynamic_shared_memory_type = posix
|
||||
checkpoint_timeout = 10min # range 30s-1d
|
||||
max_wal_size = 2GB
|
||||
min_wal_size = 80MB
|
||||
max_parallel_workers_per_gather = 4
|
||||
|
||||
log_min_duration_statement = 500 # -1 is disabled, 0 logs all statements
|
||||
# -1 disables, 0 logs all temp files
|
||||
log_timezone = 'UTC'
|
||||
|
||||
|
||||
autovacuum_vacuum_scale_factor = 0.05 # fraction of table size before vacuum
|
||||
autovacuum_analyze_scale_factor = 0.05 # fraction of table size before analyze
|
||||
|
||||
|
||||
datestyle = 'iso, dmy'
|
||||
|
||||
timezone = 'UTC'
|
||||
|
||||
lc_messages = 'en_US.utf8' # locale for system error message
|
||||
# strings
|
||||
lc_monetary = 'en_US.utf8' # locale for monetary formatting
|
||||
lc_numeric = 'en_US.utf8' # locale for number formatting
|
||||
lc_time = 'en_US.utf8' # locale for time formatting
|
||||
|
||||
|
||||
default_text_search_config = 'pg_catalog.english'
|
||||
27
spec/fixtures/files/kml/extended_data.kml
vendored
Normal file
27
spec/fixtures/files/kml/extended_data.kml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<name>Extended Data Example</name>
|
||||
<Placemark>
|
||||
<name>Location with Speed</name>
|
||||
<description>A location with extended data including speed</description>
|
||||
<TimeStamp>
|
||||
<when>2024-01-19T11:30:00Z</when>
|
||||
</TimeStamp>
|
||||
<Point>
|
||||
<coordinates>-122.0841,37.4220,10</coordinates>
|
||||
</Point>
|
||||
<ExtendedData>
|
||||
<Data name="speed">
|
||||
<value>5.5</value>
|
||||
</Data>
|
||||
<Data name="accuracy">
|
||||
<value>10</value>
|
||||
</Data>
|
||||
<Data name="battery">
|
||||
<value>85</value>
|
||||
</Data>
|
||||
</ExtendedData>
|
||||
</Placemark>
|
||||
</Document>
|
||||
</kml>
|
||||
19
spec/fixtures/files/kml/gx_track.kml
vendored
Normal file
19
spec/fixtures/files/kml/gx_track.kml
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
|
||||
<Document>
|
||||
<name>Google Earth Track</name>
|
||||
<Placemark>
|
||||
<name>GPS Track</name>
|
||||
<gx:Track>
|
||||
<when>2024-01-20T08:00:00Z</when>
|
||||
<when>2024-01-20T08:01:00Z</when>
|
||||
<when>2024-01-20T08:02:00Z</when>
|
||||
<when>2024-01-20T08:03:00Z</when>
|
||||
<gx:coord>-122.0841 37.4220 10</gx:coord>
|
||||
<gx:coord>-122.0851 37.4230 12</gx:coord>
|
||||
<gx:coord>-122.0861 37.4240 14</gx:coord>
|
||||
<gx:coord>-122.0871 37.4250 16</gx:coord>
|
||||
</gx:Track>
|
||||
</Placemark>
|
||||
</Document>
|
||||
</kml>
|
||||
24
spec/fixtures/files/kml/invalid_coordinates.kml
vendored
Normal file
24
spec/fixtures/files/kml/invalid_coordinates.kml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<name>Invalid Coordinates</name>
|
||||
<Placemark>
|
||||
<name>No Coordinates</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-23T10:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<Point>
|
||||
<coordinates></coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
<Placemark>
|
||||
<name>Only Longitude</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-23T11:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<Point>
|
||||
<coordinates>-122.0841</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
</Document>
|
||||
</kml>
|
||||
46
spec/fixtures/files/kml/large_track.kml
vendored
Normal file
46
spec/fixtures/files/kml/large_track.kml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<name>Large Track for Batch Testing</name>
|
||||
<Placemark>
|
||||
<name>Long Track</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-25T00:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<LineString>
|
||||
<coordinates>
|
||||
-122.0841,37.4220,10
|
||||
-122.0842,37.4221,10
|
||||
-122.0843,37.4222,10
|
||||
-122.0844,37.4223,10
|
||||
-122.0845,37.4224,10
|
||||
-122.0846,37.4225,10
|
||||
-122.0847,37.4226,10
|
||||
-122.0848,37.4227,10
|
||||
-122.0849,37.4228,10
|
||||
-122.0850,37.4229,10
|
||||
</coordinates>
|
||||
</LineString>
|
||||
</Placemark>
|
||||
<Placemark>
|
||||
<name>Another Long Track</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-25T12:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<LineString>
|
||||
<coordinates>
|
||||
-122.0851,37.4230,12
|
||||
-122.0852,37.4231,12
|
||||
-122.0853,37.4232,12
|
||||
-122.0854,37.4233,12
|
||||
-122.0855,37.4234,12
|
||||
-122.0856,37.4235,12
|
||||
-122.0857,37.4236,12
|
||||
-122.0858,37.4237,12
|
||||
-122.0859,37.4238,12
|
||||
-122.0860,37.4239,12
|
||||
</coordinates>
|
||||
</LineString>
|
||||
</Placemark>
|
||||
</Document>
|
||||
</kml>
|
||||
21
spec/fixtures/files/kml/linestring_track.kml
vendored
Normal file
21
spec/fixtures/files/kml/linestring_track.kml
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<name>LineString Track</name>
|
||||
<Placemark>
|
||||
<name>My Track</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-16T10:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<LineString>
|
||||
<coordinates>
|
||||
-122.0841,37.4220,10
|
||||
-122.0851,37.4230,12
|
||||
-122.0861,37.4240,14
|
||||
-122.0871,37.4250,16
|
||||
-122.0881,37.4260,18
|
||||
</coordinates>
|
||||
</LineString>
|
||||
</Placemark>
|
||||
</Document>
|
||||
</kml>
|
||||
28
spec/fixtures/files/kml/multigeometry.kml
vendored
Normal file
28
spec/fixtures/files/kml/multigeometry.kml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<name>MultiGeometry Example</name>
|
||||
<Placemark>
|
||||
<name>Multiple Geometries</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-18T15:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<MultiGeometry>
|
||||
<Point>
|
||||
<coordinates>-122.0841,37.4220,10</coordinates>
|
||||
</Point>
|
||||
<Point>
|
||||
<coordinates>-122.0851,37.4230,12</coordinates>
|
||||
</Point>
|
||||
<LineString>
|
||||
<coordinates>
|
||||
-122.0861,37.4240,14
|
||||
-122.0871,37.4250,16
|
||||
-122.0881,37.4260,18
|
||||
-122.0891,37.4270,20
|
||||
</coordinates>
|
||||
</LineString>
|
||||
</MultiGeometry>
|
||||
</Placemark>
|
||||
</Document>
|
||||
</kml>
|
||||
51
spec/fixtures/files/kml/nested_folders.kml
vendored
Normal file
51
spec/fixtures/files/kml/nested_folders.kml
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<name>Nested Folders</name>
|
||||
<Folder>
|
||||
<name>Trip 1</name>
|
||||
<Placemark>
|
||||
<name>Start Point</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-21T08:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<Point>
|
||||
<coordinates>-122.0841,37.4220,10</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
<Folder>
|
||||
<name>Day 1</name>
|
||||
<Placemark>
|
||||
<name>Checkpoint 1</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-21T12:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<Point>
|
||||
<coordinates>-122.0851,37.4230,12</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
</Folder>
|
||||
</Folder>
|
||||
<Folder>
|
||||
<name>Trip 2</name>
|
||||
<Placemark>
|
||||
<name>Location A</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-22T10:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<Point>
|
||||
<coordinates>-122.0861,37.4240,14</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
<Placemark>
|
||||
<name>Location B</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-22T14:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<Point>
|
||||
<coordinates>-122.0871,37.4250,16</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
</Folder>
|
||||
</Document>
|
||||
</kml>
|
||||
33
spec/fixtures/files/kml/points_with_timestamps.kml
vendored
Normal file
33
spec/fixtures/files/kml/points_with_timestamps.kml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<name>Points with Timestamps</name>
|
||||
<Placemark>
|
||||
<name>Location 1</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-15T12:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<Point>
|
||||
<coordinates>-122.0841,37.4220,10</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
<Placemark>
|
||||
<name>Location 2</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-15T13:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<Point>
|
||||
<coordinates>-122.0851,37.4230,15</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
<Placemark>
|
||||
<name>Location 3</name>
|
||||
<TimeStamp>
|
||||
<when>2024-01-15T14:00:00Z</when>
|
||||
</TimeStamp>
|
||||
<Point>
|
||||
<coordinates>-122.0861,37.4240,20</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
</Document>
|
||||
</kml>
|
||||
16
spec/fixtures/files/kml/timespan.kml
vendored
Normal file
16
spec/fixtures/files/kml/timespan.kml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<name>TimeSpan Example</name>
|
||||
<Placemark>
|
||||
<name>Visit Duration</name>
|
||||
<TimeSpan>
|
||||
<begin>2024-01-10T09:00:00Z</begin>
|
||||
<end>2024-01-10T17:00:00Z</end>
|
||||
</TimeSpan>
|
||||
<Point>
|
||||
<coordinates>-122.0841,37.4220,10</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
</Document>
|
||||
</kml>
|
||||
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
|
||||
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
|
||||
create(:family_membership, family: family, user: user, role: :owner)
|
||||
create_list(:family_membership, 3, family: family, role: :member)
|
||||
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(family.can_add_members?).to be true
|
||||
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 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
|
||||
|
||||
context 'when family has max members' do
|
||||
context 'when in self-hosted mode' do
|
||||
before do
|
||||
create(:family_membership, family: family, user: user, role: :owner)
|
||||
create_list(:family_membership, 4, family: family, role: :member)
|
||||
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(family.can_add_members?).to be false
|
||||
end
|
||||
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
|
||||
|
||||
context 'when family has no members' do
|
||||
it 'returns true' do
|
||||
expect(family.can_add_members?).to be true
|
||||
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 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
|
||||
|
|
@ -122,4 +191,99 @@ RSpec.describe Family, type: :model do
|
|||
expect(Family::Membership.find_by(id: membership.id)).to be_nil
|
||||
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
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue