diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 6b5f881a1..af3bbd7be 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -39,18 +39,11 @@ jobs: - '.github/workflows/deploy-staging.yml' - '.github/workflows/test.yml' - # ====================== RUN TESTS ====================== - run-tests: - name: Run Tests - needs: detect-changes - if: "!contains(github.event.head_commit.message, '[skip ci]')" - uses: ./.github/workflows/test.yml - # ====================== BUILD FRONTEND TEST ====================== build-frontend-test: name: Build Frontend (Test) runs-on: ubuntu-latest - needs: [detect-changes, run-tests] + needs: [detect-changes] if: | !contains(github.event.head_commit.message, '[skip ci]') && success() && @@ -105,7 +98,7 @@ jobs: build-backend-test: name: Build Backend (Test) runs-on: ubuntu-latest - needs: [detect-changes, run-tests] + needs: [detect-changes] if: | !contains(github.event.head_commit.message, '[skip ci]') && success() && diff --git a/.pre-commit-frontend-wrapper.sh b/.pre-commit-frontend-wrapper.sh index d1870d74b..bd79e5cee 100755 --- a/.pre-commit-frontend-wrapper.sh +++ b/.pre-commit-frontend-wrapper.sh @@ -35,7 +35,7 @@ fi echo "→ Running TypeScript (type checking)..." if command -v bun &> /dev/null; then - bun run tsc --noEmit || { + bun run tsc -b || { echo "TypeScript errors found. Please fix before committing." exit 1 } diff --git a/README.md b/README.md index 320f274d4..18840bda7 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Tests](https://github.com/dbca-wa/science-projects/actions/workflows/test.yml/badge.svg)](https://github.com/dbca-wa/science-projects/actions/workflows/test.yml) ![Frontend Coverage](https://img.shields.io/badge/frontend--coverage-66%25-orange) ![Backend Coverage](https://img.shields.io/badge/backend--coverage-83%25-green) +![WCAG 2.2 AA](https://img.shields.io/badge/WCAG%202.2-AA%20Compliant-green) A project management and approval system for scientific research projects. @@ -207,7 +208,7 @@ The CI/CD pipeline uses a modular approach with reusable workflows: **test.yml** (reusable workflow): - Called by deploy-staging.yml and deploy-prod.yml -- Frontend tests (2-way sharding, ~2 min) +- Frontend tests (2-way sharding, ~2 min) - includes accessibility tests - Backend tests (4-way sharding, ~10 min) - Coverage combining and validation - Path-based execution (only test changed code) diff --git a/backend/Dockerfile b/backend/Dockerfile index 3a99fd7d0..5878cd956 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -184,4 +184,4 @@ EXPOSE 8000 # CMD curl -f http://127.0.0.1:8000/health/ || exit 1 # Launch production server -CMD ["gunicorn", "config.wsgi", "--bind", "0.0.0.0:8000", "--timeout", "300", "--graceful-timeout", "90", "--max-requests", "2048", "--workers", "4", "--preload"] +CMD ["gunicorn", "config.wsgi", "--bind", "0.0.0.0:8000", "--timeout", "300", "--graceful-timeout", "90", "--max-requests", "2048", "--workers", "4", "--preload", "--worker-tmp-dir", "/tmp"] diff --git a/backend/caretakers/management/commands/migrate_caretaker_data.py b/backend/caretakers/management/commands/migrate_caretaker_data.py index c725d3bde..46ce389ce 100644 --- a/backend/caretakers/management/commands/migrate_caretaker_data.py +++ b/backend/caretakers/management/commands/migrate_caretaker_data.py @@ -13,7 +13,6 @@ from django.core.management.base import BaseCommand from django.db import connection, transaction -from psycopg2 import sql class Command(BaseCommand): @@ -139,10 +138,9 @@ def _table_exists(self, table_name): def _get_table_count(self, table_name): """Get the number of records in a table.""" with connection.cursor() as cursor: - # Use SQL identifier to prevent injection (table_name is hardcoded but bandit flags it) - cursor.execute( - sql.SQL("SELECT COUNT(*) FROM {}").format(sql.Identifier(table_name)) - ) + # Use string formatting with validated table name (table_name is hardcoded in callers) + # This is safe because table_name only comes from hardcoded strings in this file + cursor.execute(f"SELECT COUNT(*) FROM {table_name}") # nosec B608 return cursor.fetchone()[0] def _migrate_data(self): @@ -152,8 +150,7 @@ def _migrate_data(self): """ with connection.cursor() as cursor: # Insert new records that don't exist in destination - cursor.execute( - """ + cursor.execute(""" INSERT INTO caretakers_caretaker (id, user_id, caretaker_id, end_date, reason, notes, created_at, updated_at) SELECT @@ -172,13 +169,11 @@ def _migrate_data(self): AND dest.caretaker_id = src.caretaker_id ) ON CONFLICT (user_id, caretaker_id) DO NOTHING; - """ - ) + """) copied_count = cursor.rowcount # Update existing records to ensure data is current - cursor.execute( - """ + cursor.execute(""" UPDATE caretakers_caretaker dest SET end_date = src.end_date, @@ -196,8 +191,7 @@ def _migrate_data(self): dest.created_at IS DISTINCT FROM src.created_at OR dest.updated_at IS DISTINCT FROM src.updated_at ); - """ - ) + """) updated_count = cursor.rowcount return copied_count, updated_count diff --git a/backend/config/cache_settings.py b/backend/config/cache_settings.py index dd174ee5e..6d7aa814c 100644 --- a/backend/config/cache_settings.py +++ b/backend/config/cache_settings.py @@ -6,30 +6,117 @@ Note: Organisational Edge proxy (Nginx/Varnish/Fastly) handles HTTP-level caching. This Redis cache is for application-level caching of user-specific data that cannot be cached at the HTTP level. + +Redis is optional: +- If REDIS_URL is set and Redis is reachable: Caching and throttling enabled +- If Redis unavailable: Caching and throttling disabled (graceful degradation) +- Tests always use dummy cache (no Redis required) """ +import logging import os -# Redis Cache Configuration -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/1"), - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - "SOCKET_CONNECT_TIMEOUT": 5, # seconds - "SOCKET_TIMEOUT": 5, # seconds - "RETRY_ON_TIMEOUT": True, - "MAX_CONNECTIONS": 50, - "CONNECTION_POOL_KWARGS": { - "max_connections": 50, - "retry_on_timeout": True, - }, - }, - "KEY_PREFIX": "spms", - "TIMEOUT": 300, # 5 minutes default TTL +logger = logging.getLogger(__name__) + +# Track Redis availability globally for other modules +REDIS_AVAILABLE = False + + +def get_cache_config(): + """ + Get cache configuration - Redis if available, dummy cache otherwise. + + Behavior: + - Tests: Always use dummy cache (no Redis needed) + - Production/Staging/Development: Use Redis if REDIS_URL set and connectable + - If Redis unavailable: Use dummy cache and log clearly + + Sets global REDIS_AVAILABLE flag for other components (e.g., throttling). + + Returns: + dict: Django CACHES configuration + """ + global REDIS_AVAILABLE + + redis_url = os.environ.get("REDIS_URL") + is_testing = os.environ.get("PYTEST_RUNNING", "0") == "1" + environment = os.environ.get("ENVIRONMENT", "development") + + # Always use dummy cache for tests + if is_testing: + logger.info("CACHE: Using dummy cache for tests (Redis not required)") + REDIS_AVAILABLE = False + return { + "default": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + } + } + + # Try Redis if URL is provided + if redis_url: + try: + # Test Redis connection with short timeout + import redis + + client = redis.from_url(redis_url, socket_connect_timeout=2) + client.ping() + client.close() + + logger.info(f"CACHE: Redis connected successfully ({redis_url})") + logger.info("CACHE: Caching ENABLED") + logger.info("CACHE: Rate limiting ENABLED") + REDIS_AVAILABLE = True + + return { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": redis_url, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "SOCKET_CONNECT_TIMEOUT": 5, + "SOCKET_TIMEOUT": 5, + "RETRY_ON_TIMEOUT": True, + "MAX_CONNECTIONS": 50, + "CONNECTION_POOL_KWARGS": { + "max_connections": 50, + "retry_on_timeout": True, + }, + }, + "KEY_PREFIX": "spms", + "TIMEOUT": 300, # 5 minutes default TTL + } + } + except Exception as e: + logger.warning(f"CACHE: Redis connection failed ({redis_url}): {e}") + logger.warning("CACHE: Caching DISABLED (Redis unavailable)") + logger.warning("CACHE: Rate limiting DISABLED (Redis unavailable)") + if environment in ["staging", "production"]: + logger.warning( + f"CACHE: Redis is recommended for {environment}. " + "See documentation for setup instructions." + ) + REDIS_AVAILABLE = False + else: + logger.info("CACHE: REDIS_URL not set") + logger.info("CACHE: Caching DISABLED (no Redis configured)") + logger.info("CACHE: Rate limiting DISABLED (no Redis configured)") + if environment in ["staging", "production"]: + logger.info( + f"CACHE: Redis is recommended for {environment}. " + "See documentation for setup instructions." + ) + REDIS_AVAILABLE = False + + # Use dummy cache when Redis unavailable + return { + "default": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + } } -} + + +# Export cache configuration +CACHES = get_cache_config() # Cache Key Patterns # Use these patterns for consistent cache key generation across the application diff --git a/backend/config/settings.py b/backend/config/settings.py index 4e67e57f9..3d72be098 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -278,7 +278,44 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +# endregion ======================================================================================== + +# region Cache Configuration ====================================================== +# Import cache keys, TTL values, and Redis availability flag +from config.cache_settings import ( # noqa: E402, F401 + CACHE_KEYS, + CACHE_TTL, + CACHES, + REDIS_AVAILABLE, +) + +# endregion ======================================================================================== + +# region Logs and Tracking ======================================================================= +# Initialize logger early for use in configuration +LOGGER = logging.getLogger(__name__) + +# endregion ======================================================================================== +# region REST Framework Configuration ============================================= REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", @@ -286,6 +323,7 @@ "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.SessionAuthentication", ], + # Throttle configuration (always present, but only enforced if Redis is available) "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", "rest_framework.throttling.UserRateThrottle", @@ -301,45 +339,18 @@ }, } -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - }, - }, -] - -WSGI_APPLICATION = "config.wsgi.application" - -# endregion ======================================================================================== - -# region Cache Configuration ====================================================== -# Import cache keys and TTL values (needed by services) -from config.cache_settings import CACHE_KEYS, CACHE_TTL # noqa: E402, F401 - -# Use dummy cache for tests, Redis for production/development -if os.environ.get("PYTEST_RUNNING"): - # Dummy cache for tests (no Redis required) - CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - } - } +# Log throttling status based on Redis availability +if REDIS_AVAILABLE: + LOGGER.info("THROTTLING: Rate limiting ENABLED (Redis available)") else: - # Redis cache for production/development - from config.cache_settings import CACHES # noqa: E402, F401 + # Throttling configuration is present but won't be enforced without Redis + LOGGER.info( + "THROTTLING: Rate limiting DISABLED (Redis unavailable - throttling requires cache backend)" + ) # endregion ======================================================================================== -# region Logs and Tracking ======================================================================= +# region Sentry Configuration ===================================================== # Initialize Sentry only if SENTRY_URL is provided (optional) SENTRY_URL = env("SENTRY_URL", default=None) if ENVIRONMENT != "development" and SENTRY_URL: @@ -435,8 +446,6 @@ def format(self, record: LogRecord) -> str: }, } -LOGGER = logging.getLogger(__name__) - # endregion ======================================================================================== # region Django Debug Toolbar (Development Only) ====================================== diff --git a/backend/poetry.lock b/backend/poetry.lock index 99608f4c1..6757edd42 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -17,14 +17,14 @@ tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] [[package]] name = "autoflake" -version = "2.3.1" +version = "2.3.3" description = "Removes unused imports and unused variables" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840"}, - {file = "autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e"}, + {file = "autoflake-2.3.3-py3-none-any.whl", hash = "sha256:a51a3412aff16135ee5b3ec25922459fef10c1f23ce6d6c4977188df859e8b53"}, + {file = "autoflake-2.3.3.tar.gz", hash = "sha256:c24809541e23999f7a7b0d2faadf15deb0bc04cdde49728a2fd943a0c8055504"}, ] [package.dependencies] @@ -612,14 +612,14 @@ files = [ [[package]] name = "dj-database-url" -version = "3.1.0" +version = "3.1.2" description = "Use Database URLs in your Django Application." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "dj_database_url-3.1.0-py3-none-any.whl", hash = "sha256:155a56fbbecbaaf1348ccd73bf29138b4c9988363ba08261a0f0145e392e638c"}, - {file = "dj_database_url-3.1.0.tar.gz", hash = "sha256:d80218426b83f9302c8d27d4fccf52de5cf0cab179f0645fb2839f37605d1353"}, + {file = "dj_database_url-3.1.2-py3-none-any.whl", hash = "sha256:544e015fee3efa5127a1eb1cca465f4ace578265b3671fe61d0ed7dbafb5ec8a"}, + {file = "dj_database_url-3.1.2.tar.gz", hash = "sha256:63c20e4bbaa51690dfd4c8d189521f6bf6bc9da9fcdb23d95d2ee8ee87f9ec62"}, ] [package.dependencies] @@ -767,14 +767,14 @@ tzdata = ["tzdata"] [[package]] name = "filelock" -version = "3.23.0" +version = "3.24.3" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "filelock-3.23.0-py3-none-any.whl", hash = "sha256:4203c3f43983c7c95e4bbb68786f184f6acb7300899bf99d686bb82d526bdf62"}, - {file = "filelock-3.23.0.tar.gz", hash = "sha256:f64442f6f4707b9385049bb490be0bc48e3ab8e74ad27d4063435252917f4d4b"}, + {file = "filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d"}, + {file = "filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa"}, ] [[package]] @@ -831,14 +831,14 @@ files = [ [[package]] name = "hypothesis" -version = "6.151.6" +version = "6.151.9" description = "The property-based testing library for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "hypothesis-6.151.6-py3-none-any.whl", hash = "sha256:4e6e933a98c6f606b3e0ada97a750e7fff12277a40260b9300a05e7a5c3c5e2e"}, - {file = "hypothesis-6.151.6.tar.gz", hash = "sha256:755decfa326c8c97a4c8766fe40509985003396442138554b0ae824f9584318f"}, + {file = "hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9"}, + {file = "hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca"}, ] [package.dependencies] @@ -1199,60 +1199,60 @@ files = [ [[package]] name = "pandas" -version = "3.0.0" +version = "3.0.1" description = "Powerful data structures for data analysis, time series, and statistics" optional = false python-versions = ">=3.11" groups = ["main"] files = [ - {file = "pandas-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d64ce01eb9cdca96a15266aa679ae50212ec52757c79204dbc7701a222401850"}, - {file = "pandas-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:613e13426069793aa1ec53bdcc3b86e8d32071daea138bbcf4fa959c9cdaa2e2"}, - {file = "pandas-3.0.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0192fee1f1a8e743b464a6607858ee4b071deb0b118eb143d71c2a1d170996d5"}, - {file = "pandas-3.0.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b853319dec8d5e0c8b875374c078ef17f2269986a78168d9bd57e49bf650ae"}, - {file = "pandas-3.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:707a9a877a876c326ae2cb640fbdc4ef63b0a7b9e2ef55c6df9942dcee8e2af9"}, - {file = "pandas-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:afd0aa3d0b5cda6e0b8ffc10dbcca3b09ef3cbcd3fe2b27364f85fdc04e1989d"}, - {file = "pandas-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:113b4cca2614ff7e5b9fee9b6f066618fe73c5a83e99d721ffc41217b2bf57dd"}, - {file = "pandas-3.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c14837eba8e99a8da1527c0280bba29b0eb842f64aa94982c5e21227966e164b"}, - {file = "pandas-3.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9803b31f5039b3c3b10cc858c5e40054adb4b29b4d81cb2fd789f4121c8efbcd"}, - {file = "pandas-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14c2a4099cd38a1d18ff108168ea417909b2dea3bd1ebff2ccf28ddb6a74d740"}, - {file = "pandas-3.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d257699b9a9960e6125686098d5714ac59d05222bef7a5e6af7a7fd87c650801"}, - {file = "pandas-3.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69780c98f286076dcafca38d8b8eee1676adf220199c0a39f0ecbf976b68151a"}, - {file = "pandas-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4a66384f017240f3858a4c8a7cf21b0591c3ac885cddb7758a589f0f71e87ebb"}, - {file = "pandas-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be8c515c9bc33989d97b89db66ea0cececb0f6e3c2a87fcc8b69443a6923e95f"}, - {file = "pandas-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a453aad8c4f4e9f166436994a33884442ea62aa8b27d007311e87521b97246e1"}, - {file = "pandas-3.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:da768007b5a33057f6d9053563d6b74dd6d029c337d93c6d0d22a763a5c2ecc0"}, - {file = "pandas-3.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b78d646249b9a2bc191040988c7bb524c92fa8534fb0898a0741d7e6f2ffafa6"}, - {file = "pandas-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc9cba7b355cb4162442a88ce495e01cb605f17ac1e27d6596ac963504e0305f"}, - {file = "pandas-3.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c9a1a149aed3b6c9bf246033ff91e1b02d529546c5d6fb6b74a28fea0cf4c70"}, - {file = "pandas-3.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95683af6175d884ee89471842acfca29172a85031fccdabc35e50c0984470a0e"}, - {file = "pandas-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fbbb5a7288719e36b76b4f18d46ede46e7f916b6c8d9915b756b0a6c3f792b3"}, - {file = "pandas-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e8b9808590fa364416b49b2a35c1f4cf2785a6c156935879e57f826df22038e"}, - {file = "pandas-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:98212a38a709feb90ae658cb6227ea3657c22ba8157d4b8f913cd4c950de5e7e"}, - {file = "pandas-3.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:177d9df10b3f43b70307a149d7ec49a1229a653f907aa60a48f1877d0e6be3be"}, - {file = "pandas-3.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2713810ad3806767b89ad3b7b69ba153e1c6ff6d9c20f9c2140379b2a98b6c98"}, - {file = "pandas-3.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:15d59f885ee5011daf8335dff47dcb8a912a27b4ad7826dc6cbe809fd145d327"}, - {file = "pandas-3.0.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24e6547fb64d2c92665dd2adbfa4e85fa4fd70a9c070e7cfb03b629a0bbab5eb"}, - {file = "pandas-3.0.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48ee04b90e2505c693d3f8e8f524dab8cb8aaf7ddcab52c92afa535e717c4812"}, - {file = "pandas-3.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66f72fb172959af42a459e27a8d8d2c7e311ff4c1f7db6deb3b643dbc382ae08"}, - {file = "pandas-3.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4a4a400ca18230976724a5066f20878af785f36c6756e498e94c2a5e5d57779c"}, - {file = "pandas-3.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:940eebffe55528074341a5a36515f3e4c5e25e958ebbc764c9502cfc35ba3faa"}, - {file = "pandas-3.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:597c08fb9fef0edf1e4fa2f9828dd27f3d78f9b8c9b4a748d435ffc55732310b"}, - {file = "pandas-3.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:447b2d68ac5edcbf94655fe909113a6dba6ef09ad7f9f60c80477825b6c489fe"}, - {file = "pandas-3.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debb95c77ff3ed3ba0d9aa20c3a2f19165cc7956362f9873fce1ba0a53819d70"}, - {file = "pandas-3.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fedabf175e7cd82b69b74c30adbaa616de301291a5231138d7242596fc296a8d"}, - {file = "pandas-3.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:412d1a89aab46889f3033a386912efcdfa0f1131c5705ff5b668dda88305e986"}, - {file = "pandas-3.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e979d22316f9350c516479dd3a92252be2937a9531ed3a26ec324198a99cdd49"}, - {file = "pandas-3.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:083b11415b9970b6e7888800c43c82e81a06cd6b06755d84804444f0007d6bb7"}, - {file = "pandas-3.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:5db1e62cb99e739fa78a28047e861b256d17f88463c76b8dafc7c1338086dca8"}, - {file = "pandas-3.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:697b8f7d346c68274b1b93a170a70974cdc7d7354429894d5927c1effdcccd73"}, - {file = "pandas-3.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cb3120f0d9467ed95e77f67a75e030b67545bcfa08964e349252d674171def2"}, - {file = "pandas-3.0.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33fd3e6baa72899746b820c31e4b9688c8e1b7864d7aec2de7ab5035c285277a"}, - {file = "pandas-3.0.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8942e333dc67ceda1095227ad0febb05a3b36535e520154085db632c40ad084"}, - {file = "pandas-3.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:783ac35c4d0fe0effdb0d67161859078618b1b6587a1af15928137525217a721"}, - {file = "pandas-3.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:125eb901e233f155b268bbef9abd9afb5819db74f0e677e89a61b246228c71ac"}, - {file = "pandas-3.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b86d113b6c109df3ce0ad5abbc259fe86a1bd4adfd4a31a89da42f84f65509bb"}, - {file = "pandas-3.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1c39eab3ad38f2d7a249095f0a3d8f8c22cc0f847e98ccf5bbe732b272e2d9fa"}, - {file = "pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f"}, + {file = "pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea"}, + {file = "pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796"}, + {file = "pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389"}, + {file = "pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7"}, + {file = "pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf"}, + {file = "pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447"}, + {file = "pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79"}, + {file = "pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1"}, + {file = "pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d"}, + {file = "pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955"}, + {file = "pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b"}, + {file = "pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4"}, + {file = "pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1"}, + {file = "pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821"}, + {file = "pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43"}, + {file = "pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7"}, + {file = "pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262"}, + {file = "pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56"}, + {file = "pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e"}, + {file = "pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791"}, + {file = "pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a"}, + {file = "pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8"}, + {file = "pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25"}, + {file = "pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59"}, + {file = "pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06"}, + {file = "pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f"}, + {file = "pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324"}, + {file = "pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9"}, + {file = "pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76"}, + {file = "pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098"}, + {file = "pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35"}, + {file = "pandas-3.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a"}, + {file = "pandas-3.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f"}, + {file = "pandas-3.0.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749"}, + {file = "pandas-3.0.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249"}, + {file = "pandas-3.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee"}, + {file = "pandas-3.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c"}, + {file = "pandas-3.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66"}, + {file = "pandas-3.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132"}, + {file = "pandas-3.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32"}, + {file = "pandas-3.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87"}, + {file = "pandas-3.0.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988"}, + {file = "pandas-3.0.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221"}, + {file = "pandas-3.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff"}, + {file = "pandas-3.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5"}, + {file = "pandas-3.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937"}, + {file = "pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d"}, + {file = "pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8"}, ] [package.dependencies] @@ -1418,14 +1418,14 @@ xmp = ["defusedxml"] [[package]] name = "platformdirs" -version = "4.8.0" +version = "4.9.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "platformdirs-4.8.0-py3-none-any.whl", hash = "sha256:1c1328b4d2ea997bbcb904175a9bde14e824a3fa79f751ea3888d63d7d727557"}, - {file = "platformdirs-4.8.0.tar.gz", hash = "sha256:c1d4a51ab04087041dd602707fbe7ee8b62b64e590f30e336e5c99c2d0c542d2"}, + {file = "platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd"}, + {file = "platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291"}, ] [[package]] @@ -1465,14 +1465,14 @@ virtualenv = ">=20.10.0" [[package]] name = "psycopg" -version = "3.3.2" +version = "3.3.3" description = "PostgreSQL database adapter for Python" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b"}, - {file = "psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7"}, + {file = "psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698"}, + {file = "psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9"}, ] [package.dependencies] @@ -1480,76 +1480,76 @@ typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""} tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] -binary = ["psycopg-binary (==3.3.2) ; implementation_name != \"pypy\""] -c = ["psycopg-c (==3.3.2) ; implementation_name != \"pypy\""] -dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "cython-lint (>=0.16)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg", "isort[colors] (>=6.0)", "mypy (>=1.19.0)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "types-shapely (>=2.0)", "wheel (>=0.37)"] +binary = ["psycopg-binary (==3.3.3) ; implementation_name != \"pypy\""] +c = ["psycopg-c (==3.3.3) ; implementation_name != \"pypy\""] +dev = ["ast-comments (>=1.1.2)", "black (>=26.1.0)", "codespell (>=2.2)", "cython-lint (>=0.16)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg", "isort[colors] (>=6.0)", "mypy (>=1.19.0)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "types-shapely (>=2.0)", "wheel (>=0.37)"] docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] pool = ["psycopg-pool"] test = ["anyio (>=4.0)", "mypy (>=1.19.0) ; implementation_name != \"pypy\"", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] [[package]] name = "psycopg-binary" -version = "3.3.2" +version = "3.3.3" description = "PostgreSQL database adapter for Python -- C optimisation distribution" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "psycopg_binary-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0768c5f32934bb52a5df098317eca9bdcf411de627c5dca2ee57662b64b54b41"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:09b3014013f05cd89828640d3a1db5f829cc24ad8fa81b6e42b2c04685a0c9d4"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3789d452a9d17a841c7f4f97bbcba51a21f957ea35641a4c98507520e6b6a068"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44e89938d36acc4495735af70a886d206a5bfdc80258f95b69b52f68b2968d9e"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ed9da805e52985b0202aed4f352842c907c6b4fc6c7c109c6e646c32e2f43b"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3a9ccdfee4ae59cf9bf1822777e763bc097ed208f4901e21537fca1070e1391"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de9173f8cc0efd88ac2a89b3b6c287a9a0011cdc2f53b2a12c28d6fd55f9f81c"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0611f4822674f3269e507a307236efb62ae5a828fcfc923ac85fe22ca19fd7c8"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:522b79c7db547767ca923e441c19b97a2157f2f494272a119c854bba4804e186"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ea41c0229f3f5a3844ad0857a83a9f869aa7b840448fa0c200e6bcf85d33d19"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:8ea05b499278790a8fa0ff9854ab0de2542aca02d661ddff94e830df971ff640"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94503b79f7da0b65c80d0dbb2f81dd78b300319ec2435d5e6dcf9622160bc2fa"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07a5f030e0902ec3e27d0506ceb01238c0aecbc73ecd7fa0ee55f86134600b5b"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e09d0d93d35c134704a2cb2b15f81ffc8174fd602f3e08f7b1a3d8896156cf0"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:649c1d33bedda431e0c1df646985fbbeb9274afa964e1aef4be053c0f23a2924"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5774272f754605059521ff037a86e680342e3847498b0aa86b0f3560c70963c"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d391b70c9cc23f6e1142729772a011f364199d2c5ddc0d596f5f43316fbf982d"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f3f601f32244a677c7b029ec39412db2772ad04a28bc2cbb4b1f0931ed0ffad7"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0ae60e910531cfcc364a8f615a7941cac89efeb3f0fffe0c4824a6d11461eef7"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c43a773dd1a481dbb2fe64576aa303d80f328cce0eae5e3e4894947c41d1da7"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a327327f1188b3fbecac41bf1973a60b86b2eb237db10dc945bd3dc97ec39e4"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:136c43f185244893a527540307167f5d3ef4e08786508afe45d6f146228f5aa9"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1"}, + {file = "psycopg_binary-3.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c"}, + {file = "psycopg_binary-3.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84"}, + {file = "psycopg_binary-3.3.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b"}, + {file = "psycopg_binary-3.3.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51"}, + {file = "psycopg_binary-3.3.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7"}, + {file = "psycopg_binary-3.3.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e"}, + {file = "psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1"}, + {file = "psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e"}, + {file = "psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25"}, + {file = "psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f"}, + {file = "psycopg_binary-3.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0"}, + {file = "psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048"}, + {file = "psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181"}, + {file = "psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13"}, + {file = "psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6"}, + {file = "psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2"}, + {file = "psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6"}, + {file = "psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8"}, + {file = "psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d"}, + {file = "psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc"}, + {file = "psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628"}, + {file = "psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40"}, + {file = "psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d"}, + {file = "psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8"}, + {file = "psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df"}, + {file = "psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351"}, + {file = "psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d"}, + {file = "psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2"}, + {file = "psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e"}, + {file = "psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc"}, + {file = "psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0"}, + {file = "psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830"}, + {file = "psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14"}, + {file = "psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d"}, + {file = "psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1"}, + {file = "psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925"}, + {file = "psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d"}, + {file = "psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1"}, + {file = "psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482"}, + {file = "psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12"}, + {file = "psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83"}, + {file = "psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508"}, + {file = "psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1"}, + {file = "psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b"}, + {file = "psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2"}, + {file = "psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd"}, + {file = "psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430"}, + {file = "psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b"}, + {file = "psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead"}, + {file = "psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6"}, + {file = "psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e"}, + {file = "psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023"}, + {file = "psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d"}, + {file = "psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856"}, + {file = "psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383"}, ] [[package]] @@ -1686,23 +1686,19 @@ testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-django" -version = "4.11.1" +version = "4.12.0" description = "A Django plugin for pytest." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10"}, - {file = "pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991"}, + {file = "pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85"}, + {file = "pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758"}, ] [package.dependencies] pytest = ">=7.0.0" -[package.extras] -docs = ["sphinx", "sphinx_rtd_theme"] -testing = ["Django", "django-configurations (>=2.0)"] - [[package]] name = "pytest-mock" version = "3.15.1" @@ -1927,14 +1923,14 @@ files = [ [[package]] name = "redis" -version = "7.1.1" +version = "7.2.0" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a"}, - {file = "redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43"}, + {file = "redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497"}, + {file = "redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26"}, ] [package.extras] @@ -1942,6 +1938,8 @@ circuit-breaker = ["pybreaker (>=1.4.0)"] hiredis = ["hiredis (>=3.2.0)"] jwt = ["pyjwt (>=2.9.0)"] ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] +otel = ["opentelemetry-api (>=1.39.1)", "opentelemetry-exporter-otlp-proto-http (>=1.39.1)", "opentelemetry-sdk (>=1.39.1)"] +xxhash = ["xxhash (>=3.6.0,<3.7.0)"] [[package]] name = "requests" @@ -1967,14 +1965,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "14.3.2" +version = "14.3.3" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" groups = ["dev"] files = [ - {file = "rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69"}, - {file = "rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"}, + {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, + {file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, ] [package.dependencies] @@ -1986,14 +1984,14 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "sentry-sdk" -version = "2.52.0" +version = "2.53.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "sentry_sdk-2.52.0-py2.py3-none-any.whl", hash = "sha256:931c8f86169fc6f2752cb5c4e6480f0d516112e78750c312e081ababecbaf2ed"}, - {file = "sentry_sdk-2.52.0.tar.gz", hash = "sha256:fa0bec872cfec0302970b2996825723d67390cdd5f0229fb9efed93bd5384899"}, + {file = "sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899"}, + {file = "sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77"}, ] [package.dependencies] @@ -2197,24 +2195,24 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "virtualenv" -version = "20.36.1" +version = "20.38.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"}, - {file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"}, + {file = "virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794"}, + {file = "virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7"}, ] [package.dependencies] distlib = ">=0.3.7,<1" -filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""} +filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] +docs = ["furo (>=2023.7.26)", "pre-commit-uv (>=4.1.4)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinx-autodoc-typehints (>=3.6.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2025.12.21.14)", "sphinxcontrib-mermaid (>=2)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "pytest-xdist (>=3.5)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] name = "webencodings" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1c0d7742d..e0b9dcb6c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -34,11 +34,15 @@ services: - DEBUG=True - PYTHONPATH=/usr/src/app/backend - DATABASE_URL=postgresql://postgres:postgres@db:5432/science_projects + - REDIS_URL=redis://redis:6379/1 env_file: - ./backend/.env command: python manage.py runserver 0.0.0.0:8000 depends_on: - - db + db: + condition: service_started + redis: + condition: service_healthy networks: - science-projects-dev @@ -57,8 +61,26 @@ services: networks: - science-projects-dev + # Redis Cache Service + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + networks: + - science-projects-dev + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s + volumes: postgres_data: # Volume for persistent DB data + redis_data: # Volume for persistent Redis data networks: science-projects-dev: diff --git a/documentation/backend/README.md b/documentation/backend/README.md index 3d8a6e9dd..19ad3c9a8 100644 --- a/documentation/backend/README.md +++ b/documentation/backend/README.md @@ -10,7 +10,8 @@ Welcome to the Science Projects Management System (SPMS) backend documentation. 1. [Getting Started](development/getting-started.md) - Get up and running in under 30 minutes 2. [Local Setup](development/local-setup.md) - Detailed setup instructions -3. [Testing Guide](development/testing-guide.md) - Run your first tests +3. [Seeding Guide](development/seeding-guide.md) - Set up with production-like data +4. [Testing Guide](development/testing-guide.md) - Run your first tests ## Documentation Structure @@ -85,6 +86,7 @@ Operational procedures, monitoring, and troubleshooting for the entire system. 1. [Getting Started](development/getting-started.md) 2. [Local Setup](development/local-setup.md) +3. [Seeding Guide](development/seeding-guide.md) ### Adding a Feature diff --git a/documentation/backend/development/README.md b/documentation/backend/development/README.md index afdf85cf2..4c48cdafd 100644 --- a/documentation/backend/development/README.md +++ b/documentation/backend/development/README.md @@ -10,7 +10,8 @@ New to the project? Start here: 1. **[getting-started.md](getting-started.md)** - Quick setup guide (< 30 minutes) 2. **[local-setup.md](local-setup.md)** - Detailed environment setup -3. **[testing-guide.md](testing-guide.md)** - Run your first tests +3. **[seeding-guide.md](seeding-guide.md)** - Set up with production-like data +4. **[testing-guide.md](testing-guide.md)** - Run your first tests ## Development Guides diff --git a/documentation/backend/development/local-setup.md b/documentation/backend/development/local-setup.md index bb61e27b8..a3add9a54 100644 --- a/documentation/backend/development/local-setup.md +++ b/documentation/backend/development/local-setup.md @@ -288,27 +288,21 @@ poetry run pre-commit run --all-files ## Database Seeding -For development with realistic data, you can seed the database with production data. +For development with realistic data, you can seed your local environment with production data. -### Obtaining Data Dumps +**Quick Overview:** +- Download seeding data from SharePoint (files.zip and SQL dump) +- Extract media files to `backend/files/` +- Restore database from SQL dump using psql or pgAdmin +- Verify seeding with provided commands -Contact the Ecoinformatics Line Manager to obtain: -1. `spms_prod.sql` - Production database dump -2. `media_files.zip` - Production media files +**See the complete guide:** [Seeding Guide](seeding-guide.md) -### Importing Database Dump - -```powershell -psql -U postgres -d spms -f spms_prod.sql -``` - -### Setting Up Media Files - -```powershell -Expand-Archive -Path media_files.zip -DestinationPath temp_media -Move-Item -Path temp_media\* -Destination files\ -Force -Remove-Item -Path temp_media -Recurse -``` +The seeding guide includes: +- Step-by-step instructions for local and staging environments +- Troubleshooting common issues +- Security and data handling best practices +- OIM ticket template for staging environment seeding ## Creating a Complete Superuser diff --git a/documentation/backend/development/seeding-guide.md b/documentation/backend/development/seeding-guide.md new file mode 100644 index 000000000..566c4d04c --- /dev/null +++ b/documentation/backend/development/seeding-guide.md @@ -0,0 +1,315 @@ +# Backend Seeding Guide + +Goal: Set up your local or staging environment with production-like data for realistic testing and development. + +Related Documentation: [Getting Started](getting-started.md), [Local Setup](local-setup.md), [Change Management](../../general/operations/change-management.md) + +## Table of Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Local Environment Seeding](#local-environment-seeding) + - [Media Seeding](#media-seeding) + - [Database Seeding](#database-seeding) +- [Staging Environment Seeding](#staging-environment-seeding) +- [Troubleshooting](#troubleshooting) +- [Security and Best Practices](#security-and-best-practices) + +## Overview + +Seeding data provides production-like database records and media files for local development and staging environments. This allows you to: + +- Test features with realistic data volumes +- Develop with actual user-uploaded media files +- Verify functionality before production deployment +- Onboard new maintainers quickly with working data + +**When to use seeding data:** +- Setting up a new development environment +- Testing features that require existing data +- Reproducing production issues locally +- Preparing staging for release testing + +**Security considerations:** +- Seeding data contains production data (user information, project details, uploaded files) +- Handle with the same care as production data +- Use only in local and staging environments +- Never commit seeding data to version control (files folder is already gitignored) + +## Prerequisites + +Before seeding your environment, ensure you have: + +### Software Requirements + +- **PostgreSQL 17+** - [Installation Guide](https://www.postgresql.org/download/) +- **Python (Latest)** - [Installation Guide](https://www.python.org/downloads/) +- **Poetry** - [Installation Guide](https://python-poetry.org/docs/#installation) +- **Unzip utility** - Usually pre-installed on Windows/macOS/Linux + +Verify installations: +```bash +psql --version # Should show 17.x or higher +python --version # Should show 3.11.x or higher +poetry --version # Should show 1.7.x or higher +``` + +### Access Requirements + +- **DBCA Network Access** - VPN or network approval if working remotely +- **SharePoint Permissions** - Access to Ecoinformatics team site +- **Database Credentials** - PostgreSQL username and password + + +### Related Documentation + +Before seeding, complete the initial backend setup: +- [Getting Started](getting-started.md) - Initial setup +- [Local Setup](local-setup.md) - Detailed configuration +- [Testing Guide](testing-guide.md) - Running tests + +## Local Environment Seeding + +### Accessing Seeding Data + +The seeding data is stored on SharePoint at: + +[SharePoint Seeding Data Folder](https://dpaw.sharepoint.com/teams/Ecoinformatics/Shared%20Documents/Projects/S033%20-%20SPMS/Seeding%20Data?csf=1&web=1&e=HfqhBJ) + +You will need to be signed-in via SSO and have permissions to access the Ecoinformatics SharePoint site + +Available files: +- `files.zip` - Media files (images, documents, user uploads) +- `spms_dump.sql` - Database snapshot with production-like data + +### Media Seeding + +Media seeding involves downloading and extracting user-uploaded files to your local backend directory. + +#### Step 1: Download files.zip + +1. Navigate to the SharePoint folder (link above) +2. Locate `files.zip` in the Seeding Data folder +3. Download to your local machine (any location) + +#### Step 2: Extract to Backend Root + +Extract the contents to the backend root directory: + +```bash +# Navigate to backend directory +cd monorepo/backend + +# Extract files.zip (adjust path to your download location) +unzip ~/Downloads/files.zip +``` + +**Windows (PowerShell):** +```powershell +# Navigate to backend directory +cd monorepo\backend + +# Extract using Expand-Archive +Expand-Archive -Path "$env:USERPROFILE\Downloads\files.zip" -DestinationPath . +``` + +This will create a `files/` directory structure similar to below: +``` +backend/ +├── files/ +│ ├── projects/ +│ ├── annual_reports/ +│ ├── user_avatars/ +│ └── project_documents/ +``` + +#### Step 3: Verify Media Seeding + +Check that files were extracted successfully: + +```bash +# Check files directory exists +ls -la files/ + +# Check file count +find files/ -type f | wc -l +``` + +**Windows (PowerShell):** +```powershell +# Check files directory exists +Get-ChildItem files\ + +# Check file count +(Get-ChildItem -Path files\ -Recurse -File).Count +``` + +**Important:** The `files/` directory is gitignored and must never be committed to version control. + +### Database Seeding + +Database seeding involves restoring a PostgreSQL database from a SQL dump file containing production-like data. + +#### Prerequisites + +- PostgreSQL 17+ installed and running +- Database created (e.g., `spms`) +- Database credentials configured in `.env` + +#### Step 1: Download SQL Dump + +1. Navigate to the SharePoint folder (link above) +2. Locate `spms_dump.sql` in the Seeding Data folder +3. Download to your local machine + +#### Step 2: Create Database and Required Roles + +**Note:** You need postgres properly set up in your system environment variables to use these commands. + +```bash +# Create database +createdb spms + +# Create required PostgreSQL roles +psql spms +``` + +In the psql prompt: +```sql +CREATE ROLE azure_pg_admin; +CREATE ROLE spms_prod; +\q +``` + +**Windows (PowerShell):** +```powershell +# Create database +createdb spms + +# Create required PostgreSQL roles +psql spms +``` + +In the psql prompt: +```sql +CREATE ROLE azure_pg_admin; +CREATE ROLE spms_prod; +\q +``` + +**Note:** If you get "role already exists" errors, that's fine - the roles may have been created previously. + +#### Step 3: Restore Database + +```bash +# Restore database from SQL dump using pg_restore +pg_restore -U postgres -d spms ~/Downloads/spms_dump.sql +``` + +**Windows (PowerShell):** +```powershell +# Restore database from SQL dump using pg_restore +pg_restore -U postgres -d spms "$env:USERPROFILE\Downloads\spms_dump.sql" +``` + +**Note:** If `pg_restore` doesn't work, ensure PostgreSQL's bin directory is in your PATH. + +#### Step 4: Run Migrations + +After restoring the database, run migrations to ensure your local code is in sync: + +```bash +poetry run python manage.py migrate +``` + +#### Step 5: Verify Database Seeding + +Check that tables were created and populated: + +```bash +# Check table count (should be 20-30 tables) +psql -U postgres -d spms -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public';" + +# Check user count (should be 100-200 users) +psql -U postgres -d spms -c "SELECT COUNT(*) FROM users_user;" + +# Check project count (should be 50-100 projects) +psql -U postgres -d spms -c "SELECT COUNT(*) FROM projects_project;" +``` + + +#### Step 6: Update Environment Configuration + +Ensure your `.env` file points to the seeded database: + +```bash +DATABASE_URL=postgresql://postgres:password@127.0.0.1/spms +``` + +#### Step 7: Verify Application Works + +Start the development server and verify everything works: + +```bash +poetry run python manage.py runserver +``` + +Access the application at http://127.0.0.1:8000/api/v1/ and verify you can see data. + +## Staging Environment Seeding + +Staging environment seeding is handled by the Office of Information Management (OIM). Maintainers cannot directly seed staging environments. + +### Process + +1. **Review Change Process** - See [Change Management Documentation](../../general/operations/change-management.md) +2. **Submit OIM Ticket** - Use the template below +3. **Wait for Completion** - Typical timeline: 2-5 business days + +### OIM Ticket Template + +**Subject:** +``` +SPMS Staging Environment Seeding Request +``` + +**Details:** +``` +Request Type: Staging Environment Update + +Please perform the following actions on the SPMS staging environment: + +1. Populate staging database with production data + - Take snapshot of production database + - Restore to staging database + - Verify data integrity + +2. Populate staging files folder with production media + - Copy production media files to staging attached volume + - Verify file permissions and accessibility + +Purpose: Fast-forward staging environment to match production for proper testing of upcoming release. + +Timeline: Requested completion by [DATE] + +Contact: [YOUR NAME] - [YOUR EMAIL] +``` + +### Timeline + +- **Ticket submission:** Immediate +- **OIM review:** 1-2 business days +- **Seeding completion:** 2-5 business days total + +### Submission + +Submit tickets through the standard OIM change management process. See [Change Management Documentation](../../general/operations/change-management.md) for submission instructions and contact information. + +## Related Documentation + +- [Getting Started](getting-started.md) - Initial backend setup +- [Local Setup](local-setup.md) - Detailed configuration +- [Testing Guide](testing-guide.md) - Running tests +- [Change Management](../../general/operations/change-management.md) - OIM ticket process +- [Database Optimisation](database-optimisation.md) - Database management +- [Operations Documentation](../../general/operations/) - Troubleshooting and monitoring diff --git a/documentation/backend/development/testing-guide.md b/documentation/backend/development/testing-guide.md index 2a0d22522..9ae40ef68 100644 --- a/documentation/backend/development/testing-guide.md +++ b/documentation/backend/development/testing-guide.md @@ -439,6 +439,3 @@ def test_project_update(): - ADR-005: pytest Testing Framework --- - -**Created**: 2024-02-07 -**Purpose**: Comprehensive testing guide for SPMS backend diff --git a/documentation/frontend/development/README.md b/documentation/frontend/development/README.md index 3553f316e..e1dfd97df 100644 --- a/documentation/frontend/development/README.md +++ b/documentation/frontend/development/README.md @@ -14,6 +14,7 @@ This section contains guides and standards for developing the Science Projects M - [Code Style](./code-style.md) - TypeScript standards and ESLint configuration - [Testing Guide](./testing-guide.md) - Testing philosophy and implementation +- [Accessibility](./accessibility.md) - WCAG 2.2 AA compliance and accessible development - [Feature Development](./feature-development.md) - Feature development workflow - [Pre-commit Hooks](./pre-commit.md) - Pre-commit checks and validation @@ -21,9 +22,10 @@ This section contains guides and standards for developing the Science Projects M 1. **Setup**: Follow the [Getting Started](./getting-started.md) guide 2. **Code Style**: Adhere to [Code Style](./code-style.md) standards -3. **Testing**: Write tests following the [Testing Guide](./testing-guide.md) -4. **Feature Development**: Use the [Feature Development](./feature-development.md) workflow -5. **Pre-commit**: Ensure [Pre-commit Hooks](./pre-commit.md) pass before committing +3. **Accessibility**: Ensure [Accessibility](./accessibility.md) compliance (WCAG 2.2 AA) +4. **Testing**: Write tests following the [Testing Guide](./testing-guide.md) +5. **Feature Development**: Use the [Feature Development](./feature-development.md) workflow +6. **Pre-commit**: Ensure [Pre-commit Hooks](./pre-commit.md) pass before committing ## Quick Reference @@ -41,6 +43,12 @@ bun run test bun run test:watch bun run test:coverage +# Run accessibility tests +bun run test --run "a11y" + +# Run accessibility scanner +node scripts/accessibility/scanner.js src/pages/MyPage.tsx + # Lint and format bun run lint bun run format diff --git a/documentation/frontend/development/accessibility.md b/documentation/frontend/development/accessibility.md new file mode 100644 index 000000000..084012730 --- /dev/null +++ b/documentation/frontend/development/accessibility.md @@ -0,0 +1,514 @@ +# Accessibility Development Guide + +## Overview + +This guide provides practical guidance for developing accessible features in the Science Projects Management System. We target WCAG 2.2 Level AA compliance as our minimum standard. + +## Target Compliance + +**WCAG 2.2 Level AA** - This is our minimum accessibility standard. All new features and components must meet this level. + +## Quick Start + +### Before You Code + +1. **Use semantic HTML** - Buttons for actions, links for navigation +2. **Add labels to inputs** - Every form field needs a label +3. **Provide alt text** - Describe images meaningfully +4. **Test with keyboard** - Tab through your feature +5. **Check colour contrast** - Use DevTools colour picker + +### Testing Your Work + +```bash +# Run accessibility tests +bun run test --run "a11y" + +# Run scanner on your file +node scripts/accessibility/scanner.js src/pages/MyPage.tsx + +# Run all tests with coverage +bun run test:coverage +``` + +## Core Principles + +### 1. Semantic HTML First + +Use HTML elements that describe what the content IS, not what it looks like. + +**Good**: +```tsx + + +View Projects +``` + +**Bad**: +```tsx +
Save Project
+
navigate('/projects')}>View Projects
+``` + +**Why**: Screen readers announce element roles. A ` +``` + +**Acceptable** (when semantic HTML isn't possible): +```tsx +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }} +> + Click Me +
+``` + +### 4. Colour Contrast + +Text must have sufficient contrast against its background. + +**Minimum ratios**: +- Normal text: 4.5:1 +- Large text (18pt+ or 14pt+ bold): 3:1 +- UI components: 3:1 + +**Test**: Use browser DevTools colour picker to check contrast. + +**Good**: +```tsx +

