diff --git a/.gitignore b/.gitignore index 94e3057fe2..91ae956bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,5 @@ backups/ /test-results/ /playwright-report/ /playwright/.cache/ + +.claude diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 818edfa54e..4b8ff23b15 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -90,7 +90,7 @@ repos: - "config/keycloak/realms/ol-local-realm.json" additional_dependencies: ["gibberish-detector"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.14.13" + rev: "v0.15.1" hooks: - id: ruff-format - id: ruff diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..2065fd2504 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Claude Instructions + +@AGENTS.md diff --git a/Dockerfile b/Dockerfile index 3f9343429f..170f7eea65 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,4 +92,4 @@ USER mitodl EXPOSE 8061 ENV PORT 8061 -CMD uwsgi uwsgi.ini +CMD ["sh", "-c", "exec granian --interface wsgi --host 0.0.0.0 --port 8061 --workers ${GRANIAN_WORKERS:-3} --blocking-threads ${GRANIAN_BLOCKING_THREADS:-2} main.wsgi:application"] diff --git a/Procfile b/Procfile deleted file mode 100644 index 0ec65d80d4..0000000000 --- a/Procfile +++ /dev/null @@ -1,5 +0,0 @@ -release: bash scripts/heroku-release-phase.sh -web: bin/start-nginx bin/start-pgbouncer uwsgi uwsgi.ini -worker: bin/start-pgbouncer celery -A main.celery:app worker -E -Q default --concurrency=2 -B -l $MITOL_LOG_LEVEL -extra_worker_2x: bin/start-pgbouncer celery -A main.celery:app worker -E -Q edx_content,default --concurrency=2 -l $MITOL_LOG_LEVEL -extra_worker_performance: bin/start-pgbouncer celery -A main.celery:app worker -E -Q edx_content,default -l $MITOL_LOG_LEVEL diff --git a/RELEASE.rst b/RELEASE.rst index 805ca9baf1..d9cadd2320 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,25 @@ Release Notes ============= +Version 0.55.1 +-------------- + +- fix-duplicate-property-error (#2970) +- fix: improvements article listing page and home page (#2963) +- Product Page Instructor Section (#2964) +- fix(deps): update dependency litellm to v1.81.13 (#2741) +- fix(deps): update dependency openai to v2 (#2732) +- fix(deps): update dependency llama-index-llms-openai to ^0.6.0 (#2737) +- [pre-commit.ci] pre-commit autoupdate (#2895) +- fix(deps): update dependency llama-index to ^0.14.0 (#2736) +- Product page style updates (#2962) +- add CLAUDE.md pointing to AGENTS.md (#2966) +- fix(deps): update dependency cryptography to v46 [security] (#2946) +- fix(deps): update dependency django-imagekit to v6 (#2762) +- fix(deps): update dependency cffi to v2 (#2759) +- fix: improvements in editor exprience (#2954) +- Replace uwsgi with granian, remove heroku-specific files (#2956) + Version 0.54.1 (Released February 19, 2026) -------------- diff --git a/app.json b/app.json deleted file mode 100644 index 7333892ccd..0000000000 --- a/app.json +++ /dev/null @@ -1,749 +0,0 @@ -{ - "addons": ["heroku-postgresql:hobby-dev", "rediscloud:30"], - "buildpacks": [ - { - "url": "https://github.com/heroku/heroku-buildpack-apt" - }, - { - "url": "https://github.com/moneymeets/python-poetry-buildpack" - }, - { - "url": "https://github.com/heroku/heroku-buildpack-python" - }, - { - "url": "https://github.com/heroku/heroku-buildpack-pgbouncer" - }, - { - "url": "https://github.com/heroku/heroku-buildpack-nginx" - } - ], - "description": "mit-learn", - "env": { - "AI_CACHE_TIMEOUT": { - "description": "Timeout for AI cache", - "required": false - }, - "AI_DEBUG": { - "description": "Include debug information in AI responses if True", - "required": false - }, - "AI_MIT_SEARCH_URL": { - "description": "URL for AI search agent", - "required": false - }, - "AI_MODEL": { - "description": "Model to use for AI functionality", - "required": false - }, - "AI_MODEL_API": { - "description": "The API used for the AI model", - "required": false - }, - "AI_PROXY_CLASS": { - "description": "Proxy class for AI functionality", - "required": false - }, - "AI_PROXY_URL": { - "description": "URL for AI proxy", - "required": false - }, - "AI_PROXY_AUTH_TOKEN": { - "description": "Auth token for AI proxy", - "required": false - }, - "AI_MAX_PARALLEL_REQUESTS": { - "description": "Max parallel requests/user for AI functionality", - "required": false - }, - "AI_TPM_LIMIT": { - "description": "Tokens/minute limit per user for AI functionality", - "required": false - }, - "AI_RPM_LIMIT": { - "description": "Requests/minute limit per user for AI functionality", - "required": false - }, - "AI_BUDGET_DURATION": { - "description": "Length of time before a user's budget usage resets", - "required": false - }, - "AI_MAX_BUDGET": { - "description": "Max budget per user for AI functionality", - "required": false - }, - "AI_ANON_LIMIT_MULTIPLIER": { - "description": "Multiplier for per-user limit/budget shared by anonymous users", - "required": false - }, - "AI_MIT_SEARCH_LIMIT": { - "description": "Limit parameter value for AI search agent", - "required": false - }, - "ALLOWED_HOSTS": { - "description": "", - "required": false - }, - "AWS_ACCESS_KEY_ID": { - "description": "AWS Access Key for S3 storage.", - "required": false - }, - "AWS_SECRET_ACCESS_KEY": { - "description": "AWS Secret Key for S3 storage.", - "required": false - }, - "AWS_STORAGE_BUCKET_NAME": { - "description": "S3 Bucket name.", - "required": false - }, - "BLOCKLISTED_COURSES_URL": { - "description": "URL of a text file containing blocklisted course ids", - "required": false - }, - "DUPLICATE_COURSES_URL": { - "description": "URL of a text file containing course ids that are duplicates of each other", - "required": false - }, - "BOOTCAMPS_URL": { - "description": "URL to retrieve bootcamps data", - "required": false - }, - "CELERY_WORKER_MAX_MEMORY_PER_CHILD": { - "description": "Max memory to be used by celery worker child", - "required": false - }, - "CORS_ALLOWED_ORIGINS": { - "description": "A list of origins that are authorized to make cross-site HTTP requests", - "required": false - }, - "DRF_NESTED_PARENT_LOOKUP_PREFIX": { - "description": "DRF extensions parent lookup kwarg name prefix", - "required": false - }, - "CORS_ALLOWED_ORIGIN_REGEXES": { - "description": "A list of regexes that match origins that are authorized to make cross-site HTTP requests", - "required": false - }, - "COURSE_ARCHIVE_BUCKET_NAME": { - "description": "Name of the shared S3 bucket that stores course archive tarballs", - "required": false - }, - "CSAIL_BASE_URL": { - "description": "CSAIL courses base URL", - "required": false - }, - "CSRF_COOKIE_DOMAIN": { - "description": "The domain to set the CSRF cookie on", - "required": false - }, - "DEFAULT_SEARCH_MODE": { - "description": "Default search mode for the search API and frontend", - "required": false - }, - "DEFAULT_SEARCH_SLOP": { - "description": "Default slop value for the search API and frontend. Only used for phrase queries.", - "required": false - }, - "DEFAULT_SEARCH_STALENESS_PENALTY": { - "description": "Default staleness penalty value for the search API and frontend", - "required": false - }, - "DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF": { - "description": "Default minimum score cutoff value for the search API and frontend", - "required": false - }, - "DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY": { - "description": "Default max incompleteness penalty value for the search API and frontend", - "required": false - }, - "DEFAULT_SEARCH_CONTENT_FILE_SCORE_WEIGHT": { - "description": "Default score weight for content file search match", - "required": false - }, - "EDX_API_ACCESS_TOKEN_URL": { - "description": "URL to retrieve a MITx access token", - "required": false - }, - "EDX_API_URL": { - "description": "URL to retrieve MITx course data from", - "required": false - }, - "EDX_API_CLIENT_ID": { - "description": "EdX client id to access the MITx course catalog API", - "required": false - }, - "EDX_API_CLIENT_SECRET": { - "description": "EdX secret key to access the MITx course catalog API", - "required": false - }, - "EDX_ALT_URL": { - "description": "Base alternate URL for MITx courses hosted on edX", - "required": false - }, - "EDX_BASE_URL": { - "description": "Base default URL for MITx courses hosted on edX", - "required": false - }, - "EDX_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix within the shared archive bucket for MITx on edX exports", - "required": false - }, - "EDX_LEARNING_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix for MITx bucket keys", - "required": false - }, - "EDX_PROGRAMS_API_URL": { - "description": "The catalog url for MITx programs", - "required": false - }, - "OPENSEARCH_HTTP_AUTH": { - "description": "Basic auth settings for connecting to OpenSearch" - }, - "OPENSEARCH_CONNECTIONS_PER_NODE": { - "description": "The size of the connection pool created for each node detected within an OpenSearch cluster.", - "required": false - }, - "OPENSEARCH_DEFAULT_TIMEOUT": { - "description": "The default timeout in seconds for OpenSearch requests", - "required": false - }, - "OPENSEARCH_INDEX": { - "description": "Index to use on OpenSearch", - "required": true - }, - "OPENSEARCH_MAX_REQUEST_SIZE": { - "description": "Maximum size of JSON data requests sent to OpenSearch", - "required": false - }, - "OPENSEARCH_SHARD_COUNT": { - "description": "Number of shards to allocate when creating an OpenSearch index. Generally set to the CPU count of an individual node in the cluster.", - "required": false - }, - "OPENSEARCH_REPLICA_COUNT": { - "description": "Number of index replicas to create when initializing a new OpenSearch index. Generally set to the number of search nodes available in the cluster.", - "required": false - }, - "OPENSEARCH_INDEXING_CHUNK_SIZE": { - "description": "Chunk size to use for OpenSearch indexing tasks", - "required": false - }, - "OPENSEARCH_DOCUMENT_INDEXING_CHUNK_SIZE": { - "description": "Chunk size to use for OpenSearch course document indexing", - "required": false - }, - "OPENSEARCH_MAX_SUGGEST_HITS": { - "description": "Return suggested search terms only if the number of hits is equal to or below this value", - "required": false - }, - "OPENSEARCH_MAX_SUGGEST_RESULTS": { - "description": "The maximum number of search term suggestions to return", - "required": false - }, - "OPENSEARCH_MIN_QUERY_SIZE": { - "description": "Minimimum number of characters in a query string to search for", - "required": false - }, - "OPENSEARCH_URL": { - "description": "URL for connecting to OpenSearch cluster" - }, - "GA_TRACKING_ID": { - "description": "Google analytics tracking ID", - "required": false - }, - "GA_G_TRACKING_ID": { - "description": "Google analytics GTM tracking ID", - "required": false - }, - "GITHUB_ACCESS_TOKEN": { - "description": "Access token for the Github API", - "required": false - }, - "IMAGEKIT_CACHEFILE_DIR": { - "description": "Prefix path for cached images generated by imagekit", - "required": false - }, - "INDEXING_ERROR_RETRIES": { - "description": "Number of times to retry an indexing operation on failure", - "required": false - }, - "LEARNING_COURSE_ITERATOR_CHUNK_SIZE": { - "description": "Chunk size for iterating over xPRO/MITx Online courses for master json", - "required": false - }, - "MAILGUN_URL": { - "description": "The URL for communicating with Mailgun" - }, - "MAILGUN_KEY": { - "description": "The token for authenticating against the Mailgun API" - }, - "MAILGUN_FROM_EMAIL": { - "description": "Email which mail comes from" - }, - "MAILGUN_BCC_TO_EMAIL": { - "description": "Email address used with bcc email", - "required": false - }, - "MAILGUN_SENDER_DOMAIN": { - "description": "Domain used for emails sent via mailgun" - }, - "MAX_S3_GET_ITERATIONS": { - "description": "Max retry attempts to get an S3 object", - "required": false - }, - "MICROMASTERS_CATALOG_API_URL": { - "description": "URL to MicroMasters catalog API", - "required": "false" - }, - "MITPE_BASE_URL": { - "description": "Base URL for MIT Professional Education website", - "required": "false" - }, - "MITX_ONLINE_BASE_URL": { - "description": "Base default URL for MITx Online courses", - "required": false - }, - "MITX_ONLINE_PROGRAMS_API_URL": { - "description": "The catalog url for MITx Online programs", - "required": false - }, - "MITX_ONLINE_COURSES_API_URL": { - "description": "The api url for MITx Online courses", - "required": false - }, - "MITX_ONLINE_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix within the shared archive bucket for MITx Online exports", - "required": false - }, - "MITX_ONLINE_LEARNING_COURSE_BUCKET_NAME": { - "description": "Name of S3 bucket to upload MITx Online course media", - "required": false - }, - "MIT_WS_CERTIFICATE": { - "description": "X509 certificate as a string", - "required": false - }, - "MIT_WS_PRIVATE_KEY": { - "description": "X509 private key as a string", - "required": false - }, - "OCW_BASE_URL": { - "description": "Base URL for OCW courses", - "required": false - }, - "OCW_CONTENT_BUCKET_NAME": { - "description": "Name of S3 bucket containing OCW course data", - "required": false - }, - "OCW_ITERATOR_CHUNK_SIZE": { - "description": "Chunk size for iterating over OCW courses for master json", - "required": false - }, - "OCW_LEARNING_COURSE_BUCKET_NAME": { - "description": "Name of S3 bucket to upload OCW course media", - "required": false - }, - "OCW_UPLOAD_IMAGE_ONLY": { - "description": "Upload course image only instead of all OCW files", - "required": false - }, - "OCW_SKIP_CONTENT_FILES": { - "description": "Skip upserting of OCW content files", - "required": false - }, - "OCW_WEBHOOK_DELAY": { - "description": "Delay in seconds to process an OCW course after receiving webhook", - "required": false - }, - "OCW_WEBHOOK_KEY": { - "description": "Authentication parameter value that should be passed in a webhook", - "required": false - }, - "OLL_LEARNING_COURSE_BUCKET_NAME": { - "description": "Name of S3 bucket containing OLL content files", - "required": false - }, - "OLL_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix within the shared archive bucket for OLL exports", - "required": false - }, - "OLL_LEARNING_COURSE_BUCKET_PREFIX": { - "description": "Prefix to use with the OLL S3 bucket", - "required": false - }, - "MITOL_ADMIN_EMAIL": { - "description": "E-mail to send 500 reports to.", - "required": false - }, - "MITOL_AUTHENTICATION_PLUGINS": { - "description": "List of pluggy plugins to use for authentication", - "required": false - }, - "MITOL_LEARNING_RESOURCES_PLUGINS": { - "description": "List of pluggy plugins to use for learning resources", - "required": false - }, - "MITOL_APP_BASE_URL": { - "description": "Base url to create links to the app", - "required": false - }, - "MITOL_COOKIE_NAME": { - "description": "Name of the cookie for the JWT auth token", - "required": false - }, - "MITOL_COOKIE_DOMAIN": { - "description": "Domain for the cookie for the JWT auth token", - "required": false - }, - "MITOL_DB_CONN_MAX_AGE": { - "value": "0", - "required": false - }, - "MITOL_DB_DISABLE_SSL": { - "value": "True", - "required": false - }, - "MITOL_DB_DISABLE_SS_CURSORS": { - "description": "Disable server-side cursors", - "required": false - }, - "MITOL_EMAIL_HOST": { - "description": "Outgoing e-mail settings", - "required": false - }, - "MITOL_EMAIL_PASSWORD": { - "description": "Outgoing e-mail settings", - "required": false - }, - "MITOL_EMAIL_PORT": { - "description": "Outgoing e-mail settings", - "value": "587", - "required": false - }, - "MITOL_EMAIL_TLS": { - "description": "Outgoing e-mail settings", - "value": "True", - "required": false - }, - "MITOL_EMAIL_USER": { - "description": "Outgoing e-mail settings", - "required": false - }, - "MITOL_ENVIRONMENT": { - "description": "The execution environment that the app is in (e.g. dev, staging, prod)", - "required": false - }, - "MITOL_FROM_EMAIL": { - "description": "E-mail to use for the from field", - "required": false - }, - "MITOL_LOG_LEVEL": { - "description": "The log level for the application", - "required": false, - "value": "INFO" - }, - "MITOL_NEW_USER_LOGIN_URL": { - "description": "Url to redirect new users to", - "required": false - }, - "MITOL_JWT_SECRET": { - "description": "Shared secret for JWT auth tokens", - "required": false - }, - "MITOL_NOINDEX": { - "description": "Prevent search engines from indexing the site", - "required": false - }, - "MITOL_SECURE_SSL_REDIRECT": { - "description": "Application-level SSL redirect setting.", - "value": "True", - "required": false - }, - "MITOL_SIMILAR_RESOURCES_COUNT": { - "description": "Number of similar resources to return", - "required": false - }, - "MITOL_TITLE": { - "description": "Title of the MIT Learn site", - "required": false - }, - "MITOL_SUPPORT_EMAIL": { - "description": "Email address listed for customer support", - "required": false - }, - "MITOL_UNSUBSCRIBE_TOKEN_MAX_AGE_SECONDS": { - "description": "Maximum age of unsubscribe tokens in seconds", - "required": false - }, - "MITOL_USE_S3": { - "description": "Use S3 for storage backend (required on Heroku)", - "value": "False", - "required": false - }, - "NEWS_EVENTS_MEDIUM_NEWS_SCHEDULE_SECONDS": { - "description": "Time in seconds between periodic syncs of Medium MIT News feed", - "required": false - }, - "NEWS_EVENTS_SLOAN_NEWS_SCHEDULE_SECONDS": { - "description": "Time in seconds between periodic syncs of Sloan news", - "required": false - }, - "NEWS_EVENTS_OL_EVENTS_SCHEDULE_SECONDS": { - "description": "Time in seconds between periodic syncs of Open Learning events", - "required": false - }, - "OPEN_PODCAST_DATA_BRANCH": { - "description": "Branch in the open podcast data repository to use for podcast ingestion", - "required": false - }, - "OPEN_RESOURCES_MIN_DOC_FREQ": { - "description": "OpenSearch min_doc_freq value for determining similar resources", - "required": false - }, - "OPEN_RESOURCES_MIN_TERM_FREQ": { - "description": "OpenSearch min_term_freq value for determining similar resources", - "required": false - }, - "OPEN_VIDEO_DATA_BRANCH": { - "description": "Branch in the open video data repository to use for video downloads", - "required": false - }, - "OPEN_VIDEO_MAX_TOPICS": { - "description": "Maximum number of topics to assign a video", - "required": false - }, - "OPEN_VIDEO_MIN_DOC_FREQ": { - "description": "OpenSearch min_doc_freq value for determing video topics", - "required": false - }, - "OPEN_VIDEO_MIN_TERM_FREQ": { - "description": "OpenSearch min_term_freq value for determing video topics", - "required": false - }, - "OPEN_VIDEO_USER_LIST_OWNER": { - "description": "User who will own user lists generated from playlists", - "required": false - }, - "NEW_RELIC_APP_NAME": { - "description": "Application identifier in New Relic." - }, - "NODE_MODULES_CACHE": { - "description": "If false, disables the node_modules cache to fix yarn install", - "value": "false" - }, - "OCW_NEXT_LIVE_BUCKET": { - "description": "bucket for ocw-next courses data", - "required": false - }, - "OCW_NEXT_AWS_STORAGE_BUCKET_NAME": { - "description": "bucket for ocw-next storage data", - "required": false - }, - "OCW_OFFLINE_DELIVERY": { - "description": "Enable offline delivery of OCW courses", - "required": false - }, - "PGBOUNCER_DEFAULT_POOL_SIZE": { - "value": "50" - }, - "PGBOUNCER_MIN_POOL_SIZE": { - "value": "5" - }, - "PODCAST_FETCH_SCHEDULE_SECONDS": { - "description": "The time in seconds between periodic syncs of podcasts", - "required": false - }, - "RECAPTCHA_SITE_KEY": { - "description": "Google Recaptcha site key", - "required": false - }, - "RECAPTCHA_SECRET_KEY": { - "description": "Google Recaptcha secret key", - "required": false - }, - "REQUESTS_TIMEOUT": { - "description": "Default timeout for requests", - "required": false - }, - "RSS_FEED_EPISODE_LIMIT": { - "description": "Number of episodes included in aggregated rss feed", - "required": false - }, - "RSS_FEED_CACHE_MINUTES": { - "description": "Minutes that /podcasts/rss_feed will be cached", - "required": false - }, - "SECRET_KEY": { - "description": "Django secret key.", - "generator": "secret" - }, - "SEE_API_ACCESS_TOKEN_URL": { - "description": "URL to retrieve a MITx access token", - "required": false - }, - "SEE_API_URL": { - "description": "URL to retrieve MITx course data from", - "required": false - }, - "SEE_API_CLIENT_ID": { - "description": "EdX client id to access the MITx course catalog API", - "required": false - }, - "SEE_API_CLIENT_SECRET": { - "description": "EdX secret key to access the MITx course catalog API", - "required": false - }, - "SENTRY_DSN": { - "description": "The connection settings for Sentry" - }, - "SENTRY_LOG_LEVEL": { - "description": "The log level for Sentry", - "required": false - }, - "STATUS_TOKEN": { - "description": "Token to access the status API." - }, - "TIKA_ACCESS_TOKEN": { - "description": "X-Access-Token value for tika requests", - "required": false - }, - "TIKA_OCR_STRATEGY": { - "description": "OCR strategy to specify in header for tika requests", - "required": false - }, - "TIKA_TIMEOUT": { - "description": "Timeout for tika requests", - "required": false - }, - "USE_X_FORWARDED_PORT": { - "description": "Use the X-Forwarded-Port", - "required": false - }, - "USE_X_FORWARDED_HOST": { - "description": "Use the X-Forwarded-Host", - "required": false - }, - "CKEDITOR_ENVIRONMENT_ID": { - "description": "env ID for CKEditor EasyImage auth", - "required": false - }, - "CKEDITOR_SECRET_KEY": { - "description": "secret key for CKEditor EasyImage auth", - "required": false - }, - "CKEDITOR_UPLOAD_URL": { - "description": "upload URL for CKEditor EasyImage", - "required": false - }, - "XPRO_CATALOG_API_URL": { - "description": "The catalog url for xpro programs", - "required": false - }, - "XPRO_COURSES_API_URL": { - "description": "The api url for xpro courses", - "required": false - }, - "XPRO_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix within the shared archive bucket for xPRO exports", - "required": false - }, - "XPRO_LEARNING_COURSE_BUCKET_NAME": { - "description": "Name of S3 bucket to upload xPRO course media", - "required": false - }, - "YOUTUBE_DEVELOPER_KEY": { - "description": "The key to the google youtube api", - "required": false - }, - "YOUTUBE_FETCH_SCHEDULE_SECONDS": { - "description": "The time in seconds between periodic syncs of youtube videos", - "required": false - }, - "YOUTUBE_FETCH_TRANSCRIPT_SCHEDULE_SECONDS": { - "description": "The time in seconds between periodic syncs of youtube video transcripts", - "required": false - }, - "SOCIAL_AUTH_OL_OIDC_OIDC_ENDPOINT": { - "description": "The base URI for OpenID Connect discovery, https:/// without .well-known/openid-configuration.", - "required": false - }, - "SOCIAL_AUTH_OL_OIDC_KEY": { - "description": "The client ID provided by the OpenID Connect provider.", - "required": false - }, - "SOCIAL_AUTH_OL_OIDC_SECRET": { - "description": "The client secret provided by the OpenID Connect provider.", - "required": false - }, - "SOCIAL_AUTH_ALLOWED_REDIRECT_HOSTS": { - "description": "The list of additional redirect hosts allowed for social auth.", - "required": false - }, - "USERINFO_URL": { - "description": "Provder endpoint where client sends requests for identity claims.", - "required": false - }, - "ACCESS_TOKEN_URL": { - "description": "Provider endpoint where client exchanges the authorization code for tokens.", - "required": false - }, - "AUTHORIZATION_URL": { - "description": "Provider endpoint where the user is asked to authenticate.", - "required": false - }, - "FEATURE_KEYCLOAK_ENABLED": { - "description": "Authentication functionality is managed by Keycloak.", - "required": true - }, - "KEYCLOAK_REALM_NAME": { - "description": "The Keycloak realm name in which Open Discussions has a client configuration.", - "required": true - }, - "KEYCLOAK_BASE_URL": { - "description": "The base URL for a Keycloak configuration.", - "required": true - }, - "POSTHOG_API_HOST": { - "description": "API host for PostHog", - "required": false - }, - "POSTHOG_PROJECT_API_KEY": { - "description": "Project API key to capture events in PostHog", - "required": false - }, - "POSTHOG_PERSONAL_API_KEY": { - "description": "Private API key to communicate with PostHog", - "required": false - }, - "POSTHOG_PROJECT_ID": { - "description": "PostHog project ID for the application", - "required": false - }, - "POSTHOG_EVENT_S3_PREFIX": { - "description": "S3 prefix for PostHog event uploads", - "required": false - }, - "POSTHOG_EVENT_S3_FOLDER": { - "description": " S3 bucket where PostHog event files are stored", - "required": false - }, - "POSTHOG_TIMEOUT_MS": { - "description": "Timeout for communication with PostHog API", - "required": false - }, - "CANVAS_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix within the shared archive bucket for Canvas exports", - "required": false - }, - "CANVAS_TUTORBOT_FOLDER": { - "description": "Folder in Canvas course zip files where tutorbot problem and solution files are stored", - "required": false - } - }, - "keywords": ["Django", "Python", "MIT", "Office of Digital Learning"], - "name": "mit_open", - "repository": "https://github.com/mitodl/mit-learn", - "scripts": { - "postdeploy": "./manage.py migrate --noinput" - }, - "success_url": "/", - "website": "https://github.com/mitodl/mit-learn" -} diff --git a/articles/models.py b/articles/models.py index 401faccb6e..a5dcd28332 100644 --- a/articles/models.py +++ b/articles/models.py @@ -59,7 +59,7 @@ def get_url(self): Return the relative URL for this article. """ if self.slug: - return f"/articles/{self.slug}" + return f"/news/{self.slug}" return None diff --git a/articles/models_test.py b/articles/models_test.py index d3751f67f5..1735abc1a6 100644 --- a/articles/models_test.py +++ b/articles/models_test.py @@ -26,7 +26,7 @@ def test_get_url_with_slug(self, _mock_queue_purge, _mock_queue_list): # noqa: user=user, ) - assert article.get_url() == f"/articles/{article.slug}" + assert article.get_url() == f"/news/{article.slug}" def test_get_url_without_slug(self, _mock_queue_purge, _mock_queue_list): # noqa: PT019 """Test that get_url returns None for an article without a slug""" @@ -56,8 +56,8 @@ def test_get_url_with_different_slugs(self, _mock_queue_purge, _mock_queue_list) user=user, ) - assert article1.get_url() == f"/articles/{article1.slug}" - assert article2.get_url() == f"/articles/{article2.slug}" + assert article1.get_url() == f"/news/{article1.slug}" + assert article2.get_url() == f"/news/{article2.slug}" assert article1.get_url() != article2.get_url() def test_slug_generation_on_publish(self, _mock_queue_purge, _mock_queue_list): # noqa: PT019 @@ -80,4 +80,4 @@ def test_slug_generation_on_publish(self, _mock_queue_purge, _mock_queue_list): # Now should have a slug assert article.slug is not None assert article.slug == "test-article-title" - assert article.get_url() == "/articles/test-article-title" + assert article.get_url() == "/news/test-article-title" diff --git a/articles/tasks.py b/articles/tasks.py index a2df1e6732..0358b8f08f 100644 --- a/articles/tasks.py +++ b/articles/tasks.py @@ -72,7 +72,7 @@ def queue_fastly_purge_articles_list(): log.info("Purging articles list page from the Fastly cache...") # Purge the articles API endpoint - articles_url = "/articles" + articles_url = "/news" resp = call_fastly_purge_api(articles_url) diff --git a/articles/tasks_test.py b/articles/tasks_test.py index 89e3b7cb0a..3df5b481b5 100644 --- a/articles/tasks_test.py +++ b/articles/tasks_test.py @@ -80,7 +80,7 @@ def test_call_fastly_purge_api_no_token(self, mock_request, mock_fastly_response """Test API call without auth token - skips in dev""" mock_request.return_value = mock_fastly_response - result = call_fastly_purge_api("/api/v1/articles/test/") + result = call_fastly_purge_api("/api/v1/news/test/") # Should skip purge when API key is empty (dev environment) assert result == {"status": "ok", "skipped": True} @@ -98,7 +98,7 @@ def test_call_fastly_purge_api_error( """Test Fastly API error response""" mock_request.return_value = mock_fastly_error_response - result = call_fastly_purge_api("/api/v1/articles/test/") + result = call_fastly_purge_api("/api/v1/news/test/") assert result is False diff --git a/config/nginx.conf.erb b/config/nginx.conf.erb index 3d811f923c..97da4686ec 100644 --- a/config/nginx.conf.erb +++ b/config/nginx.conf.erb @@ -48,26 +48,14 @@ http { } location / { - uwsgi_param QUERY_STRING $query_string; - uwsgi_param REQUEST_METHOD $request_method; - uwsgi_param CONTENT_TYPE $content_type; - uwsgi_param CONTENT_LENGTH $content_length; - uwsgi_param REQUEST_URI $request_uri; - uwsgi_param PATH_INFO $document_uri; - uwsgi_param DOCUMENT_ROOT $document_root; - uwsgi_param SERVER_PROTOCOL $server_protocol; - uwsgi_param REMOTE_ADDR $remote_addr; - uwsgi_param REMOTE_PORT $remote_port; - uwsgi_param SERVER_ADDR $server_addr; - uwsgi_param SERVER_PORT $server_port; - uwsgi_param SERVER_NAME $server_name; - uwsgi_param X-Forwarded-For $proxy_add_x_forwarded_for; - uwsgi_param X-Forwarded-Proto $http_x_forwarded_proto; - uwsgi_param X-Forwarded-Port $http_x_forwarded_port; - uwsgi_param X-Forwarded-Host $http_x_forwarded_host; - uwsgi_pass <%= ENV["NGINX_UWSGI_PASS"] || "unix:/tmp/nginx.socket" %>; - uwsgi_pass_request_headers on; - uwsgi_pass_request_body on; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header X-Forwarded-Port $http_x_forwarded_port; + proxy_set_header X-Forwarded-Host $http_x_forwarded_host; + proxy_pass http://<%= ENV["NGINX_BACKEND"] || "127.0.0.1:8061" %>; + proxy_redirect off; client_max_body_size 25M; } } diff --git a/docker-compose.services.yml b/docker-compose.services.yml index d9b2ca3ad0..1776852d82 100644 --- a/docker-compose.services.yml +++ b/docker-compose.services.yml @@ -60,7 +60,7 @@ services: - web environment: PORT: 8063 - NGINX_UWSGI_PASS: "web:8061" + NGINX_BACKEND: "web:8061" volumes: - ./config:/etc/nginx/templates diff --git a/env/backend.env b/env/backend.env index 9e83ce1115..8a4ba4e51b 100644 --- a/env/backend.env +++ b/env/backend.env @@ -34,8 +34,8 @@ OPENSEARCH_INDEXING_CHUNK_SIZE=100 PORT=8061 -UWSGI_WORKERS=3 -UWSGI_THREADS=35 +GRANIAN_WORKERS=3 +GRANIAN_BLOCKING_THREADS=4 TIKA_SERVER_ENDPOINT=http://tika:9998/ TIKA_CLIENT_ONLY=True diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index 7ab6436224..7fd9b59a84 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -6,6 +6,7 @@ validateEnv() const NEXT_PUBLIC_OPTIMIZE_IMAGES = Boolean( (process.env.NEXT_PUBLIC_OPTIMIZE_IMAGES ?? "true") === "true", ) +const IS_LOCAL_DEV = process.env.NODE_ENV === "development" const processFeatureFlags = () => { const featureFlagPrefix = @@ -107,6 +108,7 @@ const nextConfig = { images: { unoptimized: !NEXT_PUBLIC_OPTIMIZE_IMAGES, + dangerouslyAllowLocalIP: IS_LOCAL_DEV, remotePatterns: [ { hostname: "**", diff --git a/frontends/main/public/images/product/icon_book_play.png b/frontends/main/public/images/product/icon_book_play.png new file mode 100644 index 0000000000..1c34690db9 Binary files /dev/null and b/frontends/main/public/images/product/icon_book_play.png differ diff --git a/frontends/main/public/images/product/icon_brains.png b/frontends/main/public/images/product/icon_brains.png new file mode 100644 index 0000000000..5cc8002cd2 Binary files /dev/null and b/frontends/main/public/images/product/icon_brains.png differ diff --git a/frontends/main/public/images/product/icon_certificate.png b/frontends/main/public/images/product/icon_certificate.png new file mode 100644 index 0000000000..8ea141d1ab Binary files /dev/null and b/frontends/main/public/images/product/icon_certificate.png differ diff --git a/frontends/main/public/images/product/icon_computer_lightbulb.png b/frontends/main/public/images/product/icon_computer_lightbulb.png new file mode 100644 index 0000000000..9f328716bc Binary files /dev/null and b/frontends/main/public/images/product/icon_computer_lightbulb.png differ diff --git a/frontends/main/public/images/product/icon_connected_people.png b/frontends/main/public/images/product/icon_connected_people.png new file mode 100644 index 0000000000..edd75592ed Binary files /dev/null and b/frontends/main/public/images/product/icon_connected_people.png differ diff --git a/frontends/main/src/app-pages/Articles/ArticleBanner.tsx b/frontends/main/src/app-pages/Articles/ArticleBanner.tsx index f5f9c13f3c..3d13a333b3 100644 --- a/frontends/main/src/app-pages/Articles/ArticleBanner.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleBanner.tsx @@ -61,7 +61,7 @@ interface ArticleBannerProps { const ArticleBanner: React.FC = ({ title, description, - currentBreadcrumb = "MIT Stories", + currentBreadcrumb = "MIT News", backgroundUrl = DEFAULT_BACKGROUND_IMAGE_URL, className, }) => { diff --git a/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx b/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx index 4de879a7fe..b6f6d3cf4e 100644 --- a/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx @@ -44,16 +44,16 @@ describe("ArticleListingPage", () => { setupAPI(0) renderWithProviders() - await screen.findByText("No Articles Available") + await screen.findByText("No News Available") expect( screen.getByText( - "There are no articles to display at this time. Please check back later.", + "There are no news to display at this time. Please check back later.", ), ).toBeInTheDocument() }) - test("displays main story and grid stories on desktop", async () => { + test("displays main news and grid stories on desktop", async () => { const news = setupAPI(21) renderWithProviders() @@ -132,11 +132,13 @@ describe("ArticleListingPage", () => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() }) - // Find links by article title - const titleLinks = screen.getAllByRole("link", { - name: news.results[0].title, - }) - expect(titleLinks[0]).toHaveAttribute("href", news.results[0].url) + // Verify links exist with correct hrefs + const allLinks = screen.getAllByRole("link") + const firstArticleLink = allLinks.find( + (link) => link.getAttribute("href") === news.results[0].url, + ) + expect(firstArticleLink).toBeInTheDocument() + expect(firstArticleLink).toHaveAttribute("href", news.results[0].url) }) test("displays article summaries with HTML stripped", async () => { @@ -300,7 +302,7 @@ describe("ArticleListingPage", () => { setupAPI(0) renderWithProviders() - await screen.findByText("No Articles Available") + await screen.findByText("No News Available") expect(screen.queryByRole("navigation")).not.toBeInTheDocument() }) diff --git a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx index b325d20d1f..fd93d647bd 100644 --- a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx @@ -73,6 +73,12 @@ const MainStoryCard = styled.div` border-top: 4px solid #a31f34; border-radius: 10px; + &:hover { + h2 { + text-decoration: underline; + } + } + ${theme.breakpoints.down("sm")} { flex-direction: column; gap: 0; @@ -203,11 +209,21 @@ const StoryCard = styled.div` flex-direction: row; gap: 24px; background: white; - border: 1px solid ${theme.custom.colors.lightGray2}; border-radius: 8px; padding: 16px 16px 16px 24px; overflow: hidden; + &:hover { + border-radius: 8px; + border: 1px solid ${theme.custom.colors.lightGray2}; + background: ${theme.custom.colors.white}; + box-shadow: 0 8px 20px 0 rgb(120 147 172 / 10%); + + h2 { + color: ${theme.custom.colors.red}; + } + } + ${theme.breakpoints.down("sm")} { flex-direction: row; gap: 12px; @@ -216,6 +232,12 @@ const StoryCard = styled.div` border: none; border-bottom: 1px solid ${theme.custom.colors.lightGray2}; border-radius: 0; + + &:hover { + border: none; + border-bottom: 1px solid ${theme.custom.colors.lightGray2}; + box-shadow: none; + } } ` @@ -469,15 +491,18 @@ const MainStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { {item.image?.url && !imageError && ( - {item.image.alt setImageError(true)} - /> + + {item.image.alt setImageError(true)} + /> + )} + @@ -590,9 +615,9 @@ const ArticleListingPage: React.FC = () => { ) : stories.length === 0 ? ( - No Articles Available + No News Available - There are no articles to display at this time. Please check back + There are no news to display at this time. Please check back later. @@ -628,7 +653,7 @@ const ArticleListingPage: React.FC = () => { {/* Grid Section: Other articles */} {gridStories.length > 0 ? ( - + {gridStories.map((item) => ( diff --git a/frontends/main/src/app-pages/HomePage/HomePage.test.tsx b/frontends/main/src/app-pages/HomePage/HomePage.test.tsx index 58653d0c52..9896061f77 100644 --- a/frontends/main/src/app-pages/HomePage/HomePage.test.tsx +++ b/frontends/main/src/app-pages/HomePage/HomePage.test.tsx @@ -191,7 +191,7 @@ describe("Home Page News and Events", () => { let section await waitFor(() => { section = screen - .getAllByRole("heading", { name: "Stories" })! + .getAllByRole("heading", { name: "News" })! .at(0)! .closest("section")! }) @@ -356,10 +356,10 @@ test("Headings", async () => { ...media.results.map((result) => ({ level: 3, name: result.title })), { level: 2, name: "Browse by Topic" }, { level: 2, name: "From Our Community" }, - { level: 2, name: "MIT Stories & Events" }, - { level: 3, name: "Stories" }, + { level: 2, name: "MIT News & Events" }, + { level: 3, name: "News" }, { level: 3, name: "Events" }, - { level: 3, name: "Stories" }, + { level: 3, name: "News" }, { level: 3, name: "Events" }, ]) }) diff --git a/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx b/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx index 6d5c0e6cc2..4708fb66be 100644 --- a/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx +++ b/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx @@ -54,7 +54,7 @@ const MobileContent = styled.div` margin: 40px 0; ` -const StoriesContainer = styled.section` +const NewsContainer = styled.section` display: flex; flex-direction: column; align-items: flex-start; @@ -86,7 +86,7 @@ const StoryCard = styled(Card)<{ mobile: boolean }>` ${({ mobile }) => (mobile ? "width: 274px" : "")} ` -const StoriesSlider = styled.div` +const NewsSlider = styled.div` display: flex; align-items: flex-start; gap: 16px; @@ -240,7 +240,7 @@ const NewsEventsSection: React.FC = () => { return null } - const stories = news.results.slice(0, 6) + const newsList = news.results.slice(0, 6) const EventCards = events.results.map((item) => ( @@ -270,7 +270,7 @@ const NewsEventsSection: React.FC = () => { return (
- MIT Stories & Events + MIT News & Events See what's happening in the world of learning with the latest news, @@ -280,22 +280,22 @@ const NewsEventsSection: React.FC = () => { - Stories + News - - {stories.map((item) => ( + + {newsList.map((item) => ( ))} - + {showArticleList && ( - - See all stories + + See all news )} @@ -311,12 +311,12 @@ const NewsEventsSection: React.FC = () => { - + - Stories + News - {stories.map((item, index) => ( + {newsList.map((item, index) => ( { {showArticleList && ( - - See all stories + + See all news )} - + Events diff --git a/frontends/main/src/app-pages/HomePage/VideoShortsSection.tsx b/frontends/main/src/app-pages/HomePage/VideoShortsSection.tsx index 74551cbbdd..0a756689d1 100644 --- a/frontends/main/src/app-pages/HomePage/VideoShortsSection.tsx +++ b/frontends/main/src/app-pages/HomePage/VideoShortsSection.tsx @@ -28,6 +28,10 @@ const Header = styled.div(({ theme }) => ({ })) const StyledCarouselV2 = styled(CarouselV2)(({ theme }) => ({ + margin: "24px 0", + ".MitCarousel-track": { + paddingBottom: "4px", + }, [theme.breakpoints.down("sm")]: { padding: "0 16px", }, diff --git a/frontends/main/src/app-pages/ProductPages/AboutSection.test.tsx b/frontends/main/src/app-pages/ProductPages/AboutSection.test.tsx index 692709fe6b..53caba2349 100644 --- a/frontends/main/src/app-pages/ProductPages/AboutSection.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/AboutSection.test.tsx @@ -15,7 +15,7 @@ const expectRawContent = (el: HTMLElement, htmlString: string) => { test("About section has expected content", async () => { const about = `

${faker.lorem.paragraph()}

` - const noun = faker.word.noun() + const noun = faker.helpers.arrayElement(["Course", "Program"] as const) renderWithProviders() const section = await screen.findByRole("region", { @@ -31,7 +31,7 @@ test("About section expands and collapses", async () => { const aboutContent = [firstParagraph, secondParagraph, thirdParagraph] .map((p) => `

${p}

`) .join("\n") - const noun = faker.word.noun() + const noun = faker.helpers.arrayElement(["Course", "Program"] as const) const page = makePage({ about: aboutContent }) invariant(page.about) @@ -71,7 +71,7 @@ test.each([ { length: aboutParagraphs }, (_, i) => `

This is paragraph ${i + 1} in the about section.

`, ).join("\n") - const noun = faker.word.noun() + const noun = faker.helpers.arrayElement(["Course", "Program"] as const) renderWithProviders( , diff --git a/frontends/main/src/app-pages/ProductPages/AboutSection.tsx b/frontends/main/src/app-pages/ProductPages/AboutSection.tsx index a6778ede6c..9b52152319 100644 --- a/frontends/main/src/app-pages/ProductPages/AboutSection.tsx +++ b/frontends/main/src/app-pages/ProductPages/AboutSection.tsx @@ -4,6 +4,7 @@ import { HeadingIds } from "./util" import RawHTML from "./RawHTML" import { Typography } from "ol-components" import { styled } from "@mitodl/smoot-design" +import type { ProductNoun } from "./util" const AboutSectionRoot = styled.section<{ expanded: boolean }>( ({ expanded }) => { @@ -33,7 +34,7 @@ const AboutSectionRoot = styled.section<{ expanded: boolean }>( const AboutSection: React.FC<{ aboutHtml: string - productNoun: string + productNoun: ProductNoun }> = ({ aboutHtml, productNoun }) => { const [aboutExpanded, setAboutExpanded] = React.useState(false) return ( diff --git a/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx b/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx index 664a3706c3..4ab2afc633 100644 --- a/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx +++ b/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx @@ -50,6 +50,7 @@ const CourseEnrollmentButton: React.FC = ({ onClick={handleClick} variant="primary" size="large" + data-testid="course-enrollment-button" > {getButtonText(nextRun)} diff --git a/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx index 172c1c3443..e6a29494ec 100644 --- a/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx @@ -90,6 +90,7 @@ describe("CoursePage", () => { test("Page has expected headings", async () => { const course = makeCourse() const page = makePage({ course_details: course }) + invariant(page.faculty.length > 0) setupApis({ course, page }) renderWithProviders() @@ -99,8 +100,10 @@ describe("CoursePage", () => { { level: 2, name: "Course summary" }, { level: 2, name: "About this Course" }, { level: 2, name: "What you'll learn" }, + { level: 2, name: "How you'll learn" }, { level: 2, name: "Prerequisites" }, { level: 2, name: "Meet your instructors" }, + { level: 3, name: page.faculty[0].instructor_name }, { level: 2, name: "Who can take this Course?" }, ]) }) @@ -123,11 +126,14 @@ describe("CoursePage", () => { expect(links[1]).toHaveTextContent("What you'll learn") expect(links[1]).toHaveAttribute("href", `#${HeadingIds.What}`) expect(document.getElementById(HeadingIds.What)).toBeVisible() - expect(links[2]).toHaveTextContent("Prerequisites") - expect(links[2]).toHaveAttribute("href", `#${HeadingIds.Prereqs}`) + expect(links[2]).toHaveTextContent("How you'll learn") + expect(links[2]).toHaveAttribute("href", `#${HeadingIds.How}`) + expect(document.getElementById(HeadingIds.How)).toBeVisible() + expect(links[3]).toHaveTextContent("Prerequisites") + expect(links[3]).toHaveAttribute("href", `#${HeadingIds.Prereqs}`) expect(document.getElementById(HeadingIds.Prereqs)).toBeVisible() - expect(links[3]).toHaveTextContent("Instructors") - expect(links[3]).toHaveAttribute("href", `#${HeadingIds.Instructors}`) + expect(links[4]).toHaveTextContent("Instructors") + expect(links[4]).toHaveAttribute("href", `#${HeadingIds.Instructors}`) expect(document.getElementById(HeadingIds.Instructors)).toBeVisible() }) @@ -158,7 +164,7 @@ describe("CoursePage", () => { expectRawContent(section, page.what_you_learn) }) - // Dialog tested in InstructorsSection.test.tsx + // Interaction and active content are tested in InstructorsSection.test.tsx test("Instructors section has expected content", async () => { const course = makeCourse() const page = makePage({ course_details: course }) @@ -169,8 +175,10 @@ describe("CoursePage", () => { const section = await screen.findByRole("region", { name: "Meet your instructors", }) - const items = within(section).getAllByRole("listitem") - expect(items.length).toBe(page.faculty.length) + const buttons = page.faculty.map((faculty) => + within(section).getByRole("button", { name: faculty.instructor_name }), + ) + expect(buttons.length).toBe(page.faculty.length) }) test("Prerequisites section has expected content", async () => { @@ -184,6 +192,16 @@ describe("CoursePage", () => { expectRawContent(section, page.prerequisites) }) + test("Renders an enrollment button", async () => { + const course = makeCourse() + const page = makePage({ course_details: course }) + setupApis({ course, page }) + renderWithProviders() + + const buttons = await screen.findAllByTestId("course-enrollment-button") + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + test.each([ { courses: [], pages: [makePage()] }, { courses: [makeCourse()], pages: [] }, diff --git a/frontends/main/src/app-pages/ProductPages/CoursePage.tsx b/frontends/main/src/app-pages/ProductPages/CoursePage.tsx index 820bb48d4c..3249fb6244 100644 --- a/frontends/main/src/app-pages/ProductPages/CoursePage.tsx +++ b/frontends/main/src/app-pages/ProductPages/CoursePage.tsx @@ -1,7 +1,7 @@ "use client" import React from "react" -import { Stack, Typography } from "ol-components" +import { Typography } from "ol-components" import { pagesQueries } from "api/mitxonline-hooks/pages" import { useQuery } from "@tanstack/react-query" @@ -15,11 +15,11 @@ import { HeadingIds } from "./util" import InstructorsSection from "./InstructorsSection" import RawHTML from "./RawHTML" import AboutSection from "./AboutSection" -import ProductPageTemplate, { - HeadingData, - ProductNavbar, - WhoCanTake, -} from "./ProductPageTemplate" +import ProductPageTemplate from "./ProductPageTemplate" +import ProductNavbar, { HeadingData } from "./ProductNavbar" +import WhoCanTakeSection from "./WhoCanTakeSection" +import WhatYoullLearnSection from "./WhatYoullLearnSection" +import HowYoullLearnSection, { DEFAULT_HOW_DATA } from "./HowYoullLearnSection" import { CoursePageItem } from "@mitodl/mitxonline-api-axios/v2" import { DEFAULT_RESOURCE_IMG } from "ol-utilities" import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" @@ -29,11 +29,6 @@ type CoursePageProps = { readableId: string } -const WhatSection = styled.section({ - display: "flex", - flexDirection: "column", - gap: "16px", -}) const PrerequisitesSection = styled.section({ display: "flex", flexDirection: "column", @@ -54,6 +49,12 @@ const getNavLinks = (page: CoursePageItem): HeadingData[] => { variant: "secondary", content: page.what_you_learn, }, + { + id: HeadingIds.How, + label: "How you'll learn", + variant: "secondary", + content: true, + }, { id: HeadingIds.Prereqs, label: "Prerequisites", @@ -107,40 +108,30 @@ const CoursePage: React.FC = ({ readableId }) => { title={page.title} shortDescription={page.course_details.page.description} imageSrc={imageSrc} - sidebarSummary={ - } - /> - } - navLinks={navLinks} + summaryTitle="Course summary" + sidebarSummary={} + enrollButton={} + navbar={} > - - - {page.about ? ( - - ) : null} - {page.what_you_learn ? ( - - - What you'll learn - - - - ) : null} - {page.prerequisites ? ( - - - Prerequisites - - - - ) : null} - {page.faculty.length ? ( - - ) : null} - - + {page.about ? ( + + ) : null} + {page.what_you_learn ? ( + + ) : null} + + {page.prerequisites ? ( + + + Prerequisites + + + + ) : null} + {page.faculty.length ? ( + + ) : null} + ) } diff --git a/frontends/main/src/app-pages/ProductPages/HowYoullLearnSection.tsx b/frontends/main/src/app-pages/ProductPages/HowYoullLearnSection.tsx new file mode 100644 index 0000000000..42502b3bce --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/HowYoullLearnSection.tsx @@ -0,0 +1,142 @@ +import React from "react" +import { Typography } from "ol-components" +import { styled } from "@mitodl/smoot-design" +import Image from "next/image" +import { HeadingIds } from "./util" + +import IconBookPlay from "@/public/images/product/icon_book_play.png" +import IconBrains from "@/public/images/product/icon_brains.png" +import IconCertificate from "@/public/images/product/icon_certificate.png" +import IconComputerBulb from "@/public/images/product/icon_computer_lightbulb.png" +import IconConnectedPeople from "@/public/images/product/icon_connected_people.png" + +const HowYoullLearnRoot = styled.section(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "32px", + [theme.breakpoints.down("md")]: { + gap: "24px", + }, +})) + +const HowYoullLearnGrid = styled.ul(({ theme }) => ({ + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: "32px 56px", + listStyle: "none", + padding: 0, + margin: 0, + [theme.breakpoints.down("md")]: { + gap: "16px", + }, + [theme.breakpoints.down("sm")]: { + gridTemplateColumns: "1fr", + gap: "24px", + }, +})) + +const HowYoullLearnItem = styled.li(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "16px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "4px", + padding: "24px", +})) + +const HowYoullLearnHeader = styled.div({ + display: "flex", + gap: "16px", + alignItems: "center", +}) + +const HowYoullLearnIcon = styled(Image)({ + width: "80px", + height: "50px", + flexShrink: 0, +}) + +const HowYoullLearnTitle = styled.strong(({ theme }) => ({ + ...theme.typography.subtitle1, +})) + +const HowYoullLearnDescription = styled.p(({ theme }) => ({ + ...theme.typography.body1, + lineHeight: "1.5", + margin: 0, + [theme.breakpoints.down("md")]: { + ...theme.typography.body2, + }, +})) + +type HowYoullLearnItemData = { + icon: typeof IconComputerBulb + title: string + text: string +} + +// Placeholder data — will be replaced by API-driven content. +const DEFAULT_HOW_DATA: HowYoullLearnItemData[] = [ + { + icon: IconComputerBulb, + title: "Learn by doing", + text: "Practice processes and methods through simulations, assessments, case studies, and tools.", + }, + { + icon: IconConnectedPeople, + title: "Learn from others", + text: "Connect with an international community of professionals while working on projects based on real-world examples.", + }, + { + icon: IconBookPlay, + title: "Learn on demand", + text: "Access course content online and watch videos at your own pace.", + }, + { + icon: IconBrains, + title: "Reflect and apply", + text: "Bring new skills to your organization through real-world examples and prompts for reflection.", + }, + { + icon: IconCertificate, + title: "Demonstrate your success", + text: "Earn a credential from MIT to showcase your achievement.", + }, + { + icon: IconConnectedPeople, + title: "Learn from the best", + text: "Gain insights from MIT faculty and industry experts.", + }, +] + +const HowYoullLearnSection: React.FC<{ + data: HowYoullLearnItemData[] +}> = ({ data }) => { + return ( + + + How you'll learn + + + {data.map((item, index) => ( + + + + {item.title} + + {item.text} + + ))} + + + ) +} + +export default HowYoullLearnSection +export { DEFAULT_HOW_DATA } +export type { HowYoullLearnItemData } diff --git a/frontends/main/src/app-pages/ProductPages/InstructorsSection.test.tsx b/frontends/main/src/app-pages/ProductPages/InstructorsSection.test.tsx index 62570a7ec2..52bd61d020 100644 --- a/frontends/main/src/app-pages/ProductPages/InstructorsSection.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/InstructorsSection.test.tsx @@ -1,13 +1,7 @@ import React from "react" import { factories } from "api/mitxonline-test-utils" -import { - renderWithProviders, - waitFor, - screen, - within, - user, -} from "@/test-utils" +import { renderWithProviders, screen, within, user } from "@/test-utils" import { getByImageSrc } from "ol-test-utilities" @@ -30,18 +24,22 @@ test("Renders each instructor", async () => { name: "Meet your instructors", }) - const items = within(section).getAllByRole("listitem") - expect(instructors.length).toBe(3) - instructors.forEach((instructor, index) => { - const item = items[index] - within(item).getByRole("button", { name: instructor.instructor_name }) - within(item).getByText(instructor.instructor_title) - getByImageSrc(item, instructor.feature_image_src) + instructors.forEach((instructor) => { + const button = within(section).getByRole("button", { + name: instructor.instructor_name, + }) + getByImageSrc(button, instructor.feature_image_src) + }) + + const defaultInstructor = instructors[0] + within(section).getByRole("heading", { + name: defaultInstructor.instructor_name, }) + expectRawContent(section, defaultInstructor.instructor_bio_long) }) -test("Opens and closes instructor dialog", async () => { +test("Changes active instructor content on portrait click", async () => { const instructors = Array.from({ length: 3 }, () => makeFaculty()) renderWithProviders() @@ -51,18 +49,13 @@ test("Opens and closes instructor dialog", async () => { }) await user.click(button) - const dialog = await screen.findByRole("dialog", { - name: `${instructor.instructor_name}`, + const section = await screen.findByRole("region", { + name: "Meet your instructors", }) - within(dialog).getByRole("heading", { - level: 2, + within(section).getByRole("heading", { name: instructor.instructor_name, }) - expectRawContent(dialog, instructor.instructor_bio_long) + expectRawContent(section, instructor.instructor_bio_long) - const closeButton = within(dialog).getByRole("button", { name: "Close" }) - await user.click(closeButton) - await waitFor(() => { - expect(dialog).not.toBeInTheDocument() - }) + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() }) diff --git a/frontends/main/src/app-pages/ProductPages/InstructorsSection.tsx b/frontends/main/src/app-pages/ProductPages/InstructorsSection.tsx index d3d14971c3..5a7700962c 100644 --- a/frontends/main/src/app-pages/ProductPages/InstructorsSection.tsx +++ b/frontends/main/src/app-pages/ProductPages/InstructorsSection.tsx @@ -1,181 +1,203 @@ "use client" -import React, { useId } from "react" -import { Typography, MuiDialog } from "ol-components" +import React from "react" +import { Typography } from "ol-components" import Image from "next/image" -import { ActionButton, styled } from "@mitodl/smoot-design" +import { styled } from "@mitodl/smoot-design" +import { CarouselV2 } from "ol-components/CarouselV2" import type { Faculty } from "@mitodl/mitxonline-api-axios/v2" import { HeadingIds } from "./util" -import { RiCloseLine } from "@remixicon/react" import RawHTML from "./RawHTML" -const InstructorsSectionRoot = styled.section({}) -const InstructorsList = styled.ul(({ theme }) => ({ +const InstructorsSectionRoot = styled.section(({ theme }) => ({ display: "flex", - flexWrap: "wrap", - padding: 0, - margin: 0, - marginTop: "24px", + flexDirection: "column", gap: "24px", + [theme.breakpoints.up("sm")]: { + padding: "32px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "8px", + }, +})) +const InstructorsHeader = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "16px", [theme.breakpoints.down("sm")]: { - gap: "16px", - justifyContent: "center", + justifyContent: "flex-start", }, })) -const InstructorCardRoot = styled.li(({ theme }) => ({ +const ArrowButtonsContainer = styled.div(({ theme }) => ({ display: "flex", - flexDirection: "column", - gap: "8px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - borderRadius: "8px", - padding: "16px", - width: "252px", - minHeight: "272px", + alignItems: "center", + gap: "16px", [theme.breakpoints.down("sm")]: { - width: "calc(50% - 8px)", - minWidth: "162px", + display: "none", }, - ":hover": { - boxShadow: "0 8px 20px 0 rgba(120, 147, 172, 0.10)", - cursor: "pointer", +})) +const InstructorsCarousel = styled(CarouselV2)(({ theme }) => ({ + ".MitCarousel-track": { + gap: "48px", + [theme.breakpoints.down("sm")]: { + gap: "8px", + }, }, })) -const InstructorButton = styled.button(({ theme }) => ({ - backgroundColor: "unset", - border: "none", - textAlign: "left", - padding: 0, - ...theme.typography.h5, - marginTop: "8px", - cursor: "inherit", +const CarouselSlide = styled.div(({ theme }) => ({ + flex: "0 0 112px", + [theme.breakpoints.down("sm")]: { + flexBasis: "104px", + }, })) -const InstructorImage = styled(Image)(({ theme }) => ({ - height: "140px", +const InstructorButton = styled.button(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "8px", + alignItems: "center", width: "100%", - objectFit: "cover", - borderRadius: "8px", - [theme.breakpoints.down("sm")]: { - height: "155px", + border: 0, + padding: 0, + margin: 0, + backgroundColor: "transparent", + textAlign: "center", + color: theme.custom.colors.darkGray2, + font: "inherit", + lineHeight: "inherit", + cursor: "pointer", + "&:focus-visible": { + outline: `2px solid ${theme.custom.colors.lightBlue}`, + outlineOffset: "4px", + borderRadius: "8px", }, })) -const InstructorCard: React.FC<{ - instructor: Faculty -}> = ({ instructor }) => { - const [open, setOpen] = React.useState(false) - return ( - <> - setOpen(true)}> - - {instructor.instructor_name} - {instructor.instructor_title} - - setOpen(false)} - /> - - ) -} - -const CloseButton = styled(ActionButton)(({ theme }) => ({ - position: "absolute", - top: "24px", - right: "28px", - backgroundColor: theme.custom.colors.lightGray2, - "&&:hover": { - backgroundColor: theme.custom.colors.red, - color: theme.custom.colors.white, +const InstructorAvatar = styled.div(({ theme }) => ({ + borderRadius: "50%", + border: `1px solid ${theme.custom.colors.silverGrayLight}`, + padding: "12px", + overflow: "hidden", + display: "flex", + alignItems: "center", + [theme.breakpoints.down("sm")]: { + padding: "8px", }, - [theme.breakpoints.down("md")]: { - right: "16px", + '[aria-pressed="true"] &': { + borderColor: "transparent", + boxShadow: `inset 0 0 0 2px ${theme.custom.colors.red}`, }, })) -const DialogImage = styled(Image)({ - width: "100%", - aspectRatio: "1.92", +const InstructorImage = styled(Image)({ + height: "84px", + width: "84px", objectFit: "cover", + borderRadius: "50%", }) -const DialogContent = styled.div(({ theme }) => ({ - padding: "32px", - ".raw-include": { - ...theme.typography.body2, - "*:first-child": { - marginTop: 0, - }, - p: { - marginTop: "8px", - marginBottom: "0", - }, +const InstructorName = styled.span(({ theme }) => ({ + ...theme.typography.body2, + lineHeight: "22px", + marginTop: "6px", + '[aria-pressed="true"] &': { + color: theme.custom.colors.red, + fontWeight: theme.typography.fontWeightBold, }, })) -const InstructorDialog: React.FC<{ +const ActiveInstructorContent = styled.div(({ theme }) => ({ + borderTop: `1px solid ${theme.custom.colors.red}`, + paddingTop: "24px", + color: theme.custom.colors.darkGray2, +})) +const ActiveInstructorName = styled.h3(({ theme }) => ({ + ...theme.typography.h4, + color: theme.custom.colors.red, + marginBottom: "8px", + marginTop: "0px", +})) + +const ActiveInstructor: React.FC<{ instructor: Faculty - open: boolean - onClose: () => void -}> = ({ instructor, open, onClose }) => { - const titleId = useId() + contentId: string +}> = ({ instructor, contentId }) => { return ( - - - - - - - - {instructor.instructor_name} - - - {instructor.instructor_title} - - - - + + {instructor.instructor_name} + + {instructor.instructor_title} + + + ) } const InstructorsSection: React.FC<{ instructors: Faculty[] }> = ({ instructors, }) => { + const panelId = React.useId() + const [arrowsContainer, setArrowsContainer] = + React.useState(null) + const [activeInstructorId, setActiveInstructorId] = React.useState< + Faculty["id"] | null + >(instructors[0]?.id ?? null) + + React.useEffect(() => { + if (!instructors.length) { + setActiveInstructorId(null) + return + } + if ( + !instructors.some((instructor) => instructor.id === activeInstructorId) + ) { + setActiveInstructorId(instructors[0].id) + } + }, [activeInstructorId, instructors]) + + const activeInstructor = + instructors.find((instructor) => instructor.id === activeInstructorId) ?? + instructors[0] + return ( - - Meet your instructors - - + + + Meet your instructors + + + + {instructors.map((instructor) => { - return + const isActive = instructor.id === activeInstructor?.id + return ( + + setActiveInstructorId(instructor.id)} + > + + + + {instructor.instructor_name} + + + ) })} - + + {activeInstructor ? ( + + ) : null} ) } diff --git a/frontends/main/src/app-pages/ProductPages/ProductNavbar.tsx b/frontends/main/src/app-pages/ProductPages/ProductNavbar.tsx new file mode 100644 index 0000000000..28447e1e7a --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/ProductNavbar.tsx @@ -0,0 +1,69 @@ +import React from "react" +import { ButtonLink, styled } from "@mitodl/smoot-design" +import { HeadingIds } from "./util" +import type { ProductNoun } from "./util" + +const StyledLink = styled(ButtonLink)(({ theme }) => ({ + backgroundColor: theme.custom.colors.white, + borderColor: theme.custom.colors.white, + [theme.breakpoints.down("md")]: { + backgroundColor: theme.custom.colors.lightGray1, + border: `1px solid ${theme.custom.colors.lightGray2}`, + }, +})) + +const LinksContainer = styled.nav(({ theme }) => ({ + display: "flex", + flexWrap: "wrap", + [theme.breakpoints.up("md")]: { + gap: "24px", + padding: "12px 16px", + backgroundColor: theme.custom.colors.white, + borderRadius: "4px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + boxShadow: "0 8px 20px 0 rgba(120, 147, 172, 0.10)", + marginTop: "-24px", + width: "calc(100%)", + }, + [theme.breakpoints.down("md")]: { + alignSelf: "center", + gap: "8px", + rowGap: "16px", + padding: 0, + }, +})) + +export type HeadingData = { + id: HeadingIds + label: string + variant: "primary" | "secondary" +} + +const ProductNavbar: React.FC<{ + navLinks: HeadingData[] + productNoun: ProductNoun +}> = ({ navLinks, productNoun }) => { + if (navLinks.length === 0) { + return null + } + return ( + + {navLinks.map((heading) => { + const LinkComponent = + heading.variant === "primary" ? ButtonLink : StyledLink + return ( + + {heading.label} + + ) + })} + + ) +} + +export default ProductNavbar diff --git a/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx b/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx index 7403c8cfa0..6e3c7647d3 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx @@ -11,9 +11,10 @@ import { import { backgroundSrcSetCSS } from "ol-utilities" import { HOME } from "@/common/urls" import backgroundSteps from "@/public/images/backgrounds/background_steps.jpg" -import { ButtonLink, styled } from "@mitodl/smoot-design" +import { styled, VisuallyHidden } from "@mitodl/smoot-design" import Image from "next/image" import { HeadingIds } from "./util" +import type { Breakpoint } from "@mui/system" const StyledBreadcrumbs = styled(Breadcrumbs)(({ theme }) => ({ paddingBottom: "24px", @@ -59,103 +60,97 @@ const BottomContainer = styled(Container)(({ theme }) => ({ flexDirection: "column", alignItems: "center", gap: "0px", + paddingTop: "24px", }, })) +const SHOW_PROPS = new Set(["showAbove", "showBelow", "showBetween"]) +/** Responsive visibility helper. Only one of showAbove/showBelow/showBetween should be used. */ +const Show = styled("div", { + shouldForwardProp: (prop) => !SHOW_PROPS.has(prop), +})<{ + showAbove?: Breakpoint + showBelow?: Breakpoint + showBetween?: [Breakpoint, Breakpoint] +}>(({ theme, showAbove, showBelow, showBetween }) => ({ + ...(showAbove && { + [theme.breakpoints.down(showAbove)]: { display: "none" }, + }), + ...(showBelow && { + [theme.breakpoints.up(showBelow)]: { display: "none" }, + }), + ...(showBetween && { + [theme.breakpoints.down(showBetween[0])]: { display: "none" }, + [theme.breakpoints.up(showBetween[1])]: { display: "none" }, + }), +})) + const MainCol = styled.div({ + width: "100%", flex: 1, + minWidth: 0, display: "flex", flexDirection: "column", }) -const SidebarCol = styled.div(({ theme }) => ({ - width: "100%", - maxWidth: "410px", - [theme.breakpoints.down("md")]: { - marginTop: "24px", - }, -})) -const SidebarSpacer = styled.div(({ theme }) => ({ - width: "410px", - [theme.breakpoints.down("md")]: { - display: "none", - }, -})) - -const StyledLink = styled(ButtonLink)(({ theme }) => ({ - backgroundColor: theme.custom.colors.white, - borderColor: theme.custom.colors.white, - [theme.breakpoints.down("md")]: { - backgroundColor: theme.custom.colors.lightGray1, - border: `1px solid ${theme.custom.colors.lightGray2}`, - }, -})) - -const LinksContainer = styled.nav(({ theme }) => ({ +const SectionsWrapper = styled.div(({ theme }) => ({ display: "flex", - flexWrap: "wrap", - [theme.breakpoints.up("md")]: { - gap: "24px", - padding: "12px 16px", - backgroundColor: theme.custom.colors.white, - borderRadius: "4px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - boxShadow: "0 8px 20px 0 rgba(120, 147, 172, 0.10)", - marginTop: "-24px", - width: "calc(100%)", - marginBottom: "40px", - }, + flexDirection: "column", + gap: "56px", + marginTop: "40px", [theme.breakpoints.down("md")]: { - alignSelf: "center", - gap: "8px", - rowGap: "16px", - padding: 0, - margin: "24px 0", + gap: "40px", + marginTop: "36px", }, })) -const SidebarImageWrapper = styled.div(({ theme }) => ({ - [theme.breakpoints.up("md")]: { - height: "0px", - }, +const SidebarCol = styled(Show, { + shouldForwardProp: (prop) => prop !== "alignSelf", +})<{ + alignSelf?: React.CSSProperties["alignSelf"] +}>(({ alignSelf }) => ({ + width: "100%", + maxWidth: "410px", + alignSelf, })) + const SidebarImage = styled(Image)(({ theme }) => ({ borderRadius: "4px", width: "100%", maxWidth: "410px", - height: "230px", + aspectRatio: "410 / 230", + height: "auto", display: "block", - [theme.breakpoints.up("md")]: { - transform: "translateY(-100%)", - }, [theme.breakpoints.down("md")]: { border: `1px solid ${theme.custom.colors.lightGray2}`, borderRadius: "4px 4px 0 0", }, })) -const WhoCanTakeSection = styled.section(({ theme }) => ({ - padding: "32px", +const SummaryRoot = styled.div(({ theme }) => ({ border: `1px solid ${theme.custom.colors.lightGray2}`, - borderRadius: "8px", + backgroundColor: theme.custom.colors.white, + borderRadius: "4px", + boxShadow: "0 8px 20px 0 rgba(120, 147, 172, 0.10)", + padding: "24px", display: "flex", flexDirection: "column", - gap: "24px", - ...theme.typography.body1, - lineHeight: "1.5", + gap: "32px", + [theme.breakpoints.up("md")]: { + position: "sticky", + marginTop: "-54px", + top: "calc(40px + 32px + 24px)", + borderRadius: "4px", + }, + [theme.breakpoints.between("sm", "md")]: { + flexDirection: "row", + gap: "48px", + }, [theme.breakpoints.down("md")]: { - padding: "16px", - gap: "16px", - ...theme.typography.body2, + marginTop: "24px", }, })) -type HeadingData = { - id: HeadingIds - label: string - variant: "primary" | "secondary" -} - type ProductPageTemplateProps = { tags: string[] currentBreadcrumbLabel: string @@ -163,8 +158,10 @@ type ProductPageTemplateProps = { shortDescription: React.ReactNode imageSrc: string sidebarSummary: React.ReactNode + summaryTitle: string children: React.ReactNode - navLinks: HeadingData[] + navbar: React.ReactNode + enrollButton?: React.ReactNode } const ProductPageTemplate: React.FC = ({ tags, @@ -173,7 +170,10 @@ const ProductPageTemplate: React.FC = ({ shortDescription, imageSrc, sidebarSummary, + summaryTitle, children, + enrollButton, + navbar, }) => { return ( @@ -201,64 +201,56 @@ const ProductPageTemplate: React.FC = ({ - + + + - - - - - {sidebarSummary} + {/* + * The summary section is rendered 3 times (desktop, tablet, mobile) + * with different layouts, but only one is visible at a time via CSS. + * A single visually-hidden heading serves all three. + */} + +

{summaryTitle}

+
+ + + {enrollButton} + {sidebarSummary} + - {children} + + {navbar} + + + {sidebarSummary} + + + {enrollButton} + + + + + + + {enrollButton} + {sidebarSummary} + + + {children} +
) } -const ProductNavbar: React.FC<{ - navLinks: HeadingData[] - productNoun: string -}> = ({ navLinks, productNoun }) => { - if (navLinks.length === 0) { - return null - } - return ( - - {navLinks.map((heading) => { - const LinkComponent = - heading.variant === "primary" ? ButtonLink : StyledLink - return ( - - {heading.label} - - ) - })} - - ) -} - -const WhoCanTake: React.FC<{ productNoun: string }> = ({ productNoun }) => { - return ( - - - Who can take this {productNoun}? - - Because of U.S. Office of Foreign Assets Control (OFAC) restrictions and - other U.S. federal regulations, learners residing in one or more of the - following countries or regions will not be able to register for this - course: Iran, Cuba, Syria, North Korea and the Crimea, Donetsk People's - Republic and Luhansk People's Republic regions of Ukraine. - - ) -} - export default ProductPageTemplate -export { WhoCanTake, ProductNavbar } -export type { HeadingData, ProductPageTemplateProps } +export type { ProductPageTemplateProps } diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx index 6bc5146a95..7ebc3a6b16 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx @@ -15,14 +15,6 @@ const makeFlexiblePrice = factories.products.flexiblePrice const { RequirementTreeBuilder } = factories.requirements describe("CourseSummary", () => { - test("renders course summary", async () => { - const course = makeCourse() - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - within(summary).getByRole("heading", { name: "Course summary" }) - }) - test.each([ { overrides: { next_run_id: null }, expectAlert: true }, { overrides: {}, expectAlert: false }, @@ -31,8 +23,7 @@ describe("CourseSummary", () => { ({ overrides, expectAlert }) => { const course = makeCourse(overrides) renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const alertMessage = within(summary).queryByText( + const alertMessage = screen.queryByText( /No sessions of this course are currently open for enrollment/, ) @@ -58,8 +49,7 @@ describe("CourseSummary", () => { courseruns: shuffle([run, makeRun()]), }) renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const alert = within(summary).queryByRole("alert") + const alert = screen.queryByRole("alert") if (expectAlert) { invariant(alert) @@ -72,629 +62,627 @@ describe("CourseSummary", () => { }, ) - test("Renders enrollButton prop when provided", () => { - const run = makeRun() - const course = makeCourse({ - next_run_id: run.id, - courseruns: shuffle([run, makeRun()]), - }) - const enrollButton = - renderWithProviders( - , - ) - const summary = screen.getByRole("region", { name: "Course summary" }) - const button = within(summary).getByRole("button", { - name: "Test Enroll Button", - }) - expect(button).toBeInTheDocument() - }) -}) + describe("Dates Row", () => { + test("Renders expected start/end dates", async () => { + const run = makeRun({ is_enrollable: true }) + const nonEnrollableRun = makeRun({ is_enrollable: false }) + const course = makeCourse({ + availability: "dated", + next_run_id: run.id, + courseruns: shuffle([run, nonEnrollableRun]), + }) + renderWithProviders() + + const datesRow = screen.getByTestId(TestIds.DatesRow) -describe("Course Dates Row", () => { - test("Renders expected start/end dates", async () => { - const run = makeRun({ is_enrollable: true }) - const nonEnrollableRun = makeRun({ is_enrollable: false }) - const course = makeCourse({ - availability: "dated", - next_run_id: run.id, - courseruns: shuffle([run, nonEnrollableRun]), + invariant(run.start_date) + expect(datesRow).toHaveTextContent(`Start: ${formatDate(run.start_date)}`) + + invariant(run.end_date) + expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Renders 'Start: Anytime' plus and end date for start anytime courses", () => { + // For "Anytime" to show, run must be self-paced, not archived, and have a start date in the past + const run = makeRun({ + start_date: "2025-01-01", // Past date + is_self_paced: true, + is_archived: false, + is_enrollable: true, + }) + const course = makeCourse({ + availability: "anytime", + courseruns: [run], + next_run_id: run.id, + }) + renderWithProviders() - invariant(run.start_date) - expect(datesRow).toHaveTextContent(`Start: ${formatDate(run.start_date)}`) + const datesRow = screen.getByTestId(TestIds.DatesRow) - invariant(run.end_date) - expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) - }) + expect(datesRow).toHaveTextContent("Start: Anytime") - test("Renders 'Start: Anytime' plus and end date for start anytime courses", () => { - // For "Anytime" to show, run must be self-paced, not archived, and have a start date in the past - const run = makeRun({ - start_date: "2025-01-01", // Past date - is_self_paced: true, - is_archived: false, - is_enrollable: true, - }) - const course = makeCourse({ - availability: "anytime", - courseruns: [run], - next_run_id: run.id, + invariant(run.end_date) + expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) - - expect(datesRow).toHaveTextContent("Start: Anytime") + test("Renders nothing if next run has no start date and is only enrollable run", () => { + const run = makeRun({ start_date: null, is_enrollable: true }) + const nonEnrollableRun = makeRun({ is_enrollable: false }) + const course = makeCourse({ + availability: "dated", + courseruns: shuffle([run, nonEnrollableRun]), + next_run_id: run.id, + }) + renderWithProviders() - invariant(run.end_date) - expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) - }) + const datesRow = screen.getByTestId(TestIds.DatesRow) - test("Renders nothing if next run has no start date and is only enrollable run", () => { - const run = makeRun({ start_date: null, is_enrollable: true }) - const nonEnrollableRun = makeRun({ is_enrollable: false }) - const course = makeCourse({ - availability: "dated", - courseruns: shuffle([run, nonEnrollableRun]), - next_run_id: run.id, + // Dates row exists but shows no date information + expect(datesRow).not.toHaveTextContent("Start") + expect(datesRow).not.toHaveTextContent("End") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Renders no end date if end date missing", () => { + const run = makeRun({ end_date: null }) + const course = makeCourse({ + availability: "dated", + courseruns: shuffle([run, makeRun()]), + next_run_id: run.id, + }) + renderWithProviders() - // Dates row exists but shows no date information - expect(datesRow).not.toHaveTextContent("Start") - expect(datesRow).not.toHaveTextContent("End") - }) + const datesRow = screen.getByTestId(TestIds.DatesRow) - test("Renders no end date if end date missing", () => { - const run = makeRun({ end_date: null }) - const course = makeCourse({ - availability: "dated", - courseruns: shuffle([run, makeRun()]), - next_run_id: run.id, + expect(datesRow).not.toHaveTextContent("End") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Displays a single date without 'More Dates' toggle when only one enrollable run", () => { + const run = makeRun({ + is_enrollable: true, + start_date: "2026-03-01", + end_date: "2026-05-01", + }) + const nonEnrollableRun = makeRun({ + is_enrollable: false, + start_date: "2025-01-01", + end_date: "2025-03-01", + }) + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run, nonEnrollableRun], + }) + renderWithProviders() - expect(datesRow).not.toHaveTextContent("End") - }) + const datesRow = screen.getByTestId(TestIds.DatesRow) - test("Displays a single date without 'More Dates' toggle when only one enrollable run", () => { - const run = makeRun({ - is_enrollable: true, - start_date: "2026-03-01", - end_date: "2026-05-01", - }) - const nonEnrollableRun = makeRun({ - is_enrollable: false, - start_date: "2025-01-01", - end_date: "2025-03-01", - }) - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run, nonEnrollableRun], - }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) - - // Should show the start and end dates - invariant(run.start_date) - expect(datesRow).toHaveTextContent(`Start: ${formatDate(run.start_date)}`) - invariant(run.end_date) - expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) - - // Should NOT have a "More Dates" toggle - expect( - within(datesRow).queryByRole("button", { name: /More Dates/i }), - ).toBeNull() - expect(datesRow).not.toHaveTextContent("Dates Available") - }) + // Should show the start and end dates + invariant(run.start_date) + expect(datesRow).toHaveTextContent(`Start: ${formatDate(run.start_date)}`) + invariant(run.end_date) + expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) - test("Displays 'More Dates' toggle when multiple enrollable runs exist", () => { - const run1 = makeRun({ - is_enrollable: true, - start_date: "2026-03-01", - end_date: "2026-05-01", - }) - const run2 = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", + // Should NOT have a "More Dates" toggle + expect( + within(datesRow).queryByRole("button", { name: /More Dates/i }), + ).toBeNull() + expect(datesRow).not.toHaveTextContent("Dates Available") }) - const course = makeCourse({ - next_run_id: run1.id, - courseruns: [run1, run2], - }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Displays 'More Dates' toggle when multiple enrollable runs exist", () => { + const run1 = makeRun({ + is_enrollable: true, + start_date: "2026-03-01", + end_date: "2026-05-01", + }) + const run2 = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const course = makeCourse({ + next_run_id: run1.id, + courseruns: [run1, run2], + }) + renderWithProviders() - // Should show "Dates Available" label - expect(datesRow).toHaveTextContent("Dates Available") + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Should show "More Dates" button - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - expect(moreDatesButton).toBeInTheDocument() - }) + // Should show "Dates Available" label + expect(datesRow).toHaveTextContent("Dates Available") - test("Clicking 'More Dates' expands to show all enrollable dates, clicking 'Show Less' collapses back", async () => { - const run1 = makeRun({ - is_enrollable: true, - start_date: "2026-03-01", - end_date: "2026-05-01", - }) - const run2 = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const run3 = makeRun({ - is_enrollable: true, - start_date: "2026-09-01", - end_date: "2026-11-01", - }) - const course = makeCourse({ - next_run_id: run1.id, - courseruns: [run1, run2, run3], + // Should show "More Dates" button + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + expect(moreDatesButton).toBeInTheDocument() }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Clicking 'More Dates' expands to show all enrollable dates, clicking 'Show Less' collapses back", async () => { + const run1 = makeRun({ + is_enrollable: true, + start_date: "2026-03-01", + end_date: "2026-05-01", + }) + const run2 = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const run3 = makeRun({ + is_enrollable: true, + start_date: "2026-09-01", + end_date: "2026-11-01", + }) + const course = makeCourse({ + next_run_id: run1.id, + courseruns: [run1, run2, run3], + }) + renderWithProviders() - // Initially, only the next_run date should be visible - invariant(run1.start_date) - expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Other dates should NOT be visible - invariant(run2.start_date) - invariant(run3.start_date) - expect(datesRow).not.toHaveTextContent(formatDate(run2.start_date)) - expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) + // Initially, only the next_run date should be visible + invariant(run1.start_date) + expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) - // Click "More Dates" - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) + // Other dates should NOT be visible + invariant(run2.start_date) + invariant(run3.start_date) + expect(datesRow).not.toHaveTextContent(formatDate(run2.start_date)) + expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) - // Now all dates should be visible - expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) - expect(datesRow).toHaveTextContent(formatDate(run2.start_date)) - expect(datesRow).toHaveTextContent(formatDate(run3.start_date)) + // Click "More Dates" + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) - // Button text should change to "Show Less" - const fewerDatesButton = within(datesRow).getByRole("button", { - name: "Show Less", - }) - expect(fewerDatesButton).toBeInTheDocument() + // Now all dates should be visible + expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) + expect(datesRow).toHaveTextContent(formatDate(run2.start_date)) + expect(datesRow).toHaveTextContent(formatDate(run3.start_date)) - // Click "Show Less" to collapse - await user.click(fewerDatesButton) + // Button text should change to "Show Less" + const fewerDatesButton = within(datesRow).getByRole("button", { + name: "Show Less", + }) + expect(fewerDatesButton).toBeInTheDocument() - // Should be back to showing only the next_run date - expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) - expect(datesRow).not.toHaveTextContent(formatDate(run2.start_date)) - expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) + // Click "Show Less" to collapse + await user.click(fewerDatesButton) - // Button should be back to "More Dates" - expect( - within(datesRow).getByRole("button", { name: "More Dates" }), - ).toBeInTheDocument() - }) + // Should be back to showing only the next_run date + expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) + expect(datesRow).not.toHaveTextContent(formatDate(run2.start_date)) + expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) - test("Multiple enrollable dates are displayed chronologically newest to oldest", async () => { - const oldestRun = makeRun({ - is_enrollable: true, - is_self_paced: true, // This will show "Start: Anytime" (past date) - is_archived: false, - start_date: "2025-01-01", // Past date - end_date: "2026-03-01", - }) - const middleRun = makeRun({ - is_enrollable: true, - is_self_paced: false, // Ensure dates display normally (not "Anytime") - is_archived: false, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const newestRun = makeRun({ - is_enrollable: true, - is_self_paced: false, // Ensure dates display normally (not "Anytime") - is_archived: false, - start_date: "2026-09-01", - end_date: "2026-11-01", + // Button should be back to "More Dates" + expect( + within(datesRow).getByRole("button", { name: "More Dates" }), + ).toBeInTheDocument() }) - const course = makeCourse({ - next_run_id: middleRun.id, - // Shuffle the runs to ensure sorting works regardless of input order - courseruns: shuffle([oldestRun, middleRun, newestRun]), - }) - renderWithProviders() + test("Multiple enrollable dates are displayed chronologically newest to oldest", async () => { + const oldestRun = makeRun({ + is_enrollable: true, + is_self_paced: true, // This will show "Start: Anytime" (past date) + is_archived: false, + start_date: "2025-01-01", // Past date + end_date: "2026-03-01", + }) + const middleRun = makeRun({ + is_enrollable: true, + is_self_paced: false, // Ensure dates display normally (not "Anytime") + is_archived: false, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const newestRun = makeRun({ + is_enrollable: true, + is_self_paced: false, // Ensure dates display normally (not "Anytime") + is_archived: false, + start_date: "2026-09-01", + end_date: "2026-11-01", + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + const course = makeCourse({ + next_run_id: middleRun.id, + // Shuffle the runs to ensure sorting works regardless of input order + courseruns: shuffle([oldestRun, middleRun, newestRun]), + }) + renderWithProviders() - // Click "More Dates" to show all dates - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Get all the date strings that should appear - invariant(newestRun.start_date) - invariant(middleRun.start_date) - invariant(oldestRun.start_date) + // Click "More Dates" to show all dates + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) - const newestDateString = formatDate(newestRun.start_date) - const middleDateString = formatDate(middleRun.start_date) - const oldestDateString = formatDate(oldestRun.start_date) + // Get all the date strings that should appear + invariant(newestRun.start_date) + invariant(middleRun.start_date) + invariant(oldestRun.start_date) - // Get the date entry containers - const dateEntryContainers = within(datesRow).getAllByTestId("date-entry") + const newestDateString = formatDate(newestRun.start_date) + const middleDateString = formatDate(middleRun.start_date) + const oldestDateString = formatDate(oldestRun.start_date) - // Verify we have 3 date entries - expect(dateEntryContainers).toHaveLength(3) + // Get the date entry containers + const dateEntryContainers = within(datesRow).getAllByTestId("date-entry") - // Verify the dates appear in chronological order (newest to oldest) - const firstDateContainer = dateEntryContainers[0] - const secondDateContainer = dateEntryContainers[1] - const thirdDateContainer = dateEntryContainers[2] + // Verify we have 3 date entries + expect(dateEntryContainers).toHaveLength(3) - expect(firstDateContainer?.textContent).toContain(newestDateString) - expect(secondDateContainer?.textContent).toContain(middleDateString) - // The oldest run should show "Anytime" instead of the actual date - expect(thirdDateContainer?.textContent).toContain("Start: Anytime") - expect(thirdDateContainer?.textContent).not.toContain(oldestDateString) - }) + // Verify the dates appear in chronological order (newest to oldest) + const firstDateContainer = dateEntryContainers[0] + const secondDateContainer = dateEntryContainers[1] + const thirdDateContainer = dateEntryContainers[2] - test("Initially displays the date for the run with next_run_id when multiple enrollable runs exist", () => { - const run1 = makeRun({ - is_enrollable: true, - start_date: "2026-01-01", - end_date: "2026-03-01", - }) - const run2 = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const run3 = makeRun({ - is_enrollable: true, - start_date: "2026-09-01", - end_date: "2026-11-01", + expect(firstDateContainer?.textContent).toContain(newestDateString) + expect(secondDateContainer?.textContent).toContain(middleDateString) + // The oldest run should show "Anytime" instead of the actual date + expect(thirdDateContainer?.textContent).toContain("Start: Anytime") + expect(thirdDateContainer?.textContent).not.toContain(oldestDateString) }) - const course = makeCourse({ - next_run_id: run2.id, // Select the middle run as next - courseruns: shuffle([run1, run2, run3]), - }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) - - // Should show run2's date (the next_run_id) - invariant(run2.start_date) - invariant(run2.end_date) - expect(datesRow).toHaveTextContent(formatDate(run2.start_date)) - expect(datesRow).toHaveTextContent(formatDate(run2.end_date)) - - // Should NOT show other runs' dates initially - invariant(run1.start_date) - invariant(run3.start_date) - expect(datesRow).not.toHaveTextContent(formatDate(run1.start_date)) - expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) - }) + test("Initially displays the date for the run with next_run_id when multiple enrollable runs exist", () => { + const run1 = makeRun({ + is_enrollable: true, + start_date: "2026-01-01", + end_date: "2026-03-01", + }) + const run2 = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const run3 = makeRun({ + is_enrollable: true, + start_date: "2026-09-01", + end_date: "2026-11-01", + }) - test("Never displays dates for non-enrollable runs", async () => { - const enrollableRun = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const nonEnrollableRun1 = makeRun({ - is_enrollable: false, - start_date: "2026-01-01", - end_date: "2026-03-01", - }) - const nonEnrollableRun2 = makeRun({ - is_enrollable: false, - start_date: "2026-09-01", - end_date: "2026-11-01", - }) + const course = makeCourse({ + next_run_id: run2.id, // Select the middle run as next + courseruns: shuffle([run1, run2, run3]), + }) + renderWithProviders() - const course = makeCourse({ - next_run_id: enrollableRun.id, - courseruns: shuffle([ - enrollableRun, - nonEnrollableRun1, - nonEnrollableRun2, - ]), + const datesRow = screen.getByTestId(TestIds.DatesRow) + + // Should show run2's date (the next_run_id) + invariant(run2.start_date) + invariant(run2.end_date) + expect(datesRow).toHaveTextContent(formatDate(run2.start_date)) + expect(datesRow).toHaveTextContent(formatDate(run2.end_date)) + + // Should NOT show other runs' dates initially + invariant(run1.start_date) + invariant(run3.start_date) + expect(datesRow).not.toHaveTextContent(formatDate(run1.start_date)) + expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Never displays dates for non-enrollable runs", async () => { + const enrollableRun = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const nonEnrollableRun1 = makeRun({ + is_enrollable: false, + start_date: "2026-01-01", + end_date: "2026-03-01", + }) + const nonEnrollableRun2 = makeRun({ + is_enrollable: false, + start_date: "2026-09-01", + end_date: "2026-11-01", + }) - // Should show only the enrollable run's date - invariant(enrollableRun.start_date) - expect(datesRow).toHaveTextContent(formatDate(enrollableRun.start_date)) + const course = makeCourse({ + next_run_id: enrollableRun.id, + courseruns: shuffle([ + enrollableRun, + nonEnrollableRun1, + nonEnrollableRun2, + ]), + }) + renderWithProviders() - // Should NOT show non-enrollable runs' dates - invariant(nonEnrollableRun1.start_date) - invariant(nonEnrollableRun2.start_date) - expect(datesRow).not.toHaveTextContent( - formatDate(nonEnrollableRun1.start_date), - ) - expect(datesRow).not.toHaveTextContent( - formatDate(nonEnrollableRun2.start_date), - ) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Should NOT have "More Dates" toggle since only one enrollable run - expect( - within(datesRow).queryByRole("button", { name: /More Dates/i }), - ).toBeNull() - }) + // Should show only the enrollable run's date + invariant(enrollableRun.start_date) + expect(datesRow).toHaveTextContent(formatDate(enrollableRun.start_date)) - test("Shows dates available header but no date entries when all enrollable runs have no start date", () => { - const run1 = makeRun({ - is_enrollable: true, - start_date: null, - }) - const run2 = makeRun({ - is_enrollable: true, - start_date: null, - }) - const course = makeCourse({ - next_run_id: run1.id, - courseruns: [run1, run2], + // Should NOT show non-enrollable runs' dates + invariant(nonEnrollableRun1.start_date) + invariant(nonEnrollableRun2.start_date) + expect(datesRow).not.toHaveTextContent( + formatDate(nonEnrollableRun1.start_date), + ) + expect(datesRow).not.toHaveTextContent( + formatDate(nonEnrollableRun2.start_date), + ) + + // Should NOT have "More Dates" toggle since only one enrollable run + expect( + within(datesRow).queryByRole("button", { name: /More Dates/i }), + ).toBeNull() }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Shows dates available header but no date entries when all enrollable runs have no start date", () => { + const run1 = makeRun({ + is_enrollable: true, + start_date: null, + }) + const run2 = makeRun({ + is_enrollable: true, + start_date: null, + }) + const course = makeCourse({ + next_run_id: run1.id, + courseruns: [run1, run2], + }) + renderWithProviders() - // Dates row renders because multiple enrollable runs exist - expect(datesRow).toHaveTextContent("Dates Available") + const datesRow = screen.getByTestId(TestIds.DatesRow) - // But no actual date information is shown - expect(datesRow).not.toHaveTextContent("Start") - expect(datesRow).not.toHaveTextContent("End") - }) + // Dates row renders because multiple enrollable runs exist + expect(datesRow).toHaveTextContent("Dates Available") - test("When multiple enrollable runs exist, only shows runs with start dates after expanding", async () => { - const runWithDate = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const runWithoutDate = makeRun({ - is_enrollable: true, - start_date: null, - end_date: "2026-11-01", - }) - const anotherRunWithDate = makeRun({ - is_enrollable: true, - start_date: "2026-09-01", - end_date: "2026-11-01", + // But no actual date information is shown + expect(datesRow).not.toHaveTextContent("Start") + expect(datesRow).not.toHaveTextContent("End") }) - const course = makeCourse({ - next_run_id: runWithDate.id, - courseruns: shuffle([runWithDate, runWithoutDate, anotherRunWithDate]), - }) - renderWithProviders() + test("When multiple enrollable runs exist, only shows runs with start dates after expanding", async () => { + const runWithDate = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const runWithoutDate = makeRun({ + is_enrollable: true, + start_date: null, + end_date: "2026-11-01", + }) + const anotherRunWithDate = makeRun({ + is_enrollable: true, + start_date: "2026-09-01", + end_date: "2026-11-01", + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + const course = makeCourse({ + next_run_id: runWithDate.id, + courseruns: shuffle([runWithDate, runWithoutDate, anotherRunWithDate]), + }) + renderWithProviders() - // Click "More Dates" - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) - - // Should show dates for runs with start dates - invariant(runWithDate.start_date) - invariant(anotherRunWithDate.start_date) - expect(datesRow).toHaveTextContent(formatDate(runWithDate.start_date)) - expect(datesRow).toHaveTextContent( - formatDate(anotherRunWithDate.start_date), - ) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // The run without a start date should not appear - // Count the number of date entry containers - const dateEntryContainers = within(datesRow).getAllByTestId("date-entry") - expect(dateEntryContainers).toHaveLength(2) - }) + // Click "More Dates" + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) + + // Should show dates for runs with start dates + invariant(runWithDate.start_date) + invariant(anotherRunWithDate.start_date) + expect(datesRow).toHaveTextContent(formatDate(runWithDate.start_date)) + expect(datesRow).toHaveTextContent( + formatDate(anotherRunWithDate.start_date), + ) - test("End date is not displayed for runs without end date when expanded", async () => { - const runWithEndDate = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const runWithoutEndDate = makeRun({ - is_enrollable: true, - start_date: "2026-09-01", - end_date: null, + // The run without a start date should not appear + // Count the number of date entry containers + const dateEntryContainers = within(datesRow).getAllByTestId("date-entry") + expect(dateEntryContainers).toHaveLength(2) }) - const course = makeCourse({ - next_run_id: runWithEndDate.id, - courseruns: [runWithEndDate, runWithoutEndDate], - }) - renderWithProviders() + test("End date is not displayed for runs without end date when expanded", async () => { + const runWithEndDate = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const runWithoutEndDate = makeRun({ + is_enrollable: true, + start_date: "2026-09-01", + end_date: null, + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + const course = makeCourse({ + next_run_id: runWithEndDate.id, + courseruns: [runWithEndDate, runWithoutEndDate], + }) + renderWithProviders() - // Click "More Dates" - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) - - // Both start dates should be visible - invariant(runWithEndDate.start_date) - invariant(runWithoutEndDate.start_date) - expect(datesRow).toHaveTextContent(formatDate(runWithEndDate.start_date)) - expect(datesRow).toHaveTextContent(formatDate(runWithoutEndDate.start_date)) - - // Should only show one "End" label (for the run with end date) - const dateEntries = within(datesRow).getAllByTestId("date-entry") - const dateEntriesWithEnd = dateEntries.filter((entry) => - entry.textContent?.includes("End:"), - ) - expect(dateEntriesWithEnd).toHaveLength(1) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Should show the end date that exists - invariant(runWithEndDate.end_date) - expect(datesRow).toHaveTextContent(formatDate(runWithEndDate.end_date)) - }) + // Click "More Dates" + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) + + // Both start dates should be visible + invariant(runWithEndDate.start_date) + invariant(runWithoutEndDate.start_date) + expect(datesRow).toHaveTextContent(formatDate(runWithEndDate.start_date)) + expect(datesRow).toHaveTextContent( + formatDate(runWithoutEndDate.start_date), + ) - test("Shows 'Start: Anytime' for self-paced runs with past start dates when multiple dates exist", async () => { - // Run that should show "Anytime" (self-paced, not archived, past start date) - const anytimeRun = makeRun({ - is_enrollable: true, - is_self_paced: true, - is_archived: false, - start_date: "2025-01-01", // Past date - end_date: "2026-12-01", - }) + // Should only show one "End" label (for the run with end date) + const dateEntries = within(datesRow).getAllByTestId("date-entry") + const dateEntriesWithEnd = dateEntries.filter((entry) => + entry.textContent?.includes("End:"), + ) + expect(dateEntriesWithEnd).toHaveLength(1) - // Run that should show actual date (not self-paced) - const instructorPacedRun = makeRun({ - is_enrollable: true, - is_self_paced: false, - is_archived: false, - start_date: "2026-06-01", - end_date: "2026-08-01", + // Should show the end date that exists + invariant(runWithEndDate.end_date) + expect(datesRow).toHaveTextContent(formatDate(runWithEndDate.end_date)) }) - // Run that should show actual date (self-paced but future start date) - const futureRun = makeRun({ - is_enrollable: true, - is_self_paced: true, - is_archived: false, - start_date: "2026-09-01", // Future date - end_date: "2026-11-01", - }) + test("Shows 'Start: Anytime' for self-paced runs with past start dates when multiple dates exist", async () => { + // Run that should show "Anytime" (self-paced, not archived, past start date) + const anytimeRun = makeRun({ + is_enrollable: true, + is_self_paced: true, + is_archived: false, + start_date: "2025-01-01", // Past date + end_date: "2026-12-01", + }) - const course = makeCourse({ - next_run_id: anytimeRun.id, - courseruns: [anytimeRun, instructorPacedRun, futureRun], - }) - renderWithProviders() + // Run that should show actual date (not self-paced) + const instructorPacedRun = makeRun({ + is_enrollable: true, + is_self_paced: false, + is_archived: false, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + // Run that should show actual date (self-paced but future start date) + const futureRun = makeRun({ + is_enrollable: true, + is_self_paced: true, + is_archived: false, + start_date: "2026-09-01", // Future date + end_date: "2026-11-01", + }) - // Click "More Dates" to see all - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) + const course = makeCourse({ + next_run_id: anytimeRun.id, + courseruns: [anytimeRun, instructorPacedRun, futureRun], + }) + renderWithProviders() - // Should show "Anytime" for the self-paced run with past start date - expect(datesRow).toHaveTextContent("Start: Anytime") + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Should show actual dates for the other runs - invariant(instructorPacedRun.start_date) - invariant(futureRun.start_date) - expect(datesRow).toHaveTextContent( - formatDate(instructorPacedRun.start_date), - ) - expect(datesRow).toHaveTextContent(formatDate(futureRun.start_date)) - - // All should show end dates - invariant(anytimeRun.end_date) - invariant(instructorPacedRun.end_date) - invariant(futureRun.end_date) - expect(datesRow).toHaveTextContent(formatDate(anytimeRun.end_date)) - expect(datesRow).toHaveTextContent(formatDate(instructorPacedRun.end_date)) - expect(datesRow).toHaveTextContent(formatDate(futureRun.end_date)) - }) + // Click "More Dates" to see all + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) - test("Archived runs show actual dates even if they meet anytime criteria", async () => { - // Archived run that would show "Anytime" if not archived - const archivedRun = makeRun({ - is_enrollable: true, - is_self_paced: true, - is_archived: true, // Archived! - start_date: "2025-01-01", // Past date - end_date: "2025-12-01", - }) + // Should show "Anytime" for the self-paced run with past start date + expect(datesRow).toHaveTextContent("Start: Anytime") - // Active anytime run for comparison - const anytimeRun = makeRun({ - is_enrollable: true, - is_self_paced: true, - is_archived: false, - start_date: "2025-06-01", // Past date - end_date: "2026-12-01", + // Should show actual dates for the other runs + invariant(instructorPacedRun.start_date) + invariant(futureRun.start_date) + expect(datesRow).toHaveTextContent( + formatDate(instructorPacedRun.start_date), + ) + expect(datesRow).toHaveTextContent(formatDate(futureRun.start_date)) + + // All should show end dates + invariant(anytimeRun.end_date) + invariant(instructorPacedRun.end_date) + invariant(futureRun.end_date) + expect(datesRow).toHaveTextContent(formatDate(anytimeRun.end_date)) + expect(datesRow).toHaveTextContent( + formatDate(instructorPacedRun.end_date), + ) + expect(datesRow).toHaveTextContent(formatDate(futureRun.end_date)) }) - const course = makeCourse({ - next_run_id: archivedRun.id, - courseruns: [archivedRun, anytimeRun], - }) - renderWithProviders() + test("Archived runs show actual dates even if they meet anytime criteria", async () => { + // Archived run that would show "Anytime" if not archived + const archivedRun = makeRun({ + is_enrollable: true, + is_self_paced: true, + is_archived: true, // Archived! + start_date: "2025-01-01", // Past date + end_date: "2025-12-01", + }) + + // Active anytime run for comparison + const anytimeRun = makeRun({ + is_enrollable: true, + is_self_paced: true, + is_archived: false, + start_date: "2025-06-01", // Past date + end_date: "2026-12-01", + }) + + const course = makeCourse({ + next_run_id: archivedRun.id, + courseruns: [archivedRun, anytimeRun], + }) + renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Click "More Dates" to see all - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) + // Click "More Dates" to see all + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) - // Active run should show "Anytime" - expect(datesRow).toHaveTextContent("Start: Anytime") + // Active run should show "Anytime" + expect(datesRow).toHaveTextContent("Start: Anytime") - // Archived run should show actual date, not "Anytime" - invariant(archivedRun.start_date) - expect(datesRow).toHaveTextContent(formatDate(archivedRun.start_date)) + // Archived run should show actual date, not "Anytime" + invariant(archivedRun.start_date) + expect(datesRow).toHaveTextContent(formatDate(archivedRun.start_date)) - // Both should show end dates - invariant(archivedRun.end_date) - invariant(anytimeRun.end_date) - expect(datesRow).toHaveTextContent(formatDate(archivedRun.end_date)) - expect(datesRow).toHaveTextContent(formatDate(anytimeRun.end_date)) + // Both should show end dates + invariant(archivedRun.end_date) + invariant(anytimeRun.end_date) + expect(datesRow).toHaveTextContent(formatDate(archivedRun.end_date)) + expect(datesRow).toHaveTextContent(formatDate(anytimeRun.end_date)) + }) }) -}) -describe("Course Format Row", () => { - test.each([ - { - descrip: "self-paced", - overrides: { is_self_paced: true, is_archived: false }, - }, - { - descrip: "archived", - overrides: { is_archived: true }, - }, - ])( - "Renders self-paced for $descrip courses with appropriate dialog", - async ({ overrides }) => { - const run = makeRun(overrides) + describe("Format Row", () => { + test.each([ + { + descrip: "self-paced", + overrides: { is_self_paced: true, is_archived: false }, + }, + { + descrip: "archived", + overrides: { is_archived: true }, + }, + ])( + "Renders self-paced for $descrip courses with appropriate dialog", + async ({ overrides }) => { + const run = makeRun(overrides) + const course = makeCourse({ + availability: "dated", + next_run_id: run.id, + courseruns: shuffle([run, makeRun()]), + }) + renderWithProviders() + + const formatRow = screen.getByTestId(TestIds.PaceRow) + expect(formatRow).toHaveTextContent("Course Format: Self-Paced") + + const dialogTitle = "What are Self-Paced courses?" + const button = within(formatRow).getByRole("button", { + name: dialogTitle, + }) + await user.click(button) + const dialog = await screen.findByRole("dialog", { + name: dialogTitle, + }) + + await user.click(within(dialog).getByRole("button", { name: "Close" })) + + expect(dialog).not.toBeVisible() + }, + ) + + test("Renders instructor-led for non-self-paced courses", async () => { + const run = makeRun({ is_self_paced: false, is_archived: false }) const course = makeCourse({ availability: "dated", next_run_id: run.id, @@ -702,179 +690,217 @@ describe("Course Format Row", () => { }) renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const formatRow = within(summary).getByTestId(TestIds.PaceRow) - expect(formatRow).toHaveTextContent("Course Format: Self-Paced") + const formatRow = screen.getByTestId(TestIds.PaceRow) + expect(formatRow).toHaveTextContent("Course Format: Instructor-Paced") + const dialogTitle = "What are Instructor-Paced courses?" const button = within(formatRow).getByRole("button", { - name: "What's this?", + name: dialogTitle, }) + await user.click(button) const dialog = await screen.findByRole("dialog", { - name: "What are Self-Paced courses?", + name: dialogTitle, }) await user.click(within(dialog).getByRole("button", { name: "Close" })) expect(dialog).not.toBeVisible() - }, - ) - - test("Renders instructor-led for non-self-paced courses", async () => { - const run = makeRun({ is_self_paced: false, is_archived: false }) - const course = makeCourse({ - availability: "dated", - next_run_id: run.id, - courseruns: shuffle([run, makeRun()]), }) - renderWithProviders() + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const formatRow = within(summary).getByTestId(TestIds.PaceRow) - expect(formatRow).toHaveTextContent("Course Format: Instructor-Paced") + describe("Duration Row", () => { + test.each([ + { + length: "5 weeks", + effort: "3-5 hours per week", + expected: "5 weeks, 3-5 hours per week", + }, + { length: "6 weeks", effort: null, expected: "6 weeks" }, + { length: "", expected: null }, + ])( + "Renders expected duration in weeks and effort", + ({ length, effort, expected }) => { + const course = makeCourse({ + page: { length, effort }, + }) - const button = within(formatRow).getByRole("button", { - name: "What's this?", - }) + renderWithProviders() - await user.click(button) - const dialog = await screen.findByRole("dialog", { - name: "What are Instructor-Paced courses?", - }) + if (!length) { + expect(screen.queryByTestId(TestIds.DurationRow)).toBeNull() + } else { + const durationRow = screen.getByTestId(TestIds.DurationRow) + expect(durationRow).toHaveTextContent(`Estimated: ${expected}`) + } + }, + ) + + test("Duration row displays even when there is no next run", () => { + const course = makeCourse({ + next_run_id: null, + courseruns: [], + page: { + length: "5 weeks", + effort: "10 hours/week", + }, + }) + renderWithProviders() - await user.click(within(dialog).getByRole("button", { name: "Close" })) + const durationRow = screen.getByTestId(TestIds.DurationRow) - expect(dialog).not.toBeVisible() + expect(durationRow).toBeInTheDocument() + expect(durationRow).toHaveTextContent("Estimated: 5 weeks, 10 hours/week") + }) }) -}) -describe("Course Duration Row", () => { - test.each([ - { - length: "5 weeks", - effort: "3-5 hours per week", - expected: "5 weeks, 3-5 hours per week", - }, - { length: "6 weeks", effort: null, expected: "6 weeks" }, - { length: "", expected: null }, - ])( - "Renders expected duration in weeks and effort", - ({ length, effort, expected }) => { + describe("Price Row", () => { + test.each([ + { + label: "has no products", + runOverrides: { is_archived: false, products: [] }, + }, + { + label: "is archived", + runOverrides: { is_archived: true, products: [makeProduct()] }, + }, + ])("Does not offer certificate if $label", ({ runOverrides }) => { + const run = makeRun(runOverrides) const course = makeCourse({ - page: { length, effort }, + next_run_id: run.id, + courseruns: shuffle([run, makeRun()]), }) - renderWithProviders() + const priceRow = screen.getByTestId(TestIds.PriceRow) - const summary = screen.getByRole("region", { name: "Course summary" }) - - if (!length) { - expect(within(summary).queryByTestId(TestIds.DurationRow)).toBeNull() - } else { - const durationRow = within(summary).getByTestId(TestIds.DurationRow) - expect(durationRow).toHaveTextContent(`Estimated: ${expected}`) - } - }, - ) + expect(priceRow).not.toHaveTextContent("Payment deadline") + expect(priceRow).toHaveTextContent("Certificate deadline passed") - test("Duration row displays even when there is no next run", () => { - const course = makeCourse({ - next_run_id: null, - courseruns: [], - page: { - length: "5 weeks", - effort: "10 hours/week", - }, + expect(priceRow).toHaveTextContent("Free to Learn") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const durationRow = within(summary).getByTestId(TestIds.DurationRow) - - expect(durationRow).toBeInTheDocument() - expect(durationRow).toHaveTextContent("Estimated: 5 weeks, 10 hours/week") - }) -}) + test("Offers certificate upgrade if not archived and has product", () => { + const run = makeRun({ + is_archived: false, + products: [makeProduct()], + is_enrollable: true, + is_upgradable: true, + }) + const course = makeCourse({ + next_run_id: run.id, + courseruns: shuffle([run, makeRun()]), + }) + renderWithProviders() + const priceRow = screen.getByTestId(TestIds.PriceRow) -describe("Course Price Row", () => { - test.each([ - { - label: "has no products", - runOverrides: { is_archived: false, products: [] }, - }, - { - label: "is archived", - runOverrides: { is_archived: true, products: [makeProduct()] }, - }, - ])("Does not offer certificate if $label", ({ runOverrides }) => { - const run = makeRun(runOverrides) - const course = makeCourse({ - next_run_id: run.id, - courseruns: shuffle([run, makeRun()]), + expect(priceRow).toHaveTextContent( + `Earn a certificate: $${run.products[0].price}`, + ) + invariant(run.upgrade_deadline) + expect(priceRow).toHaveTextContent( + `Payment deadline: ${formatDate(run.upgrade_deadline)}`, + ) + expect(priceRow).not.toHaveTextContent("Certificate deadline passed") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) - expect(priceRow).not.toHaveTextContent("Payment deadline") - expect(priceRow).toHaveTextContent("Certificate deadline passed") + test("Price row displays with 'Certificate deadline passed' when no next run is found", () => { + const course = makeCourse({ + next_run_id: null, + courseruns: [], + }) + renderWithProviders() - expect(priceRow).toHaveTextContent("Free to Learn") - }) + const priceRow = screen.getByTestId(TestIds.PriceRow) - test("Offers certificate upgrade if not archived and has product", () => { - const run = makeRun({ - is_archived: false, - products: [makeProduct()], - is_enrollable: true, - is_upgradable: true, + expect(priceRow).toBeInTheDocument() + expect(priceRow).toHaveTextContent("Free to Learn") + expect(priceRow).toHaveTextContent("Certificate deadline passed") + expect(priceRow).not.toHaveTextContent("Payment deadline") }) - const course = makeCourse({ - next_run_id: run.id, - courseruns: shuffle([run, makeRun()]), - }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) - - expect(priceRow).toHaveTextContent( - `Certificate Track: $${run.products[0].price}`, - ) - invariant(run.upgrade_deadline) - expect(priceRow).toHaveTextContent( - `Payment deadline: ${formatDate(run.upgrade_deadline)}`, - ) - expect(priceRow).not.toHaveTextContent("Certificate deadline passed") }) - test("Price row displays with 'Certificate deadline passed' when no next run is found", () => { - const course = makeCourse({ - next_run_id: null, - courseruns: [], - }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) + describe("Financial Assistance", () => { + test.each([ + { hasFinancialAid: true, expectLink: true }, + { hasFinancialAid: false, expectLink: false }, + ])( + "Financial aid link is displayed if and only if URL is non-empty (hasFinancialAid=$hasFinancialAid)", + async ({ hasFinancialAid, expectLink }) => { + const financialAidUrl = hasFinancialAid + ? `/financial-aid/${faker.string.alphanumeric(10)}` + : "" + const product = makeProduct() + const run = makeRun({ + is_archived: false, + products: [product], + is_enrollable: true, + is_upgradable: true, + }) + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + page: { financial_assistance_form_url: financialAidUrl }, + }) - expect(priceRow).toBeInTheDocument() - expect(priceRow).toHaveTextContent("Free to Learn") - expect(priceRow).toHaveTextContent("Certificate deadline passed") - expect(priceRow).not.toHaveTextContent("Payment deadline") - }) -}) + // Mock the flexible price API response when financial aid is available + if (hasFinancialAid) { + const mockFlexiblePrice = makeFlexiblePrice({ + id: product.id, + price: product.price, + product_flexible_price: null, + }) + setMockResponse.get( + urls.products.userFlexiblePriceDetail(product.id), + mockFlexiblePrice, + ) + } + + renderWithProviders() + + const priceRow = screen.getByTestId(TestIds.PriceRow) + + if (expectLink) { + const link = await within(priceRow).findByRole("link", { + name: /financial assistance/i, + }) + const expectedUrl = new URL( + financialAidUrl, + process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL, + ).toString() + expect(link).toHaveAttribute("href", expectedUrl) + expect(link).toHaveTextContent("Financial assistance available") + } else { + const link = within(priceRow).queryByRole("link", { + name: /financial assistance/i, + }) + expect(link).toBeNull() + expect(link).toBeNull() + } + }, + ) -describe("Course Financial Assistance", () => { - test.each([ - { hasFinancialAid: true, expectLink: true }, - { hasFinancialAid: false, expectLink: false }, - ])( - "Financial aid link is displayed if and only if URL is non-empty (hasFinancialAid=$hasFinancialAid)", - async ({ hasFinancialAid, expectLink }) => { - const financialAidUrl = hasFinancialAid - ? `/financial-aid/${faker.string.alphanumeric(10)}` - : "" - const product = makeProduct() + test("Displays user-specific discounted price when financial aid is available", async () => { + const originalPrice = "100.00" + const discountedAmount = "50.00" + const product = makeProduct({ price: originalPrice }) + const flexiblePrice = makeFlexiblePrice({ + id: product.id, + price: originalPrice, + product_flexible_price: { + id: faker.number.int(), + amount: discountedAmount, + discount_type: "dollars-off" as const, + discount_code: faker.string.alphanumeric(8), + redemption_type: "one-time" as const, + is_redeemed: false, + automatic: true, + max_redemptions: 1, + payment_type: null, + activation_date: faker.date.past().toISOString(), + expiration_date: faker.date.future().toISOString(), + }, + }) + const financialAidUrl = `/financial-aid/${faker.string.alphanumeric(10)}` const run = makeRun({ is_archived: false, products: [product], @@ -887,540 +913,448 @@ describe("Course Financial Assistance", () => { page: { financial_assistance_form_url: financialAidUrl }, }) - // Mock the flexible price API response when financial aid is available - if (hasFinancialAid) { - const mockFlexiblePrice = makeFlexiblePrice({ - id: product.id, - price: product.price, - product_flexible_price: null, - }) - setMockResponse.get( - urls.products.userFlexiblePriceDetail(product.id), - mockFlexiblePrice, - ) - } + setMockResponse.get( + urls.products.userFlexiblePriceDetail(product.id), + flexiblePrice, + ) renderWithProviders() - const summary = await screen.findByRole("region", { - name: "Course summary", - }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) - - if (expectLink) { - const link = await within(priceRow).findByRole("link", { - name: /financial assistance/i, - }) - const expectedUrl = new URL( - financialAidUrl, - process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL, - ).toString() - expect(link).toHaveAttribute("href", expectedUrl) - expect(link).toHaveTextContent("Financial assistance available") - } else { - const link = within(priceRow).queryByRole("link", { - name: /financial assistance/i, - }) - expect(link).toBeNull() - expect(link).toBeNull() - } - }, - ) - - test("Displays user-specific discounted price when financial aid is available", async () => { - const originalPrice = "100.00" - const discountedAmount = "50.00" - const product = makeProduct({ price: originalPrice }) - const flexiblePrice = makeFlexiblePrice({ - id: product.id, - price: originalPrice, - product_flexible_price: { - id: faker.number.int(), - amount: discountedAmount, - discount_type: "dollars-off" as const, - discount_code: faker.string.alphanumeric(8), - redemption_type: "one-time" as const, - is_redeemed: false, - automatic: true, - max_redemptions: 1, - payment_type: null, - activation_date: faker.date.past().toISOString(), - expiration_date: faker.date.future().toISOString(), - }, - }) - const financialAidUrl = `/financial-aid/${faker.string.alphanumeric(10)}` - const run = makeRun({ - is_archived: false, - products: [product], - is_enrollable: true, - is_upgradable: true, - }) - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - page: { financial_assistance_form_url: financialAidUrl }, - }) - - setMockResponse.get( - urls.products.userFlexiblePriceDetail(product.id), - flexiblePrice, - ) - - renderWithProviders() - - const summary = await screen.findByRole("region", { - name: "Course summary", - }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) - - // Wait for the flexible price API to be called and prices to be displayed - // The discounted price is calculated as: $100 - $50 = $50 - await within(priceRow).findByText("Financial assistance applied") - expect(priceRow).toHaveTextContent("$50.00") - expect(priceRow).toHaveTextContent("$100.00") - }) + const priceRow = screen.getByTestId(TestIds.PriceRow) - test("Does NOT call flexible price API when financial aid URL is empty", () => { - const product = makeProduct({ price: "100.00" }) - const run = makeRun({ - is_archived: false, - products: [product], - is_enrollable: true, - is_upgradable: true, - }) - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - page: { financial_assistance_form_url: "" }, + // Wait for the flexible price API to be called and prices to be displayed + // The discounted price is calculated as: $100 - $50 = $50 + await within(priceRow).findByText("Financial assistance applied") + expect(priceRow).toHaveTextContent("$50.00") + expect(priceRow).toHaveTextContent("$100.00") }) - // We're NOT setting up a mock response for the flexible price API - // If it's called, the test will fail + test("Does NOT call flexible price API when financial aid URL is empty", () => { + const product = makeProduct({ price: "100.00" }) + const run = makeRun({ + is_archived: false, + products: [product], + is_enrollable: true, + is_upgradable: true, + }) + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + page: { financial_assistance_form_url: "" }, + }) - renderWithProviders() + // We're NOT setting up a mock response for the flexible price API + // If it's called, the test will fail - const summary = screen.getByRole("region", { name: "Course summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) + renderWithProviders() - // Should show the regular price - expect(priceRow).toHaveTextContent(`$${product.price}`) - // Should NOT show financial assistance link - expect( - within(priceRow).queryByRole("link", { name: /financial assistance/i }), - ).toBeNull() - }) + const priceRow = screen.getByTestId(TestIds.PriceRow) - test("Does NOT show financial assistance when certificate link is present but products array is empty", () => { - const financialAidUrl = `/financial-aid/${faker.string.alphanumeric(10)}` - const run = makeRun({ - is_archived: false, - products: [], - is_enrollable: true, - is_upgradable: false, - }) - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - page: { financial_assistance_form_url: financialAidUrl }, + // Should show the regular price + expect(priceRow).toHaveTextContent(`$${product.price}`) + // Should NOT show financial assistance link + expect( + within(priceRow).queryByRole("link", { name: /financial assistance/i }), + ).toBeNull() }) - renderWithProviders() + test("Does NOT show financial assistance when certificate link is present but products array is empty", () => { + const financialAidUrl = `/financial-aid/${faker.string.alphanumeric(10)}` + const run = makeRun({ + is_archived: false, + products: [], + is_enrollable: true, + is_upgradable: false, + }) + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + page: { financial_assistance_form_url: financialAidUrl }, + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) + renderWithProviders() - // Should show "Certificate deadline passed" since no products - expect(priceRow).toHaveTextContent("Certificate deadline passed") + const priceRow = screen.getByTestId(TestIds.PriceRow) - // Certificate link should be present - const certLink = within(priceRow).getByRole("link", { - name: /Learn More/i, - }) - expect(certLink).toBeInTheDocument() + // Should show "Certificate deadline passed" since no products + expect(priceRow).toHaveTextContent("Certificate deadline passed") - // Financial assistance link should NOT be present - expect( - within(priceRow).queryByRole("link", { name: /financial assistance/i }), - ).toBeNull() - }) -}) + // Certificate link should be present + const certLink = within(priceRow).getByRole("link", { + name: /Learn More/i, + }) + expect(certLink).toBeInTheDocument() -describe("Course In Programs Row", () => { - test("Does not render when programs array is null", () => { - const run = makeRun() - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - programs: null, + // Financial assistance link should NOT be present + expect( + within(priceRow).queryByRole("link", { name: /financial assistance/i }), + ).toBeNull() }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - const programsRow = within(summary).queryByTestId( - TestIds.CourseInProgramsRow, - ) - expect(programsRow).toBeNull() }) - test("Does not render when programs array is empty", () => { - const run = makeRun() - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - programs: [], - }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - const programsRow = within(summary).queryByTestId( - TestIds.CourseInProgramsRow, - ) - expect(programsRow).toBeNull() - }) + describe("In Programs Row", () => { + test("Does not render when programs array is null", () => { + const run = makeRun() + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + programs: null, + }) + renderWithProviders() - test("Renders link to one program", () => { - const program = { - id: 1, - readable_id: "program-1", - title: "Test Program 1", - type: "program", - } - const run = makeRun() - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - programs: [program], + const programsRow = screen.queryByTestId(TestIds.CourseInProgramsRow) + expect(programsRow).toBeNull() }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const programsRow = within(summary).getByTestId(TestIds.CourseInProgramsRow) + test("Does not render when programs array is empty", () => { + const run = makeRun() + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + programs: [], + }) + renderWithProviders() - expect(programsRow).toHaveTextContent("Part of the following program") - const link = within(programsRow).getByRole("link", { - name: "Test Program 1", + const programsRow = screen.queryByTestId(TestIds.CourseInProgramsRow) + expect(programsRow).toBeNull() }) - expect(link).toHaveAttribute("href", "/programs/program-1") - }) - test("Renders links to multiple programs", () => { - const programs = [ - { + test("Renders link to one program", () => { + const program = { id: 1, readable_id: "program-1", title: "Test Program 1", type: "program", - }, - { - id: 2, - readable_id: "program-2", - title: "Test Program 2", - type: "program", - }, - { - id: 3, - readable_id: "program-3", - title: "Test Program 3", - type: "program", - }, - ] - const run = makeRun() - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - programs: programs, + } + const run = makeRun() + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + programs: [program], + }) + renderWithProviders() + + const programsRow = screen.getByTestId(TestIds.CourseInProgramsRow) + + expect(programsRow).toHaveTextContent("Part of the following program") + const link = within(programsRow).getByRole("link", { + name: "Test Program 1", + }) + expect(link).toHaveAttribute("href", "/programs/program-1") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const programsRow = within(summary).getByTestId(TestIds.CourseInProgramsRow) + test("Renders links to multiple programs", () => { + const programs = [ + { + id: 1, + readable_id: "program-1", + title: "Test Program 1", + type: "program", + }, + { + id: 2, + readable_id: "program-2", + title: "Test Program 2", + type: "program", + }, + { + id: 3, + readable_id: "program-3", + title: "Test Program 3", + type: "program", + }, + ] + const run = makeRun() + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + programs: programs, + }) + renderWithProviders() - expect(programsRow).toHaveTextContent("Part of the following programs") + const programsRow = screen.getByTestId(TestIds.CourseInProgramsRow) - const link1 = within(programsRow).getByRole("link", { - name: "Test Program 1", - }) - expect(link1).toHaveAttribute("href", "/programs/program-1") + expect(programsRow).toHaveTextContent("Part of the following programs") - const link2 = within(programsRow).getByRole("link", { - name: "Test Program 2", - }) - expect(link2).toHaveAttribute("href", "/programs/program-2") + const link1 = within(programsRow).getByRole("link", { + name: "Test Program 1", + }) + expect(link1).toHaveAttribute("href", "/programs/program-1") - const link3 = within(programsRow).getByRole("link", { - name: "Test Program 3", - }) - expect(link3).toHaveAttribute("href", "/programs/program-3") - }) + const link2 = within(programsRow).getByRole("link", { + name: "Test Program 2", + }) + expect(link2).toHaveAttribute("href", "/programs/program-2") - test("Displays programs row even when no next run is found", () => { - const program = { - id: 1, - readable_id: "program-1", - title: "Test Program 1", - type: "program", - } - const course = makeCourse({ - next_run_id: null, - courseruns: [], - programs: [program], + const link3 = within(programsRow).getByRole("link", { + name: "Test Program 3", + }) + expect(link3).toHaveAttribute("href", "/programs/program-3") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const programsRow = within(summary).getByTestId(TestIds.CourseInProgramsRow) + test("Displays programs row even when no next run is found", () => { + const program = { + id: 1, + readable_id: "program-1", + title: "Test Program 1", + type: "program", + } + const course = makeCourse({ + next_run_id: null, + courseruns: [], + programs: [program], + }) + renderWithProviders() + + const programsRow = screen.getByTestId(TestIds.CourseInProgramsRow) - expect(programsRow).toBeInTheDocument() - expect(programsRow).toHaveTextContent("Part of the following program") - const link = within(programsRow).getByRole("link", { - name: "Test Program 1", + expect(programsRow).toBeInTheDocument() + expect(programsRow).toHaveTextContent("Part of the following program") + const link = within(programsRow).getByRole("link", { + name: "Test Program 1", + }) + expect(link).toHaveAttribute("href", "/programs/program-1") }) - expect(link).toHaveAttribute("href", "/programs/program-1") }) }) describe("ProgramSummary", () => { - test("renders program summary", async () => { + test("renders program summary rows", async () => { const program = factories.programs.program() renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - within(summary).getByRole("heading", { name: "Program summary" }) + screen.getByTestId(TestIds.PriceRow) }) -}) -describe("Program RequirementsRow", () => { - test("Renders requirement count with required + elective courses", () => { - const requirements = new RequirementTreeBuilder() - const required = requirements.addOperator({ operator: "all_of" }) - required.addCourse() - required.addCourse() - required.addCourse() - const electives = requirements.addOperator({ - operator: "min_number_of", - operator_value: "2", - }) - electives.addCourse() - electives.addCourse() - electives.addCourse() - electives.addCourse() - - const program = factories.programs.program({ - requirements: { - courses: { - required: - required.children?.map((n) => ({ - id: n.id, - readable_id: `readale-${n.id}`, - })) ?? [], - electives: - electives.children?.map((n) => ({ - id: n.id, - readable_id: `readale-${n.id}`, - })) ?? [], - }, - programs: { required: [], electives: [] }, - }, - req_tree: requirements.serialize(), - }) + describe("RequirementsRow", () => { + test("Renders requirement count with required + elective courses", () => { + const requirements = new RequirementTreeBuilder() + const required = requirements.addOperator({ operator: "all_of" }) + required.addCourse() + required.addCourse() + required.addCourse() + const electives = requirements.addOperator({ + operator: "min_number_of", + operator_value: "2", + }) + electives.addCourse() + electives.addCourse() + electives.addCourse() + electives.addCourse() - renderWithProviders() + const program = factories.programs.program({ + requirements: { + courses: { + required: + required.children?.map((n) => ({ + id: n.id, + readable_id: `readale-${n.id}`, + })) ?? [], + electives: + electives.children?.map((n) => ({ + id: n.id, + readable_id: `readale-${n.id}`, + })) ?? [], + }, + programs: { required: [], electives: [] }, + }, + req_tree: requirements.serialize(), + }) - const summary = screen.getByRole("region", { name: "Program summary" }) - const reqRow = within(summary).getByTestId(TestIds.RequirementsRow) + renderWithProviders() - // The text is split more nicely on screen, but via html tags not spaces - expect(reqRow).toHaveTextContent("5 Courses to complete program") - }) + const reqRow = screen.getByTestId(TestIds.RequirementsRow) - test("Renders requirement count correctly if no electives", () => { - const requirements = new RequirementTreeBuilder() - const required = requirements.addOperator({ operator: "all_of" }) - required.addCourse() - required.addCourse() - required.addCourse() - - const program = factories.programs.program({ - requirements: { - courses: { - required: - required.children?.map((n) => ({ - id: n.id, - readable_id: `readale-${n.id}`, - })) ?? [], - electives: [], - }, - programs: { required: [], electives: [] }, - }, - req_tree: requirements.serialize(), + // The text is split more nicely on screen, but via html tags not spaces + expect(reqRow).toHaveTextContent("5 Courses to complete program") }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Program summary" }) - const reqRow = within(summary).getByTestId(TestIds.RequirementsRow) - - expect(reqRow).toHaveTextContent("3 Courses to complete program") - }) -}) + test("Renders requirement count correctly if no electives", () => { + const requirements = new RequirementTreeBuilder() + const required = requirements.addOperator({ operator: "all_of" }) + required.addCourse() + required.addCourse() + required.addCourse() -describe("Program Duration Row", () => { - test.each([ - { - length: "5 weeks", - effort: "3-5 hours per week", - expected: "5 weeks, 3-5 hours per week", - }, - { length: "6 weeks", effort: null, expected: "6 weeks" }, - { length: "", expected: null }, - ])( - "Renders expected duration in weeks and effort", - ({ length, effort, expected }) => { const program = factories.programs.program({ - page: { length, effort }, + requirements: { + courses: { + required: + required.children?.map((n) => ({ + id: n.id, + readable_id: `readale-${n.id}`, + })) ?? [], + electives: [], + }, + programs: { required: [], electives: [] }, + }, + req_tree: requirements.serialize(), }) renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - if (!length) { - expect(within(summary).queryByTestId(TestIds.DurationRow)).toBeNull() - } else { - const durationRow = within(summary).getByTestId(TestIds.DurationRow) - expect(durationRow).toHaveTextContent(`Estimated: ${expected}`) - } - }, - ) -}) - -describe("Program Pacing Row", () => { - const courses = { - selfPaced: makeCourse({ - courseruns: [makeRun({ is_self_paced: true })], - }), - instructorPaced: makeCourse({ - courseruns: [makeRun({ is_self_paced: false, is_archived: false })], - }), - archived: makeCourse({ - // counts as self-paced. - courseruns: [makeRun({ is_self_paced: false, is_archived: true })], - }), - noRuns: makeCourse({ - courseruns: [], - }), - } + const reqRow = screen.getByTestId(TestIds.RequirementsRow) - test.each([ - { courses: [courses.selfPaced], expected: "Self-Paced" }, - { courses: [courses.archived], expected: "Self-Paced" }, - { courses: [courses.instructorPaced], expected: "Instructor-Paced" }, - { - courses: [courses.selfPaced, courses.instructorPaced], - expected: "Instructor-Paced", - }, - { - courses: [courses.noRuns, courses.instructorPaced], - expected: "Instructor-Paced", - }, - { - courses: [courses.noRuns, courses.selfPaced], - expected: "Self-Paced", - }, - ])("Shows correct pacing information", ({ courses, expected }) => { - const program = factories.programs.program() - renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - const paceRow = within(summary).getByTestId(TestIds.PaceRow) - expect(paceRow).toHaveTextContent(`Course Format: ${expected}`) + expect(reqRow).toHaveTextContent("3 Courses to complete program") + }) }) - test.each([ - { courses: [courses.selfPaced], dialogName: /What are Self-Paced/ }, - { - courses: [courses.instructorPaced], - dialogName: /What are Instructor-Paced/, - }, - { - courses: [courses.selfPaced, courses.instructorPaced], - dialogName: /What are Instructor-Paced/, - }, - { - courses: [courses.archived], - dialogName: /What are Self-Paced/, - }, - ])("Renders expected dialog", async ({ courses, dialogName }) => { - const program = factories.programs.program() - renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - const paceRow = within(summary).getByTestId(TestIds.PaceRow) - const button = within(paceRow).getByRole("button", { name: "What's this?" }) - await user.click(button) - const dialog = await screen.findByRole("dialog", { - name: dialogName, - }) + describe("Duration Row", () => { + test.each([ + { + length: "5 weeks", + effort: "3-5 hours per week", + expected: "5 weeks, 3-5 hours per week", + }, + { length: "6 weeks", effort: null, expected: "6 weeks" }, + { length: "", expected: null }, + ])( + "Renders expected duration in weeks and effort", + ({ length, effort, expected }) => { + const program = factories.programs.program({ + page: { length, effort }, + }) - await user.click(within(dialog).getByRole("button", { name: "Close" })) + renderWithProviders() - expect(dialog).not.toBeVisible() + if (!length) { + expect(screen.queryByTestId(TestIds.DurationRow)).toBeNull() + } else { + const durationRow = screen.getByTestId(TestIds.DurationRow) + expect(durationRow).toHaveTextContent(`Estimated: ${expected}`) + } + }, + ) }) -}) -describe("Price & Certificate Row", () => { - test("Shows 'Free to Learn'", () => { - const program = factories.programs.program() - renderWithProviders() + describe("Pacing Row", () => { + const courses = { + selfPaced: makeCourse({ + courseruns: [makeRun({ is_self_paced: true })], + }), + instructorPaced: makeCourse({ + courseruns: [makeRun({ is_self_paced: false, is_archived: false })], + }), + archived: makeCourse({ + // counts as self-paced. + courseruns: [makeRun({ is_self_paced: false, is_archived: true })], + }), + noRuns: makeCourse({ + courseruns: [], + }), + } - const summary = screen.getByRole("region", { name: "Program summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) + test.each([ + { courses: [courses.selfPaced], expected: "Self-Paced" }, + { courses: [courses.archived], expected: "Self-Paced" }, + { courses: [courses.instructorPaced], expected: "Instructor-Paced" }, + { + courses: [courses.selfPaced, courses.instructorPaced], + expected: "Instructor-Paced", + }, + { + courses: [courses.noRuns, courses.instructorPaced], + expected: "Instructor-Paced", + }, + { + courses: [courses.noRuns, courses.selfPaced], + expected: "Self-Paced", + }, + ])("Shows correct pacing information", ({ courses, expected }) => { + const program = factories.programs.program() + renderWithProviders( + , + ) + const paceRow = screen.getByTestId(TestIds.PaceRow) + expect(paceRow).toHaveTextContent(`Course Format: ${expected}`) + }) - expect(priceRow).toHaveTextContent("Free to Learn") + test.each([ + { courses: [courses.selfPaced], dialogName: /What are Self-Paced/ }, + { + courses: [courses.instructorPaced], + dialogName: /What are Instructor-Paced/, + }, + { + courses: [courses.selfPaced, courses.instructorPaced], + dialogName: /What are Instructor-Paced/, + }, + { + courses: [courses.archived], + dialogName: /What are Self-Paced/, + }, + ])("Renders expected dialog", async ({ courses, dialogName }) => { + const program = factories.programs.program() + renderWithProviders( + , + ) + const paceRow = screen.getByTestId(TestIds.PaceRow) + const button = within(paceRow).getByRole("button", { name: dialogName }) + await user.click(button) + const dialog = await screen.findByRole("dialog", { + name: dialogName, + }) + + await user.click(within(dialog).getByRole("button", { name: "Close" })) + + expect(dialog).not.toBeVisible() + }) }) - test("Renders certificate information", () => { - const program = factories.programs.program() - invariant(program.page.price) - renderWithProviders() + describe("Price & Certificate Row", () => { + test("Shows 'Free to Learn'", () => { + const program = factories.programs.program() + renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - const certRow = within(summary).getByTestId(TestIds.PriceRow) + const priceRow = screen.getByTestId(TestIds.PriceRow) - expect(certRow).toHaveTextContent("Certificate Track") - expect(certRow).toHaveTextContent(program.page.price) - }) + expect(priceRow).toHaveTextContent("Free to Learn") + }) - test.each([ - { hasFinancialAid: true, expectLink: true }, - { hasFinancialAid: false, expectLink: false }, - ])( - "Program financial aid link is displayed if and only if URL is non-empty (hasFinancialAid=$hasFinancialAid)", - ({ hasFinancialAid, expectLink }) => { - const financialAidUrl = hasFinancialAid - ? `/financial-aid/${faker.string.alphanumeric(10)}` - : "" - const program = factories.programs.program({ - page: { financial_assistance_form_url: financialAidUrl }, - }) + test("Renders certificate information", () => { + const program = factories.programs.program() + invariant(program.page.price) renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) + const certRow = screen.getByTestId(TestIds.PriceRow) - if (expectLink) { - const link = within(priceRow).getByRole("link", { - name: /financial assistance/i, - }) - const expectedUrl = new URL( - financialAidUrl, - process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL, - ).toString() - expect(link).toHaveAttribute("href", expectedUrl) - expect(link).toHaveTextContent("Financial assistance available") - } else { - const link = within(priceRow).queryByRole("link", { - name: /financial assistance/i, + expect(certRow).toHaveTextContent("Earn a certificate") + expect(certRow).toHaveTextContent(program.page.price) + }) + + test.each([ + { hasFinancialAid: true, expectLink: true }, + { hasFinancialAid: false, expectLink: false }, + ])( + "Program financial aid link is displayed if and only if URL is non-empty (hasFinancialAid=$hasFinancialAid)", + ({ hasFinancialAid, expectLink }) => { + const financialAidUrl = hasFinancialAid + ? `/financial-aid/${faker.string.alphanumeric(10)}` + : "" + const program = factories.programs.program({ + page: { financial_assistance_form_url: financialAidUrl }, }) - expect(link).toBeNull() - } - }, - ) + renderWithProviders() + + const priceRow = screen.getByTestId(TestIds.PriceRow) + + if (expectLink) { + const link = within(priceRow).getByRole("link", { + name: /financial assistance/i, + }) + const expectedUrl = new URL( + financialAidUrl, + process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL, + ).toString() + expect(link).toHaveAttribute("href", expectedUrl) + expect(link).toHaveTextContent("Financial assistance available") + } else { + const link = within(priceRow).queryByRole("link", { + name: /financial assistance/i, + }) + expect(link).toBeNull() + } + }, + ) + }) }) diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx index fcfab82b9a..d67579b005 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx @@ -1,5 +1,5 @@ import React, { HTMLAttributes, useState } from "react" -import { Alert, styled, VisuallyHidden } from "@mitodl/smoot-design" +import { ActionButton, Alert, styled } from "@mitodl/smoot-design" import { productQueries } from "api/mitxonline-hooks/products" import { Dialog, Link, Skeleton, Stack, Typography } from "ol-components" import type { StackProps } from "ol-components" @@ -10,6 +10,7 @@ import { RiTimeLine, RiFileCopy2Line, RiMenuAddLine, + RiInformation2Line, } from "@remixicon/react" import { formatDate, isInPast, LocalDate, NoSSR, pluralize } from "ol-utilities" import type { @@ -41,16 +42,29 @@ const InfoRow = styled.div(({ theme }) => ({ width: "100%", display: "flex", gap: "8px", - alignItems: "baseline", + alignItems: "flex-start", color: theme.custom.colors.darkGray2, ...theme.typography.body2, [theme.breakpoints.down("sm")]: { ...theme.typography.body3, }, - svg: { +})) + +/** + * Centers an icon within a flex row. Uses height matching the text line-height + * so that flex-start alignment on the parent keeps it pinned to the first line. + */ +const InfoRowIcon = styled.span(({ theme }) => ({ + display: "inline-flex", + alignItems: "center", + height: theme.typography.body2.lineHeight, + [theme.breakpoints.down("sm")]: { + height: theme.typography.body3.lineHeight, + }, + flexShrink: 0, + "> svg": { width: "20px", height: "20px", - transform: "translateY(25%)", }, })) @@ -80,7 +94,7 @@ const InfoLabel = styled.span<{ underline && { textDecoration: "underline" }, ]) const InfoLabelValue: React.FC<{ - label: string + label: React.ReactNode value: React.ReactNode labelVariant?: "light" | "normal" }> = ({ label, value, labelVariant }) => @@ -128,7 +142,9 @@ const CourseDatesRow: React.FC = ({ return ( -