+ High contrast text (21:1 ratio) +

+``` + +**Bad**: +```tsx +

+ Low contrast text (2.8:1 ratio) +

+``` + +## Common Patterns + +### Modal Dialogs + +Use shadcn Dialog components - they handle focus management automatically. + +```tsx + + + + + + + Dialog Title + + Description for screen readers + + + {/* Content */} + + + + + + + +``` + +### Form Validation + +Associate error messages with inputs using `aria-describedby`. + +```tsx +
+ + + {errors.email && ( + + )} +
+``` + +### Select Dropdowns + +Add `aria-label` to shadcn Select components. + +```tsx + +``` + +### Icon Buttons + +Provide accessible names for icon-only buttons. + +```tsx + +``` + +### Images + +Provide descriptive alt text for meaningful images, empty alt for decorative images. + +```tsx +// Meaningful image +Bar chart showing 23% increase in native species + +// Decorative image + +``` + +## Testing Strategy + +### Two-Tier Approach + +We use a two-tier testing strategy: + +1. **Unit Tests** (90%) - Test functions, hooks, services, stores +2. **Page Tests** (10%) - Test user flows + accessibility + +**Don't test components** - They're tested via page tests. + +### Writing Accessibility Tests + +Create `.a11y.test.tsx` files for pages: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; + +expect.extend(toHaveNoViolations); + +describe('MyPage Accessibility', () => { + it('should be accessible', async () => { + // Dynamic import to ensure mocks are set up first + const { default: MyPage } = await import('./MyPage'); + + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); +``` + +### Running Tests + +```bash +# Run all accessibility tests +bun run test --run "a11y" + +# Run specific test +bun run test --run "ProjectListPage.a11y" + +# Run with coverage +bun run test:coverage +``` + +## Pre-commit Checks + +The accessibility scanner runs automatically on commit (if enabled). + +### Enable/Disable + +Edit `package.json`: + +```json +{ + "accessibility": { + "enabled": true, // Set to false to disable + "severity": "warning" + } +} +``` + +### What Gets Checked + +- Semantic HTML violations +- Heading hierarchy issues +- Form accessibility +- ARIA attribute validation +- Image alt text +- Keyboard accessibility + +### Bypass (Emergency Only) + +```bash +git commit --no-verify -m "emergency fix" +``` + +## CI/CD Integration + +Accessibility checks run automatically on pull requests. + +### What Happens + +1. Scanner runs on changed `.tsx`/`.jsx` files +2. All accessibility tests run +3. Results posted as PR comment +4. Test results uploaded as artifacts + +### Workflow Status + +Check the accessibility badge in README or the Actions tab. + +**Note**: Accessibility checks are informational and don't block merging. + +## Common Issues + +### Issue: "Interactive div should be a button" + +**Problem**: Using `
` with `onClick` handler. + +**Fix**: Use ` +``` + +### Issue: "Input missing label" + +**Problem**: Form input without associated label. + +**Fix**: Add label with `htmlFor`/`id`. + +```tsx +// Before + + +// After + + +``` + +### Issue: "Image missing alt text" + +**Problem**: Image without `alt` attribute. + +**Fix**: Add descriptive alt text. + +```tsx +// Before + + +// After +Revenue chart showing Q3 growth +``` + +### Issue: "Heading hierarchy skipped" + +**Problem**: Jumping from `

` to `

` without `

`. + +**Fix**: Use proper heading order. + +```tsx +// Before +

Page Title

+

Subsection

+ +// After +

Page Title

+

Section

+

Subsection

+``` + +### Issue: "Insufficient colour contrast" + +**Problem**: Text colour doesn't have enough contrast with background. + +**Fix**: Use darker text colour. + +```tsx +// Before (2.8:1 ratio) +

Low contrast

+ +// After (10.89:1 ratio) +

Good contrast

+``` + +## Tools and Resources + +### Browser Tools + +- **Chrome DevTools** - Accessibility pane, colour picker +- **axe DevTools Extension** - Automated accessibility testing +- **WAVE Extension** - Visual accessibility feedback + +### Testing Tools + +- **axe-core** - Automated accessibility testing library +- **jest-axe** - Jest matchers for axe-core +- **Vitest** - Test runner + +### Documentation + +- **WCAG 2.2 Guidelines**: https://www.w3.org/WAI/WCAG22/quickref/ +- **MDN Accessibility**: https://developer.mozilla.org/en-US/docs/Web/Accessibility +- **WebAIM Resources**: https://webaim.org/resources/ + +### Internal Resources + +- **Accessibility Guide**: This document - Practical solutions and patterns +- **Semantic HTML**: Use semantic HTML elements for better accessibility + +## Decision Trees + +### Which Element Should I Use? + +``` +Need to trigger an action? +├─ YES → + + - - Layers - - - -
e.stopPropagation()} - onMouseDown={(e: React.MouseEvent) => e.stopPropagation()} - onMouseMove={(e: React.MouseEvent) => e.stopPropagation()} - onMouseUp={(e: React.MouseEvent) => e.stopPropagation()} - onDoubleClick={(e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - }} - > -
- {/* Header */} -
-

- Map Layers -

-
- - +
e.stopPropagation()} + onMouseDown={(e: React.MouseEvent) => e.stopPropagation()} + onMouseMove={(e: React.MouseEvent) => e.stopPropagation()} + onMouseUp={(e: React.MouseEvent) => e.stopPropagation()} + onDoubleClick={(e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }} + > +
+ {/* Header */} +
+

+ Map Layers +

+
+ + +
-
- {/* Layer checkboxes */} -
- {(Object.keys(LAYER_NAMES) as StoreLayerType[]).map( - (layerType) => { - const isVisible = - store.state.visibleLayerTypes.includes(layerType); - const layerColor = LAYER_COLORS[layerType]; + {/* Layer checkboxes */} +
+ {(Object.keys(LAYER_NAMES) as StoreLayerType[]).map( + (layerType) => { + const isVisible = + store.state.visibleLayerTypes.includes(layerType); + const layerColor = LAYER_COLORS[layerType]; - return ( -
e.stopPropagation()} - > - - handleLayerToggle(layerType, checked as boolean) - } - aria-describedby={`layer-${layerType}-desc`} - /> + return ( + ); + } + )} +
- {/* Display options */} -
-

- Display Options -

+ {/* Display options */} +
+

+ Display Options +

- {/* Show Labels toggle */} -
-
- {store.state.showLabels ? ( - - ) : ( - - )} - + {/* Show Labels toggle */} +
+
+ {store.state.showLabels ? ( + + ) : ( + + )} + +
+ { + store.toggleLabels(); + mapAnnouncements.displayOptionToggle("labels", checked); + }} + aria-describedby="show-labels-desc" + />
- { - store.toggleLabels(); - mapAnnouncements.displayOptionToggle("labels", checked); - }} - aria-describedby="show-labels-desc" - /> -
-

- Display region names on the map -

+

+ Display region names on the map +

- {/* Show Colors toggle */} -
-
- - + {/* Show Colors toggle */} +
+
+ + +
+ { + store.toggleColors(); + mapAnnouncements.displayOptionToggle("colors", checked); + }} + aria-describedby="show-colors-desc" + />
- { - store.toggleColors(); - mapAnnouncements.displayOptionToggle("colors", checked); - }} - aria-describedby="show-colors-desc" - /> +

+ Fill regions with colors or show outline only +

-

- Fill regions with colors or show outline only -

-
- {/* Layer count info */} -
-

- {store.state.visibleLayerTypes.length} of 5 layers visible -

+ {/* Layer count info */} +
+

+ {store.state.visibleLayerTypes.length} of 5 layers visible +

+
-
- - -
- ); -}); + + +
+ ); + } +); LayerPopover.displayName = "LayerPopover"; diff --git a/frontend/src/features/projects/components/map/MapClickHandler.tsx b/frontend/src/features/projects/components/map/MapClickHandler.tsx new file mode 100644 index 000000000..87a232a13 --- /dev/null +++ b/frontend/src/features/projects/components/map/MapClickHandler.tsx @@ -0,0 +1,42 @@ +import { useMapEvents } from "react-leaflet"; +import { useProjectMapStore } from "@/app/stores/store-context"; + +interface MapClickHandlerProps { + onZoomChange: (zoom: number) => void; +} + +/** + * MapClickHandler component + * + * Handles map click events to clear marker selection and custom double-click zoom + */ +export const MapClickHandler = ({ onZoomChange }: MapClickHandlerProps) => { + const store = useProjectMapStore(); + + const map = useMapEvents({ + click: () => { + store.clearMarkerSelection(); + }, + dblclick: (e) => { + // Check if the double-click originated from a button or control element + const target = e.originalEvent.target as HTMLElement; + + // Check if click is on a button, control, or their children + const isOnControl = + target.closest("button") || + target.closest(".leaflet-control") || + target.closest('[role="dialog"]') || // Popovers + target.closest("[data-radix-popper-content-wrapper]"); // Radix popovers + + // Only zoom if NOT on a control + if (!isOnControl) { + map.zoomIn(); + } + }, + zoomend: () => { + onZoomChange(map.getZoom()); + }, + }); + + return null; +}; diff --git a/frontend/src/features/projects/components/map/MapControls.test.tsx b/frontend/src/features/projects/components/map/MapControls.test.tsx index 0c7b0132b..68a6a9c22 100644 --- a/frontend/src/features/projects/components/map/MapControls.test.tsx +++ b/frontend/src/features/projects/components/map/MapControls.test.tsx @@ -46,17 +46,17 @@ describe("MapControls", () => { render(); expect(screen.getByLabelText("Enter map fullscreen")).toBeInTheDocument(); - expect(screen.getByLabelText("Reset map view")).toBeInTheDocument(); + expect( + screen.getByLabelText("Reset map view to Western Australia") + ).toBeInTheDocument(); expect(screen.getByTestId("layer-popover")).toBeInTheDocument(); }); it("should have proper button titles", () => { render(); - expect(screen.getByTitle("Enter map fullscreen")).toBeInTheDocument(); - expect( - screen.getByTitle("Reset to Western Australia view") - ).toBeInTheDocument(); + expect(screen.getByTitle("Toggle Fullscreen")).toBeInTheDocument(); + expect(screen.getByTitle("Reset")).toBeInTheDocument(); }); it("should handle map fullscreen toggle", () => { @@ -73,7 +73,7 @@ describe("MapControls", () => { render(); expect(screen.getByLabelText("Enter map fullscreen")).toBeInTheDocument(); - expect(screen.getByTitle("Enter map fullscreen")).toBeInTheDocument(); + expect(screen.getByTitle("Toggle Fullscreen")).toBeInTheDocument(); }); it("should show correct fullscreen button state when in fullscreen", () => { @@ -81,7 +81,7 @@ describe("MapControls", () => { render(); expect(screen.getByLabelText("Exit map fullscreen")).toBeInTheDocument(); - expect(screen.getByTitle("Exit map fullscreen")).toBeInTheDocument(); + expect(screen.getByTitle("Toggle Fullscreen")).toBeInTheDocument(); }); it("should render LayerPopover component", () => { @@ -93,18 +93,13 @@ describe("MapControls", () => { it("should have proper styling classes", () => { render(); - // Check top-right controls - const topControls = document.querySelector(".absolute.top-4.right-4"); - expect(topControls).toBeInTheDocument(); - - const fullscreenButton = topControls?.querySelector( - 'button[title="Enter map fullscreen"]' - ); - const resetButton = topControls?.querySelector( - 'button[title="Reset to Western Australia view"]' + // Check that buttons exist with proper styling + const fullscreenButton = screen.getByLabelText("Enter map fullscreen"); + const resetButton = screen.getByLabelText( + "Reset map view to Western Australia" ); - // Check that top buttons have proper styling (flex-1 h-8 for matching layers button width) + // Check that buttons have proper styling (flex-1 h-8 for matching layers button width) expect(fullscreenButton).toHaveClass("flex-1", "h-8", "p-0"); expect(resetButton).toHaveClass("flex-1", "h-8", "p-0"); }); @@ -112,21 +107,19 @@ describe("MapControls", () => { it("should have proper accessibility attributes", () => { render(); - // Check top-right controls - const topControls = document.querySelector(".absolute.top-4.right-4"); - expect(topControls).toBeInTheDocument(); - - const fullscreenButton = topControls?.querySelector( - 'button[title="Enter map fullscreen"]' - ); - const resetButton = topControls?.querySelector( - 'button[title="Reset to Western Australia view"]' + // Check that buttons have proper accessibility attributes + const fullscreenButton = screen.getByLabelText("Enter map fullscreen"); + const resetButton = screen.getByLabelText( + "Reset map view to Western Australia" ); expect(fullscreenButton).toHaveAttribute( "aria-label", "Enter map fullscreen" ); - expect(resetButton).toHaveAttribute("aria-label", "Reset map view"); + expect(resetButton).toHaveAttribute( + "aria-label", + "Reset map view to Western Australia" + ); }); }); diff --git a/frontend/src/features/projects/components/map/MapControls.tsx b/frontend/src/features/projects/components/map/MapControls.tsx index a47139a4a..50cee3fa0 100644 --- a/frontend/src/features/projects/components/map/MapControls.tsx +++ b/frontend/src/features/projects/components/map/MapControls.tsx @@ -1,13 +1,12 @@ import { observer } from "mobx-react-lite"; import { Maximize, Minimize, RotateCcw } from "lucide-react"; -import { useMap } from "react-leaflet"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; +import type L from "leaflet"; import { Button } from "@/shared/components/ui/button"; import { LayerPopover } from "./LayerPopover"; import { HeatmapToggle } from "./HeatmapToggle"; import { useProjectMapStore } from "@/app/stores/store-context"; import { mapAnnouncements } from "@/shared/utils/screen-reader.utils"; -import L from "leaflet"; /** * ZoomControls component @@ -84,21 +83,12 @@ import L from "leaflet"; /** * MapControlButtons component * - * Internal component that uses useMap hook to access the Leaflet map instance. - * Must be rendered inside a MapContainer. + * Map control buttons that can be rendered inside or outside the Leaflet map. + * When rendered outside, they don't need Leaflet event prevention. */ const MapControlButtons = observer(() => { - const map = useMap(); const store = useProjectMapStore(); const [isMapFullscreen, setIsMapFullscreen] = useState(false); - const containerRef = useRef(null); - - useEffect(() => { - if (containerRef.current) { - L.DomEvent.disableClickPropagation(containerRef.current); - L.DomEvent.disableScrollPropagation(containerRef.current); - } - }, []); // Listen for fullscreen changes useEffect(() => { @@ -125,27 +115,17 @@ const MapControlButtons = observer(() => { const handleResetView = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - // Reset to Western Australia view - map.setView([-25.2744, 122.2402], 6); - mapAnnouncements.viewReset(); + // Get the map instance from the window (set by FullMapContainer) + const map = (window as Window & { __leafletMap?: L.Map }).__leafletMap; + if (map) { + // Reset to Western Australia view + map.setView([-25.2744, 122.2402], 6); + mapAnnouncements.viewReset(); + } }; return ( -
e.stopPropagation()} - onMouseMove={(e) => e.stopPropagation()} - onMouseUp={(e) => e.stopPropagation()} - onDragStart={(e) => { - e.stopPropagation(); - e.preventDefault(); - }} - onDoubleClick={(e) => { - e.stopPropagation(); - e.preventDefault(); - }} - > + <> {/* Map Actions - Fullscreen and Reset in same row, matching layers button width */}
@@ -170,9 +150,7 @@ const MapControlButtons = observer(() => { aria-label={ isMapFullscreen ? "Exit map fullscreen" : "Enter map fullscreen" } - title={ - isMapFullscreen ? "Exit map fullscreen" : "Enter map fullscreen" - } + title="Toggle Fullscreen" > {isMapFullscreen ? ( @@ -185,39 +163,25 @@ const MapControlButtons = observer(() => { {/* Heatmap Toggle - Full width button */} - {/* Layer Controls - Hidden on small screens unless fullscreen */} -
- -
-
+ {/* Layer Controls - Always rendered for tab order, but visually hidden on mobile */} + + ); }); /** * MapControls component * - * Floating action buttons positioned in the corners of the map. - * Provides map interaction controls and layer management. - * - * Layout: - * - Top-left: Zoom in, Zoom out (next to MapStats badge) - * - Top-right: Reset view, Fullscreen toggle, Heatmap toggle, Layer controls + * Floating action buttons for map interaction controls. + * Can be rendered inside or outside the Leaflet MapContainer. * * Features: * - Map fullscreen toggle (not browser fullscreen) * - Reset view button (fit all markers) - * - Zoom in/out buttons + * - Heatmap toggle * - Layer controls via LayerPopover * - Proper ARIA labels and keyboard support - * - Leaflet event prevention to avoid map pan on button clicks - * - * Note: This component must be rendered inside a MapContainer to access the map instance. */ export const MapControls = observer(() => { - return ( - <> - - {/* */} - - ); + return ; }); diff --git a/frontend/src/features/projects/components/map/MapFilters.tsx b/frontend/src/features/projects/components/map/MapFilters.tsx index 6ed52d6fa..f2f8c97f0 100644 --- a/frontend/src/features/projects/components/map/MapFilters.tsx +++ b/frontend/src/features/projects/components/map/MapFilters.tsx @@ -16,6 +16,7 @@ import { useProjectMapStore } from "@/app/stores/store-context"; import { SearchControls } from "@/shared/components/SearchControls"; import { BusinessAreaMultiSelect } from "@/shared/components/BusinessAreaMultiSelect"; import { UserCombobox } from "@/shared/components/user"; +import { ResponsiveLayout } from "@/shared/components/ResponsiveLayout"; interface MapFiltersProps { projectCount: number; @@ -108,46 +109,83 @@ export const MapFilters = observer( return (
- {/* Row 1: Search and User Filter (side by side on lg+, stacked on mobile) */} -
- {/* Search Input - FIRST on mobile, right on desktop - EMPHASIZED */} -
- - - {localSearchTerm && ( - - )} -
+ + {/* Row 1 Mobile: Search Input (top) */} +
+ + + {localSearchTerm && ( + + )} +
- {/* User Filter with icon - SECOND on mobile, left on desktop */} -
- -
-
+ {/* Row 2 Mobile: User Filter */} +
+ +
+
+ } + desktopContent={ +
+ {/* Row 1 Desktop: User Filter (left) */} +
+ +
+ + {/* Row 1 Desktop: Search Input (right) */} +
+ + + {localSearchTerm && ( + + )} +
+
+ } + /> {/* Row 2: Year, Project Status, Project Kind, Business Area (4-column grid) */}
{ const store = useProjectMapStore(); const popupRef = useRef(null); + const markerRef = useRef(null); const [isHovered, setIsHovered] = useState(false); + const lastFocusedElement = useRef(null); + const focusTimeoutRef = useRef(null); // Determine if this marker is selected const isSelected = store.isMarkerSelected(position); @@ -48,16 +51,43 @@ const ProjectMarkerComponent = observer( ); const icon = marker.getIcon(); - // Handle marker click for selection - const handleMarkerClick = () => { + // Handle marker click for selection and open popup + const handleMarkerClick = useCallback(() => { + // Save the currently focused element before opening popup + lastFocusedElement.current = document.activeElement as HTMLElement; + store.selectMarker(position); - }; + // Open popup + if (markerRef.current) { + markerRef.current.openPopup(); + } + }, [store, position]); - // Handle popup close + // Handle popup close - return focus to marker const handlePopupClose = () => { if (popupRef.current) { popupRef.current.close(); } + + // Clear any existing timeout + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + + // Return focus to the marker that opened the popup + if (lastFocusedElement.current) { + // Small delay to ensure popup is fully closed + focusTimeoutRef.current = setTimeout(() => { + // Check if element is still in the DOM before focusing + if ( + lastFocusedElement.current && + document.contains(lastFocusedElement.current) + ) { + lastFocusedElement.current.focus(); + } + focusTimeoutRef.current = null; + }, 50); + } }; // Handle mouse enter @@ -77,8 +107,214 @@ const ProjectMarkerComponent = observer( return 0; // Normal markers }; + // Make marker keyboard accessible + useEffect(() => { + if (!markerRef.current) return; + + const marker = markerRef.current; + let cleanup: (() => void) | undefined; + let observer: MutationObserver | undefined; + + const setupKeyboardAccess = () => { + // Access Leaflet's internal _icon property (HTMLElement) + const markerElement = (marker as L.Marker & { _icon?: HTMLElement }) + ._icon; + if (!markerElement) return; + + // Make marker focusable + markerElement.setAttribute("tabindex", "0"); + markerElement.setAttribute("role", "button"); + markerElement.setAttribute( + "aria-label", + `Project marker: ${projects.length} project${projects.length > 1 ? "s" : ""}. Press Enter to view details.` + ); + + // Function to disable all non-marker divs + const disableTooltipDivs = () => { + const markerPane = markerElement.closest(".leaflet-marker-pane"); + if (markerPane) { + // Find all divs that are NOT project markers + const allDivs = markerPane.querySelectorAll("div"); + allDivs.forEach((div: Element) => { + if ( + div instanceof HTMLElement && + !div.classList.contains("project-marker") + ) { + div.setAttribute("tabindex", "-1"); + div.setAttribute("aria-hidden", "true"); + div.style.pointerEvents = "none"; + } + }); + } + }; + + // Disable tooltip divs immediately + disableTooltipDivs(); + + // Set up MutationObserver to catch dynamically added tooltip divs + const markerPane = markerElement.closest(".leaflet-marker-pane"); + if (markerPane) { + observer = new MutationObserver(() => { + disableTooltipDivs(); + }); + + observer.observe(markerPane, { + childList: true, + subtree: true, + }); + } + + // Add keyboard event handler + const handleKeyDown = (e: KeyboardEvent) => { + // Only handle Enter and Space, ignore arrow keys + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + handleMarkerClick(); + } + // Prevent arrow keys from doing anything (they would normally pan the map) + else if ( + ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key) + ) { + e.preventDefault(); + e.stopPropagation(); + // Keep focus on the marker, don't let it move + } + }; + + // Handle focus - apply visual effects and center map on marker + const handleFocus = (_e: FocusEvent) => { + // Pan map to center this marker + const map = (window as Window & { __leafletMap?: L.Map }) + .__leafletMap; + + if (map && marker) { + // Get marker's lat/lng position + const markerLatLng = marker.getLatLng(); + + // Pan to marker with smooth animation + map.panTo(markerLatLng, { + animate: true, + duration: 0.5, // 500ms + easeLinearity: 0.25, + }); + } + + // Find the marker pane - it's the parent of all marker containers + const markerPane = document.querySelector(".leaflet-marker-pane"); + if (markerPane) { + // Get ALL marker divs (not img tags!) in the marker pane + const allMarkers = markerPane.querySelectorAll(".project-marker"); + + // First, restore ALL markers to normal (clear any previous focus) + allMarkers.forEach((marker: Element) => { + const el = marker as HTMLElement; + el.style.opacity = "1"; + el.style.filter = ""; + el.style.transition = ""; + el.style.boxShadow = ""; + el.style.borderRadius = ""; + el.style.zIndex = ""; + el.style.animation = ""; + }); + + // Then dim all OTHER markers (make them gray but still visible) + allMarkers.forEach((marker: Element) => { + if (marker !== markerElement) { + const el = marker as HTMLElement; + el.style.opacity = "0.8"; + el.style.filter = "grayscale(80%) blur(1px)"; + el.style.transition = "all 0.2s ease-in-out"; + } + }); + } + + // Apply glow effect to THIS marker + markerElement.style.opacity = "1"; + markerElement.style.filter = ""; + markerElement.style.boxShadow = ` + 0 0 0 5px white, + 0 0 0 10px #2563eb, + 0 0 0 15px white, + 0 0 0 20px #2563eb, + 0 0 40px 20px rgba(37, 99, 235, 0.9), + 0 0 60px 30px rgba(37, 99, 235, 0.6), + 0 0 80px 40px rgba(37, 99, 235, 0.3) + `; + markerElement.style.borderRadius = "50%"; + markerElement.style.zIndex = "9999"; + markerElement.style.animation = + "marker-focus-pulse 1.5s ease-in-out infinite"; + }; + + // Handle blur - restore all markers + const handleBlur = (_e: FocusEvent) => { + // Find the marker pane + const markerPane = document.querySelector(".leaflet-marker-pane"); + if (markerPane) { + const allMarkers = markerPane.querySelectorAll(".project-marker"); + + // Restore ALL markers to normal + allMarkers.forEach((marker: Element) => { + const el = marker as HTMLElement; + el.style.opacity = ""; + el.style.filter = ""; + el.style.transition = ""; + el.style.boxShadow = ""; + el.style.borderRadius = ""; + el.style.zIndex = ""; + el.style.animation = ""; + }); + } + }; + + markerElement.addEventListener("keydown", handleKeyDown); + markerElement.addEventListener("focus", handleFocus); + markerElement.addEventListener("blur", handleBlur); + + // Return cleanup function + cleanup = () => { + markerElement.removeEventListener("keydown", handleKeyDown); + markerElement.removeEventListener("focus", handleFocus); + markerElement.removeEventListener("blur", handleBlur); + if (observer) { + observer.disconnect(); + } + }; + }; + + // Try to set up immediately if icon exists + if ((marker as L.Marker & { _icon?: HTMLElement })._icon) { + setupKeyboardAccess(); + } + + // Also listen for 'add' event in case icon doesn't exist yet + const onAdd = () => { + // Small delay to ensure DOM is ready + setTimeout(setupKeyboardAccess, 0); + }; + + marker.on("add", onAdd); + + // Cleanup + return () => { + marker.off("add", onAdd); + if (cleanup) cleanup(); + }; + }, [projects.length, handleMarkerClick]); + + // Cleanup focus timeout on unmount + useEffect(() => { + return () => { + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + }; + }, []); + return ( { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && onClose) { + event.preventDefault(); + event.stopPropagation(); onClose(); mapAnnouncements.popupClosed(); } @@ -148,6 +150,7 @@ function MultiProjectPopup({ const navigate = useNavigate(); const headerRef = useRef(null); const [displayCount, setDisplayCount] = useState(20); + const focusIndexAfterLoadRef = useRef(null); // Focus the first interactive element when popup opens useEffect(() => { @@ -162,10 +165,29 @@ function MultiProjectPopup({ mapAnnouncements.markerSelected(projects.length); }, [projects.length]); + // Focus management after loading more items + useEffect(() => { + if (focusIndexAfterLoadRef.current !== null) { + const targetIndex = focusIndexAfterLoadRef.current; + // Small delay to ensure DOM has updated + setTimeout(() => { + const targetProject = document.querySelector( + `[data-project-item="${targetIndex}"]` + ) as HTMLElement; + if (targetProject) { + targetProject.focus(); + } + focusIndexAfterLoadRef.current = null; + }, 50); + } + }, [displayCount]); + // Handle escape key to close popup useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && onClose) { + event.preventDefault(); + event.stopPropagation(); onClose(); mapAnnouncements.popupClosed(); } @@ -190,6 +212,8 @@ function MultiProjectPopup({ }; const handleLoadMore = () => { + // Save the index of the last currently displayed item + focusIndexAfterLoadRef.current = displayProjects.length - 1; // Load 10 more projects at a time setDisplayCount((prev) => Math.min(prev + 10, projects.length)); }; @@ -202,39 +226,45 @@ function MultiProjectPopup({ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleProjectClick(projectId); - } else if (event.key === "Tab") { - // Handle circular tab navigation within the popup - const isLastItem = currentIndex === displayProjects.length - 1; - const isFirstItem = currentIndex === 0; - - if (!event.shiftKey && isLastItem && !hasMore) { - // Tab on last item when no Load More button - go to first item - event.preventDefault(); - const firstProject = document.querySelector( - '[data-project-item="0"]' + } else if (event.key === "ArrowDown") { + // Arrow down - move to next project + event.preventDefault(); + const nextIndex = currentIndex + 1; + if (nextIndex < displayProjects.length) { + const nextProject = document.querySelector( + `[data-project-item="${nextIndex}"]` ) as HTMLElement; - if (firstProject) { - firstProject.focus(); + if (nextProject) { + nextProject.focus(); } - } else if (event.shiftKey && isFirstItem) { - // Shift+Tab on first item - go to last focusable item - event.preventDefault(); - if (hasMore) { - const loadMoreButton = document.querySelector( - "[data-load-more-button]" - ) as HTMLElement; - if (loadMoreButton) { - loadMoreButton.focus(); - } - } else { - const lastProject = document.querySelector( - `[data-project-item="${displayProjects.length - 1}"]` - ) as HTMLElement; - if (lastProject) { - lastProject.focus(); - } + } else if (hasMore) { + // If at last project and there's a Load More button, focus it + const loadMoreButton = document.querySelector( + "[data-load-more-button]" + ) as HTMLElement; + if (loadMoreButton) { + loadMoreButton.focus(); } } + } else if (event.key === "ArrowUp") { + // Arrow up - move to previous project + event.preventDefault(); + const prevIndex = currentIndex - 1; + if (prevIndex >= 0) { + const prevProject = document.querySelector( + `[data-project-item="${prevIndex}"]` + ) as HTMLElement; + if (prevProject) { + prevProject.focus(); + } + } + } else if (event.key === "Tab") { + // Tab should close the popup - use setTimeout to avoid interfering with focus events + if (onClose) { + setTimeout(() => { + onClose(); + }, 0); + } } }; @@ -242,25 +272,21 @@ function MultiProjectPopup({ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleLoadMore(); + } else if (event.key === "ArrowUp") { + // Arrow up from Load More - go to last project + event.preventDefault(); + const lastProject = document.querySelector( + `[data-project-item="${displayProjects.length - 1}"]` + ) as HTMLElement; + if (lastProject) { + lastProject.focus(); + } } else if (event.key === "Tab") { - if (event.shiftKey) { - // Shift+Tab on Load More - go to last project - event.preventDefault(); - const lastProject = document.querySelector( - `[data-project-item="${displayProjects.length - 1}"]` - ) as HTMLElement; - if (lastProject) { - lastProject.focus(); - } - } else { - // Tab on Load More - go to first project - event.preventDefault(); - const firstProject = document.querySelector( - '[data-project-item="0"]' - ) as HTMLElement; - if (firstProject) { - firstProject.focus(); - } + // Tab should close the popup - use setTimeout to avoid interfering with focus events + if (onClose) { + setTimeout(() => { + onClose(); + }, 0); } } }; diff --git a/frontend/src/features/projects/components/map/RegionLayer.tsx b/frontend/src/features/projects/components/map/RegionLayer.tsx index eea1a0c33..43379bb91 100644 --- a/frontend/src/features/projects/components/map/RegionLayer.tsx +++ b/frontend/src/features/projects/components/map/RegionLayer.tsx @@ -54,7 +54,17 @@ export const RegionLayer = ({ // Store original style const originalStyle = { ...style }; - // Hover effects + // CRITICAL: Immediately make non-focusable when layer is created + const element = (layer as any)._path; + if (element) { + element.setAttribute("tabindex", "-1"); + element.setAttribute("aria-hidden", "true"); + element.setAttribute("focusable", "false"); + element.style.outline = "none"; + element.style.pointerEvents = "auto"; // Keep mouse events + } + + // Hover effects only (no keyboard interaction) layer.on({ mouseover: (e) => { const target = e.target; @@ -80,9 +90,19 @@ export const RegionLayer = ({ }); } }, + add: () => { + // Double-check attributes after add event + const el = (layer as any)._path; + if (el) { + el.setAttribute("tabindex", "-1"); + el.setAttribute("aria-hidden", "true"); + el.setAttribute("focusable", "false"); + el.style.outline = "none"; + } + }, }); - // Add tooltip with region name if available + // Add tooltip with region name if available (hover only, not on focus) const regionName = feature.properties?.name || feature.properties?.NAME || @@ -94,10 +114,22 @@ export const RegionLayer = ({ "Unknown Region"; if (regionName && regionName !== "Unknown Region") { - layer.bindTooltip(regionName, { + // CRITICAL: Bind tooltip but make it non-interactive + const tooltip = layer.bindTooltip(regionName, { permanent: false, direction: "center", className: "region-tooltip", + interactive: false, // Prevent tooltip from being interactive + }); + + // Make tooltip element non-focusable + layer.on("tooltipopen", () => { + const tooltipElement = (tooltip as any)._tooltip?._container; + if (tooltipElement) { + tooltipElement.setAttribute("tabindex", "-1"); + tooltipElement.setAttribute("aria-hidden", "true"); + tooltipElement.style.pointerEvents = "none"; + } }); } } diff --git a/frontend/src/features/projects/components/map/map-accessibility.css b/frontend/src/features/projects/components/map/map-accessibility.css new file mode 100644 index 000000000..116dce63c --- /dev/null +++ b/frontend/src/features/projects/components/map/map-accessibility.css @@ -0,0 +1,104 @@ +/** + * Map Accessibility Styles + * + * These styles ensure proper keyboard navigation in the Leaflet map: + * - Region layer SVG paths are NOT focusable (no tab stops) + * - Project markers ARE focusable (handled via JavaScript) + * - TileLayer is NOT focusable + * - Tooltips and popups are NOT focusable + */ + +/* CRITICAL: Prevent ALL SVG paths from being focusable */ +.leaflet-container svg path { + pointer-events: auto !important; /* Keep mouse events */ + outline: none !important; /* Remove focus outline */ +} + +/* Prevent region layer SVG paths from being focusable */ +.leaflet-container .leaflet-overlay-pane svg path { + pointer-events: auto !important; /* Keep mouse events */ + outline: none !important; /* Remove focus outline */ +} + +/* Prevent region layer tooltips from being keyboard-accessible */ +.leaflet-container .leaflet-tooltip { + pointer-events: none; /* Tooltips should not intercept events */ +} + +/* Ensure tile layer images are not focusable */ +.leaflet-container .leaflet-tile-pane img { + pointer-events: none; +} + +/* Prevent ALL Leaflet panes from being focusable except marker pane */ +.leaflet-pane:not(.leaflet-marker-pane) { + pointer-events: auto !important; +} + +.leaflet-pane:not(.leaflet-marker-pane) * { + outline: none !important; +} + +/* CRITICAL: Prevent tooltip/popup divs from being focusable (causes double-tab issue) */ +.leaflet-marker-pane > div:not(.project-marker) { + pointer-events: none !important; + outline: none !important; +} + +.leaflet-marker-pane > div:not(.project-marker) * { + pointer-events: none !important; + outline: none !important; +} + +/* Ensure ONLY project-marker divs are focusable */ +.leaflet-marker-pane .project-marker { + pointer-events: auto !important; +} + +/* Project markers should be focusable - ensure they have SUPER OBVIOUS focus */ +.leaflet-container .leaflet-marker-pane .project-marker { + cursor: pointer !important; + transition: all 0.15s ease-in-out !important; +} + +/* Pulsing animation for focused marker - pulses the glow */ +@keyframes marker-focus-pulse { + 0%, + 100% { + box-shadow: + 0 0 0 5px white, + 0 0 0 10px #2563eb, + 0 0 0 15px white, + 0 0 0 20px #2563eb, + 0 0 40px 20px rgba(37, 99, 235, 0.9), + 0 0 60px 30px rgba(37, 99, 235, 0.6), + 0 0 80px 40px rgba(37, 99, 235, 0.3); + } + 50% { + box-shadow: + 0 0 0 5px white, + 0 0 0 10px #2563eb, + 0 0 0 15px white, + 0 0 0 20px #2563eb, + 0 0 50px 25px rgba(37, 99, 235, 1), + 0 0 70px 35px rgba(37, 99, 235, 0.8), + 0 0 90px 45px rgba(37, 99, 235, 0.5); + } +} + +/* Prevent map container and tile pane from being focusable */ +.leaflet-container { + outline: none !important; +} + +.leaflet-container:focus { + outline: none !important; +} + +.leaflet-pane { + outline: none !important; +} + +.leaflet-pane:focus { + outline: none !important; +} diff --git a/frontend/src/features/projects/components/map/map-utils.ts b/frontend/src/features/projects/components/map/map-utils.ts new file mode 100644 index 000000000..8558392cb --- /dev/null +++ b/frontend/src/features/projects/components/map/map-utils.ts @@ -0,0 +1,25 @@ +import { GEOJSON_PROPERTY_NAMES } from "@/features/projects/types/map.types"; + +/** + * Mapping from layer type strings to GeoJSON data keys + */ +export const LAYER_TO_GEOJSON_KEY: Record< + string, + keyof typeof GEOJSON_PROPERTY_NAMES +> = { + dbcaregion: "dbcaRegions", + dbcadistrict: "dbcaDistricts", + nrm: "nrm", + ibra: "ibra", + imcra: "imcra", +}; + +/** + * Map configuration + */ +export const MAP_CONFIG = { + center: [-25.2744, 122.2402] as [number, number], + zoom: 6, + minZoom: 5, + maxZoom: 18, +}; diff --git a/frontend/src/pages/auth/Login.a11y.test.tsx b/frontend/src/pages/auth/Login.a11y.test.tsx new file mode 100644 index 000000000..115aebb53 --- /dev/null +++ b/frontend/src/pages/auth/Login.a11y.test.tsx @@ -0,0 +1,25 @@ +/** + * Accessibility tests for Login page + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; +import Login from "./Login"; + +// Mock authentication hook +vi.mock("@/features/auth/hooks/useAuth", () => ({ + useLogin: () => ({ + mutate: vi.fn(), + isPending: false, + error: null, + }), +})); + +describe("Login Page - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/dash/Dashboard.a11y.test.tsx b/frontend/src/pages/dash/Dashboard.a11y.test.tsx new file mode 100644 index 000000000..2c4a07dc9 --- /dev/null +++ b/frontend/src/pages/dash/Dashboard.a11y.test.tsx @@ -0,0 +1,41 @@ +/** + * Accessibility tests for Dashboard page + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; +import Dashboard from "./Dashboard"; + +// Mock hooks +vi.mock("@/features/users/hooks/useCurrentUser", () => ({ + useCurrentUser: () => ({ + data: { + id: 1, + username: "testuser", + display_first_name: "Test", + display_last_name: "User", + email: "test@example.com", + }, + isLoading: false, + }), +})); + +vi.mock("@/features/dashboard/hooks/useDashboardData", () => ({ + useDashboardData: () => ({ + data: { + recentProjects: [], + pendingTasks: [], + notifications: [], + }, + isLoading: false, + }), +})); + +describe("Dashboard Page - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/dash/Dashboard.tsx b/frontend/src/pages/dash/Dashboard.tsx index e4ba88b1b..b50d6bed3 100644 --- a/frontend/src/pages/dash/Dashboard.tsx +++ b/frontend/src/pages/dash/Dashboard.tsx @@ -80,6 +80,12 @@ const Dashboard = observer(() => { refetch: refetchAdminTasks, } = useAdminTasks(); + // Debug logging for admin tasks + console.log("🎯 [DASHBOARD] Admin tasks received:", adminTasks); + console.log("🎯 [DASHBOARD] Admin tasks count:", adminTasks.length); + console.log("🎯 [DASHBOARD] Admin tasks loading:", adminTasksLoading); + console.log("🎯 [DASHBOARD] Admin tasks error:", adminTasksError); + // Fetch endorsement tasks const { data: endorsementTasks, @@ -138,6 +144,13 @@ const Dashboard = observer(() => { const adminTasksCount = caretakerTasksCount + projectDeletionTasksCount + endorsementTasksCount; + // Debug logging for task counts + console.log("📊 [DASHBOARD] Task counts breakdown:"); + console.log(" - Caretaker tasks:", caretakerTasksCount); + console.log(" - Project deletion tasks:", projectDeletionTasksCount); + console.log(" - Endorsement tasks:", endorsementTasksCount); + console.log(" - Total admin tasks count:", adminTasksCount); + const handleProjectClick = (projectId: number, event: React.MouseEvent) => { const url = `/projects/${projectId}/overview`; diff --git a/frontend/src/pages/dash/HowTo.a11y.test.tsx b/frontend/src/pages/dash/HowTo.a11y.test.tsx new file mode 100644 index 000000000..79d5e5ca0 --- /dev/null +++ b/frontend/src/pages/dash/HowTo.a11y.test.tsx @@ -0,0 +1,16 @@ +/** + * Accessibility tests for HowTo page + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; +import HowTo from "./HowTo"; + +describe("HowTo Page - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/dash/UserGuide.a11y.test.tsx b/frontend/src/pages/dash/UserGuide.a11y.test.tsx new file mode 100644 index 000000000..1f967a156 --- /dev/null +++ b/frontend/src/pages/dash/UserGuide.a11y.test.tsx @@ -0,0 +1,16 @@ +/** + * Accessibility tests for UserGuide page + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; +import UserGuide from "./UserGuide"; + +describe("UserGuide Page - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/projects/ProjectCreatePage.a11y.test.tsx b/frontend/src/pages/projects/ProjectCreatePage.a11y.test.tsx new file mode 100644 index 000000000..23b2aa2da --- /dev/null +++ b/frontend/src/pages/projects/ProjectCreatePage.a11y.test.tsx @@ -0,0 +1,34 @@ +/** + * Accessibility tests for ProjectCreatePage + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; +import ProjectCreatePage from "./ProjectCreatePage"; + +// Mock hooks +vi.mock("@/features/projects/hooks/useCreateProject", () => ({ + useCreateProject: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + +vi.mock("@/features/users/hooks/useCurrentUser", () => ({ + useCurrentUser: () => ({ + data: { + id: 1, + username: "testuser", + }, + isLoading: false, + }), +})); + +describe("ProjectCreatePage - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/projects/ProjectDetailPage.a11y.test.tsx b/frontend/src/pages/projects/ProjectDetailPage.a11y.test.tsx new file mode 100644 index 000000000..1ac035d11 --- /dev/null +++ b/frontend/src/pages/projects/ProjectDetailPage.a11y.test.tsx @@ -0,0 +1,40 @@ +/** + * Accessibility tests for ProjectDetailPage + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; +import ProjectDetailPage from "./ProjectDetailPage"; + +// Mock hooks +vi.mock("@/features/projects/hooks/useProject", () => ({ + useProject: () => ({ + data: { + id: 1, + title: "Test Project", + description: "Test description", + status: "active", + created_at: "2024-01-01", + }, + isLoading: false, + }), +})); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useParams: () => ({ id: "1" }), + }; +}); + +describe("ProjectDetailPage - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(, { + initialEntries: ["/projects/1"], + }); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/projects/ProjectListPage.a11y.test.tsx b/frontend/src/pages/projects/ProjectListPage.a11y.test.tsx new file mode 100644 index 000000000..b02fdd1b6 --- /dev/null +++ b/frontend/src/pages/projects/ProjectListPage.a11y.test.tsx @@ -0,0 +1,91 @@ +/** + * Accessibility tests for ProjectListPage + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; + +// Mock all dependencies BEFORE importing the component +vi.mock("@/features/projects/hooks/useProjects", () => ({ + useProjects: () => ({ + data: { + projects: [], + total_results: 0, + total_pages: 0, + }, + isLoading: false, + error: null, + refetch: vi.fn(), + }), +})); + +vi.mock("@/shared/hooks/useSearchStoreInit", () => ({ + useSearchStoreInit: vi.fn(), +})); + +vi.mock("@/app/stores/store-context", () => ({ + useAuthStore: () => ({ + state: { + isAuthenticated: true, + user: { + id: 1, + username: "testuser", + is_staff: false, + }, + }, + isSuperuser: false, + checkAuth: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + }), + useProjectSearchStore: () => ({ + state: { + searchTerm: "", + filters: { + businessArea: "All", + projectKind: "All", + projectStatus: "All", + year: 0, + user: null, + onlyActive: false, + onlyInactive: false, + }, + currentPage: 1, + totalPages: 0, + totalResults: 0, + saveSearch: true, + }, + setSearchTerm: vi.fn(), + setFilters: vi.fn(), + setCurrentPage: vi.fn(), + setPagination: vi.fn(), + resetFilters: vi.fn(), + toggleSaveSearch: vi.fn(), + hasActiveFilters: false, + filterCount: 0, + searchParams: new URLSearchParams(), + }), +})); + +// Mock react-router to avoid navigation issues +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useSearchParams: () => [new URLSearchParams(), vi.fn()], + }; +}); + +// Now import the component AFTER all mocks are set up +const ProjectListPage = await import("./ProjectListPage").then( + (m) => m.default +); + +describe("ProjectListPage - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/projects/ProjectListPage.tsx b/frontend/src/pages/projects/ProjectListPage.tsx index 41e837850..7303dabaa 100644 --- a/frontend/src/pages/projects/ProjectListPage.tsx +++ b/frontend/src/pages/projects/ProjectListPage.tsx @@ -105,7 +105,6 @@ const ProjectListPage = observer(() => { const handlePageChange = (newPage: number) => { projectSearchStore.setCurrentPage(newPage); - window.scrollTo({ top: 0, behavior: "smooth" }); }; const handleClearFilters = () => { diff --git a/frontend/src/pages/projects/ProjectMapPage.a11y.test.tsx b/frontend/src/pages/projects/ProjectMapPage.a11y.test.tsx new file mode 100644 index 000000000..3fc5bf55d --- /dev/null +++ b/frontend/src/pages/projects/ProjectMapPage.a11y.test.tsx @@ -0,0 +1,50 @@ +/** + * Accessibility tests for ProjectMapPage + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; +import ProjectMapPage from "./ProjectMapPage"; + +// Mock map components +vi.mock("@/features/projects/components/map/FullMapContainer", () => ({ + FullMapContainer: () =>
Map
, +})); + +vi.mock("@/features/projects/components/map/MapFilters", () => ({ + MapFilters: () =>
Filters
, +})); + +vi.mock("@/features/projects/hooks/useProjectsForMap", () => ({ + useProjectsForMap: () => ({ + data: { + projects: [], + total_projects: 0, + }, + isLoading: false, + }), +})); + +vi.mock("@/shared/hooks/useSearchStoreInit", () => ({ + useSearchStoreInit: vi.fn(), +})); + +vi.mock("@/app/stores/store-context", () => ({ + useProjectMapStore: () => ({ + state: { + mapFullscreen: false, + filtersMinimized: false, + }, + toggleMapFullscreen: vi.fn(), + toggleFiltersMinimized: vi.fn(), + }), +})); + +describe("ProjectMapPage - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/projects/ProjectMapPage.tsx b/frontend/src/pages/projects/ProjectMapPage.tsx index ceaa7cd18..62e7f4f91 100644 --- a/frontend/src/pages/projects/ProjectMapPage.tsx +++ b/frontend/src/pages/projects/ProjectMapPage.tsx @@ -169,14 +169,14 @@ const ProjectMapPage = observer(() => { // Fullscreen mode: true fullscreen map with floating sidebar or minimized filter button return (
- {/* Fullscreen map */} -
- - - -
+ {/* Hide navigation menu in fullscreen mode */} + - {/* Minimized filter button - top right corner, positioned to not conflict with map controls */} + {/* FIRST: Minimized filter button - for tab order */} {store.state.filtersMinimized && (
{
)} - {/* Floating sidebar - animated */} + {/* SECOND: Floating sidebar - for tab order */} {!store.state.filtersMinimized && (
{
)} + + {/* THIRD: Fullscreen map (map buttons will be inside, markers after) */} +
+ + + +
); }); diff --git a/frontend/src/pages/users/CaretakerModePage.a11y.test.tsx b/frontend/src/pages/users/CaretakerModePage.a11y.test.tsx new file mode 100644 index 000000000..ac957bbe9 --- /dev/null +++ b/frontend/src/pages/users/CaretakerModePage.a11y.test.tsx @@ -0,0 +1,37 @@ +/** + * Accessibility tests for CaretakerModePage + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; +import CaretakerModePage from "./CaretakerModePage"; + +// Mock hooks +vi.mock("@/features/caretakers/hooks/useCaretakerRequests", () => ({ + useCaretakerRequests: () => ({ + data: { + results: [], + count: 0, + }, + isLoading: false, + }), +})); + +vi.mock("@/features/users/hooks/useCurrentUser", () => ({ + useCurrentUser: () => ({ + data: { + id: 1, + username: "testuser", + }, + isLoading: false, + }), +})); + +describe("CaretakerModePage - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/users/MyProfilePage.a11y.test.tsx b/frontend/src/pages/users/MyProfilePage.a11y.test.tsx new file mode 100644 index 000000000..7a7a6bca8 --- /dev/null +++ b/frontend/src/pages/users/MyProfilePage.a11y.test.tsx @@ -0,0 +1,30 @@ +/** + * Accessibility tests for MyProfilePage + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; +import MyProfilePage from "./MyProfilePage"; + +// Mock hooks +vi.mock("@/features/users/hooks/useCurrentUser", () => ({ + useCurrentUser: () => ({ + data: { + id: 1, + username: "testuser", + display_first_name: "Test", + display_last_name: "User", + email: "test@example.com", + }, + isLoading: false, + }), +})); + +describe("MyProfilePage - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/users/UserDetailPage.a11y.test.tsx b/frontend/src/pages/users/UserDetailPage.a11y.test.tsx new file mode 100644 index 000000000..3fc83c370 --- /dev/null +++ b/frontend/src/pages/users/UserDetailPage.a11y.test.tsx @@ -0,0 +1,40 @@ +/** + * Accessibility tests for UserDetailPage + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; +import UserDetailPage from "./UserDetailPage"; + +// Mock hooks +vi.mock("@/features/users/hooks/useUser", () => ({ + useUser: () => ({ + data: { + id: 1, + username: "testuser", + display_first_name: "Test", + display_last_name: "User", + email: "test@example.com", + }, + isLoading: false, + }), +})); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useParams: () => ({ id: "1" }), + }; +}); + +describe("UserDetailPage - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(, { + initialEntries: ["/users/1"], + }); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/users/UserEditPage.a11y.test.tsx b/frontend/src/pages/users/UserEditPage.a11y.test.tsx new file mode 100644 index 000000000..717720529 --- /dev/null +++ b/frontend/src/pages/users/UserEditPage.a11y.test.tsx @@ -0,0 +1,47 @@ +/** + * Accessibility tests for UserEditPage + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; +import UserEditPage from "./UserEditPage"; + +// Mock hooks +vi.mock("@/features/users/hooks/useUser", () => ({ + useUser: () => ({ + data: { + id: 1, + username: "testuser", + display_first_name: "Test", + display_last_name: "User", + email: "test@example.com", + }, + isLoading: false, + }), +})); + +vi.mock("@/features/users/hooks/useUpdateUser", () => ({ + useUpdateUser: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useParams: () => ({ id: "1" }), + }; +}); + +describe("UserEditPage - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(, { + initialEntries: ["/users/1/edit"], + }); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/users/UserListPage.a11y.test.tsx b/frontend/src/pages/users/UserListPage.a11y.test.tsx new file mode 100644 index 000000000..b2f11240e --- /dev/null +++ b/frontend/src/pages/users/UserListPage.a11y.test.tsx @@ -0,0 +1,84 @@ +/** + * Accessibility tests for UserListPage + * Tests WCAG 2.2 Level AA compliance using axe-core + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderPage, testAccessibility } from "@/test/page-test-utils"; + +// Mock all dependencies BEFORE importing the component +vi.mock("@/features/users/hooks/useUsers", () => ({ + useUsers: () => ({ + data: { + results: [], + count: 0, + }, + isLoading: false, + error: null, + refetch: vi.fn(), + }), +})); + +vi.mock("@/shared/hooks/useSearchStoreInit", () => ({ + useSearchStoreInit: vi.fn(), +})); + +vi.mock("@/app/stores/store-context", () => ({ + useAuthStore: () => ({ + state: { + isAuthenticated: true, + user: { + id: 1, + username: "testuser", + is_staff: false, + }, + }, + checkAuth: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + }), + useUserSearchStore: () => ({ + state: { + searchTerm: "", + filters: { + onlyExternal: false, + onlyStaff: false, + onlySuperuser: false, + onlyBALead: false, + businessArea: undefined, + }, + currentPage: 1, + saveSearch: true, + totalResults: 0, + }, + setSearchTerm: vi.fn(), + setFilters: vi.fn(), + setCurrentPage: vi.fn(), + setPagination: vi.fn(), + resetFilters: vi.fn(), + toggleSaveSearch: vi.fn(), + hasActiveFilters: false, + filterCount: 0, + searchParams: new URLSearchParams(), + }), +})); + +// Mock react-router to avoid navigation issues +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useSearchParams: () => [new URLSearchParams(), vi.fn()], + }; +}); + +// Now import the component AFTER all mocks are set up +const UserListPage = await import("./UserListPage").then((m) => m.default); + +describe("UserListPage - Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = renderPage(); + const results = await testAccessibility(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/pages/users/UserListPage.tsx b/frontend/src/pages/users/UserListPage.tsx index dfa558155..c71445798 100644 --- a/frontend/src/pages/users/UserListPage.tsx +++ b/frontend/src/pages/users/UserListPage.tsx @@ -18,6 +18,17 @@ import { SearchControls } from "@/shared/components/SearchControls"; import { useAuthStore, useUserSearchStore } from "@/app/stores/store-context"; import { useSearchStoreInit } from "@/shared/hooks/useSearchStoreInit"; import { PageTransition } from "@/shared/components/PageTransition"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select"; +import { Checkbox } from "@/shared/components/ui/checkbox"; +import { Label } from "@/shared/components/ui/label"; +import { useBusinessAreas } from "@/shared/hooks/queries/useBusinessAreas"; +import { ResponsiveLayout } from "@/shared/components/ResponsiveLayout"; /** * UserListPage @@ -56,6 +67,10 @@ const UserListPage = observer(() => { page: userSearchStore.state.currentPage, }); + // Fetch business areas for desktop filter dropdown + const { data: businessAreas, isLoading: isLoadingBusinessAreas } = + useBusinessAreas(); + // Delay sheet opening until after page transition completes const [shouldShowSheet, setShouldShowSheet] = useState(false); const [shouldShowError, setShouldShowError] = useState(false); @@ -142,6 +157,24 @@ const UserListPage = observer(() => { userSearchStore.clearSearchAndFilters(); }; + // Sort business areas by division for desktop dropdown + const orderedDivisionSlugs = ["BCS", "CEM", "RFMS"]; + const sortedBusinessAreas = businessAreas?.slice().sort((a, b) => { + const aDivSlug = + typeof a.division === "object" && a.division?.slug ? a.division.slug : ""; + const bDivSlug = + typeof b.division === "object" && b.division?.slug ? b.division.slug : ""; + + const aIndex = orderedDivisionSlugs.indexOf(aDivSlug); + const bIndex = orderedDivisionSlugs.indexOf(bDivSlug); + + if (aIndex !== bIndex) { + return aIndex - bIndex; + } + + return a.name.localeCompare(b.name); + }); + // Error state - only show if error persists if (shouldShowError) { return ( @@ -194,46 +227,215 @@ const UserListPage = observer(() => {
- {/* Layout: UserFilterPanel on left, UserSearchBar on right */} -
- {/* Search Input - shows first on mobile */} -
- - - {/* Remember my search and Clear - hidden on mobile, shown on desktop below search */} -
- + {/* Search Input - visual position 1 on mobile */} + + + {/* Business Area + Filters - visual position 2 on mobile */} + + + {/* Remember my search and Clear - visual position 3 on mobile (at the bottom) */} +
+ +
+
+ } + desktopContent={ +
+ {/* Row 1: Business Area (left) and Search (right) */} +
+ {/* Business Area Dropdown - left side, row 1 */} +
+ +
+ + {/* Search Input - right side, row 1 */} +
+ +
+
+ + {/* Row 2: Filter Checkboxes (left) and Remember Search (right) */} +
+ {/* Filter Checkboxes - left side, row 2 */} +
+
+
+ { + handleFiltersChange({ + ...userSearchStore.state.filters, + onlyExternal: + !userSearchStore.state.filters.onlyExternal, + onlyStaff: false, + onlySuperuser: false, + onlyBALead: false, + }); + }} + /> + +
+ +
+ { + handleFiltersChange({ + ...userSearchStore.state.filters, + onlyStaff: + !userSearchStore.state.filters.onlyStaff, + onlyExternal: false, + onlySuperuser: false, + onlyBALead: false, + }); + }} + className="data-[state=checked]:bg-green-600 data-[state=checked]:border-green-500" + /> + +
+ +
+ { + handleFiltersChange({ + ...userSearchStore.state.filters, + onlyBALead: + !userSearchStore.state.filters.onlyBALead, + onlyExternal: false, + onlyStaff: false, + onlySuperuser: false, + }); + }} + className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-500" + /> + +
+ +
+ { + handleFiltersChange({ + ...userSearchStore.state.filters, + onlySuperuser: + !userSearchStore.state.filters + .onlySuperuser, + onlyExternal: false, + onlyStaff: false, + onlyBALead: false, + }); + }} + className="data-[state=checked]:bg-blue-600 data-[state-checked]:border-blue-500" + /> + +
+
+
+ + {/* Remember Search Controls - right side, row 2 */} +
+ +
+
-
- - {/* Business Area + Filters - shows second on mobile, expands naturally */} -
- -
- - {/* Remember my search and Clear - shown on mobile at bottom, hidden on desktop */} -
- -
-
+ } + />
diff --git a/frontend/src/shared/components/BusinessAreaMultiSelect.tsx b/frontend/src/shared/components/BusinessAreaMultiSelect.tsx index dac224562..6d0824eb8 100644 --- a/frontend/src/shared/components/BusinessAreaMultiSelect.tsx +++ b/frontend/src/shared/components/BusinessAreaMultiSelect.tsx @@ -1,14 +1,12 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; import { Button } from "@/shared/components/ui/button"; import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/shared/components/ui/popover"; -import { Checkbox } from "@/shared/components/ui/checkbox"; -import { Label } from "@/shared/components/ui/label"; -import { ChevronDown, X } from "lucide-react"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu"; +import { ChevronDown, X, Check } from "lucide-react"; import { useBusinessAreas } from "@/shared/hooks/queries/useBusinessAreas"; import type { IBusinessArea } from "@/shared/types/org.types"; @@ -27,12 +25,19 @@ interface BusinessAreaMultiSelectProps { * BusinessAreaMultiSelect - Reusable multi-select component for business areas * * Features: - * - Multi-select with checkboxes using shadcn Popover + * - Multi-select with checkboxes using DropdownMenu * - "Select All" and "Clear All" functionality * - Display selected count in trigger button * - Show inactive business areas with "(Inactive)" label - * - Keyboard navigation and accessibility + * - Keyboard navigation (Arrow keys, Enter, Escape, Tab) + * - Focus management and accessibility * - Optional tags display for selected items + * + * Keyboard Navigation: + * - Arrow Up/Down: Navigate between items + * - Enter/Space: Toggle checkbox (menu stays open for multi-select) + * - Escape: Close menu and return focus to trigger + * - Tab/Shift+Tab: Close menu and move to next/previous element */ export const BusinessAreaMultiSelect = observer( ({ @@ -47,7 +52,10 @@ export const BusinessAreaMultiSelect = observer( }: BusinessAreaMultiSelectProps) => { const { data: businessAreas, isLoading: isLoadingBusinessAreas } = useBusinessAreas(); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(0); + const menuItemsRef = useRef<(HTMLElement | null)[]>([]); + const preventCloseRef = useRef(false); // Sort business areas alphabetically const sortedBusinessAreas = useMemo(() => { @@ -55,6 +63,9 @@ export const BusinessAreaMultiSelect = observer( return [...businessAreas].sort((a, b) => a.name.localeCompare(b.name)); }, [businessAreas]); + // Total number of menu items (2 buttons + business areas) + const totalItems = 2 + sortedBusinessAreas.length; + // Get selected business area names for display const selectedNames = useMemo(() => { if (selectedBusinessAreas.length === 0) return placeholder; @@ -106,74 +117,234 @@ export const BusinessAreaMultiSelect = observer( return businessArea.name; }; + // Close handler - but NOT for checkbox clicks + const handleClose = () => { + setIsOpen(false); + setFocusedIndex(0); + }; + + // Prevent closing when clicking checkboxes + const handleOpenChange = (open: boolean) => { + // If trying to close and we're preventing close, re-open immediately + if (!open && preventCloseRef.current) { + preventCloseRef.current = false; + // Re-open immediately + setTimeout(() => setIsOpen(true), 0); + return; + } + setIsOpen(open); + if (!open) { + setFocusedIndex(0); + } + }; + + // Handle checkbox toggle - prevent menu from closing + const handleCheckboxToggle = (baId: number) => { + preventCloseRef.current = true; + onToggleBusinessArea(baId); + // Reset the flag after a short delay to allow the close event to be ignored + setTimeout(() => { + preventCloseRef.current = false; + }, 100); + }; + + // Keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + // If on Select All (0) or Clear (1), jump to first checkbox (2) + if (focusedIndex < 2) { + setFocusedIndex(2); + menuItemsRef.current[2]?.focus(); + } else { + // Normal down navigation through checkboxes + setFocusedIndex((prev) => { + const next = (prev + 1) % totalItems; + menuItemsRef.current[next]?.focus(); + return next; + }); + } + break; + case "ArrowUp": + e.preventDefault(); + // If on first checkbox (2), jump to Clear button (1) + if (focusedIndex === 2) { + setFocusedIndex(1); + menuItemsRef.current[1]?.focus(); + } else { + // Normal up navigation + setFocusedIndex((prev) => { + const next = (prev - 1 + totalItems) % totalItems; + menuItemsRef.current[next]?.focus(); + return next; + }); + } + break; + case "ArrowLeft": + e.preventDefault(); + // Only works on Select All (0) and Clear (1) buttons + if (focusedIndex === 0 || focusedIndex === 1) { + const newIndex = focusedIndex === 0 ? 1 : 0; + setFocusedIndex(newIndex); + menuItemsRef.current[newIndex]?.focus(); + } + break; + case "ArrowRight": + e.preventDefault(); + // Only works on Select All (0) and Clear (1) buttons + if (focusedIndex === 0 || focusedIndex === 1) { + const newIndex = focusedIndex === 0 ? 1 : 0; + setFocusedIndex(newIndex); + menuItemsRef.current[newIndex]?.focus(); + } + break; + case "Enter": + case " ": + e.preventDefault(); + e.stopPropagation(); + // Trigger click on focused item + menuItemsRef.current[focusedIndex]?.click(); + break; + case "Escape": + e.preventDefault(); + handleClose(); + break; + case "Tab": + // Allow Tab to close and move focus + handleClose(); + break; + } + }; + + // Focus first menu item when dropdown opens + useEffect(() => { + if (isOpen) { + // Small delay to ensure DOM is ready + const timer = setTimeout(() => { + menuItemsRef.current[0]?.focus(); + setFocusedIndex(0); + }, 50); + return () => clearTimeout(timer); + } + }, [isOpen]); + return (
- - - - - -
-
- Business Areas -
- - -
-
-
-
-
- {isLoadingBusinessAreas && ( -
- Loading... -
- )} - {sortedBusinessAreas.map((ba) => { - if (ba.id === undefined) return null; - return ( -
- onToggleBusinessArea(ba.id!)} - /> -
- - + + +
{/* Selected Business Area Tags */} {shouldShowTags && ( diff --git a/frontend/src/shared/components/ResponsiveLayout.tsx b/frontend/src/shared/components/ResponsiveLayout.tsx new file mode 100644 index 000000000..c677d2888 --- /dev/null +++ b/frontend/src/shared/components/ResponsiveLayout.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from "react"; + +interface ResponsiveLayoutProps { + /** + * Content to render on mobile (< 768px) + * DOM order should match mobile visual order (top to bottom) + */ + mobileContent: ReactNode; + + /** + * Content to render on desktop (≥ 768px) + * DOM order should match desktop visual order (left to right, then down) + */ + desktopContent: ReactNode; + + /** + * Optional className for the container + */ + className?: string; + + /** + * Breakpoint at which to switch from mobile to desktop layout + * @default "md" (768px) + */ + breakpoint?: "sm" | "md" | "lg" | "xl"; +} + +/** + * ResponsiveLayout component + * + * Solves the CSS `order` property tab navigation issue by rendering + * separate layouts for mobile and desktop, each with DOM order matching + * its visual order. + * + * **Problem**: CSS `order` property changes visual order but NOT DOM order, + * breaking keyboard tab navigation. + * + * **Solution**: Duplicate layouts with show/hide pattern - mobile layout + * hidden on desktop, desktop layout hidden on mobile. + * + * **Usage**: + * ```tsx + * + * + * + * + * + * } + * desktopContent={ + *
+ * + * + * + *
+ * } + * /> + * ``` + * + * **Key Principles**: + * - Never use CSS `order` property for interactive elements + * - DOM order must match visual order at EACH breakpoint + * - Desktop: Left-to-right, then down (row-by-row scanning) + * - Mobile: Top-to-bottom (natural stacking) + * + * **WCAG Compliance**: + * - 2.4.3 Focus Order (Level A): Tab order follows meaningful sequence + * - 2.1.1 Keyboard (Level A): All functionality available via keyboard + * - 1.3.2 Meaningful Sequence (Level A): Reading order is correct + */ +export const ResponsiveLayout = ({ + mobileContent, + desktopContent, + className = "", + breakpoint = "md", +}: ResponsiveLayoutProps) => { + // Map breakpoint to Tailwind classes + const breakpointClasses = { + sm: { mobile: "sm:hidden", desktop: "hidden sm:block" }, + md: { mobile: "md:hidden", desktop: "hidden md:block" }, + lg: { mobile: "lg:hidden", desktop: "hidden lg:block" }, + xl: { mobile: "xl:hidden", desktop: "hidden xl:block" }, + }; + + const classes = breakpointClasses[breakpoint]; + + return ( + <> + {/* Mobile layout - DOM order matches mobile visual order */} +
{mobileContent}
+ + {/* Desktop layout - DOM order matches desktop visual order */} +
{desktopContent}
+ + ); +}; diff --git a/frontend/src/shared/components/combobox/BaseCombobox.tsx b/frontend/src/shared/components/combobox/BaseCombobox.tsx index a4923043f..b75651974 100644 --- a/frontend/src/shared/components/combobox/BaseCombobox.tsx +++ b/frontend/src/shared/components/combobox/BaseCombobox.tsx @@ -69,6 +69,7 @@ export const BaseCombobox = forwardRef( debounceMs = 300, maxResults = 10, minSearchLength = 0, + ariaLabel, } = props; const inputRef = useRef(null); @@ -79,6 +80,14 @@ export const BaseCombobox = forwardRef( const [filteredItems, setFilteredItems] = useState([]); const [isCreating, setIsCreating] = useState(false); + // Generate unique IDs for accessibility + const inputId = useRef( + `combobox-input-${Math.random().toString(36).substr(2, 9)}` + ).current; + const helperTextId = useRef( + `combobox-helper-${Math.random().toString(36).substr(2, 9)}` + ).current; + // Debounce search term const debouncedSearchTerm = useDebouncedValue(searchTerm, debounceMs); @@ -209,7 +218,11 @@ export const BaseCombobox = forwardRef( ref={wrapperRef} className={cn("w-full", isRequired && "required", wrapperClassName)} > - {label && } + {label && ( + + )} {value && renderSelected ? ( renderSelected(value, handleClearSelection) ) : ( @@ -238,6 +251,7 @@ export const BaseCombobox = forwardRef( )} { @@ -261,6 +275,9 @@ export const BaseCombobox = forwardRef( autoFocus={autoFocus} autoComplete="off" disabled={disabled} + aria-required={isRequired} + aria-describedby={helperText ? helperTextId : undefined} + aria-label={!label ? ariaLabel : undefined} />
)} @@ -285,7 +302,10 @@ export const BaseCombobox = forwardRef(
)} {helperText && ( -

+

{helperText}

)} diff --git a/frontend/src/shared/components/combobox/types.ts b/frontend/src/shared/components/combobox/types.ts index a16249b45..4d5a3b4dd 100644 --- a/frontend/src/shared/components/combobox/types.ts +++ b/frontend/src/shared/components/combobox/types.ts @@ -38,6 +38,9 @@ export interface BaseComboboxProps { className?: string; wrapperClassName?: string; + // Accessibility + ariaLabel?: string; // Fallback accessible name when label is not provided + // Search configuration debounceMs?: number; maxResults?: number; diff --git a/frontend/src/shared/components/index.ts b/frontend/src/shared/components/index.ts index 6fe4907b0..2d164f0a3 100644 --- a/frontend/src/shared/components/index.ts +++ b/frontend/src/shared/components/index.ts @@ -3,6 +3,7 @@ export { NoResultsState } from "./NoResultsState"; export { ErrorState } from "./ErrorState"; // Breadcrumb is exported from navigation/Breadcrumb.tsx export { Pagination } from "./Pagination"; +export { ResponsiveLayout } from "./ResponsiveLayout"; // Effects export { ConfettiPortal, SuccessAnimation } from "./effects"; diff --git a/frontend/src/shared/components/layout/AppLayout.tsx b/frontend/src/shared/components/layout/AppLayout.tsx index 3d4bd8c6c..2928758b0 100644 --- a/frontend/src/shared/components/layout/AppLayout.tsx +++ b/frontend/src/shared/components/layout/AppLayout.tsx @@ -55,14 +55,18 @@ export function AppLayout() {
{/* Content Wrapper with responsive padding */} -
+
{/* Background Image - Handles its own theme reactivity */} -
+ {/* Footer */}
diff --git a/frontend/src/shared/components/layout/Header.tsx b/frontend/src/shared/components/layout/Header.tsx index f8c0f398d..fda40902d 100644 --- a/frontend/src/shared/components/layout/Header.tsx +++ b/frontend/src/shared/components/layout/Header.tsx @@ -1,21 +1,15 @@ import { Link, useNavigate } from "react-router"; import { observer } from "mobx-react-lite"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { GiHamburgerMenu } from "react-icons/gi"; import { ImUsers } from "react-icons/im"; import { FaUserPlus, FaMapMarkedAlt } from "react-icons/fa"; import { CgBrowse, CgPlayListAdd } from "react-icons/cg"; -import { IoCaretDown } from "react-icons/io5"; import { Navitar } from "./Navitar"; import { Button } from "@/shared/components/ui/button"; -import { NavigationDropdownMenuItem } from "@/shared/components/navigation/NavigationDropdownMenuItem"; +import { NavigationDropdownMenu } from "@/shared/components/navigation/NavigationDropdownMenu"; +import { NavigationDropdownMenuContent } from "@/shared/components/navigation/NavigationDropdownMenuContent"; import { Sheet, SheetContent } from "@/shared/components/ui/sheet"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "@/shared/components/ui/dropdown-menu"; import { useWindowSize } from "@/shared/hooks/useWindowSize"; import { BREAKPOINTS } from "@/shared/constants/breakpoints"; import { useUIStore, useAuthStore } from "@/app/stores/store-context"; @@ -75,6 +69,29 @@ export const Header = observer(() => { // Controlled dropdown state const [projectsOpen, setProjectsOpen] = useState(false); const [usersOpen, setUsersOpen] = useState(false); + const [navitarOpen, setNavitarOpen] = useState(false); + + // Close other menus when one opens + useEffect(() => { + if (projectsOpen) { + setUsersOpen(false); + setNavitarOpen(false); + } + }, [projectsOpen]); + + useEffect(() => { + if (usersOpen) { + setProjectsOpen(false); + setNavitarOpen(false); + } + }, [usersOpen]); + + useEffect(() => { + if (navitarOpen) { + setProjectsOpen(false); + setUsersOpen(false); + } + }, [navitarOpen]); // Show hamburger menu on screens smaller than lg breakpoint const shouldShowHamburger = width < BREAKPOINTS.lg; @@ -87,6 +104,21 @@ export const Header = observer(() => { return (
+ {/* Skip to main content link for keyboard users */} + +
{/* Left side - Logo */} @@ -95,6 +127,7 @@ export const Header = observer(() => { SPMS @@ -107,100 +140,91 @@ export const Header = observer(() => {
) : ( /* Desktop - Full Navigation */ -
+ )}
diff --git a/frontend/src/shared/components/layout/HeaderContent.tsx b/frontend/src/shared/components/layout/HeaderContent.tsx index f758c5a3c..53959bb0d 100644 --- a/frontend/src/shared/components/layout/HeaderContent.tsx +++ b/frontend/src/shared/components/layout/HeaderContent.tsx @@ -38,7 +38,7 @@ export default function HeaderContent({ }; return ( -
+ ); } diff --git a/frontend/src/shared/components/layout/Navitar.tsx b/frontend/src/shared/components/layout/Navitar.tsx index 1909bc915..2c352d03a 100644 --- a/frontend/src/shared/components/layout/Navitar.tsx +++ b/frontend/src/shared/components/layout/Navitar.tsx @@ -20,57 +20,78 @@ import NavitarContent from "./NavitarContent"; interface NavitarProps { shouldShowName?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; } /** * Navitar component - User avatar dropdown menu * Wrapped with observer to react to user data loading */ -export const Navitar = observer(({ shouldShowName = false }: NavitarProps) => { - const authStore = useAuthStore(); - const { data: currentUser } = useCurrentUser(); - const { width: windowSize } = useWindowSize(); - const [open, setOpen] = useState(false); +export const Navitar = observer( + ({ + shouldShowName = false, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + }: NavitarProps) => { + const authStore = useAuthStore(); + const { data: currentUser } = useCurrentUser(); + const { width: windowSize } = useWindowSize(); + const [internalOpen, setInternalOpen] = useState(false); - // Use fresh user data from TanStack Query, fallback to authStore - const userData = currentUser || authStore.user; + // Use controlled or uncontrolled state + const open = controlledOpen !== undefined ? controlledOpen : internalOpen; + const setOpen = controlledOnOpenChange || setInternalOpen; - // Calculate display name with truncation logic - const displayName = userData?.display_first_name - ? userData.display_first_name.length < 12 - ? userData.display_first_name - : windowSize >= BREAKPOINTS.xl + // Use fresh user data from TanStack Query, fallback to authStore + const userData = currentUser || authStore.user; + + // Calculate display name with truncation logic + const displayName = userData?.display_first_name + ? userData.display_first_name.length < 12 ? userData.display_first_name - : `${userData.display_first_name.substring(0, 9)}...` - : userData?.username; + : windowSize >= BREAKPOINTS.xl + ? userData.display_first_name + : `${userData.display_first_name.substring(0, 9)}...` + : userData?.username; - const avatarSrc = getImageUrl(userData?.image); - const userInitial = userData?.username - ? userData.username.charAt(0).toUpperCase() - : "U"; + const avatarSrc = getImageUrl(userData?.image); + const userInitial = userData?.username + ? userData.username.charAt(0).toUpperCase() + : "U"; - return ( -
- - - - + return ( +
+ + + + - - setOpen(false)} /> - - -
- ); -}); + + setOpen(false)} /> + +
+
+ ); + } +); diff --git a/frontend/src/shared/components/layout/NavitarContent.tsx b/frontend/src/shared/components/layout/NavitarContent.tsx index 3dfb82207..4afb32790 100644 --- a/frontend/src/shared/components/layout/NavitarContent.tsx +++ b/frontend/src/shared/components/layout/NavitarContent.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from "react"; import { useNavigate } from "react-router"; import { useAuthStore, useUIStore } from "@/app/stores/store-context"; import { useLogout } from "@/features/auth/hooks/useAuth"; @@ -8,9 +9,9 @@ import { } from "@/shared/components/ui/avatar"; import { Separator } from "@/shared/components/ui/separator"; import { User, LogOut, Moon, Sun, BookOpen } from "lucide-react"; -import { useState } from "react"; import { getUserDisplayName, getUserInitials } from "@/shared/utils/user.utils"; import { getImageUrl } from "@/shared/utils/image.utils"; +import { useMenuKeyboardNavigation } from "@/shared/hooks/useMenuKeyboardNavigation"; interface NavitarContentProps { onClose: () => void; @@ -19,12 +20,15 @@ interface NavitarContentProps { /** * NavitarContent - The content inside the Navitar popover * Captures MobX state on mount to prevent flickering during close animation + * Implements WCAG 2.2 keyboard navigation with arrow keys and focus management */ export default function NavitarContent({ onClose }: NavitarContentProps) { const navigate = useNavigate(); const authStore = useAuthStore(); const uiStore = useUIStore(); const { mutate: logout } = useLogout(); + const { handleKeyDown, registerMenuItem, focusFirstItem } = + useMenuKeyboardNavigation(onClose); // Capture store values once on mount using useState with initializer function const [snapshot] = useState(() => ({ @@ -36,8 +40,13 @@ export default function NavitarContent({ onClose }: NavitarContentProps) { const displayName = getUserDisplayName(snapshot.userData); const initials = getUserInitials(snapshot.userData); + // Focus first menu item when component mounts + useEffect(() => { + focusFirstItem(); + }, [focusFirstItem]); + return ( -
+
{/* User Info Section */}
@@ -65,18 +74,21 @@ export default function NavitarContent({ onClose }: NavitarContentProps) {
{/* My SPMS Profile */} -
{ navigate("/users/me"); onClose(); }} - className="cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" + className="w-full text-left cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 focus:bg-gray-100 dark:focus:bg-gray-700 focus:outline-none rounded" + role="menuitem" >
- +
-
+
@@ -90,22 +102,25 @@ export default function NavitarContent({ onClose }: NavitarContentProps) {
{/* Toggle Dark Mode */} -
{ uiStore.toggleTheme(); onClose(); }} - className="cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" + className="w-full text-left cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 focus:bg-gray-100 dark:focus:bg-gray-700 focus:outline-none rounded" + role="menuitem" >
{snapshot.theme === "dark" ? ( - +
-
+
@@ -119,53 +134,64 @@ export default function NavitarContent({ onClose }: NavitarContentProps) {
{/* Quick Guide */} -
{ navigate("/guide"); onClose(); }} - className="cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" + className="w-full text-left cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 focus:bg-gray-100 dark:focus:bg-gray-700 focus:outline-none rounded" + role="menuitem" >
- +
-
+ {/* Data Catalogue */} -
{ window.open("https://data.bio.wa.gov.au/", "_blank"); onClose(); }} - className="cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" + className="w-full text-left cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 focus:bg-gray-100 dark:focus:bg-gray-700 focus:outline-none rounded" + role="menuitem" >
- +
-
+ {/* Scientific Sites Register */} -
{ window.open("https://scientificsites.dpaw.wa.gov.au/", "_blank"); onClose(); }} - className="cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" + className="w-full text-left cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 focus:bg-gray-100 dark:focus:bg-gray-700 focus:outline-none rounded" + role="menuitem" >
- +
-
+
{/* Logout Section */}
-
{ logout(undefined, { onSuccess: () => { @@ -174,13 +200,14 @@ export default function NavitarContent({ onClose }: NavitarContentProps) { }); onClose(); }} - className="cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-red-600 dark:text-red-400" + className="w-full text-left cursor-pointer p-2.5 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 focus:bg-gray-100 dark:focus:bg-gray-700 focus:outline-none rounded text-red-600 dark:text-red-400" + role="menuitem" >
- +
-
+
); diff --git a/frontend/src/shared/components/navigation/NavigationDropdownMenu.tsx b/frontend/src/shared/components/navigation/NavigationDropdownMenu.tsx new file mode 100644 index 000000000..3ae24d875 --- /dev/null +++ b/frontend/src/shared/components/navigation/NavigationDropdownMenu.tsx @@ -0,0 +1,59 @@ +import { type ReactNode } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu"; +import { Button } from "@/shared/components/ui/button"; +import { IoCaretDown } from "react-icons/io5"; + +interface NavigationDropdownMenuProps { + label: string; + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; +} + +/** + * NavigationDropdownMenu - Accessible dropdown menu wrapper + * + * Simplified wrapper that uses DropdownMenu component. + * Content component (NavigationDropdownMenuContent) handles keyboard navigation. + * + * WCAG 2.2 Compliance: + * - 2.1.1 Keyboard (Level A) - Full keyboard support with arrow keys + * - 2.4.3 Focus Order (Level A) - Logical focus order + * - 4.1.2 Name, Role, Value (Level A) - Proper ARIA attributes + */ +export function NavigationDropdownMenu({ + label, + open, + onOpenChange, + children, +}: NavigationDropdownMenuProps) { + return ( +
+ + + + + + {children} + + +
+ ); +} diff --git a/frontend/src/shared/components/navigation/NavigationDropdownMenuContent.tsx b/frontend/src/shared/components/navigation/NavigationDropdownMenuContent.tsx new file mode 100644 index 000000000..f32f6f163 --- /dev/null +++ b/frontend/src/shared/components/navigation/NavigationDropdownMenuContent.tsx @@ -0,0 +1,114 @@ +import { useRef, useEffect, type ReactNode } from "react"; +import { useNavigate, useLocation } from "react-router"; +import { DropdownMenuLabel } from "@/shared/components/ui/dropdown-menu"; +import { hasModifierKey } from "@/shared/utils/navigation.utils"; +import { cn } from "@/shared/lib/utils"; +import { useMenuKeyboardNavigation } from "@/shared/hooks/useMenuKeyboardNavigation"; + +interface NavigationDropdownMenuContentProps { + label: string; + items: Array<{ + targetPath: string; + icon: ReactNode; + label: string; + }>; + onClose: () => void; +} + +/** + * NavigationDropdownMenuContent - Menu content with keyboard navigation + * + * Mirrors NavitarContent pattern: + * - Uses buttons with refs (not anchor tags) + * - Attaches onKeyDown to container div + * - Focuses first button on mount + * - Arrow keys navigate between buttons + */ +export function NavigationDropdownMenuContent({ + label, + items, + onClose, +}: NavigationDropdownMenuContentProps) { + const navigate = useNavigate(); + const location = useLocation(); + const menuRef = useRef(null); + const { handleKeyDown, registerMenuItem, focusFirstItem } = + useMenuKeyboardNavigation(onClose); + + // Focus first non-disabled menu item when component mounts + useEffect(() => { + focusFirstItem(); + }, [focusFirstItem]); + + // Get base path without query params or hash + const getBasePath = (path: string) => { + return path.split("?")[0].split("#")[0]; + }; + + // Check if path is active + const isPathActive = (targetPath: string) => { + const currentBasePath = getBasePath(location.pathname); + const targetBasePath = getBasePath(targetPath); + return currentBasePath === targetBasePath; + }; + + // Handle navigation + const handleNavigate = ( + targetPath: string, + event: React.MouseEvent + ) => { + const isActive = isPathActive(targetPath); + + // If already on this route, do nothing + if (isActive) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + // Ctrl+Click: Open in new tab + if (hasModifierKey(event.nativeEvent)) { + window.open(targetPath, "_blank"); + onClose(); + return; + } + + // Standard click: Navigate with React Router + navigate(targetPath); + onClose(); + }; + + return ( +
+ + {label} + + + {items.map((item, index) => { + const isActive = isPathActive(item.targetPath); + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/shared/components/navigation/NavigationDropdownMenuItem.tsx b/frontend/src/shared/components/navigation/NavigationDropdownMenuItem.tsx index 4ba4b143c..ed0e6d328 100644 --- a/frontend/src/shared/components/navigation/NavigationDropdownMenuItem.tsx +++ b/frontend/src/shared/components/navigation/NavigationDropdownMenuItem.tsx @@ -5,8 +5,10 @@ import { hasModifierKey } from "@/shared/utils/navigation.utils"; import { cn } from "@/shared/lib/utils"; import type { ComponentProps } from "react"; -interface NavigationDropdownMenuItemProps - extends Omit, "onClick"> { +interface NavigationDropdownMenuItemProps extends Omit< + ComponentProps, + "onClick" +> { targetPath: string; onNavigate?: () => void; // Callback to close dropdown after navigation children: React.ReactNode; @@ -26,12 +28,14 @@ interface NavigationDropdownMenuItemProps * - Active route highlighting: Light blue background when on current route * - Disabled when active: Cannot click to navigate to current route * - No race conditions: Navigation completes before dropdown closes + * - Arrow key navigation: NOT tab-focusable (use arrow keys in menu) * * Implementation: * - Uses asChild pattern to make anchor tag become the DropdownMenuItem * - Anchor inherits all padding and styling from DropdownMenuItem * - Entire clickable area (including padding) triggers navigation * - Compares base paths (strips query params) for active state + * - tabIndex={-1} prevents Tab key from focusing (arrow keys only) * * @example * const [open, setOpen] = useState(false); @@ -104,12 +108,13 @@ export const NavigationDropdownMenuItem = forwardRef< href={isActive ? undefined : targetPath} onClick={handleClick} className={cn( - "no-underline select-none", + "no-underline select-none outline-none", isActive && "cursor-default pointer-events-none" )} + role="menuitem" aria-current={isActive ? "page" : undefined} aria-disabled={isActive ? "true" : undefined} - tabIndex={isActive ? -1 : undefined} + tabIndex={isActive ? undefined : -1} > {children}
diff --git a/frontend/src/shared/components/user/UserCombobox.tsx b/frontend/src/shared/components/user/UserCombobox.tsx index 858c1cafb..438497507 100644 --- a/frontend/src/shared/components/user/UserCombobox.tsx +++ b/frontend/src/shared/components/user/UserCombobox.tsx @@ -65,6 +65,9 @@ export interface UserComboboxProps { disabled?: boolean; className?: string; wrapperClassName?: string; + + // Accessibility + ariaLabel?: string; // Accessible name when label is not provided } export interface UserComboboxRef { @@ -81,6 +84,7 @@ export const UserCombobox = forwardRef( excludeUserIds = [], placeholder = "Search for a user...", showIcon = false, + ariaLabel = "Search for a user", // Default accessible name ...props }, ref @@ -136,6 +140,7 @@ export const UserCombobox = forwardRef( } showIcon={showIcon} placeholder={placeholder} + ariaLabel={ariaLabel} {...props} ref={ref} /> diff --git a/frontend/src/shared/hooks/ui/useScrollToTop.ts b/frontend/src/shared/hooks/ui/useScrollToTop.ts index cf4520cf9..9e3649628 100644 --- a/frontend/src/shared/hooks/ui/useScrollToTop.ts +++ b/frontend/src/shared/hooks/ui/useScrollToTop.ts @@ -3,12 +3,39 @@ import { useEffect } from "react"; import { useLocation } from "react-router"; +/** + * Hook that smoothly scrolls to top on route change + * + * Automatically scrolls to the top of the page when the route changes. + * Uses smooth scrolling for better UX. + * + * Triggers on both pathname changes (route navigation) and search param changes (pagination, filters). + * + * Note: This finds the scrollable container in AppLayout (the div with overflow-y-auto) + * and scrolls that, not the window. + */ export const useScrollToTop = () => { const location = useLocation(); useEffect(() => { - window.scrollTo(0, 0); - }, [location.pathname]); + // Find the scrollable container (the div with overflow-y-auto in AppLayout) + const scrollContainer = document.querySelector(".overflow-y-auto"); + + if (scrollContainer) { + scrollContainer.scrollTo({ + top: 0, + left: 0, + behavior: "smooth", + }); + } else { + // Fallback to window scroll if container not found + window.scrollTo({ + top: 0, + left: 0, + behavior: "smooth", + }); + } + }, [location.pathname, location.search]); return null; }; diff --git a/frontend/src/shared/hooks/useMenuKeyboardNavigation.ts b/frontend/src/shared/hooks/useMenuKeyboardNavigation.ts new file mode 100644 index 000000000..45ac9df74 --- /dev/null +++ b/frontend/src/shared/hooks/useMenuKeyboardNavigation.ts @@ -0,0 +1,183 @@ +import { useRef, useState, useCallback } from "react"; + +/** + * useMenuKeyboardNavigation - Complete WCAG-compliant keyboard navigation for menus + * + * This hook provides full keyboard navigation for dropdown menus: + * - Arrow Up/Down: Navigate between menu items (skips disabled items) + * - Home: Jump to first enabled item + * - End: Jump to last enabled item + * - Tab/Shift+Tab: Close menu and move focus to next/previous element + * + * WCAG 2.2 Compliance: + * - Menus are "composite widgets" with single tab stop + * - Arrow keys for internal navigation + * - Tab/Shift+Tab closes menu and moves to next/previous element + * - Automatic focus management on mount + * + * @param onClose - Callback to close the menu + * @returns Object with menuItems ref, handleKeyDown, registerMenuItem, and focusFirstItem + * + * @example + * ```tsx + * const { menuItems, handleKeyDown, registerMenuItem, focusFirstItem } = + * useMenuKeyboardNavigation(onClose); + * + * useEffect(() => { + * focusFirstItem(); + * }, [focusFirstItem]); + * + * return ( + *
+ * + * + *
+ * ); + * ``` + */ +export function useMenuKeyboardNavigation(onClose: () => void) { + const menuItems = useRef([]); + const [_focusedIndex, setFocusedIndex] = useState(0); + + // Helper to find next enabled item + const findNextEnabled = (startIndex: number, direction: 1 | -1): number => { + const itemCount = menuItems.current.length; + let index = startIndex; + let attempts = 0; + + while (attempts < itemCount) { + index = + direction === 1 + ? (index + 1) % itemCount + : (index - 1 + itemCount) % itemCount; + + if (menuItems.current[index] && !menuItems.current[index].disabled) { + return index; + } + attempts++; + } + return startIndex; // No enabled items found, stay at current + }; + + // Helper to find first enabled item + const findFirstEnabled = (): number => { + return menuItems.current.findIndex((item) => item && !item.disabled); + }; + + // Helper to find last enabled item + const findLastEnabled = (): number => { + for (let i = menuItems.current.length - 1; i >= 0; i--) { + if (menuItems.current[i] && !menuItems.current[i].disabled) { + return i; + } + } + return 0; + }; + + // Handle Tab/Shift+Tab navigation + const handleTabNavigation = (isShiftTab: boolean) => { + // Find all focusable elements BEFORE closing menu + // CRITICAL: Exclude menu items (role="menuitem") to prevent focusing menu items + const allButtons = Array.from( + document.querySelectorAll( + 'button:not([disabled]):not([role="menuitem"]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ); + + // Find the trigger button by aria-expanded="true" (the open menu's trigger) + const trigger = allButtons.find( + (btn) => btn.getAttribute("aria-expanded") === "true" + ); + + if (!trigger) return; + + const triggerIndex = allButtons.indexOf(trigger); + + // Close menu first + onClose(); + + // After menu closes, move focus to previous/next element + // Delay ensures Radix has finished its cleanup + setTimeout(() => { + if (isShiftTab) { + // Shift+Tab: focus previous element + const prevElement = allButtons[triggerIndex - 1]; + if (prevElement) { + prevElement.focus(); + } + } else { + // Tab: focus next element + const nextElement = allButtons[triggerIndex + 1]; + if (nextElement) { + nextElement.focus(); + } + } + }, 100); + }; + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": { + e.preventDefault(); + setFocusedIndex((prev) => { + const next = findNextEnabled(prev, 1); + menuItems.current[next]?.focus(); + return next; + }); + break; + } + case "ArrowUp": { + e.preventDefault(); + setFocusedIndex((prev) => { + const next = findNextEnabled(prev, -1); + menuItems.current[next]?.focus(); + return next; + }); + break; + } + case "Home": { + e.preventDefault(); + const firstEnabled = findFirstEnabled(); + setFocusedIndex(firstEnabled); + menuItems.current[firstEnabled]?.focus(); + break; + } + case "End": { + e.preventDefault(); + const lastEnabled = findLastEnabled(); + setFocusedIndex(lastEnabled); + menuItems.current[lastEnabled]?.focus(); + break; + } + case "Tab": + e.preventDefault(); + handleTabNavigation(e.shiftKey); + break; + } + }; + + // Register menu item ref + const registerMenuItem = + (index: number) => (el: HTMLButtonElement | null) => { + if (el) { + menuItems.current[index] = el; + } + }; + + // Focus first enabled menu item (call in useEffect) + const focusFirstItem = useCallback(() => { + const firstEnabledIndex = findFirstEnabled(); + if (firstEnabledIndex !== -1) { + setFocusedIndex(firstEnabledIndex); + menuItems.current[firstEnabledIndex]?.focus(); + } + }, []); + + return { + menuItems, + handleKeyDown, + registerMenuItem, + focusFirstItem, + }; +} diff --git a/frontend/src/shared/utils/image-compression.performance.test.ts b/frontend/src/shared/utils/image-compression.performance.test.ts index 7823ca17f..52963803c 100644 --- a/frontend/src/shared/utils/image-compression.performance.test.ts +++ b/frontend/src/shared/utils/image-compression.performance.test.ts @@ -5,7 +5,7 @@ * and that there's no significant performance regression. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; import { compressImage } from "./image-compression.utils"; import imageCompression from "browser-image-compression"; import { getCompressionWorkerUrl } from "./compression-worker"; @@ -13,10 +13,8 @@ import { getCompressionWorkerUrl } from "./compression-worker"; vi.mock("browser-image-compression"); vi.mock("./compression-worker"); -const mockImageCompression = imageCompression as ReturnType; -const mockGetCompressionWorkerUrl = getCompressionWorkerUrl as ReturnType< - typeof vi.fn ->; +const mockImageCompression = imageCompression as unknown as Mock; +const mockGetCompressionWorkerUrl = getCompressionWorkerUrl as unknown as Mock; describe("Image Compression Performance", () => { beforeEach(() => { diff --git a/frontend/src/test/axe-utils.ts b/frontend/src/test/axe-utils.ts new file mode 100644 index 000000000..9f9230968 --- /dev/null +++ b/frontend/src/test/axe-utils.ts @@ -0,0 +1,200 @@ +/** + * Axe-core test utilities for accessibility testing + * + * Provides utilities for running axe-core automated accessibility tests + * configured for WCAG 2.2 Level AA compliance. + */ + +import { configureAxe, type JestAxeConfigureOptions } from "jest-axe"; +import type { AxeResults, RunOptions, UnlabelledFrameSelector } from "axe-core"; + +/** + * WCAG 2.2 Level AA configuration for axe-core + */ +export const WCAG_22_AA_CONFIG: RunOptions = { + runOnly: { + type: "tag", + values: [ + "wcag2a", + "wcag2aa", + "wcag21a", + "wcag21aa", + "wcag22aa", // WCAG 2.2 Level AA + ], + }, + rules: { + // Enable all WCAG 2.2 AA rules + "color-contrast": { enabled: true }, + "valid-lang": { enabled: true }, + "html-has-lang": { enabled: true }, + "landmark-one-main": { enabled: true }, + "page-has-heading-one": { enabled: true }, + region: { enabled: true }, + bypass: { enabled: true }, + "focus-order-semantics": { enabled: true }, + "target-size": { enabled: true }, // WCAG 2.2 + }, +}; + +/** + * Configure axe for testing with WCAG 2.2 Level AA rules + */ +export const axe = configureAxe({ + ...WCAG_22_AA_CONFIG, + // Additional configuration + elementRef: true, // Include element references in results + resultTypes: ["violations", "incomplete"], // Include violations and incomplete tests +} as JestAxeConfigureOptions); + +/** + * Axe violation with enhanced information + */ +export interface AxeViolation { + id: string; + impact: "critical" | "serious" | "moderate" | "minor"; + description: string; + help: string; + helpUrl: string; + tags: string[]; + nodes: Array<{ + target: UnlabelledFrameSelector; + html: string; + failureSummary: string; + impact: "critical" | "serious" | "moderate" | "minor"; + }>; +} + +/** + * Processed axe test results + */ +export interface ProcessedAxeResults { + violations: AxeViolation[]; + violationCount: number; + criticalCount: number; + seriousCount: number; + moderateCount: number; + minorCount: number; + wcagCriteria: Set; +} + +/** + * Process axe-core results into structured format + */ +export function processAxeResults(results: AxeResults): ProcessedAxeResults { + const violations: AxeViolation[] = results.violations.map((violation) => ({ + id: violation.id, + impact: (violation.impact || "minor") as + | "critical" + | "serious" + | "moderate" + | "minor", + description: violation.description, + help: violation.help, + helpUrl: violation.helpUrl, + tags: violation.tags, + nodes: violation.nodes.map((node) => ({ + target: node.target, + html: node.html, + failureSummary: node.failureSummary || "", + impact: (node.impact || "minor") as + | "critical" + | "serious" + | "moderate" + | "minor", + })), + })); + + // Count violations by severity + const criticalCount = violations.filter( + (v) => v.impact === "critical" + ).length; + const seriousCount = violations.filter((v) => v.impact === "serious").length; + const moderateCount = violations.filter( + (v) => v.impact === "moderate" + ).length; + const minorCount = violations.filter((v) => v.impact === "minor").length; + + // Extract WCAG criteria from tags + const wcagCriteria = new Set(); + violations.forEach((violation) => { + violation.tags.forEach((tag) => { + if (tag.startsWith("wcag")) { + wcagCriteria.add(tag); + } + }); + }); + + return { + violations, + violationCount: violations.length, + criticalCount, + seriousCount, + moderateCount, + minorCount, + wcagCriteria, + }; +} + +/** + * Format axe violation for reporting + */ +export function formatViolation(violation: AxeViolation): string { + const lines: string[] = []; + + lines.push(`\n${violation.id} (${violation.impact})`); + lines.push(`Description: ${violation.description}`); + lines.push(`Help: ${violation.help}`); + lines.push(`URL: ${violation.helpUrl}`); + lines.push( + `WCAG: ${violation.tags.filter((t) => t.startsWith("wcag")).join(", ")}` + ); + lines.push(`\nAffected elements (${violation.nodes.length}):`); + + violation.nodes.forEach((node, index) => { + const targetStr = Array.isArray(node.target) + ? node.target.join(" > ") + : String(node.target); + lines.push(`\n ${index + 1}. ${targetStr}`); + lines.push( + ` ${node.html.substring(0, 100)}${node.html.length > 100 ? "..." : ""}` + ); + if (node.failureSummary) { + lines.push(` ${node.failureSummary}`); + } + }); + + return lines.join("\n"); +} + +/** + * Format processed results summary + */ +export function formatResultsSummary(results: ProcessedAxeResults): string { + const lines: string[] = []; + + lines.push("\n=== Accessibility Test Results ==="); + lines.push(`Total Violations: ${results.violationCount}`); + lines.push(` Critical: ${results.criticalCount}`); + lines.push(` Serious: ${results.seriousCount}`); + lines.push(` Moderate: ${results.moderateCount}`); + lines.push(` Minor: ${results.minorCount}`); + lines.push( + `\nWCAG Criteria Affected: ${Array.from(results.wcagCriteria).join(", ")}` + ); + + return lines.join("\n"); +} + +/** + * Custom matcher for jest-axe that provides better error messages + */ +export function expectNoViolations(results: AxeResults): void { + const processed = processAxeResults(results); + + if (processed.violationCount > 0) { + const summary = formatResultsSummary(processed); + const violations = processed.violations.map(formatViolation).join("\n"); + + throw new Error(`${summary}\n${violations}`); + } +} diff --git a/frontend/src/test/page-test-utils.tsx b/frontend/src/test/page-test-utils.tsx new file mode 100644 index 000000000..309c5b43d --- /dev/null +++ b/frontend/src/test/page-test-utils.tsx @@ -0,0 +1,128 @@ +/** + * Page test utilities + * + * Provides utilities for testing pages with routing, query client, and accessibility + */ + +import { render, type RenderOptions } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, type MemoryRouterProps } from "react-router"; +import type { ReactElement, ReactNode } from "react"; +import { axe } from "./axe-utils"; + +/** + * Options for rendering pages in tests + */ +export interface PageTestOptions extends Omit { + /** + * Initial route entries for MemoryRouter + */ + initialEntries?: MemoryRouterProps["initialEntries"]; + /** + * Initial index for MemoryRouter + */ + initialIndex?: MemoryRouterProps["initialIndex"]; + /** + * Custom QueryClient instance + */ + queryClient?: QueryClient; +} + +/** + * Create a test wrapper with QueryClient and Router + */ +export function createTestWrapper(options: PageTestOptions = {}) { + const { initialEntries = ["/"], initialIndex, queryClient } = options; + + const testQueryClient = + queryClient || + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + staleTime: 0, + }, + mutations: { + retry: false, + }, + }, + }); + + function Wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); + } + + return Wrapper; +} + +/** + * Render a page component with all necessary providers + */ +export function renderPage(ui: ReactElement, options: PageTestOptions = {}) { + const { initialEntries, initialIndex, queryClient, ...renderOptions } = + options; + + const Wrapper = createTestWrapper({ + initialEntries, + initialIndex, + queryClient, + }); + + return { + ...render(ui, { wrapper: Wrapper, ...renderOptions }), + queryClient: + queryClient || + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + mutations: { retry: false }, + }, + }), + }; +} + +/** + * Run accessibility tests on a rendered component + * + * @example + * ```typescript + * const { container } = renderPage(); + * const results = await testAccessibility(container); + * expect(results).toHaveNoViolations(); + * ``` + */ +export async function testAccessibility(container: Element) { + return await axe(container); +} + +/** + * Render page and run accessibility tests + * + * @example + * ```typescript + * const { results } = await renderPageWithA11y(); + * expect(results).toHaveNoViolations(); + * ``` + */ +export async function renderPageWithA11y( + ui: ReactElement, + options: PageTestOptions = {} +) { + const renderResult = renderPage(ui, options); + const results = await testAccessibility(renderResult.container); + + return { + ...renderResult, + results, + }; +} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 4792d9f28..89582216d 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,54 +1,76 @@ import { afterEach, beforeAll, vi } from "vitest"; import { cleanup } from "@testing-library/react"; import "@testing-library/jest-dom"; +import { toHaveNoViolations } from "jest-axe"; import { JSDOM } from "jsdom"; +// Extend vitest matchers with jest-axe +expect.extend(toHaveNoViolations); + +// Mock localStorage with actual storage +const storage: Record = {}; + +const localStorageMock = { + getItem: (key: string) => storage[key] || null, + setItem: (key: string, value: string) => { + storage[key] = value; + }, + removeItem: (key: string) => { + delete storage[key]; + }, + clear: () => { + Object.keys(storage).forEach((key) => delete storage[key]); + }, + get length() { + return Object.keys(storage).length; + }, + key: (index: number) => { + const keys = Object.keys(storage); + return keys[index] || null; + }, +}; + +// Use vi.stubGlobal to ensure localStorage is available globally +vi.stubGlobal("localStorage", localStorageMock); + +// Mock matchMedia for responsive components +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + // Setup JSDOM for tests that need DOM APIs beforeAll(() => { if (typeof window === "undefined") { const jsdom = new JSDOM(""); global.window = jsdom.window as unknown as Window & typeof globalThis; global.document = jsdom.window.document; - } - // Mock localStorage with actual storage - const storage: Record = {}; - - const localStorageMock = { - getItem: vi.fn((key: string) => storage[key] || null), - setItem: vi.fn((key: string, value: string) => { - storage[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete storage[key]; - }), - clear: vi.fn(() => { - Object.keys(storage).forEach((key) => delete storage[key]); - }), - get length() { - return Object.keys(storage).length; - }, - key: vi.fn((index: number) => { - const keys = Object.keys(storage); - return keys[index] || null; - }), - }; - - // Use Object.defineProperty to override read-only property - Object.defineProperty(global, "localStorage", { - value: localStorageMock, - writable: true, - configurable: true, - }); + // Ensure window.localStorage uses the same mock + Object.defineProperty(global.window, "localStorage", { + value: localStorageMock, + writable: true, + configurable: true, + }); + } }); // Cleanup after each test (important for component tests in future phases) afterEach(() => { cleanup(); // Clear localStorage between tests - localStorage.clear(); - // Reset mock call counts - vi.clearAllMocks(); + if (typeof localStorage !== "undefined" && localStorage.clear) { + localStorage.clear(); + } }); // Custom matchers can be added here in future phases diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 48fa7593d..ab0e2de50 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -61,7 +61,8 @@ const CSP_CONFIG = { objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], - reportUri: "/api/csp-report", + // Note: report-uri is deprecated and doesn't work in meta tags anyway + // Use report-to directive in HTTP headers instead if CSP reporting is needed } as CSPConfig, }; @@ -108,9 +109,6 @@ function generateCSP(config: CSPConfig): string { if (config.formAction.length > 0) { directives.push(`form-action ${config.formAction.join(" ")}`); } - if (config.reportUri) { - directives.push(`report-uri ${config.reportUri}`); - } return directives.join("; "); } @@ -168,6 +166,9 @@ export default defineConfig({ globals: true, environment: "jsdom", setupFiles: "./src/test/setup.ts", + testTimeout: 20000, // 20 second timeout per test + hookTimeout: 20000, // 20 second timeout for hooks + teardownTimeout: 20000, // 20 second timeout for teardown coverage: { provider: "v8", reporter: ["text", "json", "json-summary", "html", "lcov"], @@ -179,12 +180,16 @@ export default defineConfig({ "**/*.d.ts", "**/types/", ], - thresholds: { - lines: 40, - functions: 40, - branches: 40, - statements: 40, - }, + // Disable thresholds during CI sharded runs (checked after combining coverage) + // Enable thresholds for local development runs + thresholds: process.env.CI + ? undefined + : { + lines: 40, + functions: 40, + branches: 40, + statements: 40, + }, }, }, }); diff --git a/kustomize/overlays/test/ingress.yaml b/kustomize/overlays/test/ingress.yaml index 6b0b7c452..9d5228075 100644 --- a/kustomize/overlays/test/ingress.yaml +++ b/kustomize/overlays/test/ingress.yaml @@ -8,13 +8,6 @@ spec: - host: scienceprojects-test.dbca.wa.gov.au http: paths: - - path: / - pathType: Prefix - backend: - service: - name: spms-clusterip-test - port: - number: 3000 - path: /admin pathType: Prefix backend: @@ -43,6 +36,13 @@ spec: name: spms-clusterip-test port: number: 8000 + - path: / + pathType: Prefix + backend: + service: + name: spms-clusterip-test + port: + number: 3000 - host: science-profiles-test.dbca.wa.gov.au http: paths: