mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-03-09 22:47:21 -04:00
Compare commits
8 Commits
feat-lti-i
...
frontend-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df4b0422d5 | ||
|
|
0434f24691 | ||
|
|
c2043fafa1 | ||
|
|
9f9dd699b2 | ||
|
|
e2bc9399b9 | ||
|
|
45d94069b9 | ||
|
|
b7427869b6 | ||
|
|
11449c2187 |
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [7.6.0](https://github.com/mediacms-io/mediacms/compare/v7.5.0...v7.6.0) (2026-02-07)
|
||||
|
||||
### Features
|
||||
|
||||
* Create SECURITY.md ([#1485](https://github.com/mediacms-io/mediacms/issues/1485)) ([11449c2](https://github.com/mediacms-io/mediacms/commit/11449c2187d0f450b86915d88f92595a1825e4cf))
|
||||
|
||||
## [7.5.0](https://github.com/mediacms-io/mediacms/compare/v7.4.0...v7.5.0) (2026-02-06)
|
||||
|
||||
### Features
|
||||
|
||||
23
HISTORY.md
Normal file
23
HISTORY.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# History
|
||||
|
||||
## 3.0.0
|
||||
|
||||
### Features
|
||||
- Updates Python/Django requirements and Dockerfile to use latest 3.11 Python - https://github.com/mediacms-io/mediacms/pull/826/files. This update requires some manual steps, for existing (not new) installations. Check the update section under the [Admin docs](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#2-server-installation), either for single server or for Docker Compose installations
|
||||
- Upgrade postgres on Docker Compose - https://github.com/mediacms-io/mediacms/pull/749
|
||||
|
||||
### Fixes
|
||||
- video player options for HLS - https://github.com/mediacms-io/mediacms/pull/832
|
||||
- AVI videos not correctly recognised as videos - https://github.com/mediacms-io/mediacms/pull/833
|
||||
|
||||
## 2.1.0
|
||||
|
||||
### Fixes
|
||||
- Increase uwsgi buffer-size parameter. This prevents an error by uwsgi with large headers - [#5b60](https://github.com/mediacms-io/mediacms/commit/5b601698a41ad97f08c1830e14b1c18f73ab8315)
|
||||
- Fix issues with comments. These were not reported on the tracker but it is certain that they would not show comments on media files (non videos but also videos). Unfortunately this reverts work done with Timestamps on comments + Mentions on comments, more on PR [#802](https://github.com/mediacms-io/mediacms/pull/802)
|
||||
|
||||
### Features
|
||||
- Allow tags to contains other characters too, not only English alphabet ones [#801](https://github.com/mediacms-io/mediacms/pull/801)
|
||||
- Add simple cookie consent code [#799](https://github.com/mediacms-io/mediacms/pull/799)
|
||||
- Allow password reset & email verify pages on global login required [#790](https://github.com/mediacms-io/mediacms/pull/790)
|
||||
- Add api_url field to search api [#692](https://github.com/mediacms-io/mediacms/pull/692)
|
||||
@@ -108,7 +108,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
|
||||
|
||||
## Technology
|
||||
|
||||
This software uses the following list of awesome technologies: Python, Django, Django Rest Framework, Celery, PostgreSQL, Redis, Nginx, Gunicorn, React, Fine Uploader, video.js, FFMPEG, Bento4
|
||||
This software uses the following list of awesome technologies: Python, Django, Django Rest Framework, Celery, PostgreSQL, Redis, Nginx, uWSGI, React, Fine Uploader, video.js, FFMPEG, Bento4
|
||||
|
||||
|
||||
## Who is using it
|
||||
|
||||
54
SECURITY.md
Normal file
54
SECURITY.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Security Policy
|
||||
|
||||
Thank you for helping improve the security of MediaCMS.
|
||||
We take security vulnerabilities seriously and appreciate responsible disclosure.
|
||||
|
||||
---
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in MediaCMS, **please do not open a public GitHub issue**.
|
||||
|
||||
Instead, report it using one of the following methods:
|
||||
|
||||
- **GitHub Security Advisories (preferred)**
|
||||
Use the "Report a vulnerability" feature in this repository.
|
||||
|
||||
- **Contact Form**
|
||||
Submit details via the official contact page:
|
||||
https://mediacms.io/contact/
|
||||
|
||||
Please include as much of the following information as possible:
|
||||
- Affected version(s)
|
||||
- Detailed description of the issue
|
||||
- Steps to reproduce (PoC if available)
|
||||
- Impact assessment (e.g. RCE, XSS, privilege escalation)
|
||||
- Any potential mitigations you are aware of
|
||||
|
||||
---
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are provided for the **latest stable release** of MediaCMS.
|
||||
Older versions may not receive security patches.
|
||||
|
||||
---
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
- We aim to acknowledge reports within **7 days**
|
||||
- We aim to provide a fix or mitigation within **90 days**, depending on severity
|
||||
- Please allow us time to investigate before any public disclosure
|
||||
|
||||
We follow responsible disclosure practices and will coordinate disclosure timelines when appropriate.
|
||||
|
||||
---
|
||||
|
||||
## Recognition
|
||||
|
||||
At this time, MediaCMS does not operate a formal bug bounty program.
|
||||
However, we are happy to acknowledge valid security reports in release notes or advisories (with your permission).
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping keep MediaCMS secure.
|
||||
@@ -24,7 +24,6 @@ INSTALLED_APPS = [
|
||||
"actions.apps.ActionsConfig",
|
||||
"rbac.apps.RbacConfig",
|
||||
"identity_providers.apps.IdentityProvidersConfig",
|
||||
"lti.apps.LtiConfig",
|
||||
"debug_toolbar",
|
||||
"mptt",
|
||||
"crispy_forms",
|
||||
|
||||
@@ -300,7 +300,6 @@ INSTALLED_APPS = [
|
||||
"actions.apps.ActionsConfig",
|
||||
"rbac.apps.RbacConfig",
|
||||
"identity_providers.apps.IdentityProvidersConfig",
|
||||
"lti.apps.LtiConfig",
|
||||
"debug_toolbar",
|
||||
"mptt",
|
||||
"crispy_forms",
|
||||
@@ -556,7 +555,6 @@ DJANGO_ADMIN_URL = "admin/"
|
||||
USE_SAML = False
|
||||
USE_RBAC = False
|
||||
USE_IDENTITY_PROVIDERS = False
|
||||
USE_LTI = False # Enable LTI 1.3 integration
|
||||
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
|
||||
|
||||
USE_ROUNDED_CORNERS = True
|
||||
@@ -652,18 +650,3 @@ if USERS_NEEDS_TO_BE_APPROVED:
|
||||
)
|
||||
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
||||
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")
|
||||
|
||||
|
||||
# LTI 1.3 Integration Settings
|
||||
if USE_LTI:
|
||||
# Session timeout for LTI launches (seconds)
|
||||
LTI_SESSION_TIMEOUT = 3600 # 1 hour
|
||||
|
||||
# Cookie settings required for iframe embedding from LMS
|
||||
# IMPORTANT: Requires HTTPS to be enabled
|
||||
SESSION_COOKIE_SAMESITE = 'None'
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SAMESITE = 'None'
|
||||
CSRF_COOKIE_SECURE = True
|
||||
# SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
|
||||
# Consider using cached_db for reliability if sessions are lost between many LTI launches
|
||||
|
||||
@@ -25,7 +25,6 @@ urlpatterns = [
|
||||
re_path(r"^", include("files.urls")),
|
||||
re_path(r"^", include("users.urls")),
|
||||
re_path(r"^accounts/", include("allauth.urls")),
|
||||
re_path(r"^lti/", include("lti.urls")),
|
||||
re_path(r"^api-auth/", include("rest_framework.urls")),
|
||||
path(settings.DJANGO_ADMIN_URL, admin.site.urls),
|
||||
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||
|
||||
@@ -1 +1 @@
|
||||
VERSION = "7.ki"
|
||||
VERSION = "7.9"
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
# Use existing X-Forwarded-Proto from reverse proxy if present, otherwise use $scheme
|
||||
map $http_x_forwarded_proto $forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
'' $scheme;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 ;
|
||||
|
||||
@@ -34,10 +28,7 @@ server {
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
|
||||
proxy_pass http://127.0.0.1:9000;
|
||||
proxy_set_header Host $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 $forwarded_proto;
|
||||
include /etc/nginx/sites-enabled/uwsgi_params;
|
||||
uwsgi_pass 127.0.0.1:9000;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ fi
|
||||
|
||||
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-available/default
|
||||
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-enabled/default
|
||||
cp deploy/docker/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
|
||||
cp deploy/docker/nginx.conf /etc/nginx/
|
||||
|
||||
#### Supervisord Configurations #####
|
||||
@@ -44,12 +45,12 @@ cp deploy/docker/nginx.conf /etc/nginx/
|
||||
cp deploy/docker/supervisord/supervisord-debian.conf /etc/supervisor/conf.d/supervisord-debian.conf
|
||||
|
||||
if [ X"$ENABLE_UWSGI" = X"yes" ] ; then
|
||||
echo "Enabling gunicorn app server"
|
||||
cp deploy/docker/supervisord/supervisord-gunicorn.conf /etc/supervisor/conf.d/supervisord-gunicorn.conf
|
||||
echo "Enabling uwsgi app server"
|
||||
cp deploy/docker/supervisord/supervisord-uwsgi.conf /etc/supervisor/conf.d/supervisord-uwsgi.conf
|
||||
fi
|
||||
|
||||
if [ X"$ENABLE_NGINX" = X"yes" ] ; then
|
||||
echo "Enabling nginx as gunicorn app proxy and media server"
|
||||
echo "Enabling nginx as uwsgi app proxy and media server"
|
||||
cp deploy/docker/supervisord/supervisord-nginx.conf /etc/supervisor/conf.d/supervisord-nginx.conf
|
||||
fi
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ else
|
||||
echo "There is no script $PRE_START_PATH"
|
||||
fi
|
||||
|
||||
# Start Supervisor, with Nginx and Gunicorn
|
||||
# Start Supervisor, with Nginx and uWSGI
|
||||
echo "Starting server using supervisord..."
|
||||
|
||||
exec /usr/bin/supervisord
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
[program:gunicorn]
|
||||
command=/home/mediacms.io/bin/gunicorn cms.wsgi:application --workers=2 --threads=2 --worker-class=gthread --bind=127.0.0.1:9000 --user=www-data --group=www-data --timeout=120 --keep-alive=5 --max-requests=1000 --max-requests-jitter=50 --access-logfile=- --error-logfile=- --log-level=info --chdir=/home/mediacms.io/mediacms
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
priority=100
|
||||
startinorder=true
|
||||
startsecs=0
|
||||
9
deploy/docker/supervisord/supervisord-uwsgi.conf
Normal file
9
deploy/docker/supervisord/supervisord-uwsgi.conf
Normal file
@@ -0,0 +1,9 @@
|
||||
[program:uwsgi]
|
||||
command=/home/mediacms.io/bin/uwsgi --ini /home/mediacms.io/mediacms/deploy/docker/uwsgi.ini
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
priority=100
|
||||
startinorder=true
|
||||
startsecs=0
|
||||
24
deploy/docker/uwsgi.ini
Normal file
24
deploy/docker/uwsgi.ini
Normal file
@@ -0,0 +1,24 @@
|
||||
[uwsgi]
|
||||
|
||||
chdir = /home/mediacms.io/mediacms/
|
||||
virtualenv = /home/mediacms.io
|
||||
module = cms.wsgi
|
||||
|
||||
uid=www-data
|
||||
gid=www-data
|
||||
|
||||
processes = 2
|
||||
threads = 2
|
||||
|
||||
master = true
|
||||
|
||||
socket = 127.0.0.1:9000
|
||||
|
||||
workers = 2
|
||||
|
||||
vacuum = true
|
||||
|
||||
hook-master-start = unix_signal:15 gracefully_kill_them_all
|
||||
need-app = true
|
||||
die-on-term = true
|
||||
buffer-size=32768
|
||||
16
deploy/docker/uwsgi_params
Normal file
16
deploy/docker/uwsgi_params
Normal file
@@ -0,0 +1,16 @@
|
||||
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 REQUEST_SCHEME $scheme;
|
||||
uwsgi_param HTTPS $https if_not_empty;
|
||||
|
||||
uwsgi_param REMOTE_ADDR $remote_addr;
|
||||
uwsgi_param REMOTE_PORT $remote_port;
|
||||
uwsgi_param SERVER_PORT $server_port;
|
||||
uwsgi_param SERVER_NAME $server_name;
|
||||
22
deploy/local_install/celery_beat.service
Normal file
22
deploy/local_install/celery_beat.service
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=MediaCMS celery beat
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/home/mediacms.io/mediacms
|
||||
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
|
||||
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/beat%n.pid"
|
||||
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/beat%N.log"
|
||||
Environment=CELERYD_LOG_LEVEL="INFO"
|
||||
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms beat --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL}'
|
||||
ExecStop=/bin/kill -s TERM $MAINPID
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
29
deploy/local_install/celery_long.service
Normal file
29
deploy/local_install/celery_long.service
Normal file
@@ -0,0 +1,29 @@
|
||||
[Unit]
|
||||
Description=MediaCMS celery long queue
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/home/mediacms.io/mediacms
|
||||
Environment=CELERYD_NODES="long1"
|
||||
Environment=CELERY_QUEUE="long_tasks"
|
||||
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
|
||||
Environment=CELERYD_MULTI="multi"
|
||||
Environment=CELERYD_OPTS="-Ofair --prefetch-multiplier=1"
|
||||
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/%n.pid"
|
||||
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/%N.log"
|
||||
Environment=CELERYD_LOG_LEVEL="INFO"
|
||||
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms multi start ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
ExecStop=/bin/sh -c '${CELERY_BIN} -A cms multi stopwait ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE}'
|
||||
|
||||
ExecReload=/bin/sh -c '${CELERY_BIN} -A cms multi restart ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
39
deploy/local_install/celery_short.service
Normal file
39
deploy/local_install/celery_short.service
Normal file
@@ -0,0 +1,39 @@
|
||||
[Unit]
|
||||
Description=MediaCMS celery short queue
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/home/mediacms.io/mediacms
|
||||
Environment=CELERYD_NODES="short1 short2"
|
||||
Environment=CELERY_QUEUE="short_tasks"
|
||||
# Absolute or relative path to the 'celery' command:
|
||||
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
|
||||
# App instance to use
|
||||
# comment out this line if you don't use an app
|
||||
# or fully qualified:
|
||||
#CELERY_APP="proj.tasks:app"
|
||||
# How to call manage.py
|
||||
Environment=CELERYD_MULTI="multi"
|
||||
# Extra command-line arguments to the worker
|
||||
Environment=CELERYD_OPTS="--soft-time-limit=300 -c10"
|
||||
# - %n will be replaced with the first part of the nodename.
|
||||
# - %I will be replaced with the current child process index
|
||||
# and is important when using the prefork pool to avoid race conditions.
|
||||
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/%n.pid"
|
||||
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/%N.log"
|
||||
Environment=CELERYD_LOG_LEVEL="INFO"
|
||||
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms multi start ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
ExecStop=/bin/sh -c '${CELERY_BIN} -A cms multi stopwait ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE}'
|
||||
|
||||
ExecReload=/bin/sh -c '${CELERY_BIN} -A cms multi restart ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
13
deploy/local_install/dhparams.pem
Normal file
13
deploy/local_install/dhparams.pem
Normal file
@@ -0,0 +1,13 @@
|
||||
-----BEGIN DH PARAMETERS-----
|
||||
MIICCAKCAgEAo3MMiEY/fNbu+usIM0cDi6x8G3JBApv0Lswta4kiyedWT1WN51iQ
|
||||
9zhOFpmcu6517f/fR9MUdyhVKHxxSqWQTcmTEFtz4P3VLTS/W1N5VbKE2VEMLpIi
|
||||
wr350aGvV1Er0ujcp5n4O4h0I1tn4/fNyDe7+pHCdwM+hxe8hJ3T0/tKtad4fnIs
|
||||
WHDjl4f7m7KuFfheiK7Efb8MsT64HDDAYXn+INjtDZrbE5XPw20BqyWkrf07FcPx
|
||||
8o9GW50Ox7/FYq7jVMI/skEu0BRc8u6uUD9+UOuWUQpdeHeFcvLOgW53Z03XwWuX
|
||||
RXosUKzBPuGtUDAaKD/HsGW6xmGr2W9yRmu27jKpfYLUb/eWbbnRJwCw04LdzPqv
|
||||
jmtq02Gioo3lf5H5wYV9IYF6M8+q/slpbttsAcKERimD1273FBRt5VhSugkXWKjr
|
||||
XDhoXu6vZgj8Opei38qPa8pI1RUFoXHFlCe6WpZQmU8efL8gAMrJr9jUIY8eea1n
|
||||
u20t5B9ueb9JMjrNafcq6QkKhZLi6fRDDTUyeDvc0dN9R/3Yts97SXfdi1/lX7HS
|
||||
Ht4zXd5hEkvjo8GcnjsfZpAC39QfHWkDaeUGEqsl3jXjVMfkvoVY51OuokPWZzrJ
|
||||
M5+wyXNpfGbH67dPk7iHgN7VJvgX0SYscDPTtms50Vk7RwEzLeGuSHMCAQI=
|
||||
-----END DH PARAMETERS-----
|
||||
84
deploy/local_install/mediacms.io
Normal file
84
deploy/local_install/mediacms.io
Normal file
@@ -0,0 +1,84 @@
|
||||
server {
|
||||
listen 80 ;
|
||||
server_name localhost;
|
||||
|
||||
gzip on;
|
||||
access_log /var/log/nginx/mediacms.io.access.log;
|
||||
|
||||
error_log /var/log/nginx/mediacms.io.error.log warn;
|
||||
|
||||
# # redirect to https if logged in
|
||||
# if ($http_cookie ~* "sessionid") {
|
||||
# rewrite ^/(.*)$ https://localhost/$1 permanent;
|
||||
# }
|
||||
|
||||
# # redirect basic forms to https
|
||||
# location ~ (login|login_form|register|mail_password_form)$ {
|
||||
# rewrite ^/(.*)$ https://localhost/$1 permanent;
|
||||
# }
|
||||
|
||||
location /static {
|
||||
alias /home/mediacms.io/mediacms/static ;
|
||||
}
|
||||
|
||||
location /media/original {
|
||||
alias /home/mediacms.io/mediacms/media_files/original;
|
||||
}
|
||||
|
||||
location /media {
|
||||
alias /home/mediacms.io/mediacms/media_files ;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
|
||||
include /etc/nginx/sites-enabled/uwsgi_params;
|
||||
uwsgi_pass 127.0.0.1:9000;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name localhost;
|
||||
|
||||
ssl_certificate_key /etc/letsencrypt/live/localhost/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/localhost/fullchain.pem;
|
||||
ssl_dhparam /etc/nginx/dhparams/dhparams.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_ecdh_curve secp521r1:secp384r1;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
gzip on;
|
||||
access_log /var/log/nginx/mediacms.io.access.log;
|
||||
|
||||
error_log /var/log/nginx/mediacms.io.error.log warn;
|
||||
|
||||
location /static {
|
||||
alias /home/mediacms.io/mediacms/static ;
|
||||
}
|
||||
|
||||
location /media/original {
|
||||
alias /home/mediacms.io/mediacms/media_files/original;
|
||||
#auth_basic "auth protected area";
|
||||
#auth_basic_user_file /home/mediacms.io/mediacms/deploy/local_install/.htpasswd;
|
||||
}
|
||||
|
||||
location /media {
|
||||
alias /home/mediacms.io/mediacms/media_files ;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
|
||||
include /etc/nginx/sites-enabled/uwsgi_params;
|
||||
uwsgi_pass 127.0.0.1:9000;
|
||||
}
|
||||
}
|
||||
58
deploy/local_install/mediacms.io_fullchain.pem
Normal file
58
deploy/local_install/mediacms.io_fullchain.pem
Normal file
@@ -0,0 +1,58 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFTjCCBDagAwIBAgISBNOUeDlerH9MkKmHLvZJeMYgMA0GCSqGSIb3DQEBCwUA
|
||||
MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD
|
||||
ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0yMDAzMTAxNzUxNDFaFw0y
|
||||
MDA2MDgxNzUxNDFaMBYxFDASBgNVBAMTC21lZGlhY21zLmlvMIIBIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAps5Jn18nW2tq/LYFDgQ1YZGLlpF/B2AAPvvH
|
||||
3yuD+AcT4skKdZouVL/a5pXrptuYL5lthO9dlcja2tuO2ltYrb7Dp01dAIFaJE8O
|
||||
DKd+Sv5wr8VWQZykqzMiMBgviml7TBvUHQjvCJg8UwmnN0XSUILCttd6u4qOzS7d
|
||||
lKMMsKpYzLhElBT0rzhhsWulDiy6aAZbMV95bfR74nIWsBJacy6jx3jvxAuvCtkB
|
||||
OVdOoVL6BPjDE3SNEk53bAZGIb5A9ri0O5jh/zBFT6tQSjUhAUTkmv9oZP547RnV
|
||||
fDj+rdvCVk/fE+Jno36mcT183Qd/Ty3fWuqFoM5g/luhnfvWEwIDAQABo4ICYDCC
|
||||
AlwwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
|
||||
AjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTd5EZBt74zu5XxT1uXQs6oM8qOuDAf
|
||||
BgNVHSMEGDAWgBSoSmpjBH3duubRObemRWXv86jsoTBvBggrBgEFBQcBAQRjMGEw
|
||||
LgYIKwYBBQUHMAGGImh0dHA6Ly9vY3NwLmludC14My5sZXRzZW5jcnlwdC5vcmcw
|
||||
LwYIKwYBBQUHMAKGI2h0dHA6Ly9jZXJ0LmludC14My5sZXRzZW5jcnlwdC5vcmcv
|
||||
MBYGA1UdEQQPMA2CC21lZGlhY21zLmlvMEwGA1UdIARFMEMwCAYGZ4EMAQIBMDcG
|
||||
CysGAQQBgt8TAQEBMCgwJgYIKwYBBQUHAgEWGmh0dHA6Ly9jcHMubGV0c2VuY3J5
|
||||
cHQub3JnMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHYAXqdz+d9WwOe1Nkh90Eng
|
||||
MnqRmgyEoRIShBh1loFxRVgAAAFwxcnL+AAABAMARzBFAiAb3yeBuW3j9MxcRc0T
|
||||
icUBvEa/rH7Fv2eB0oQlnZ1exQIhAPf+CtTXmzxoeT/BBiivj4AmGDsq4xWhe/U6
|
||||
BytYrKLeAHYAB7dcG+V9aP/xsMYdIxXHuuZXfFeUt2ruvGE6GmnTohwAAAFwxcnM
|
||||
HAAABAMARzBFAiAuP5gKyyaT0LVXxwjYD9zhezvxf4Icx0P9pk75c5ao+AIhAK0+
|
||||
fSJv+WTXciMT6gA1sk/tuCHuDFAuexSA/6TcRXcVMA0GCSqGSIb3DQEBCwUAA4IB
|
||||
AQCPCYBU4Q/ro2MUkjDPKGmeqdxQycS4R9WvKTG/nmoahKNg30bnLaDPUcpyMU2k
|
||||
sPDemdZ7uTGLZ3ZrlIva8DbrnJmrTPf9BMwaM6j+ZV/QhxvKZVIWkLkZrwiVI57X
|
||||
Ba+rs5IEB4oWJ0EBaeIrzeKG5zLMkRcIdE4Hlhuwu3zGG56c+wmAPuvpIDlYoO6o
|
||||
W22xRdxoTIHBvkzwonpVYUaRcaIw+48xnllxh1dHO+X69DT45wlF4tKveOUi+L50
|
||||
4GWJ8Vjv7Fot/WNHEM4Mnmw0jHj9TPkIZKnPNRMdHmJ5CF/FJFDiptOeuzbfohG+
|
||||
mdvuInb8JDc0XBE99Gf/S4/y
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEkjCCA3qgAwIBAgIQCgFBQgAAAVOFc2oLheynCDANBgkqhkiG9w0BAQsFADA/
|
||||
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
|
||||
DkRTVCBSb290IENBIFgzMB4XDTE2MDMxNzE2NDA0NloXDTIxMDMxNzE2NDA0Nlow
|
||||
SjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUxldCdzIEVuY3J5cHQxIzAhBgNVBAMT
|
||||
GkxldCdzIEVuY3J5cHQgQXV0aG9yaXR5IFgzMIIBIjANBgkqhkiG9w0BAQEFAAOC
|
||||
AQ8AMIIBCgKCAQEAnNMM8FrlLke3cl03g7NoYzDq1zUmGSXhvb418XCSL7e4S0EF
|
||||
q6meNQhY7LEqxGiHC6PjdeTm86dicbp5gWAf15Gan/PQeGdxyGkOlZHP/uaZ6WA8
|
||||
SMx+yk13EiSdRxta67nsHjcAHJyse6cF6s5K671B5TaYucv9bTyWaN8jKkKQDIZ0
|
||||
Z8h/pZq4UmEUEz9l6YKHy9v6Dlb2honzhT+Xhq+w3Brvaw2VFn3EK6BlspkENnWA
|
||||
a6xK8xuQSXgvopZPKiAlKQTGdMDQMc2PMTiVFrqoM7hD8bEfwzB/onkxEz0tNvjj
|
||||
/PIzark5McWvxI0NHWQWM6r6hCm21AvA2H3DkwIDAQABo4IBfTCCAXkwEgYDVR0T
|
||||
AQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwfwYIKwYBBQUHAQEEczBxMDIG
|
||||
CCsGAQUFBzABhiZodHRwOi8vaXNyZy50cnVzdGlkLm9jc3AuaWRlbnRydXN0LmNv
|
||||
bTA7BggrBgEFBQcwAoYvaHR0cDovL2FwcHMuaWRlbnRydXN0LmNvbS9yb290cy9k
|
||||
c3Ryb290Y2F4My5wN2MwHwYDVR0jBBgwFoAUxKexpHsscfrb4UuQdf/EFWCFiRAw
|
||||
VAYDVR0gBE0wSzAIBgZngQwBAgEwPwYLKwYBBAGC3xMBAQEwMDAuBggrBgEFBQcC
|
||||
ARYiaHR0cDovL2Nwcy5yb290LXgxLmxldHNlbmNyeXB0Lm9yZzA8BgNVHR8ENTAz
|
||||
MDGgL6AthitodHRwOi8vY3JsLmlkZW50cnVzdC5jb20vRFNUUk9PVENBWDNDUkwu
|
||||
Y3JsMB0GA1UdDgQWBBSoSmpjBH3duubRObemRWXv86jsoTANBgkqhkiG9w0BAQsF
|
||||
AAOCAQEA3TPXEfNjWDjdGBX7CVW+dla5cEilaUcne8IkCJLxWh9KEik3JHRRHGJo
|
||||
uM2VcGfl96S8TihRzZvoroed6ti6WqEBmtzw3Wodatg+VyOeph4EYpr/1wXKtx8/
|
||||
wApIvJSwtmVi4MFU5aMqrSDE6ea73Mj2tcMyo5jMd6jmeWUHK8so/joWUoHOUgwu
|
||||
X4Po1QYz+3dszkDqMp4fklxBwXRsW10KXzPMTZ+sOPAveyxindmjkW8lGy+QsRlG
|
||||
PfZ+G6Z6h7mjem0Y+iWlkYcV4PIWL1iwBi8saCbGS5jN2p8M+X+Q7UNKEkROb3N6
|
||||
KOqkqm57TH2H3eDJAkSnh6/DNFu0Qg==
|
||||
-----END CERTIFICATE-----
|
||||
28
deploy/local_install/mediacms.io_privkey.pem
Normal file
28
deploy/local_install/mediacms.io_privkey.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmzkmfXydba2r8
|
||||
tgUOBDVhkYuWkX8HYAA++8ffK4P4BxPiyQp1mi5Uv9rmleum25gvmW2E712VyNra
|
||||
247aW1itvsOnTV0AgVokTw4Mp35K/nCvxVZBnKSrMyIwGC+KaXtMG9QdCO8ImDxT
|
||||
Cac3RdJQgsK213q7io7NLt2UowywqljMuESUFPSvOGGxa6UOLLpoBlsxX3lt9Hvi
|
||||
chawElpzLqPHeO/EC68K2QE5V06hUvoE+MMTdI0STndsBkYhvkD2uLQ7mOH/MEVP
|
||||
q1BKNSEBROSa/2hk/njtGdV8OP6t28JWT98T4mejfqZxPXzdB39PLd9a6oWgzmD+
|
||||
W6Gd+9YTAgMBAAECggEADnEJuryYQbf5GUwBAAepP3tEZJLQNqk/HDTcRxwTXuPt
|
||||
+tKBD1F79WZu40vTjSyx7l0QOFQo/BDZsd0Ubx89fD1p3xA5nxOT5FTb2IifzIpe
|
||||
4zjokOGo+BGDQjq10vvy6tH1+VWOrGXRwzawvX5UCRhpFz9sptQGLQmDsZy0Oo9B
|
||||
LtavYVUqsbyqRWlzaclHgbythegIACWkqcalOzOtx+l6TGBRjej+c7URcwYBfr7t
|
||||
XTAzbP+vnpaJovZyZT1eekr0OLzMpnjx4HvRvzL+NxauRpn6KfabsTfZlk8nrs4I
|
||||
UdSjeukj1Iz8rGQilHdN/4dVJ3KzrlHVkVTBSjmMUQKBgQDaVXZnhAScfdiKeZbO
|
||||
rdUAWcnwfkDghtRuAmzHaRM/FhFBEoVhdSbBuu+OUyBnIw/Ra4o2ePuEBcKIUiQO
|
||||
w2tnE1CY5PPAcjw+OCSpvzy5xxjaqaRbm9BJp3FTeEYGLXERnchPpHg/NpexuF22
|
||||
QOJ+FrysPyNMxuQp47ZwO9WT3QKBgQDDlSGjq/eeWxemwf7ZqMVlRyqsdJsgnCew
|
||||
DkC62IGiYCBDfeEmndN+vcA/uzJHYV4iXiqS3aYJCWGaZFMhdIhIn5MgULvO1j5G
|
||||
u/MxuzaaNPz22FlNCWTLBw4T1HOOvyTL+nLtZDKJ/BHxgHCmur1kiGvvZWrcCthD
|
||||
afLEmseqrwKBgBuLZKCymxJTHhp6NHhmndSpfzyD8RNibzJhw+90ZiUzV4HqIEGn
|
||||
Ufhm6Qn/mrroRXqaIpm0saZ6Q4yHMF1cchRS73wahlXlE4yV8KopojOd1pjfhgi4
|
||||
o5JnOXjaV5s36GfcjATgLvtqm8CkDc6MaQaXP75LSNzKysYuIDoQkmVRAoGAAghF
|
||||
rja2Pv4BU+lGJarcSj4gEmSvy/nza5/qSka/qhlHnIvtUAJp1TJRkhf24MkBOmgy
|
||||
Fw6YkBV53ynVt05HsEGAPOC54t9VDFUdpNGmMpoEWuhKnUNQuc9b9RbLEJup3TjA
|
||||
Avl8kPR+lzzXbtQX7biBLp6mKp0uPB0YubRGCN8CgYA0JMxK0x38Q2x3AQVhOmZh
|
||||
YubtIa0JqVJhvpweOCFnkq3ebBpLsWYwiLTn86vuD0jupe5M3sxtefjkJmAKd8xY
|
||||
aBU7QWhjh1fX4mzmggnbjcrIFbkIHsxwMeg567U/4AGxOOUsv9QUn37mqycqRKEn
|
||||
YfUyYNLM6F3MmQAOs2kaHw==
|
||||
-----END PRIVATE KEY-----
|
||||
13
deploy/local_install/mediacms.service
Normal file
13
deploy/local_install/mediacms.service
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=MediaCMS uwsgi
|
||||
|
||||
[Service]
|
||||
ExecStart=/home/mediacms.io/bin/uwsgi --ini /home/mediacms.io/mediacms/deploy/local_install/uwsgi.ini
|
||||
ExecStop=/usr/bin/killall -9 uwsgi
|
||||
RestartSec=3
|
||||
#ExecRestart=killall -9 uwsgi; sleep 5; /home/sss/bin/uwsgi --ini /home/sss/wordgames/uwsgi.ini
|
||||
Restart=always
|
||||
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
7
deploy/local_install/mediacms_logrorate
Normal file
7
deploy/local_install/mediacms_logrorate
Normal file
@@ -0,0 +1,7 @@
|
||||
/home/mediacms.io/mediacms/logs/*.log {
|
||||
weekly
|
||||
missingok
|
||||
rotate 7
|
||||
compress
|
||||
notifempty
|
||||
}
|
||||
38
deploy/local_install/nginx.conf
Normal file
38
deploy/local_install/nginx.conf
Normal file
@@ -0,0 +1,38 @@
|
||||
user www-data;
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 10240;
|
||||
}
|
||||
|
||||
worker_rlimit_nofile 20000; #each connection needs a filehandle (or 2 if you are proxying)
|
||||
http {
|
||||
proxy_connect_timeout 75;
|
||||
proxy_read_timeout 12000;
|
||||
client_max_body_size 5800M;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 10;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
|
||||
log_format compression '$remote_addr - $remote_user [$time_local] '
|
||||
'"$request" $status $body_bytes_sent '
|
||||
'"$http_referer" "$http_user_agent" "$gzip_ratio"';
|
||||
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
include /etc/nginx/sites-enabled/*;
|
||||
}
|
||||
|
||||
34
deploy/local_install/selinux-mediacms.te
Normal file
34
deploy/local_install/selinux-mediacms.te
Normal file
@@ -0,0 +1,34 @@
|
||||
module selinux-mediacms 1.0;
|
||||
|
||||
require {
|
||||
type init_t;
|
||||
type var_t;
|
||||
type redis_port_t;
|
||||
type postgresql_port_t;
|
||||
type httpd_t;
|
||||
type httpd_sys_content_t;
|
||||
type httpd_sys_rw_content_t;
|
||||
class file { append create execute execute_no_trans getattr ioctl lock open read rename setattr unlink write };
|
||||
class dir { add_name remove_name rmdir };
|
||||
class tcp_socket name_connect;
|
||||
class lnk_file read;
|
||||
}
|
||||
|
||||
#============= httpd_t ==============
|
||||
|
||||
allow httpd_t var_t:file { getattr open read };
|
||||
|
||||
#============= init_t ==============
|
||||
allow init_t postgresql_port_t:tcp_socket name_connect;
|
||||
|
||||
allow init_t redis_port_t:tcp_socket name_connect;
|
||||
|
||||
allow init_t httpd_sys_content_t:dir rmdir;
|
||||
|
||||
allow init_t httpd_sys_content_t:file { append create execute execute_no_trans ioctl lock open read rename setattr unlink write };
|
||||
|
||||
allow init_t httpd_sys_content_t:lnk_file read;
|
||||
|
||||
allow init_t httpd_sys_rw_content_t:dir { add_name remove_name rmdir };
|
||||
|
||||
allow init_t httpd_sys_rw_content_t:file { create ioctl lock open read setattr unlink write };
|
||||
27
deploy/local_install/uwsgi.ini
Normal file
27
deploy/local_install/uwsgi.ini
Normal file
@@ -0,0 +1,27 @@
|
||||
[uwsgi]
|
||||
|
||||
chdir = /home/mediacms.io/mediacms/
|
||||
virtualenv = /home/mediacms.io
|
||||
module = cms.wsgi
|
||||
|
||||
uid=www-data
|
||||
gid=www-data
|
||||
|
||||
processes = 2
|
||||
threads = 2
|
||||
|
||||
master = true
|
||||
|
||||
socket = 127.0.0.1:9000
|
||||
#socket = /home/mediacms.io/mediacms/deploy/uwsgi.sock
|
||||
|
||||
|
||||
workers = 2
|
||||
|
||||
|
||||
vacuum = true
|
||||
|
||||
logto = /home/mediacms.io/mediacms/logs/errorlog.txt
|
||||
|
||||
disable-logging = true
|
||||
buffer-size=32768
|
||||
16
deploy/local_install/uwsgi_params
Normal file
16
deploy/local_install/uwsgi_params
Normal file
@@ -0,0 +1,16 @@
|
||||
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 REQUEST_SCHEME $scheme;
|
||||
uwsgi_param HTTPS $https if_not_empty;
|
||||
|
||||
uwsgi_param REMOTE_ADDR $remote_addr;
|
||||
uwsgi_param REMOTE_PORT $remote_port;
|
||||
uwsgi_param SERVER_PORT $server_port;
|
||||
uwsgi_param SERVER_NAME $server_name;
|
||||
@@ -23,7 +23,7 @@ and will start all services required for MediaCMS, as Celery/Redis for asynchron
|
||||
For Django, the changes from the image produced by docker-compose.yaml are these:
|
||||
|
||||
* Django runs in debug mode, with `python manage.py runserver`
|
||||
* gunicorn and nginx are not run
|
||||
* uwsgi and nginx are not run
|
||||
* Django runs in Debug mode, with Debug Toolbar
|
||||
* Static files (js/css) are loaded from static/ folder
|
||||
* corsheaders is installed and configured to allow all origins
|
||||
|
||||
@@ -65,7 +65,6 @@ class CategoryAdminForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Category
|
||||
# LTI fields will be shown as read-only when USE_LTI is enabled
|
||||
fields = '__all__'
|
||||
|
||||
def clean(self):
|
||||
@@ -136,7 +135,7 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "user", "add_date", "media_count"]
|
||||
list_filter = []
|
||||
ordering = ("-add_date",)
|
||||
readonly_fields = ("user", "media_count", "lti_platform", "lti_context_id")
|
||||
readonly_fields = ("user", "media_count")
|
||||
change_form_template = 'admin/files/category/change_form.html'
|
||||
|
||||
def get_list_filter(self, request):
|
||||
@@ -146,8 +145,6 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
list_filter.insert(0, "is_rbac_category")
|
||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
list_filter.insert(-1, "identity_provider")
|
||||
if getattr(settings, 'USE_LTI', False):
|
||||
list_filter.append("is_lms_course")
|
||||
|
||||
return list_filter
|
||||
|
||||
@@ -157,8 +154,6 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display.insert(-1, "is_rbac_category")
|
||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
list_display.insert(-1, "identity_provider")
|
||||
if getattr(settings, 'USE_LTI', False):
|
||||
list_display.insert(-1, "is_lms_course")
|
||||
|
||||
return list_display
|
||||
|
||||
@@ -172,14 +167,6 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
),
|
||||
]
|
||||
|
||||
additional_fieldsets = []
|
||||
|
||||
if getattr(settings, 'USE_LTI', False):
|
||||
lti_fieldset = [
|
||||
('LTI Integration', {'fields': ['lti_platform', 'lti_context_id'], 'classes': ['tab'], 'description': 'LTI/LMS integration settings (automatically managed by LTI provisioning)'}),
|
||||
]
|
||||
additional_fieldsets.extend(lti_fieldset)
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_fieldset = [
|
||||
('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
|
||||
@@ -190,9 +177,9 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
('RBAC Settings', {'fields': ['is_rbac_category', 'identity_provider'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
|
||||
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
|
||||
]
|
||||
additional_fieldsets.extend(rbac_fieldset)
|
||||
|
||||
return basic_fieldset + additional_fieldsets
|
||||
return basic_fieldset + rbac_fieldset
|
||||
else:
|
||||
return basic_fieldset
|
||||
|
||||
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -64,10 +64,4 @@ def stuff(request):
|
||||
if request.user.is_superuser:
|
||||
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL
|
||||
|
||||
if getattr(settings, 'USE_LTI', False):
|
||||
lti_session = request.session.get('lti_session')
|
||||
|
||||
if lti_session and request.user.is_authenticated:
|
||||
ret['lti_session'] = lti_session
|
||||
|
||||
return ret
|
||||
|
||||
@@ -127,7 +127,6 @@ class MediaPublishForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
self.request = kwargs.pop('request', None)
|
||||
super(MediaPublishForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.has_custom_permissions = self.instance.permissions.exists() if self.instance.pk else False
|
||||
@@ -170,16 +169,6 @@ class MediaPublishForm(forms.ModelForm):
|
||||
|
||||
self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title')
|
||||
|
||||
# Filter for LMS courses only when in embed mode
|
||||
if self.request and 'category' in self.fields:
|
||||
is_embed_mode = self._check_embed_mode()
|
||||
if is_embed_mode:
|
||||
current_queryset = self.fields['category'].queryset
|
||||
self.fields['category'].queryset = current_queryset.filter(is_lms_course=True)
|
||||
self.fields['category'].label = 'Course'
|
||||
self.fields['category'].help_text = 'Media can be shared with one or more courses'
|
||||
self.fields['category'].widget.is_lms_mode = True
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = True
|
||||
self.helper.form_class = 'post-form'
|
||||
@@ -197,22 +186,6 @@ class MediaPublishForm(forms.ModelForm):
|
||||
|
||||
self.helper.layout.append(FormActions(Submit('submit', 'Publish Media', css_class='primaryAction')))
|
||||
|
||||
def _check_embed_mode(self):
|
||||
"""Check if the current request is in embed mode"""
|
||||
if not self.request:
|
||||
return False
|
||||
|
||||
# Check query parameter
|
||||
mode = self.request.GET.get('mode', '')
|
||||
if mode == 'lms_embed_mode':
|
||||
return True
|
||||
|
||||
# Check session storage
|
||||
if self.request.session.get('lms_embed_mode') == 'true':
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
state = cleaned_data.get("state")
|
||||
|
||||
@@ -965,13 +965,3 @@ def get_alphanumeric_only(string):
|
||||
"""
|
||||
string = "".join([char for char in string if char.isalnum()])
|
||||
return string.lower()
|
||||
|
||||
|
||||
def get_alphanumeric_and_spaces(string):
|
||||
"""Returns a query that contains only alphanumeric characters and spaces
|
||||
This include characters other than the English alphabet too
|
||||
"""
|
||||
string = "".join([char for char in string if char.isalnum() or char.isspace()])
|
||||
# Replace multiple spaces with single space and strip
|
||||
string = " ".join(string.split())
|
||||
return string
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-29 16:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0014_alter_subtitle_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='is_lms_course',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='Whether this category represents an LMS course'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='lti_context_id',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='LTI context ID from platform', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-29 16:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0015_category_is_lms_course_category_lti_context_id'),
|
||||
('lti', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='lti_platform',
|
||||
field=models.ForeignKey(
|
||||
blank=True, help_text='LTI Platform if this is an LTI course', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='categories', to='lti.ltiplatform'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -47,13 +47,6 @@ class Category(models.Model):
|
||||
verbose_name='IDP Config Name',
|
||||
)
|
||||
|
||||
# LTI/LMS integration fields
|
||||
is_lms_course = models.BooleanField(default=False, db_index=True, help_text='Whether this category represents an LMS course')
|
||||
|
||||
lti_platform = models.ForeignKey('lti.LTIPlatform', blank=True, null=True, on_delete=models.SET_NULL, related_name='categories', help_text='LTI Platform if this is an LTI course')
|
||||
|
||||
lti_context_id = models.CharField(max_length=255, blank=True, db_index=True, help_text='LTI context ID from platform')
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@@ -144,7 +137,7 @@ class Tag(models.Model):
|
||||
return True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.title = helpers.get_alphanumeric_and_spaces(self.title)
|
||||
self.title = helpers.get_alphanumeric_only(self.title)
|
||||
self.title = self.title[:100]
|
||||
super(Tag, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -352,11 +352,20 @@ class Media(models.Model):
|
||||
# first get anything interesting out of the media
|
||||
# that needs to be search able
|
||||
|
||||
a_tags = ""
|
||||
a_tags = b_tags = ""
|
||||
if self.id:
|
||||
a_tags = " ".join([tag.title for tag in self.tags.all()])
|
||||
b_tags = " ".join([tag.title.replace("-", " ") for tag in self.tags.all()])
|
||||
|
||||
items = [self.friendly_token, self.title, self.user.username, self.user.email, self.user.name, self.description, a_tags]
|
||||
items = [
|
||||
self.title,
|
||||
self.user.username,
|
||||
self.user.email,
|
||||
self.user.name,
|
||||
self.description,
|
||||
a_tags,
|
||||
b_tags,
|
||||
]
|
||||
|
||||
for subtitle in self.subtitles.all():
|
||||
items.append(subtitle.subtitle_text)
|
||||
@@ -736,7 +745,7 @@ class Media(models.Model):
|
||||
|
||||
ret = []
|
||||
for cat in self.category.all():
|
||||
ret.append({"title": cat.title, "url": cat.get_absolute_url(), "is_lms_course": cat.is_lms_course})
|
||||
ret.append({"title": cat.title, "url": cat.get_absolute_url()})
|
||||
return ret
|
||||
|
||||
@property
|
||||
|
||||
@@ -226,7 +226,6 @@ class CategorySerializer(serializers.ModelSerializer):
|
||||
"media_count",
|
||||
"user",
|
||||
"thumbnail_url",
|
||||
"is_lms_course",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ urlpatterns = [
|
||||
views.trim_video,
|
||||
),
|
||||
re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
|
||||
re_path(r"^api/v1/categories/contributor$", views.CategoryListContributor.as_view()),
|
||||
re_path(r"^api/v1/tags$", views.TagList.as_view()),
|
||||
re_path(r"^api/v1/comments$", views.CommentList.as_view()),
|
||||
re_path(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Import all views for backward compatibility
|
||||
|
||||
from .auth import custom_login_view, saml_metadata # noqa: F401
|
||||
from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401
|
||||
from .categories import CategoryList, TagList # noqa: F401
|
||||
from .comments import CommentDetail, CommentList # noqa: F401
|
||||
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
|
||||
from .media import MediaActions # noqa: F401
|
||||
|
||||
@@ -43,49 +43,6 @@ class CategoryList(APIView):
|
||||
return Response(ret)
|
||||
|
||||
|
||||
class CategoryListContributor(APIView):
|
||||
"""List categories where user has contributor access"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
name='lms_courses_only',
|
||||
type=openapi.TYPE_BOOLEAN,
|
||||
in_=openapi.IN_QUERY,
|
||||
description='Filter to show only LMS courses (categories with is_lms_course=True)',
|
||||
),
|
||||
],
|
||||
tags=['Categories'],
|
||||
operation_summary='Lists Categories for Contributors',
|
||||
operation_description='Lists all categories where the user has contributor access',
|
||||
responses={
|
||||
200: openapi.Response('response description', CategorySerializer),
|
||||
},
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
if not request.user.is_authenticated:
|
||||
return Response([])
|
||||
|
||||
categories = Category.objects.none()
|
||||
|
||||
# Filter for LMS courses only if requested
|
||||
lms_courses_only = request.GET.get('lms_courses_only', '').lower() in ['true', '1', 'yes']
|
||||
if lms_courses_only:
|
||||
categories = categories.filter(is_lms_course=True)
|
||||
else:
|
||||
categories = Category.objects.filter(is_rbac_category=False).prefetch_related("user")
|
||||
|
||||
# Get RBAC categories where user has contributor access
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_categories = request.user.get_rbac_categories_as_contributor()
|
||||
categories = categories.union(rbac_categories)
|
||||
|
||||
categories = categories.order_by("title")
|
||||
|
||||
serializer = CategorySerializer(categories, many=True, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class TagList(APIView):
|
||||
"""List tags"""
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ class MediaList(APIView):
|
||||
rbac_categories = request.user.get_rbac_categories_as_member()
|
||||
conditions |= Q(category__in=rbac_categories)
|
||||
|
||||
media = base_queryset.filter(conditions).exclude(user=request.user).distinct()
|
||||
media = base_queryset.filter(conditions).distinct()
|
||||
elif author_param:
|
||||
user_queryset = User.objects.all()
|
||||
user = get_object_or_404(user_queryset, username=author_param)
|
||||
@@ -574,31 +574,12 @@ class MediaBulkUserActions(APIView):
|
||||
|
||||
elif action == "add_to_category":
|
||||
category_uids = request.data.get('category_uids', [])
|
||||
lti_context_id = request.data.get('lti_context_id')
|
||||
|
||||
if not category_uids and not lti_context_id:
|
||||
return Response({"detail": "category_uids or lti_context_id is required for add_to_category action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
categories = Category.objects.none()
|
||||
|
||||
# Prioritize category_uids
|
||||
if category_uids:
|
||||
categories = Category.objects.filter(uid__in=category_uids)
|
||||
elif lti_context_id:
|
||||
# Filter categories by lti_context_id and ensure they ARE RBAC categories
|
||||
potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True)
|
||||
|
||||
# Check user access (must have contributor access)
|
||||
valid_category_ids = []
|
||||
for cat in potential_categories:
|
||||
if request.user.has_contributor_access_to_category(cat):
|
||||
valid_category_ids.append(cat.id)
|
||||
|
||||
if valid_category_ids:
|
||||
categories = Category.objects.filter(id__in=valid_category_ids)
|
||||
if not category_uids:
|
||||
return Response({"detail": "category_uids is required for add_to_category action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
categories = Category.objects.filter(uid__in=category_uids)
|
||||
if not categories:
|
||||
return Response({"detail": "No matching categories found or access denied"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
added_count = 0
|
||||
for category in categories:
|
||||
@@ -716,9 +697,12 @@ class MediaDetail(APIView):
|
||||
return media
|
||||
|
||||
serializer = SingleMediaSerializer(media, context={"request": request})
|
||||
related_media = show_related_media(media, request=request, limit=100)
|
||||
related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request})
|
||||
related_media = related_media_serializer.data
|
||||
if media.state == "private":
|
||||
related_media = []
|
||||
else:
|
||||
related_media = show_related_media(media, request=request, limit=100)
|
||||
related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request})
|
||||
related_media = related_media_serializer.data
|
||||
ret = serializer.data
|
||||
|
||||
# update rattings info with user specific ratings
|
||||
|
||||
@@ -24,7 +24,7 @@ from ..forms import (
|
||||
WhisperSubtitlesForm,
|
||||
)
|
||||
from ..frontend_translations import translate_string
|
||||
from ..helpers import get_alphanumeric_and_spaces
|
||||
from ..helpers import get_alphanumeric_only
|
||||
from ..methods import (
|
||||
can_transcribe_video,
|
||||
create_video_trim_request,
|
||||
@@ -310,8 +310,8 @@ def edit_media(request):
|
||||
media.tags.remove(tag)
|
||||
if form.cleaned_data.get("new_tags"):
|
||||
for tag in form.cleaned_data.get("new_tags").split(","):
|
||||
tag = get_alphanumeric_and_spaces(tag)
|
||||
tag = tag[:100]
|
||||
tag = get_alphanumeric_only(tag)
|
||||
tag = tag[:99]
|
||||
if tag:
|
||||
try:
|
||||
tag = Tag.objects.get(title=tag)
|
||||
@@ -350,13 +350,13 @@ def publish_media(request):
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
|
||||
if request.method == "POST":
|
||||
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media, request=request)
|
||||
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media)
|
||||
if form.is_valid():
|
||||
media = form.save()
|
||||
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
else:
|
||||
form = MediaPublishForm(request.user, instance=media, request=request)
|
||||
form = MediaPublishForm(request.user, instance=media)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
@@ -3,8 +3,6 @@ import json
|
||||
from django import forms
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from .models import Category
|
||||
|
||||
|
||||
class CategoryModalWidget(forms.SelectMultiple):
|
||||
"""Two-panel category selector with modal"""
|
||||
@@ -14,42 +12,28 @@ class CategoryModalWidget(forms.SelectMultiple):
|
||||
js = ('js/category_modal.js',)
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
is_lms_mode = getattr(self, 'is_lms_mode', False)
|
||||
|
||||
# Get all categories as JSON
|
||||
categories = []
|
||||
for opt_value, opt_label in self.choices:
|
||||
if opt_value: # Skip empty choice
|
||||
# Extract the actual ID value from ModelChoiceIteratorValue if needed
|
||||
category_id = opt_value.value if hasattr(opt_value, 'value') else opt_value
|
||||
|
||||
# Get is_lms_course info from the Category object
|
||||
try:
|
||||
cat_obj = Category.objects.get(id=category_id)
|
||||
categories.append({'id': str(category_id), 'title': str(opt_label), 'is_lms_course': cat_obj.is_lms_course})
|
||||
except Category.DoesNotExist:
|
||||
categories.append({'id': str(category_id), 'title': str(opt_label), 'is_lms_course': False})
|
||||
categories.append({'id': str(opt_value), 'title': str(opt_label)})
|
||||
|
||||
all_categories_json = json.dumps(categories)
|
||||
selected_ids_json = json.dumps([str(v) for v in (value or [])])
|
||||
lms_mode_json = json.dumps(is_lms_mode)
|
||||
|
||||
search_placeholder = "Search courses..." if is_lms_mode else "Search categories..."
|
||||
selected_header = "Selected Courses" if is_lms_mode else "Selected Categories"
|
||||
|
||||
html = f'''<div class="category-widget" data-name="{name}">
|
||||
<div class="category-content">
|
||||
<div class="category-panel">
|
||||
<input type="text" class="category-search" placeholder="{search_placeholder}">
|
||||
<input type="text" class="category-search" placeholder="Search categories...">
|
||||
<div class="category-list scrollable" data-panel="left"></div>
|
||||
</div>
|
||||
<div class="category-panel">
|
||||
<h3>{selected_header}</h3>
|
||||
<h3>Selected Categories</h3>
|
||||
<div class="category-list scrollable" data-panel="right"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden-inputs"></div>
|
||||
<script type="application/json" class="category-data">{{"all":{all_categories_json},"selected":{selected_ids_json},"lms_mode":{lms_mode_json}}}</script>
|
||||
<script type="application/json" class="category-data">{{"all":{all_categories_json},"selected":{selected_ids_json}}}</script>
|
||||
</div>'''
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
5
frontend/.vscode/settings.json
vendored
5
frontend/.vscode/settings.json
vendored
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
"editor.formatOnSave": true,
|
||||
"prettier.configPath": "../.prettierrc"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ module.exports = {
|
||||
'^.+\\.tsx?$': 'ts-jest',
|
||||
'^.+\\.jsx?$': 'babel-jest',
|
||||
},
|
||||
collectCoverageFrom: ['src/**'],
|
||||
collectCoverageFrom: ['src/**', '!src/static/lib/**'],
|
||||
};
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"@babel/core": "^7.26.9",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@types/flux": "^3.1.15",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './BulkActionCategoryModal.scss';
|
||||
import { translateString } from '../utils/helpers/';
|
||||
import { inEmbeddedApp } from '../utils/helpers/embeddedApp';
|
||||
|
||||
interface Category {
|
||||
title: string;
|
||||
@@ -25,7 +24,6 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
onError,
|
||||
csrfToken,
|
||||
}) => {
|
||||
const isLmsMode = inEmbeddedApp();
|
||||
const [existingCategories, setExistingCategories] = useState<Category[]>([]);
|
||||
const [allCategories, setAllCategories] = useState<Category[]>([]);
|
||||
const [categoriesToAdd, setCategoriesToAdd] = useState<Category[]>([]);
|
||||
@@ -68,27 +66,20 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
const existingData = await existingResponse.json();
|
||||
const existing = existingData.results || [];
|
||||
|
||||
// Fetch all categories (or LMS courses only in embed mode)
|
||||
const categoriesUrl = isLmsMode
|
||||
? '/api/v1/categories/contributor?lms_courses_only=true'
|
||||
: '/api/v1/categories';
|
||||
const allResponse = await fetch(categoriesUrl);
|
||||
// Fetch all categories
|
||||
const allResponse = await fetch('/api/v1/categories');
|
||||
if (!allResponse.ok) {
|
||||
throw new Error(isLmsMode ? translateString('Failed to fetch courses') : translateString('Failed to fetch all categories'));
|
||||
throw new Error(translateString('Failed to fetch all categories'));
|
||||
}
|
||||
|
||||
const allData = await allResponse.json();
|
||||
const all = allData.results || allData;
|
||||
|
||||
// In LMS mode, filter existing to only show LMS course categories
|
||||
const allUids = new Set(all.map((c: Category) => c.uid));
|
||||
const filteredExisting = isLmsMode ? existing.filter((c: Category) => allUids.has(c.uid)) : existing;
|
||||
|
||||
setExistingCategories(filteredExisting);
|
||||
setExistingCategories(existing);
|
||||
setAllCategories(all);
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
onError(isLmsMode ? translateString('Failed to load courses') : translateString('Failed to load categories'));
|
||||
onError(translateString('Failed to load categories'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -135,7 +126,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
});
|
||||
|
||||
if (!addResponse.ok) {
|
||||
throw new Error(isLmsMode ? translateString('Failed to add courses') : translateString('Failed to add categories'));
|
||||
throw new Error(translateString('Failed to add categories'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,15 +147,15 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
});
|
||||
|
||||
if (!removeResponse.ok) {
|
||||
throw new Error(isLmsMode ? translateString('Failed to remove courses') : translateString('Failed to remove categories'));
|
||||
throw new Error(translateString('Failed to remove categories'));
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess(isLmsMode ? translateString('Successfully updated courses') : translateString('Successfully updated categories'));
|
||||
onSuccess(translateString('Successfully updated categories'));
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
console.error('Error processing categories:', error);
|
||||
onError(isLmsMode ? translateString('Failed to update courses. Please try again.') : translateString('Failed to update categories. Please try again.'));
|
||||
onError(translateString('Failed to update categories. Please try again.'));
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -193,7 +184,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
<div className="category-modal-overlay">
|
||||
<div className="category-modal">
|
||||
<div className="category-modal-header">
|
||||
<h2>{isLmsMode ? translateString('Share with Course') : translateString('Add / Remove from Categories')}</h2>
|
||||
<h2>{translateString('Add / Remove from Categories')}</h2>
|
||||
<button className="category-modal-close" onClick={onCancel}>
|
||||
×
|
||||
</button>
|
||||
@@ -201,14 +192,14 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
|
||||
<div className="category-modal-content">
|
||||
<div className="category-panel">
|
||||
<h3>{isLmsMode ? translateString('Courses') : translateString('Categories')}</h3>
|
||||
<h3>{translateString('Categories')}</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="loading-message">{isLmsMode ? translateString('Loading courses...') : translateString('Loading categories...')}</div>
|
||||
<div className="loading-message">{translateString('Loading categories...')}</div>
|
||||
) : (
|
||||
<div className="category-list scrollable">
|
||||
{leftPanelCategories.length === 0 ? (
|
||||
<div className="empty-message">{isLmsMode ? translateString('All courses already added') : translateString('All categories already added')}</div>
|
||||
<div className="empty-message">{translateString('All categories already added')}</div>
|
||||
) : (
|
||||
leftPanelCategories.map((category) => (
|
||||
<div
|
||||
@@ -236,11 +227,11 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="loading-message">{isLmsMode ? translateString('Loading courses...') : translateString('Loading categories...')}</div>
|
||||
<div className="loading-message">{translateString('Loading categories...')}</div>
|
||||
) : (
|
||||
<div className="category-list scrollable">
|
||||
{rightPanelCategories.length === 0 ? (
|
||||
<div className="empty-message">{isLmsMode ? translateString('No courses') : translateString('No categories')}</div>
|
||||
<div className="empty-message">{translateString('No categories')}</div>
|
||||
) : (
|
||||
rightPanelCategories.map((category) => {
|
||||
const isExisting = existingCategories.some((c) => c.uid === category.uid);
|
||||
@@ -260,7 +251,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
|
||||
removeCategoryFromAddList(category);
|
||||
}
|
||||
}}
|
||||
title={isMarkedForRemoval ? translateString('Undo removal') : isExisting ? (isLmsMode ? translateString('Remove course') : translateString('Remove category')) : translateString('Remove from list')}
|
||||
title={isMarkedForRemoval ? translateString('Undo removal') : isExisting ? translateString('Remove category') : translateString('Remove from list')}
|
||||
>
|
||||
{isMarkedForRemoval ? '↺' : '×'}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import './BulkActionsDropdown.scss';
|
||||
import { translateString } from '../utils/helpers/';
|
||||
import { inEmbeddedApp } from '../utils/helpers/embeddedApp';
|
||||
|
||||
interface BulkActionsDropdownProps {
|
||||
selectedCount: number;
|
||||
@@ -13,7 +12,7 @@ const BULK_ACTIONS = [
|
||||
{ value: 'add-remove-coeditors', label: translateString('Add / Remove Co-Editors'), enabled: true },
|
||||
{ value: 'add-remove-coowners', label: translateString('Add / Remove Co-Owners'), enabled: true },
|
||||
{ value: 'add-remove-playlist', label: translateString('Add to / Remove from Playlist'), enabled: true },
|
||||
{ value: 'add-remove-category', label: inEmbeddedApp() ? translateString('Share with Course') : translateString('Add to / Remove from Category'), enabled: true },
|
||||
{ value: 'add-remove-category', label: translateString('Add to / Remove from Category'), enabled: true },
|
||||
{ value: 'add-remove-tags', label: translateString('Add / Remove Tags'), enabled: true },
|
||||
{ value: 'enable-comments', label: translateString('Enable Comments'), enabled: true },
|
||||
{ value: 'disable-comments', label: translateString('Disable Comments'), enabled: true },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { translateString, inSelectMediaEmbedMode } from '../utils/helpers/';
|
||||
import { translateString } from '../utils/helpers/';
|
||||
|
||||
interface MediaListHeaderProps {
|
||||
title?: string;
|
||||
@@ -11,12 +11,10 @@ interface MediaListHeaderProps {
|
||||
|
||||
export const MediaListHeader: React.FC<MediaListHeaderProps> = (props) => {
|
||||
const viewAllText = props.viewAllText || translateString('VIEW ALL');
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
return (
|
||||
<div className={(props.className ? props.className + ' ' : '') + 'media-list-header'} style={props.style}>
|
||||
<h2>{props.title}</h2>
|
||||
{!isSelectMediaMode && props.viewAllLink ? (
|
||||
{props.viewAllLink ? (
|
||||
<h3>
|
||||
{' '}
|
||||
<a href={props.viewAllLink} title={viewAllText}>
|
||||
|
||||
@@ -1,50 +1,18 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useMediaItem } from '../../utils/hooks/';
|
||||
import { PositiveInteger, PositiveIntegerOrZero, inSelectMediaEmbedMode } from '../../utils/helpers/';
|
||||
import { PositiveInteger, PositiveIntegerOrZero } from '../../utils/helpers/';
|
||||
import { MediaItemThumbnailLink, itemClassname } from './includes/items/';
|
||||
import { Item } from './Item';
|
||||
|
||||
export function MediaItem(props) {
|
||||
const type = props.type;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
const [titleComponentOrig, descriptionComponent, thumbnailUrl, UnderThumbWrapperOrig, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
useMediaItem({ ...props, type });
|
||||
|
||||
// In embed mode, override components to remove links
|
||||
const ItemTitle = ({ title }) => (
|
||||
<h3>
|
||||
<span>{title}</span>
|
||||
</h3>
|
||||
);
|
||||
|
||||
const ItemMain = ({ children }) => <div className="item-main">{children}</div>;
|
||||
|
||||
const titleComponent = isSelectMediaMode
|
||||
? () => <ItemTitle title={props.title} />
|
||||
: titleComponentOrig;
|
||||
|
||||
const UnderThumbWrapper = isSelectMediaMode ? ItemMain : UnderThumbWrapperOrig;
|
||||
|
||||
function thumbnailComponent() {
|
||||
if (isSelectMediaMode) {
|
||||
// In embed mode, render thumbnail without link
|
||||
const thumbStyle = thumbnailUrl ? { backgroundImage: "url('" + thumbnailUrl + "')" } : null;
|
||||
return (
|
||||
<div
|
||||
key="item-thumb"
|
||||
className={'item-thumb' + (!thumbnailUrl ? ' no-thumb' : '')}
|
||||
style={thumbStyle}
|
||||
>
|
||||
{thumbnailUrl ? (
|
||||
<div key="item-type-icon" className="item-type-icon">
|
||||
<div></div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <MediaItemThumbnailLink src={thumbnailUrl} title={props.title} link={props.link} />;
|
||||
}
|
||||
|
||||
@@ -57,13 +25,11 @@ export function MediaItem(props) {
|
||||
const finalClassname = containerClassname +
|
||||
(props.showSelection ? ' with-selection' : '') +
|
||||
(props.isSelected ? ' selected' : '') +
|
||||
(props.hasAnySelection || isSelectMediaMode ? ' has-any-selection' : '');
|
||||
(props.hasAnySelection ? ' has-any-selection' : '');
|
||||
|
||||
const handleItemClick = (e) => {
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
// In select media mode or if there's any selection active, clicking the item should toggle selection
|
||||
if ((isSelectMediaMode || props.hasAnySelection) && props.onCheckboxChange) {
|
||||
// If there's any selection active, clicking the item should toggle selection
|
||||
if (props.hasAnySelection && props.onCheckboxChange) {
|
||||
// Check if clicking on the checkbox itself, edit icon, or view icon
|
||||
if (e.target.closest('.item-selection-checkbox') ||
|
||||
e.target.closest('.item-edit-icon') ||
|
||||
@@ -93,24 +59,16 @@ export function MediaItem(props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSelectMediaMode && editMediaComponent()}
|
||||
{!isSelectMediaMode && viewMediaComponent()}
|
||||
{editMediaComponent()}
|
||||
{viewMediaComponent()}
|
||||
|
||||
{thumbnailComponent()}
|
||||
|
||||
{isSelectMediaMode ? (
|
||||
<UnderThumbWrapper>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
) : (
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
)}
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useMediaItem } from '../../utils/hooks/';
|
||||
import { PositiveIntegerOrZero, inSelectMediaEmbedMode } from '../../utils/helpers/';
|
||||
import { PositiveIntegerOrZero } from '../../utils/helpers/';
|
||||
import { MediaDurationInfo } from '../../utils/classes/';
|
||||
import { MediaPlaylistOptions } from '../media-playlist-options/MediaPlaylistOptions';
|
||||
import { MediaItemDuration, MediaItemPlaylistIndex, itemClassname } from './includes/items/';
|
||||
@@ -9,26 +9,10 @@ import { MediaItem } from './MediaItem';
|
||||
|
||||
export function MediaItemAudio(props) {
|
||||
const type = props.type;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
const [titleComponentOrig, descriptionComponent, thumbnailUrl, UnderThumbWrapperOrig, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
useMediaItem({ ...props, type });
|
||||
|
||||
// In embed mode, override components to remove links
|
||||
const ItemTitle = ({ title }) => (
|
||||
<h3>
|
||||
<span>{title}</span>
|
||||
</h3>
|
||||
);
|
||||
|
||||
const ItemMain = ({ children }) => <div className="item-main">{children}</div>;
|
||||
|
||||
const titleComponent = isSelectMediaMode
|
||||
? () => <ItemTitle title={props.title} />
|
||||
: titleComponentOrig;
|
||||
|
||||
const UnderThumbWrapper = isSelectMediaMode ? ItemMain : UnderThumbWrapperOrig;
|
||||
|
||||
const _MediaDurationInfo = new MediaDurationInfo();
|
||||
|
||||
_MediaDurationInfo.update(props.duration);
|
||||
@@ -38,21 +22,6 @@ export function MediaItemAudio(props) {
|
||||
const durationISO8601 = _MediaDurationInfo.ISO8601();
|
||||
|
||||
function thumbnailComponent() {
|
||||
if (isSelectMediaMode) {
|
||||
// In embed mode, render thumbnail without link
|
||||
return (
|
||||
<div
|
||||
key="item-thumb"
|
||||
className={'item-thumb' + (!thumbnailUrl ? ' no-thumb' : '')}
|
||||
style={!thumbnailUrl ? null : { backgroundImage: "url('" + thumbnailUrl + "')" }}
|
||||
>
|
||||
{props.inPlaylistView ? null : (
|
||||
<MediaItemDuration ariaLabel={duration} time={durationISO8601} text={durationStr} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const attr = {
|
||||
key: 'item-thumb',
|
||||
href: props.link,
|
||||
@@ -99,11 +68,11 @@ export function MediaItemAudio(props) {
|
||||
const finalClassname = containerClassname +
|
||||
(props.showSelection ? ' with-selection' : '') +
|
||||
(props.isSelected ? ' selected' : '') +
|
||||
(props.hasAnySelection || isSelectMediaMode ? ' has-any-selection' : '');
|
||||
(props.hasAnySelection ? ' has-any-selection' : '');
|
||||
|
||||
const handleItemClick = (e) => {
|
||||
// In embed mode or if there's any selection active, clicking the item should toggle selection
|
||||
if ((isSelectMediaMode || props.hasAnySelection) && props.onCheckboxChange) {
|
||||
// If there's any selection active, clicking the item should toggle selection
|
||||
if (props.hasAnySelection && props.onCheckboxChange) {
|
||||
// Check if clicking on the checkbox itself, edit icon, or view icon
|
||||
if (e.target.closest('.item-selection-checkbox') ||
|
||||
e.target.closest('.item-edit-icon') ||
|
||||
@@ -135,24 +104,16 @@ export function MediaItemAudio(props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSelectMediaMode && editMediaComponent()}
|
||||
{!isSelectMediaMode && viewMediaComponent()}
|
||||
{editMediaComponent()}
|
||||
{viewMediaComponent()}
|
||||
|
||||
{thumbnailComponent()}
|
||||
|
||||
{isSelectMediaMode ? (
|
||||
<UnderThumbWrapper>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
) : (
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
)}
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
|
||||
{playlistOptionsComponent()}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useMediaItem } from '../../utils/hooks/';
|
||||
import { PositiveIntegerOrZero, inSelectMediaEmbedMode } from '../../utils/helpers/';
|
||||
import { PositiveIntegerOrZero } from '../../utils/helpers/';
|
||||
import { MediaDurationInfo } from '../../utils/classes/';
|
||||
import { MediaPlaylistOptions } from '../media-playlist-options/MediaPlaylistOptions.jsx';
|
||||
import { MediaItemVideoPlayer, MediaItemDuration, MediaItemVideoPreviewer, MediaItemPlaylistIndex, itemClassname } from './includes/items/';
|
||||
@@ -9,26 +9,10 @@ import { MediaItem } from './MediaItem';
|
||||
|
||||
export function MediaItemVideo(props) {
|
||||
const type = props.type;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
const [titleComponentOrig, descriptionComponent, thumbnailUrl, UnderThumbWrapperOrig, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
|
||||
useMediaItem({ ...props, type });
|
||||
|
||||
// In embed mode, override components to remove links
|
||||
const ItemTitle = ({ title }) => (
|
||||
<h3>
|
||||
<span>{title}</span>
|
||||
</h3>
|
||||
);
|
||||
|
||||
const ItemMain = ({ children }) => <div className="item-main">{children}</div>;
|
||||
|
||||
const titleComponent = isSelectMediaMode
|
||||
? () => <ItemTitle title={props.title} />
|
||||
: titleComponentOrig;
|
||||
|
||||
const UnderThumbWrapper = isSelectMediaMode ? ItemMain : UnderThumbWrapperOrig;
|
||||
|
||||
const _MediaDurationInfo = new MediaDurationInfo();
|
||||
|
||||
_MediaDurationInfo.update(props.duration);
|
||||
@@ -42,24 +26,6 @@ export function MediaItemVideo(props) {
|
||||
}
|
||||
|
||||
function thumbnailComponent() {
|
||||
if (isSelectMediaMode) {
|
||||
// In select media mode, render thumbnail without link
|
||||
return (
|
||||
<div
|
||||
key="item-thumb"
|
||||
className={'item-thumb' + (!thumbnailUrl ? ' no-thumb' : '')}
|
||||
style={!thumbnailUrl ? null : { backgroundImage: "url('" + thumbnailUrl + "')" }}
|
||||
>
|
||||
{props.inPlaylistView ? null : (
|
||||
<MediaItemDuration ariaLabel={duration} time={durationISO8601} text={durationStr} />
|
||||
)}
|
||||
{props.inPlaylistView || props.inPlaylistPage ? null : (
|
||||
<MediaItemVideoPreviewer url={props.preview_thumbnail} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const attr = {
|
||||
key: 'item-thumb',
|
||||
href: props.link,
|
||||
@@ -109,11 +75,11 @@ export function MediaItemVideo(props) {
|
||||
const finalClassname = containerClassname +
|
||||
(props.showSelection ? ' with-selection' : '') +
|
||||
(props.isSelected ? ' selected' : '') +
|
||||
(props.hasAnySelection || isSelectMediaMode ? ' has-any-selection' : '');
|
||||
(props.hasAnySelection ? ' has-any-selection' : '');
|
||||
|
||||
const handleItemClick = (e) => {
|
||||
// In select media mode or if there's any selection active, clicking the item should toggle selection
|
||||
if ((isSelectMediaMode || props.hasAnySelection) && props.onCheckboxChange) {
|
||||
// If there's any selection active, clicking the item should toggle selection
|
||||
if (props.hasAnySelection && props.onCheckboxChange) {
|
||||
// Check if clicking on the checkbox itself, edit icon, or view icon
|
||||
if (e.target.closest('.item-selection-checkbox') ||
|
||||
e.target.closest('.item-edit-icon') ||
|
||||
@@ -145,27 +111,19 @@ export function MediaItemVideo(props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSelectMediaMode && editMediaComponent()}
|
||||
{!isSelectMediaMode && viewMediaComponent()}
|
||||
{editMediaComponent()}
|
||||
{viewMediaComponent()}
|
||||
|
||||
{props.hasMediaViewer ? videoViewerComponent() : thumbnailComponent()}
|
||||
|
||||
{isSelectMediaMode ? (
|
||||
<UnderThumbWrapper>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
) : (
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
)}
|
||||
|
||||
{playlistOptionsComponent()}
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
{descriptionComponent()}
|
||||
</UnderThumbWrapper>
|
||||
</div>
|
||||
|
||||
{playlistOptionsComponent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,10 +79,6 @@ function EditMediaButton(props) {
|
||||
link = '/edit-media.html';
|
||||
}
|
||||
|
||||
if (link && inEmbeddedApp()) {
|
||||
link += '&mode=lms_embed_mode';
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media-icon">
|
||||
<i className="material-icons">edit</i>
|
||||
@@ -98,18 +94,11 @@ export default function ViewerInfoContent(props) {
|
||||
!PageStore.get('config-enabled').taxonomies.tags || PageStore.get('config-enabled').taxonomies.tags.enabled
|
||||
? metafield(MediaPageStore.get('media-tags'))
|
||||
: [];
|
||||
let mediaCategories = MediaPageStore.get('media-categories');
|
||||
|
||||
// Filter to show only LMS courses when in embed mode
|
||||
if (inEmbeddedApp()) {
|
||||
mediaCategories = mediaCategories.filter(cat => cat.is_lms_course === true);
|
||||
}
|
||||
|
||||
const categoriesContent = PageStore.get('config-options').pages.media.categoriesWithTitle
|
||||
? []
|
||||
: !PageStore.get('config-enabled').taxonomies.categories ||
|
||||
PageStore.get('config-enabled').taxonomies.categories.enabled
|
||||
? metafield(mediaCategories)
|
||||
? metafield(MediaPageStore.get('media-categories'))
|
||||
: [];
|
||||
|
||||
let summary = MediaPageStore.get('media-summary');
|
||||
@@ -231,13 +220,9 @@ export default function ViewerInfoContent(props) {
|
||||
<MediaMetaField
|
||||
value={categoriesContent}
|
||||
title={
|
||||
inEmbeddedApp()
|
||||
? (1 < categoriesContent.length
|
||||
? translateString('Courses')
|
||||
: translateString('Course'))
|
||||
: (1 < categoriesContent.length
|
||||
? translateString('Categories')
|
||||
: translateString('Category'))
|
||||
1 < categoriesContent.length
|
||||
? translateString('Categories')
|
||||
: translateString('Category')
|
||||
}
|
||||
id="categories"
|
||||
/>
|
||||
@@ -289,7 +274,7 @@ export default function ViewerInfoContent(props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommentsList />
|
||||
{!inEmbeddedApp() && <CommentsList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { VideoViewerActions } from '../../../utils/actions/';
|
||||
import { SiteContext, SiteConsumer } from '../../../utils/contexts/';
|
||||
import { PageStore, MediaPageStore, VideoViewerStore } from '../../../utils/stores/';
|
||||
import { addClassname, removeClassname, formatInnerLink, inEmbeddedApp } from '../../../utils/helpers/';
|
||||
import { addClassname, removeClassname, formatInnerLink } from '../../../utils/helpers/';
|
||||
import { BrowserCache, UpNextLoaderView, MediaDurationInfo } from '../../../utils/classes/';
|
||||
import {
|
||||
orderedSupportedVideoFormats,
|
||||
@@ -176,13 +176,11 @@ export default class VideoViewer extends React.PureComponent {
|
||||
topLeftHtml = document.createElement('div');
|
||||
topLeftHtml.setAttribute('class', 'media-links-top-left');
|
||||
|
||||
const linkTarget = inEmbeddedApp() || window.location.href.indexOf('lms_embed_mode') > -1 ? '_self' : '_blank';
|
||||
|
||||
if (titleLink) {
|
||||
titleLink.setAttribute('class', 'title-link');
|
||||
titleLink.setAttribute('href', this.props.data.url);
|
||||
titleLink.setAttribute('title', this.props.data.title);
|
||||
titleLink.setAttribute('target', linkTarget);
|
||||
titleLink.setAttribute('target', '_blank');
|
||||
titleLink.innerHTML = this.props.data.title;
|
||||
}
|
||||
|
||||
@@ -193,7 +191,7 @@ export default class VideoViewer extends React.PureComponent {
|
||||
formatInnerLink(this.props.data.author_profile, this.props.siteUrl)
|
||||
);
|
||||
userThumbLink.setAttribute('title', this.props.data.author_name);
|
||||
userThumbLink.setAttribute('target', linkTarget);
|
||||
userThumbLink.setAttribute('target', '_blank');
|
||||
userThumbLink.setAttribute(
|
||||
'style',
|
||||
'background-image:url(' +
|
||||
|
||||
@@ -656,6 +656,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.fixed-nav {
|
||||
.profile-info-nav-wrap {
|
||||
padding-bottom: $_authorPage-navHeight;
|
||||
}
|
||||
|
||||
.profile-nav {
|
||||
z-index: 3;
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.visible-sidebar & {
|
||||
padding-left: var(--sidebar-width);
|
||||
}
|
||||
|
||||
.sliding-sidebar & {
|
||||
transition-property: padding-left;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-main {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LinksContext, MemberContext, SiteContext } from '../../utils/contexts/'
|
||||
import { PageStore, ProfilePageStore } from '../../utils/stores/';
|
||||
import { PageActions, ProfilePageActions } from '../../utils/actions/';
|
||||
import { CircleIconButton, PopupMain } from '../_shared';
|
||||
import { translateString, inEmbeddedApp, inSelectMediaEmbedMode, isSelectMediaMode } from '../../utils/helpers/';
|
||||
import { translateString } from '../../utils/helpers/';
|
||||
|
||||
class ProfileSearchBar extends React.PureComponent {
|
||||
constructor(props) {
|
||||
@@ -372,47 +372,30 @@ class NavMenuInlineTabs extends React.PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
// Append action=select_media to links when in select mode
|
||||
const mediaLink = isSelectMediaMode
|
||||
? `${LinksContext._currentValue.profile.media}${LinksContext._currentValue.profile.media.includes('?') ? '&' : '?'}action=select_media`
|
||||
: LinksContext._currentValue.profile.media;
|
||||
|
||||
const sharedByMeLink = isSelectMediaMode
|
||||
? `${LinksContext._currentValue.profile.shared_by_me}${LinksContext._currentValue.profile.shared_by_me.includes('?') ? '&' : '?'}action=select_media`
|
||||
: LinksContext._currentValue.profile.shared_by_me;
|
||||
|
||||
const sharedWithMeLink = isSelectMediaMode
|
||||
? `${LinksContext._currentValue.profile.shared_with_me}${LinksContext._currentValue.profile.shared_with_me.includes('?') ? '&' : '?'}action=select_media`
|
||||
: LinksContext._currentValue.profile.shared_with_me;
|
||||
|
||||
return (
|
||||
<nav ref="tabsNav" className="profile-nav items-list-outer list-inline list-slider">
|
||||
<div className="profile-nav-inner items-list-outer">
|
||||
{this.state.displayPrev ? this.previousBtn : null}
|
||||
|
||||
<ul className="items-list-wrap" ref="itemsListWrap">
|
||||
{!isSelectMediaMode && !inEmbeddedApp() ? (
|
||||
<InlineTab
|
||||
id="about"
|
||||
isActive={'about' === this.props.type}
|
||||
label={translateString('About')}
|
||||
link={LinksContext._currentValue.profile.about}
|
||||
/>
|
||||
) : null}
|
||||
<InlineTab
|
||||
id="about"
|
||||
isActive={'about' === this.props.type}
|
||||
label={translateString('About')}
|
||||
link={LinksContext._currentValue.profile.about}
|
||||
/>
|
||||
<InlineTab
|
||||
id="media"
|
||||
isActive={'media' === this.props.type}
|
||||
label={translateString(this.userIsAuthor ? 'Media I own' : 'Media')}
|
||||
link={mediaLink}
|
||||
link={LinksContext._currentValue.profile.media}
|
||||
/>
|
||||
{this.userIsAuthor ? (
|
||||
<InlineTab
|
||||
id="shared_by_me"
|
||||
isActive={'shared_by_me' === this.props.type}
|
||||
label={translateString('Shared by me')}
|
||||
link={sharedByMeLink}
|
||||
link={LinksContext._currentValue.profile.shared_by_me}
|
||||
/>
|
||||
) : null}
|
||||
{this.userIsAuthor ? (
|
||||
@@ -420,11 +403,11 @@ class NavMenuInlineTabs extends React.PureComponent {
|
||||
id="shared_with_me"
|
||||
isActive={'shared_with_me' === this.props.type}
|
||||
label={translateString('Shared with me')}
|
||||
link={sharedWithMeLink}
|
||||
link={LinksContext._currentValue.profile.shared_with_me}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isSelectMediaMode && MemberContext._currentValue.can.saveMedia ? (
|
||||
{MemberContext._currentValue.can.saveMedia ? (
|
||||
<InlineTab
|
||||
id="playlists"
|
||||
isActive={'playlists' === this.props.type}
|
||||
@@ -623,6 +606,12 @@ export default function ProfilePagesHeader(props) {
|
||||
const profilePageHeaderRef = useRef(null);
|
||||
const profileNavRef = useRef(null);
|
||||
|
||||
const [fixedNav, setFixedNav] = useState(false);
|
||||
|
||||
const positions = {
|
||||
profileNavTop: 0,
|
||||
};
|
||||
|
||||
const userIsAdmin = !MemberContext._currentValue.is.anonymous && MemberContext._currentValue.is.admin;
|
||||
const userIsAuthor =
|
||||
!MemberContext._currentValue.is.anonymous &&
|
||||
@@ -634,6 +623,18 @@ export default function ProfilePagesHeader(props) {
|
||||
userIsAuthor ||
|
||||
(!MemberContext._currentValue.is.anonymous && MemberContext._currentValue.can.deleteProfile);
|
||||
|
||||
function updateProfileNavTopPosition() {
|
||||
positions.profileHeaderTop = profilePageHeaderRef.current.offsetTop;
|
||||
positions.profileNavTop =
|
||||
positions.profileHeaderTop +
|
||||
profilePageHeaderRef.current.offsetHeight -
|
||||
profileNavRef.current.refs.tabsNav.offsetHeight;
|
||||
}
|
||||
|
||||
function updateFixedNavPosition() {
|
||||
setFixedNav(positions.profileHeaderTop + window.scrollY > positions.profileNavTop);
|
||||
}
|
||||
|
||||
function cancelProfileRemoval() {
|
||||
popupContentRef.current.toggle();
|
||||
}
|
||||
@@ -668,26 +669,42 @@ export default function ProfilePagesHeader(props) {
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
updateProfileNavTopPosition();
|
||||
updateFixedNavPosition();
|
||||
}
|
||||
|
||||
function onWindowScroll() {
|
||||
updateFixedNavPosition();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (userCanDeleteProfile) {
|
||||
ProfilePageStore.on('profile_delete', onProfileDelete);
|
||||
ProfilePageStore.on('profile_delete_fail', onProfileDeleteFail);
|
||||
}
|
||||
|
||||
PageStore.on('resize', onWindowResize);
|
||||
PageStore.on('changed_page_sidebar_visibility', onWindowResize);
|
||||
PageStore.on('window_scroll', onWindowScroll);
|
||||
|
||||
updateProfileNavTopPosition();
|
||||
updateFixedNavPosition();
|
||||
|
||||
return () => {
|
||||
if (userCanDeleteProfile) {
|
||||
ProfilePageStore.removeListener('profile_delete', onProfileDelete);
|
||||
ProfilePageStore.removeListener('profile_delete_fail', onProfileDeleteFail);
|
||||
}
|
||||
|
||||
PageStore.removeListener('resize', onWindowResize);
|
||||
PageStore.removeListener('changed_page_sidebar_visibility', onWindowResize);
|
||||
PageStore.removeListener('window_scroll', onWindowScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={profilePageHeaderRef}
|
||||
className={'profile-page-header'}
|
||||
{...(isSelectMediaMode() ? { 'data-action': 'select_media' } : {})}
|
||||
>
|
||||
<div ref={profilePageHeaderRef} className={'profile-page-header' + (fixedNav ? ' fixed-nav' : '')}>
|
||||
{!props.hideChannelBanner && (
|
||||
<span className="profile-banner-wrap">
|
||||
{props.author.banner_thumbnail_url ? (
|
||||
@@ -751,7 +768,7 @@ export default function ProfilePagesHeader(props) {
|
||||
)}
|
||||
|
||||
<div className="profile-info-nav-wrap">
|
||||
{!inEmbeddedApp() && (props.author.thumbnail_url || props.author.name) ? (
|
||||
{props.author.thumbnail_url || props.author.name ? (
|
||||
<div className="profile-info">
|
||||
<div className="profile-info-inner">
|
||||
<div>
|
||||
|
||||
@@ -3,14 +3,14 @@ import { ApiUrlConsumer } from '../utils/contexts/';
|
||||
import { MediaListWrapper } from '../components/MediaListWrapper';
|
||||
import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListAsync.jsx';
|
||||
import { Page } from './Page';
|
||||
import { translateString, inEmbeddedApp } from '../utils/helpers/';
|
||||
import { translateString } from '../utils/helpers/';
|
||||
|
||||
interface CategoriesPageProps {
|
||||
id?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const CategoriesPage: React.FC<CategoriesPageProps> = ({ id = 'categories', title = inEmbeddedApp() ? translateString('Courses') : translateString('Categories') }) => (
|
||||
export const CategoriesPage: React.FC<CategoriesPageProps> = ({ id = 'categories', title = translateString('Categories') }) => (
|
||||
<Page id={id}>
|
||||
<ApiUrlConsumer>
|
||||
{(apiUrl) => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { ApiUrlContext, LinksConsumer, MemberContext } from '../utils/contexts';
|
||||
import { PageStore, ProfilePageStore } from '../utils/stores';
|
||||
import { ProfilePageActions, PageActions } from '../utils/actions';
|
||||
import { inEmbeddedApp, inSelectMediaEmbedMode, translateString } from '../utils/helpers/';
|
||||
import { inEmbeddedApp, translateString } from '../utils/helpers/';
|
||||
import { MediaListWrapper } from '../components/MediaListWrapper';
|
||||
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
|
||||
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
|
||||
@@ -202,45 +202,13 @@ export class ProfileMediaPage extends Page {
|
||||
}
|
||||
|
||||
handleMediaSelection(mediaId, isSelected) {
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
this.setState((prevState) => {
|
||||
const newSelectedMedia = new Set();
|
||||
|
||||
// In select media mode, only allow single selection
|
||||
if (isSelectMediaMode) {
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
console.log('Selected media item:', mediaId);
|
||||
|
||||
// Send postMessage to parent window (Moodle TinyMCE plugin)
|
||||
if (window.parent !== window) {
|
||||
// Construct the embed URL
|
||||
const baseUrl = window.location.origin;
|
||||
const embedUrl = `${baseUrl}/embed?m=${mediaId}`;
|
||||
|
||||
// Send message in the format expected by the Moodle plugin
|
||||
window.parent.postMessage({
|
||||
type: 'videoSelected',
|
||||
embedUrl: embedUrl,
|
||||
videoId: mediaId
|
||||
}, '*');
|
||||
|
||||
console.log('Sent postMessage to parent:', { embedUrl, videoId: mediaId });
|
||||
}
|
||||
}
|
||||
const newSelectedMedia = new Set(prevState.selectedMedia);
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
} else {
|
||||
// Normal mode: allow multiple selection
|
||||
newSelectedMedia.clear();
|
||||
prevState.selectedMedia.forEach((id) => newSelectedMedia.add(id));
|
||||
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
} else {
|
||||
newSelectedMedia.delete(mediaId);
|
||||
}
|
||||
newSelectedMedia.delete(mediaId);
|
||||
}
|
||||
|
||||
return { selectedMedia: newSelectedMedia };
|
||||
});
|
||||
}
|
||||
@@ -949,7 +917,6 @@ export class ProfileMediaPage extends Page {
|
||||
const authorData = ProfilePageStore.get('author-data');
|
||||
|
||||
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
// Check if any filters are active (excluding default sort and tags)
|
||||
const hasActiveFilters =
|
||||
@@ -981,16 +948,15 @@ export class ProfileMediaPage extends Page {
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
<MediaListWrapper
|
||||
title={inEmbeddedApp() ? undefined : this.state.title}
|
||||
title={this.state.title}
|
||||
className="items-list-ver"
|
||||
style={inEmbeddedApp() ? { marginTop: '24px' } : undefined}
|
||||
showBulkActions={!isSelectMediaMode && isMediaAuthor}
|
||||
showBulkActions={isMediaAuthor}
|
||||
selectedCount={this.state.selectedMedia.size}
|
||||
totalCount={this.state.availableMediaIds.length}
|
||||
onBulkAction={this.handleBulkAction}
|
||||
onSelectAll={this.handleSelectAll}
|
||||
onDeselectAll={this.handleDeselectAll}
|
||||
showAddMediaButton={!isSelectMediaMode && isMediaAuthor}
|
||||
showAddMediaButton={isMediaAuthor}
|
||||
>
|
||||
<ProfileMediaFilters
|
||||
hidden={this.state.hiddenFilters}
|
||||
@@ -1013,7 +979,7 @@ export class ProfileMediaPage extends Page {
|
||||
hideViews={!PageStore.get('config-media-item').displayViews}
|
||||
hideDate={!PageStore.get('config-media-item').displayPublishDate}
|
||||
canEdit={isMediaAuthor}
|
||||
showSelection={isMediaAuthor || isSelectMediaMode}
|
||||
showSelection={isMediaAuthor}
|
||||
hasAnySelection={this.state.selectedMedia.size > 0}
|
||||
selectedMedia={this.state.selectedMedia}
|
||||
onMediaSelection={this.handleMediaSelection}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFi
|
||||
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
|
||||
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
|
||||
import { BulkActionsModals } from '../components/BulkActionsModals';
|
||||
import { inEmbeddedApp, inSelectMediaEmbedMode } from '../utils/helpers';
|
||||
import { inEmbeddedApp, translateString } from '../utils/helpers';
|
||||
import { withBulkActions } from '../utils/hoc/withBulkActions';
|
||||
|
||||
import { Page } from './_Page';
|
||||
@@ -51,7 +51,6 @@ class ProfileSharedByMePage extends Page {
|
||||
availableTags: [],
|
||||
selectedTag: 'all',
|
||||
selectedSort: 'date_added_desc',
|
||||
selectedMedia: new Set(), // For select media mode
|
||||
};
|
||||
|
||||
this.authorDataLoad = this.authorDataLoad.bind(this);
|
||||
@@ -65,7 +64,6 @@ class ProfileSharedByMePage extends Page {
|
||||
this.onTagSelect = this.onTagSelect.bind(this);
|
||||
this.onSortSelect = this.onSortSelect.bind(this);
|
||||
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
|
||||
this.handleMediaSelection = this.handleMediaSelection.bind(this);
|
||||
|
||||
ProfilePageStore.on('load-author-data', this.authorDataLoad);
|
||||
}
|
||||
@@ -345,55 +343,10 @@ class ProfileSharedByMePage extends Page {
|
||||
}
|
||||
}
|
||||
|
||||
handleMediaSelection(mediaId, isSelected) {
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
this.setState((prevState) => {
|
||||
const newSelectedMedia = new Set();
|
||||
|
||||
// In select media mode, only allow single selection
|
||||
if (isSelectMediaMode) {
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
console.log('Selected media item:', mediaId);
|
||||
|
||||
// Send postMessage to parent window (Moodle TinyMCE plugin)
|
||||
if (window.parent !== window) {
|
||||
// Construct the embed URL
|
||||
const baseUrl = window.location.origin;
|
||||
const embedUrl = `${baseUrl}/embed?m=${mediaId}`;
|
||||
|
||||
// Send message in the format expected by the Moodle plugin
|
||||
window.parent.postMessage({
|
||||
type: 'videoSelected',
|
||||
embedUrl: embedUrl,
|
||||
videoId: mediaId
|
||||
}, '*');
|
||||
|
||||
console.log('Sent postMessage to parent:', { embedUrl, videoId: mediaId });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal mode: should not reach here as bulk actions handle this
|
||||
newSelectedMedia.clear();
|
||||
prevState.selectedMedia.forEach((id) => newSelectedMedia.add(id));
|
||||
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
} else {
|
||||
newSelectedMedia.delete(mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
return { selectedMedia: newSelectedMedia };
|
||||
});
|
||||
}
|
||||
|
||||
pageContent() {
|
||||
const authorData = ProfilePageStore.get('author-data');
|
||||
|
||||
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters =
|
||||
@@ -422,12 +375,11 @@ class ProfileSharedByMePage extends Page {
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
<MediaListWrapper
|
||||
title={inEmbeddedApp() ? undefined : this.state.title}
|
||||
title={this.state.title}
|
||||
className="items-list-ver"
|
||||
style={inEmbeddedApp() ? { marginTop: '24px' } : undefined}
|
||||
showBulkActions={!isSelectMediaMode && isMediaAuthor}
|
||||
selectedCount={isSelectMediaMode ? this.state.selectedMedia.size : this.props.bulkActions.selectedMedia.size}
|
||||
totalCount={isSelectMediaMode ? 0 : this.props.bulkActions.availableMediaIds.length}
|
||||
showBulkActions={isMediaAuthor}
|
||||
selectedCount={this.props.bulkActions.selectedMedia.size}
|
||||
totalCount={this.props.bulkActions.availableMediaIds.length}
|
||||
onBulkAction={this.props.bulkActions.handleBulkAction}
|
||||
onSelectAll={this.props.bulkActions.handleSelectAll}
|
||||
onDeselectAll={this.props.bulkActions.handleDeselectAll}
|
||||
@@ -444,19 +396,19 @@ class ProfileSharedByMePage extends Page {
|
||||
/>
|
||||
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
|
||||
<LazyLoadItemListAsync
|
||||
key={isSelectMediaMode ? this.state.requestUrl : `${this.state.requestUrl}-${this.props.bulkActions.listKey}`}
|
||||
key={`${this.state.requestUrl}-${this.props.bulkActions.listKey}`}
|
||||
requestUrl={this.state.requestUrl}
|
||||
hideAuthor={true}
|
||||
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
|
||||
hideViews={!PageStore.get('config-media-item').displayViews}
|
||||
hideDate={!PageStore.get('config-media-item').displayPublishDate}
|
||||
canEdit={!isSelectMediaMode && isMediaAuthor}
|
||||
canEdit={isMediaAuthor}
|
||||
onResponseDataLoaded={this.onResponseDataLoaded}
|
||||
showSelection={isMediaAuthor || isSelectMediaMode}
|
||||
hasAnySelection={isSelectMediaMode ? this.state.selectedMedia.size > 0 : this.props.bulkActions.selectedMedia.size > 0}
|
||||
selectedMedia={isSelectMediaMode ? this.state.selectedMedia : this.props.bulkActions.selectedMedia}
|
||||
onMediaSelection={isSelectMediaMode ? this.handleMediaSelection : this.props.bulkActions.handleMediaSelection}
|
||||
onItemsUpdate={!isSelectMediaMode ? this.props.bulkActions.handleItemsUpdate : undefined}
|
||||
showSelection={isMediaAuthor}
|
||||
hasAnySelection={this.props.bulkActions.selectedMedia.size > 0}
|
||||
selectedMedia={this.props.bulkActions.selectedMedia}
|
||||
onMediaSelection={this.props.bulkActions.handleMediaSelection}
|
||||
onItemsUpdate={this.props.bulkActions.handleItemsUpdate}
|
||||
/>
|
||||
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
|
||||
<EmptySharedByMe name={this.state.author.name} />
|
||||
@@ -464,7 +416,7 @@ class ProfileSharedByMePage extends Page {
|
||||
</MediaListWrapper>
|
||||
</ProfilePagesContent>
|
||||
) : null,
|
||||
this.state.author && isMediaAuthor && !isSelectMediaMode ? (
|
||||
this.state.author && isMediaAuthor ? (
|
||||
<BulkActionsModals
|
||||
key="BulkActionsModals"
|
||||
{...this.props.bulkActions}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListA
|
||||
import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters';
|
||||
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
|
||||
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
|
||||
import { inEmbeddedApp, inSelectMediaEmbedMode } from '../utils/helpers';
|
||||
import { inEmbeddedApp, translateString } from '../utils/helpers';
|
||||
|
||||
import { Page } from './_Page';
|
||||
|
||||
@@ -49,7 +49,6 @@ export class ProfileSharedWithMePage extends Page {
|
||||
availableTags: [],
|
||||
selectedTag: 'all',
|
||||
selectedSort: 'date_added_desc',
|
||||
selectedMedia: new Set(), // For select media mode
|
||||
};
|
||||
|
||||
this.authorDataLoad = this.authorDataLoad.bind(this);
|
||||
@@ -63,7 +62,6 @@ export class ProfileSharedWithMePage extends Page {
|
||||
this.onTagSelect = this.onTagSelect.bind(this);
|
||||
this.onSortSelect = this.onSortSelect.bind(this);
|
||||
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
|
||||
this.handleMediaSelection = this.handleMediaSelection.bind(this);
|
||||
|
||||
ProfilePageStore.on('load-author-data', this.authorDataLoad);
|
||||
}
|
||||
@@ -343,55 +341,10 @@ export class ProfileSharedWithMePage extends Page {
|
||||
}
|
||||
}
|
||||
|
||||
handleMediaSelection(mediaId, isSelected) {
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
this.setState((prevState) => {
|
||||
const newSelectedMedia = new Set();
|
||||
|
||||
// In select media mode, only allow single selection
|
||||
if (isSelectMediaMode) {
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
console.log('Selected media item:', mediaId);
|
||||
|
||||
// Send postMessage to parent window (Moodle TinyMCE plugin)
|
||||
if (window.parent !== window) {
|
||||
// Construct the embed URL
|
||||
const baseUrl = window.location.origin;
|
||||
const embedUrl = `${baseUrl}/embed?m=${mediaId}`;
|
||||
|
||||
// Send message in the format expected by the Moodle plugin
|
||||
window.parent.postMessage({
|
||||
type: 'videoSelected',
|
||||
embedUrl: embedUrl,
|
||||
videoId: mediaId
|
||||
}, '*');
|
||||
|
||||
console.log('Sent postMessage to parent:', { embedUrl, videoId: mediaId });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal mode: no selection UI in this page normally
|
||||
newSelectedMedia.clear();
|
||||
prevState.selectedMedia.forEach((id) => newSelectedMedia.add(id));
|
||||
|
||||
if (isSelected) {
|
||||
newSelectedMedia.add(mediaId);
|
||||
} else {
|
||||
newSelectedMedia.delete(mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
return { selectedMedia: newSelectedMedia };
|
||||
});
|
||||
}
|
||||
|
||||
pageContent() {
|
||||
const authorData = ProfilePageStore.get('author-data');
|
||||
|
||||
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
|
||||
const isSelectMediaMode = inSelectMediaEmbedMode();
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters =
|
||||
@@ -419,11 +372,7 @@ export class ProfileSharedWithMePage extends Page {
|
||||
) : null,
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
<MediaListWrapper
|
||||
title={inEmbeddedApp() ? undefined : this.state.title}
|
||||
className="items-list-ver"
|
||||
style={inEmbeddedApp() ? { marginTop: '24px' } : undefined}
|
||||
>
|
||||
<MediaListWrapper title={this.state.title} className="items-list-ver">
|
||||
<ProfileMediaFilters
|
||||
hidden={this.state.hiddenFilters}
|
||||
tags={this.state.availableTags}
|
||||
@@ -444,10 +393,6 @@ export class ProfileSharedWithMePage extends Page {
|
||||
hideDate={!PageStore.get('config-media-item').displayPublishDate}
|
||||
canEdit={false}
|
||||
onResponseDataLoaded={this.onResponseDataLoaded}
|
||||
showSelection={isSelectMediaMode}
|
||||
hasAnySelection={this.state.selectedMedia.size > 0}
|
||||
selectedMedia={this.state.selectedMedia}
|
||||
onMediaSelection={this.handleMediaSelection}
|
||||
/>
|
||||
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
|
||||
<EmptySharedWithMe name={this.state.author.name} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListA
|
||||
import { SearchMediaFiltersRow } from '../components/search-filters/SearchMediaFiltersRow';
|
||||
import { SearchResultsFilters } from '../components/search-filters/SearchResultsFilters';
|
||||
import { Page } from './_Page';
|
||||
import { translateString, inEmbeddedApp } from '../utils/helpers/';
|
||||
import { translateString } from '../utils/helpers/';
|
||||
|
||||
export class SearchPage extends Page {
|
||||
constructor(props) {
|
||||
@@ -115,7 +115,7 @@ export class SearchPage extends Page {
|
||||
} else {
|
||||
if (this.state.searchCategories) {
|
||||
title = null === this.state.resultsCount || 0 === this.state.resultsCount ? 'No' : this.state.resultsCount;
|
||||
title += ' ' + translateString(inEmbeddedApp() ? 'media in course' : 'media in category') + ' "' + this.state.searchCategories + '"';
|
||||
title += ' ' + translateString('media in category') + ' "' + this.state.searchCategories + '"';
|
||||
} else if (this.state.searchTags) {
|
||||
title = null === this.state.resultsCount || 0 === this.state.resultsCount ? 'No' : this.state.resultsCount;
|
||||
title += ' ' + translateString('media in tag') + ' "' + this.state.searchTags + '"';
|
||||
|
||||
@@ -3,49 +3,18 @@ export function inEmbeddedApp() {
|
||||
const params = new URL(globalThis.location.href).searchParams;
|
||||
const mode = params.get('mode');
|
||||
|
||||
if (mode === 'lms_embed_mode') {
|
||||
sessionStorage.setItem('lms_embed_mode', 'true');
|
||||
if (mode === 'embed_mode') {
|
||||
sessionStorage.setItem('media_cms_embed_mode', 'true');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (mode === 'standard') {
|
||||
sessionStorage.removeItem('lms_embed_mode');
|
||||
sessionStorage.removeItem('media_cms_embed_mode');
|
||||
return false;
|
||||
}
|
||||
|
||||
return sessionStorage.getItem('lms_embed_mode') === 'true';
|
||||
return sessionStorage.getItem('media_cms_embed_mode') === 'true';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSelectMediaMode() {
|
||||
try {
|
||||
const params = new URL(globalThis.location.href).searchParams;
|
||||
const action = params.get('action');
|
||||
|
||||
return action === 'select_media';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function inSelectMediaEmbedMode() {
|
||||
return inEmbeddedApp() && isSelectMediaMode();
|
||||
}
|
||||
|
||||
export function getLtiContextId(): string | null {
|
||||
try {
|
||||
const params = new URL(globalThis.location.href).searchParams;
|
||||
const contextId = params.get('lti_context_id');
|
||||
|
||||
if (contextId) {
|
||||
sessionStorage.setItem('lti_context_id', contextId);
|
||||
return contextId;
|
||||
}
|
||||
|
||||
return sessionStorage.getItem('lti_context_id');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,4 @@ export * from './quickSort';
|
||||
export * from './requests';
|
||||
export { translateString } from './translate';
|
||||
export { replaceString } from './replacementStrings';
|
||||
export { inEmbeddedApp, inSelectMediaEmbedMode, isSelectMediaMode } from './embeddedApp';
|
||||
export * from './embeddedApp';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,88 +9,89 @@ let browserCache;
|
||||
const _StoreData = {};
|
||||
|
||||
class VideoPlayerStore extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.mediacms_config = mediacmsConfig(window.MediaCMS);
|
||||
this.mediacms_config = mediacmsConfig(window.MediaCMS);
|
||||
|
||||
browserCache = new BrowserCache(this.mediacms_config.site.id, 86400); // Keep cache data "fresh" for one day.
|
||||
browserCache = new BrowserCache(this.mediacms_config.site.id, 86400); // Keep cache data "fresh" for one day.
|
||||
|
||||
_StoreData.inTheaterMode = browserCache.get('in-theater-mode');
|
||||
_StoreData.inTheaterMode = null !== _StoreData.inTheaterMode ? _StoreData.inTheaterMode : !1;
|
||||
_StoreData.inTheaterMode = browserCache.get('in-theater-mode');
|
||||
_StoreData.inTheaterMode = null !== _StoreData.inTheaterMode ? _StoreData.inTheaterMode : !1;
|
||||
|
||||
_StoreData.playerVolume = browserCache.get('player-volume');
|
||||
_StoreData.playerVolume =
|
||||
null === _StoreData.playerVolume ? 1 : Math.max(Math.min(Number(_StoreData.playerVolume), 1), 0);
|
||||
_StoreData.playerVolume = browserCache.get('player-volume');
|
||||
_StoreData.playerVolume =
|
||||
null === _StoreData.playerVolume ? 1 : Math.max(Math.min(Number(_StoreData.playerVolume), 1), 0);
|
||||
|
||||
_StoreData.playerSoundMuted = browserCache.get('player-sound-muted');
|
||||
_StoreData.playerSoundMuted = null !== _StoreData.playerSoundMuted ? _StoreData.playerSoundMuted : !1;
|
||||
_StoreData.playerSoundMuted = browserCache.get('player-sound-muted');
|
||||
_StoreData.playerSoundMuted = null !== _StoreData.playerSoundMuted ? _StoreData.playerSoundMuted : !1;
|
||||
|
||||
_StoreData.videoQuality = browserCache.get('video-quality');
|
||||
_StoreData.videoQuality = null !== _StoreData.videoQuality ? _StoreData.videoQuality : 'Auto';
|
||||
_StoreData.videoQuality = browserCache.get('video-quality');
|
||||
_StoreData.videoQuality = null !== _StoreData.videoQuality ? _StoreData.videoQuality : 'Auto';
|
||||
|
||||
_StoreData.videoPlaybackSpeed = browserCache.get('video-playback-speed');
|
||||
_StoreData.videoPlaybackSpeed = null !== _StoreData.videoPlaybackSpeed ? _StoreData.videoPlaybackSpeed : !1;
|
||||
}
|
||||
|
||||
get(type) {
|
||||
let r = null;
|
||||
switch (type) {
|
||||
case 'player-volume':
|
||||
r = _StoreData.playerVolume;
|
||||
break;
|
||||
case 'player-sound-muted':
|
||||
r = _StoreData.playerSoundMuted;
|
||||
break;
|
||||
case 'in-theater-mode':
|
||||
r = _StoreData.inTheaterMode;
|
||||
break;
|
||||
case 'video-data':
|
||||
r = _StoreData.videoData;
|
||||
break;
|
||||
case 'video-quality':
|
||||
r = _StoreData.videoQuality;
|
||||
break;
|
||||
case 'video-playback-speed':
|
||||
r = _StoreData.videoPlaybackSpeed;
|
||||
break;
|
||||
_StoreData.videoPlaybackSpeed = browserCache.get('video-playback-speed');
|
||||
_StoreData.videoPlaybackSpeed = null !== _StoreData.videoPlaybackSpeed ? _StoreData.videoPlaybackSpeed : !1;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
actions_handler(action) {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE_VIEWER_MODE':
|
||||
_StoreData.inTheaterMode = !_StoreData.inTheaterMode;
|
||||
this.emit('changed_viewer_mode');
|
||||
break;
|
||||
case 'SET_VIEWER_MODE':
|
||||
_StoreData.inTheaterMode = action.inTheaterMode;
|
||||
browserCache.set('in-theater-mode', _StoreData.inTheaterMode);
|
||||
this.emit('changed_viewer_mode');
|
||||
break;
|
||||
case 'SET_PLAYER_VOLUME':
|
||||
_StoreData.playerVolume = action.playerVolume;
|
||||
browserCache.set('player-volume', action.playerVolume);
|
||||
this.emit('changed_player_volume');
|
||||
break;
|
||||
case 'SET_PLAYER_SOUND_MUTED':
|
||||
_StoreData.playerSoundMuted = action.playerSoundMuted;
|
||||
browserCache.set('player-sound-muted', action.playerSoundMuted);
|
||||
this.emit('changed_player_sound_muted');
|
||||
break;
|
||||
case 'SET_VIDEO_QUALITY':
|
||||
_StoreData.videoQuality = action.quality;
|
||||
browserCache.set('video-quality', action.quality);
|
||||
this.emit('changed_video_quality');
|
||||
break;
|
||||
case 'SET_VIDEO_PLAYBACK_SPEED':
|
||||
_StoreData.videoPlaybackSpeed = action.playbackSpeed;
|
||||
browserCache.set('video-playback-speed', action.playbackSpeed);
|
||||
this.emit('changed_video_playback_speed');
|
||||
break;
|
||||
get(type) {
|
||||
let r = null;
|
||||
switch (type) {
|
||||
case 'player-volume':
|
||||
r = _StoreData.playerVolume;
|
||||
break;
|
||||
case 'player-sound-muted':
|
||||
r = _StoreData.playerSoundMuted;
|
||||
break;
|
||||
case 'in-theater-mode':
|
||||
r = _StoreData.inTheaterMode;
|
||||
break;
|
||||
case 'video-data':
|
||||
r = _StoreData.videoData;
|
||||
break;
|
||||
case 'video-quality':
|
||||
r = _StoreData.videoQuality;
|
||||
break;
|
||||
case 'video-playback-speed':
|
||||
r = _StoreData.videoPlaybackSpeed;
|
||||
break;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
actions_handler(action) {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE_VIEWER_MODE':
|
||||
_StoreData.inTheaterMode = !_StoreData.inTheaterMode;
|
||||
browserCache.set('in-theater-mode', _StoreData.inTheaterMode);
|
||||
this.emit('changed_viewer_mode');
|
||||
break;
|
||||
case 'SET_VIEWER_MODE':
|
||||
_StoreData.inTheaterMode = action.inTheaterMode;
|
||||
browserCache.set('in-theater-mode', _StoreData.inTheaterMode);
|
||||
this.emit('changed_viewer_mode');
|
||||
break;
|
||||
case 'SET_PLAYER_VOLUME':
|
||||
_StoreData.playerVolume = action.playerVolume;
|
||||
browserCache.set('player-volume', action.playerVolume);
|
||||
this.emit('changed_player_volume');
|
||||
break;
|
||||
case 'SET_PLAYER_SOUND_MUTED':
|
||||
_StoreData.playerSoundMuted = action.playerSoundMuted;
|
||||
browserCache.set('player-sound-muted', action.playerSoundMuted);
|
||||
this.emit('changed_player_sound_muted');
|
||||
break;
|
||||
case 'SET_VIDEO_QUALITY':
|
||||
_StoreData.videoQuality = action.quality;
|
||||
browserCache.set('video-quality', action.quality);
|
||||
this.emit('changed_video_quality');
|
||||
break;
|
||||
case 'SET_VIDEO_PLAYBACK_SPEED':
|
||||
_StoreData.videoPlaybackSpeed = action.playbackSpeed;
|
||||
browserCache.set('video-playback-speed', action.playbackSpeed);
|
||||
this.emit('changed_video_playback_speed');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default exportStore(new VideoPlayerStore(), 'actions_handler');
|
||||
|
||||
385
frontend/tests/tests-constants.ts
Normal file
385
frontend/tests/tests-constants.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
export const sampleGlobalMediaCMS = {
|
||||
profileId: 'john',
|
||||
site: {
|
||||
id: 'my-site',
|
||||
url: 'https://example.com/',
|
||||
api: 'https://example.com/api/',
|
||||
title: 'Example',
|
||||
theme: { mode: 'dark', switch: { enabled: true, position: 'sidebar' } },
|
||||
logo: {
|
||||
lightMode: { img: '/img/light.png', svg: '/img/light.svg' },
|
||||
darkMode: { img: '/img/dark.png', svg: '/img/dark.svg' },
|
||||
},
|
||||
devEnv: false,
|
||||
useRoundedCorners: true,
|
||||
version: '1.0.0',
|
||||
taxonomies: {
|
||||
tags: { enabled: true, title: 'Topic Tags' },
|
||||
categories: { enabled: false, title: 'Kinds' },
|
||||
},
|
||||
pages: {
|
||||
featured: { enabled: true, title: 'Featured picks' },
|
||||
latest: { enabled: true, title: 'Recent uploads' },
|
||||
members: { enabled: true, title: 'People' },
|
||||
recommended: { enabled: false, title: 'You may like' },
|
||||
},
|
||||
userPages: {
|
||||
liked: { enabled: true, title: 'Favorites' },
|
||||
history: { enabled: true, title: 'Watched' },
|
||||
},
|
||||
},
|
||||
url: {
|
||||
home: '/',
|
||||
admin: '/admin',
|
||||
error404: '/404',
|
||||
latestMedia: '/latest',
|
||||
featuredMedia: '/featured',
|
||||
recommendedMedia: '/recommended',
|
||||
signin: '/signin',
|
||||
signout: '/signout',
|
||||
register: '/register',
|
||||
changePassword: '/password',
|
||||
members: '/members',
|
||||
search: '/search',
|
||||
likedMedia: '/liked',
|
||||
history: '/history',
|
||||
addMedia: '/add',
|
||||
editChannel: '/edit/channel',
|
||||
editProfile: '/edit/profile',
|
||||
tags: '/tags',
|
||||
categories: '/categories',
|
||||
manageMedia: '/manage/media',
|
||||
manageUsers: '/manage/users',
|
||||
manageComments: '/manage/comments',
|
||||
},
|
||||
api: {
|
||||
media: 'v1/media/',
|
||||
playlists: 'v1/playlists',
|
||||
members: 'v1/users',
|
||||
liked: 'v1/user/liked',
|
||||
history: 'v1/user/history',
|
||||
tags: 'v1/tags',
|
||||
categories: 'v1/categories',
|
||||
manage_media: 'v1/manage/media',
|
||||
manage_users: 'v1/manage/users',
|
||||
manage_comments: 'v1/manage/comments',
|
||||
search: 'v1/search',
|
||||
actions: 'v1/actions',
|
||||
comments: 'v1/comments',
|
||||
},
|
||||
contents: {
|
||||
header: {
|
||||
right: '',
|
||||
onLogoRight: '',
|
||||
},
|
||||
notifications: {
|
||||
messages: { addToLiked: 'Yay', removeFromLiked: 'Oops', addToDisliked: 'nay', removeFromDisliked: 'ok' },
|
||||
},
|
||||
sidebar: {
|
||||
belowNavMenu: '__belowNavMenu__',
|
||||
belowThemeSwitcher: '__belowThemeSwitcher__',
|
||||
footer: '__footer__',
|
||||
mainMenuExtraItems: [
|
||||
{ text: '__text_1__', link: '__link_1__', icon: '__icon_1__', className: '__className_1__' },
|
||||
],
|
||||
navMenuItems: [
|
||||
{ text: '__text_2__', link: '__link_2__', icon: '__icon_2__', className: '__className_2__' },
|
||||
],
|
||||
},
|
||||
uploader: {
|
||||
belowUploadArea: '__belowUploadArea__',
|
||||
postUploadMessage: '__postUploadMessage__',
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
home: {
|
||||
sections: {
|
||||
latest: { title: 'Latest T' },
|
||||
featured: { title: 'Featured T' },
|
||||
recommended: { title: 'Recommended T' },
|
||||
},
|
||||
},
|
||||
media: { categoriesWithTitle: true, htmlInDescription: true, hideViews: true, related: { initialSize: 5 } },
|
||||
profile: { htmlInDescription: true, includeHistory: true, includeLikedMedia: true },
|
||||
search: { advancedFilters: true },
|
||||
},
|
||||
features: {
|
||||
mediaItem: { hideAuthor: true, hideViews: false, hideDate: true },
|
||||
media: {
|
||||
actions: {
|
||||
like: true,
|
||||
dislike: true,
|
||||
report: true,
|
||||
comment: true,
|
||||
comment_mention: true,
|
||||
download: true,
|
||||
save: true,
|
||||
share: true,
|
||||
},
|
||||
shareOptions: ['embed', 'email'],
|
||||
},
|
||||
playlists: { mediaTypes: ['audio'] },
|
||||
sideBar: { hideHomeLink: false, hideTagsLink: true, hideCategoriesLink: false },
|
||||
embeddedVideo: { initialDimensions: { width: 640, height: 360 } },
|
||||
headerBar: { hideLogin: false, hideRegister: true },
|
||||
},
|
||||
user: {
|
||||
is: { anonymous: false, admin: true },
|
||||
name: ' John ',
|
||||
username: ' john ',
|
||||
thumbnail: ' /img/j.png ',
|
||||
can: {
|
||||
changePassword: true,
|
||||
deleteProfile: true,
|
||||
addComment: true,
|
||||
mentionComment: true,
|
||||
deleteComment: true,
|
||||
editMedia: true,
|
||||
deleteMedia: true,
|
||||
editSubtitle: true,
|
||||
manageMedia: true,
|
||||
manageUsers: true,
|
||||
manageComments: true,
|
||||
contactUser: true,
|
||||
canSeeMembersPage: true,
|
||||
usersNeedsToBeApproved: false,
|
||||
addMedia: true,
|
||||
editProfile: true,
|
||||
readComment: true,
|
||||
},
|
||||
pages: { about: '/u/john/about ', media: '/u/john ', playlists: '/u/john/playlists ' },
|
||||
},
|
||||
};
|
||||
|
||||
export const sampleMediaCMSConfig = {
|
||||
api: {
|
||||
archive: {
|
||||
tags: '',
|
||||
categories: '',
|
||||
},
|
||||
featured: '',
|
||||
manage: {
|
||||
media: '',
|
||||
users: '',
|
||||
comments: '',
|
||||
},
|
||||
media: '',
|
||||
playlists: '/v1/playlists',
|
||||
recommended: '',
|
||||
search: {
|
||||
query: '',
|
||||
titles: './search.html?titles=',
|
||||
tag: '',
|
||||
category: '',
|
||||
},
|
||||
user: {
|
||||
liked: '',
|
||||
history: '',
|
||||
playlists: '/playlists/?author=',
|
||||
},
|
||||
users: '/users',
|
||||
},
|
||||
contents: {
|
||||
header: {
|
||||
right: '',
|
||||
onLogoRight: '',
|
||||
},
|
||||
uploader: {
|
||||
belowUploadArea: '',
|
||||
postUploadMessage: '',
|
||||
},
|
||||
sidebar: {
|
||||
belowNavMenu: '__belowNavMenu__',
|
||||
belowThemeSwitcher: '__belowThemeSwitcher__',
|
||||
footer: '__footer__',
|
||||
mainMenuExtra: {
|
||||
items: [{ text: '__text_1__', link: '__link_1__', icon: '__icon_1__', className: '__className_1__' }],
|
||||
},
|
||||
navMenu: {
|
||||
items: [{ text: '__text_2__', link: '__link_2__', icon: '__icon_2__', className: '__className_2__' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
enabled: {
|
||||
taxonomies: sampleGlobalMediaCMS.site.taxonomies,
|
||||
pages: {
|
||||
featured: { enabled: true, title: 'Featured picks' },
|
||||
latest: { enabled: true, title: 'Recent uploads' },
|
||||
members: { enabled: true, title: 'People' },
|
||||
recommended: { enabled: true, title: 'You may like' },
|
||||
liked: { enabled: true, title: 'Favorites' },
|
||||
history: { enabled: true, title: 'Watched' },
|
||||
},
|
||||
},
|
||||
member: {
|
||||
name: null,
|
||||
username: 'john',
|
||||
thumbnail: null,
|
||||
is: {
|
||||
admin: false,
|
||||
anonymous: false,
|
||||
},
|
||||
can: {
|
||||
addComment: false,
|
||||
addMedia: false,
|
||||
canSeeMembersPage: false,
|
||||
changePassword: false,
|
||||
contactUser: false,
|
||||
deleteComment: false,
|
||||
deleteMedia: false,
|
||||
deleteProfile: false,
|
||||
dislikeMedia: false,
|
||||
downloadMedia: false,
|
||||
editMedia: false,
|
||||
editProfile: false,
|
||||
editSubtitle: false,
|
||||
likeMedia: false,
|
||||
login: false,
|
||||
manageComments: false,
|
||||
manageMedia: false,
|
||||
manageUsers: false,
|
||||
mentionComment: false,
|
||||
readComment: true,
|
||||
register: false,
|
||||
reportMedia: false,
|
||||
saveMedia: true,
|
||||
shareMedia: false,
|
||||
usersNeedsToBeApproved: false,
|
||||
},
|
||||
pages: {
|
||||
home: null,
|
||||
about: null,
|
||||
media: null,
|
||||
playlists: null,
|
||||
},
|
||||
},
|
||||
media: {
|
||||
item: {
|
||||
displayAuthor: false,
|
||||
displayViews: false,
|
||||
displayPublishDate: false,
|
||||
},
|
||||
share: {
|
||||
options: [],
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
messages: {
|
||||
addToLiked: '',
|
||||
removeFromLiked: '',
|
||||
addToDisliked: '',
|
||||
removeFromDisliked: '',
|
||||
},
|
||||
},
|
||||
options: {
|
||||
pages: {
|
||||
home: {
|
||||
sections: {
|
||||
latest: {
|
||||
title: '',
|
||||
},
|
||||
featured: {
|
||||
title: '',
|
||||
},
|
||||
recommended: {
|
||||
title: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
search: {
|
||||
advancedFilters: false,
|
||||
},
|
||||
media: {
|
||||
categoriesWithTitle: true,
|
||||
htmlInDescription: true,
|
||||
related: { initialSize: 5 },
|
||||
displayViews: true,
|
||||
},
|
||||
profile: {
|
||||
htmlInDescription: false,
|
||||
includeHistory: false,
|
||||
includeLikedMedia: false,
|
||||
},
|
||||
},
|
||||
embedded: {
|
||||
video: {
|
||||
dimensions: {
|
||||
width: 0,
|
||||
widthUnit: 'px',
|
||||
height: 0,
|
||||
heightUnit: 'px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
playlists: {
|
||||
mediaTypes: [],
|
||||
},
|
||||
sidebar: {
|
||||
hideHomeLink: false,
|
||||
hideTagsLink: false,
|
||||
hideCategoriesLink: false,
|
||||
},
|
||||
site: {
|
||||
api: '',
|
||||
id: '',
|
||||
title: '',
|
||||
url: '',
|
||||
useRoundedCorners: false,
|
||||
version: '',
|
||||
},
|
||||
theme: {
|
||||
logo: {
|
||||
lightMode: { img: '/img/light.png', svg: '/img/light.svg' },
|
||||
darkMode: { img: '/img/dark.png', svg: '/img/dark.svg' },
|
||||
},
|
||||
mode: 'dark',
|
||||
switch: {
|
||||
enabled: true,
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
url: {
|
||||
admin: '',
|
||||
archive: {
|
||||
categories: '',
|
||||
tags: '',
|
||||
},
|
||||
changePassword: '',
|
||||
embed: '',
|
||||
error404: '',
|
||||
featured: '',
|
||||
home: '',
|
||||
latest: '',
|
||||
manage: {
|
||||
comments: '',
|
||||
media: '',
|
||||
users: '',
|
||||
},
|
||||
members: '',
|
||||
profile: {
|
||||
about: '',
|
||||
media: '',
|
||||
playlists: '',
|
||||
shared_by_me: '',
|
||||
shared_with_me: '',
|
||||
},
|
||||
recommended: '',
|
||||
register: '',
|
||||
search: {
|
||||
base: '',
|
||||
category: '',
|
||||
query: '',
|
||||
tag: '',
|
||||
},
|
||||
signin: '',
|
||||
signout: '',
|
||||
user: {
|
||||
addMedia: '',
|
||||
editChannel: '',
|
||||
editProfile: '',
|
||||
history: '',
|
||||
liked: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
145
frontend/tests/utils/actions/MediaPageActions.test.ts
Normal file
145
frontend/tests/utils/actions/MediaPageActions.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import * as MediaPageActions from '../../../src/static/js/utils/actions/MediaPageActions';
|
||||
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||
|
||||
// Mock the dispatcher module used by MediaPageActions
|
||||
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||
|
||||
describe('utils/actions', () => {
|
||||
describe('MediaPageActions', () => {
|
||||
const dispatch = dispatcher.dispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('loadMediaData', () => {
|
||||
it('Should dispatch LOAD_MEDIA_DATA action', () => {
|
||||
MediaPageActions.loadMediaData();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'LOAD_MEDIA_DATA' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('likeMedia / dislikeMedia', () => {
|
||||
it('Should dispatch LIKE_MEDIA action', () => {
|
||||
MediaPageActions.likeMedia();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'LIKE_MEDIA' });
|
||||
});
|
||||
|
||||
it('Should dispatch DISLIKE_MEDIA action', () => {
|
||||
MediaPageActions.dislikeMedia();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'DISLIKE_MEDIA' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportMedia', () => {
|
||||
it('Should dispatch REPORT_MEDIA with empty string when description is undefined', () => {
|
||||
MediaPageActions.reportMedia();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'REPORT_MEDIA', reportDescription: '' });
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
it('Should dispatch REPORT_MEDIA with stripped description when provided', () => {
|
||||
MediaPageActions.reportMedia(' some text ');
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'REPORT_MEDIA', reportDescription: 'sometext' });
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
it('Should remove all whitespace characters including newlines and tabs', () => {
|
||||
MediaPageActions.reportMedia('\n\t spaced\ntext \t');
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'REPORT_MEDIA', reportDescription: 'spacedtext' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyShareLink / copyEmbedMediaCode', () => {
|
||||
it('Should dispatch COPY_SHARE_LINK carrying the provided input element', () => {
|
||||
const inputElem = document.createElement('input');
|
||||
MediaPageActions.copyShareLink(inputElem);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'COPY_SHARE_LINK', inputElement: inputElem });
|
||||
});
|
||||
|
||||
it('Should dispatch COPY_EMBED_MEDIA_CODE carrying the provided textarea element', () => {
|
||||
const textarea = document.createElement('textarea');
|
||||
MediaPageActions.copyEmbedMediaCode(textarea);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'COPY_EMBED_MEDIA_CODE', inputElement: textarea });
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMedia', () => {
|
||||
it('Should dispatch REMOVE_MEDIA action', () => {
|
||||
MediaPageActions.removeMedia();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_MEDIA' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('comments', () => {
|
||||
it('Should dispatch SUBMIT_COMMENT with provided text', () => {
|
||||
const commentText = 'Nice one';
|
||||
MediaPageActions.submitComment(commentText);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'SUBMIT_COMMENT', commentText });
|
||||
});
|
||||
|
||||
it('Should dispatch DELETE_COMMENT with provided comment id', () => {
|
||||
const commentId = 'c-123';
|
||||
MediaPageActions.deleteComment(commentId);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'DELETE_COMMENT', commentId });
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
it('Should dispatch DELETE_COMMENT with numeric comment id', () => {
|
||||
const commentId = 42;
|
||||
MediaPageActions.deleteComment(commentId);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'DELETE_COMMENT', commentId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('playlists', () => {
|
||||
it('Should dispatch CREATE_PLAYLIST with provided data', () => {
|
||||
const payload = { title: 'My list', description: 'Desc' };
|
||||
MediaPageActions.createPlaylist(payload);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'CREATE_PLAYLIST', playlist_data: payload });
|
||||
});
|
||||
|
||||
it('Should dispatch ADD_MEDIA_TO_PLAYLIST with ids', () => {
|
||||
const playlist_id = 'pl-1';
|
||||
const media_id = 'm-1';
|
||||
MediaPageActions.addMediaToPlaylist(playlist_id, media_id);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'ADD_MEDIA_TO_PLAYLIST', playlist_id, media_id });
|
||||
});
|
||||
|
||||
it('Should dispatch REMOVE_MEDIA_FROM_PLAYLIST with ids', () => {
|
||||
const playlist_id = 'pl-1';
|
||||
const media_id = 'm-1';
|
||||
MediaPageActions.removeMediaFromPlaylist(playlist_id, media_id);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_MEDIA_FROM_PLAYLIST', playlist_id, media_id });
|
||||
});
|
||||
|
||||
it('Should dispatch APPEND_NEW_PLAYLIST with provided playlist data', () => {
|
||||
const playlist_data = {
|
||||
playlist_id: 'pl-2',
|
||||
add_date: new Date('2020-01-01T00:00:00Z'),
|
||||
description: 'Cool',
|
||||
title: 'T',
|
||||
media_list: ['a', 'b'],
|
||||
};
|
||||
MediaPageActions.addNewPlaylist(playlist_data);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'APPEND_NEW_PLAYLIST', playlist_data });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
55
frontend/tests/utils/actions/PageActions.test.ts
Normal file
55
frontend/tests/utils/actions/PageActions.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as PageActions from '../../../src/static/js/utils/actions/PageActions';
|
||||
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||
|
||||
// Mock the dispatcher module used by PageActions
|
||||
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||
|
||||
describe('utils/actions', () => {
|
||||
describe('PageActions', () => {
|
||||
const dispatch = dispatcher.dispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('initPage', () => {
|
||||
it('Should dispatch INIT_PAGE with provided page string', () => {
|
||||
PageActions.initPage('home');
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'INIT_PAGE', page: 'home' });
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
it('Should dispatch INIT_PAGE with empty string', () => {
|
||||
PageActions.initPage('');
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'INIT_PAGE', page: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleMediaAutoPlay', () => {
|
||||
it('Should dispatch TOGGLE_AUTO_PLAY action', () => {
|
||||
PageActions.toggleMediaAutoPlay();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_AUTO_PLAY' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('addNotification', () => {
|
||||
it('Should dispatch ADD_NOTIFICATION with message and id', () => {
|
||||
const notification = 'Saved!';
|
||||
const notificationId = 'notif-1';
|
||||
PageActions.addNotification(notification, notificationId);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'ADD_NOTIFICATION', notification, notificationId });
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
it('Should dispatch ADD_NOTIFICATION with empty notification message', () => {
|
||||
const notification = '';
|
||||
const notificationId = 'id-empty';
|
||||
PageActions.addNotification(notification, notificationId);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'ADD_NOTIFICATION', notification, notificationId });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
96
frontend/tests/utils/actions/PlaylistPageActions.test.ts
Normal file
96
frontend/tests/utils/actions/PlaylistPageActions.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { PlaylistPageActions } from '../../../src/static/js/utils/actions';
|
||||
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||
|
||||
// Mock the dispatcher module used by PlaylistPageActions
|
||||
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||
|
||||
describe('utils/actions', () => {
|
||||
describe('PlaylistPageActions', () => {
|
||||
const dispatch = dispatcher.dispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('loadPlaylistData', () => {
|
||||
it('Should dispatch LOAD_PLAYLIST_DATA action', () => {
|
||||
PlaylistPageActions.loadPlaylistData();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'LOAD_PLAYLIST_DATA' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleSave', () => {
|
||||
it('Should dispatch TOGGLE_SAVE action', () => {
|
||||
PlaylistPageActions.toggleSave();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SAVE' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlaylist', () => {
|
||||
it('Should dispatch UPDATE_PLAYLIST with provided title and description', () => {
|
||||
const payload = { title: 'My Playlist', description: 'A description' };
|
||||
PlaylistPageActions.updatePlaylist(payload);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_PLAYLIST', playlist_data: payload });
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
it('Should dispatch UPDATE_PLAYLIST with empty strings for title and description', () => {
|
||||
const payload = { title: '', description: '' };
|
||||
PlaylistPageActions.updatePlaylist(payload);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_PLAYLIST', playlist_data: payload });
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePlaylist', () => {
|
||||
it('Should dispatch REMOVE_PLAYLIST action', () => {
|
||||
PlaylistPageActions.removePlaylist();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_PLAYLIST' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('removedMediaFromPlaylist', () => {
|
||||
it('Should dispatch MEDIA_REMOVED_FROM_PLAYLIST with media and playlist ids', () => {
|
||||
PlaylistPageActions.removedMediaFromPlaylist('m1', 'p1');
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'MEDIA_REMOVED_FROM_PLAYLIST',
|
||||
media_id: 'm1',
|
||||
playlist_id: 'p1',
|
||||
});
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
it('Should dispatch MEDIA_REMOVED_FROM_PLAYLIST with empty ids as strings', () => {
|
||||
PlaylistPageActions.removedMediaFromPlaylist('', '');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'MEDIA_REMOVED_FROM_PLAYLIST',
|
||||
media_id: '',
|
||||
playlist_id: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderedMediaInPlaylist', () => {
|
||||
it('Should dispatch PLAYLIST_MEDIA_REORDERED with provided array', () => {
|
||||
const items = [
|
||||
{ id: '1', url: '/1', thumbnail_url: '/t1' },
|
||||
{ id: '2', url: '/2', thumbnail_url: '/t2' },
|
||||
];
|
||||
PlaylistPageActions.reorderedMediaInPlaylist(items);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'PLAYLIST_MEDIA_REORDERED', playlist_media: items });
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
it('Should dispatch PLAYLIST_MEDIA_REORDERED with empty array for playlist media', () => {
|
||||
const items: any[] = [];
|
||||
PlaylistPageActions.reorderedMediaInPlaylist(items);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'PLAYLIST_MEDIA_REORDERED', playlist_media: items });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
39
frontend/tests/utils/actions/PlaylistViewActions.test.ts
Normal file
39
frontend/tests/utils/actions/PlaylistViewActions.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { PlaylistViewActions } from '../../../src/static/js/utils/actions';
|
||||
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||
|
||||
// Mock the dispatcher module used by PlaylistViewActions
|
||||
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||
|
||||
describe('utils/actions', () => {
|
||||
describe('PlaylistViewActions', () => {
|
||||
const dispatch = dispatcher.dispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('toggleLoop', () => {
|
||||
it('Should dispatch TOGGLE_LOOP action', () => {
|
||||
PlaylistViewActions.toggleLoop();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_LOOP' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleShuffle', () => {
|
||||
it('Should dispatch TOGGLE_SHUFFLE action', () => {
|
||||
PlaylistViewActions.toggleShuffle();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SHUFFLE' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleSave', () => {
|
||||
it('Should dispatch TOGGLE_SAVE action', () => {
|
||||
PlaylistViewActions.toggleSave();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SAVE' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
27
frontend/tests/utils/actions/ProfilePageActions.test.ts
Normal file
27
frontend/tests/utils/actions/ProfilePageActions.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ProfilePageActions } from '../../../src/static/js/utils/actions';
|
||||
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||
|
||||
// Mock the dispatcher module used by ProfilePageActions
|
||||
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||
|
||||
describe('utils/actions', () => {
|
||||
describe('ProfilePageActions', () => {
|
||||
const dispatch = dispatcher.dispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
it('Should dispatch LOAD_AUTHOR_DATA ', () => {
|
||||
ProfilePageActions.load_author_data();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'LOAD_AUTHOR_DATA' });
|
||||
});
|
||||
|
||||
it('Should dispatch REMOVE_PROFILE ', () => {
|
||||
ProfilePageActions.remove_profile();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_PROFILE' });
|
||||
});
|
||||
});
|
||||
});
|
||||
25
frontend/tests/utils/actions/SearchFieldActions.test.ts
Normal file
25
frontend/tests/utils/actions/SearchFieldActions.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SearchFieldActions } from '../../../src/static/js/utils/actions';
|
||||
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||
|
||||
// Mock the dispatcher module used by SearchFieldActions
|
||||
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||
|
||||
describe('utils/actions', () => {
|
||||
describe('SearchFieldActions', () => {
|
||||
const dispatch = dispatcher.dispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('requestPredictions', () => {
|
||||
it('Should dispatch REQUEST_PREDICTIONS with provided query strings', () => {
|
||||
SearchFieldActions.requestPredictions('cats');
|
||||
SearchFieldActions.requestPredictions('');
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'REQUEST_PREDICTIONS', query: 'cats' });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'REQUEST_PREDICTIONS', query: '' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
72
frontend/tests/utils/actions/VideoViewerActions.test.ts
Normal file
72
frontend/tests/utils/actions/VideoViewerActions.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { VideoViewerActions } from '../../../src/static/js/utils/actions';
|
||||
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||
|
||||
// Mock the dispatcher module used by VideoViewerActions
|
||||
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||
|
||||
describe('utils/actions', () => {
|
||||
describe('VideoViewerActions', () => {
|
||||
const dispatch = dispatcher.dispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('set_viewer_mode', () => {
|
||||
it('Should dispatch SET_VIEWER_MODE with "true" and "false" for enabling and disabling theater mode', () => {
|
||||
VideoViewerActions.set_viewer_mode(true);
|
||||
VideoViewerActions.set_viewer_mode(false);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_VIEWER_MODE', inTheaterMode: true });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_VIEWER_MODE', inTheaterMode: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('set_player_volume', () => {
|
||||
it('Should dispatch SET_PLAYER_VOLUME with provided volume numbers', () => {
|
||||
VideoViewerActions.set_player_volume(0);
|
||||
VideoViewerActions.set_player_volume(0.75);
|
||||
VideoViewerActions.set_player_volume(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_PLAYER_VOLUME', playerVolume: 0 });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_PLAYER_VOLUME', playerVolume: 0.75 });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(3, { type: 'SET_PLAYER_VOLUME', playerVolume: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('set_player_sound_muted', () => {
|
||||
it('Should dispatch SET_PLAYER_SOUND_MUTED with "true" and "false"', () => {
|
||||
VideoViewerActions.set_player_sound_muted(true);
|
||||
VideoViewerActions.set_player_sound_muted(false);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_PLAYER_SOUND_MUTED', playerSoundMuted: true });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, {
|
||||
type: 'SET_PLAYER_SOUND_MUTED',
|
||||
playerSoundMuted: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('set_video_quality', () => {
|
||||
it('Should dispatch SET_VIDEO_QUALITY with "auto" and numeric quality', () => {
|
||||
VideoViewerActions.set_video_quality('auto');
|
||||
VideoViewerActions.set_video_quality(720);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_VIDEO_QUALITY', quality: 'auto' });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_VIDEO_QUALITY', quality: 720 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('set_video_playback_speed', () => {
|
||||
it('Should dispatch SET_VIDEO_PLAYBACK_SPEED with different speeds', () => {
|
||||
VideoViewerActions.set_video_playback_speed(1.5);
|
||||
VideoViewerActions.set_video_playback_speed(0.5);
|
||||
VideoViewerActions.set_video_playback_speed(2);
|
||||
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_VIDEO_PLAYBACK_SPEED', playbackSpeed: 1.5 });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_VIDEO_PLAYBACK_SPEED', playbackSpeed: 0.5 });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(3, { type: 'SET_VIDEO_PLAYBACK_SPEED', playbackSpeed: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
92
frontend/tests/utils/classes/BrowserCache.test.ts
Normal file
92
frontend/tests/utils/classes/BrowserCache.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { BrowserCache } from '../../../src/static/js/utils/classes/BrowserCache';
|
||||
|
||||
// Mocks for helpers used by BrowserCache
|
||||
jest.mock('../../../src/static/js/utils/helpers/', () => ({
|
||||
logErrorAndReturnError: jest.fn((args: any[]) => ({ error: true, args })),
|
||||
logWarningAndReturnError: jest.fn((args: any[]) => ({ warning: true, args })),
|
||||
}));
|
||||
|
||||
const { logErrorAndReturnError } = jest.requireMock('../../../src/static/js/utils/helpers/');
|
||||
|
||||
describe('utils/classes', () => {
|
||||
describe('BrowserCache', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Returns error when prefix is missing', () => {
|
||||
const cache = BrowserCache(undefined, 3600);
|
||||
expect(cache).toEqual(expect.objectContaining({ error: true }));
|
||||
expect(logErrorAndReturnError).toHaveBeenCalledWith(['Cache object prefix is required']);
|
||||
});
|
||||
|
||||
test('Set and get returns stored primitive value before expiration', () => {
|
||||
const cache = BrowserCache('prefix', 3600);
|
||||
|
||||
if (cache instanceof Error) {
|
||||
expect(cache instanceof Error).toBe(false);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(cache.set('foo', 'bar')).toBe(true);
|
||||
expect(cache.get('foo')).toBe('bar');
|
||||
|
||||
// Ensure value serialized in localStorage with namespaced key
|
||||
const raw = localStorage.getItem('prefix[foo]') as string;
|
||||
const parsed = JSON.parse(raw);
|
||||
expect(parsed.value).toBe('bar');
|
||||
expect(typeof parsed.expire).toBe('number');
|
||||
expect(parsed.expire).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
test('Get returns null when expired', () => {
|
||||
const cache = BrowserCache('prefix', 1);
|
||||
|
||||
if (cache instanceof Error) {
|
||||
expect(cache instanceof Error).toBe(false);
|
||||
return;
|
||||
}
|
||||
|
||||
cache.set('exp', { a: 1 });
|
||||
|
||||
jest.useFakeTimers();
|
||||
jest.advanceTimersByTime(1_000);
|
||||
|
||||
expect(cache.get('exp')).toBeNull();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('Clear removes only keys for its prefix', () => {
|
||||
const cacheA = BrowserCache('A', 3600);
|
||||
const cacheB = BrowserCache('B', 3600);
|
||||
|
||||
if (cacheA instanceof Error) {
|
||||
expect(cacheA instanceof Error).toBe(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cacheB instanceof Error) {
|
||||
expect(cacheB instanceof Error).toBe(false);
|
||||
return;
|
||||
}
|
||||
|
||||
cacheA.set('x', 1);
|
||||
cacheB.set('x', 2);
|
||||
|
||||
expect(localStorage.getItem('A[x]')).toBeTruthy();
|
||||
expect(localStorage.getItem('B[x]')).toBeTruthy();
|
||||
|
||||
cacheA.clear();
|
||||
|
||||
expect(localStorage.getItem('A[x]')).toBeNull();
|
||||
expect(localStorage.getItem('B[x]')).toBeTruthy();
|
||||
|
||||
cacheB.clear();
|
||||
|
||||
expect(localStorage.getItem('A[x]')).toBeNull();
|
||||
expect(localStorage.getItem('B[x]')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
101
frontend/tests/utils/classes/MediaDurationInfo.test.ts
Normal file
101
frontend/tests/utils/classes/MediaDurationInfo.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { MediaDurationInfo } from '../../../src/static/js/utils/classes/MediaDurationInfo';
|
||||
|
||||
describe('utils/classes', () => {
|
||||
describe('MediaDurationInfo', () => {
|
||||
test('Initializes via constructor when seconds is a positive integer (<= 59)', () => {
|
||||
const mdi = new MediaDurationInfo(42);
|
||||
expect(mdi.toString()).toBe('0:42');
|
||||
expect(mdi.ariaLabel()).toBe('42 seconds');
|
||||
expect(mdi.ISO8601()).toBe('P0Y0M0DT0H0M42S');
|
||||
});
|
||||
|
||||
test('Formats minutes and zero-pads seconds; no hours prefix under 60 minutes', () => {
|
||||
const mdi = new MediaDurationInfo();
|
||||
mdi.update(5 * 60 + 7);
|
||||
expect(mdi.toString()).toBe('5:07');
|
||||
expect(mdi.ariaLabel()).toBe('5 minutes, 7 seconds');
|
||||
expect(mdi.ISO8601()).toBe('P0Y0M0DT0H5M7S');
|
||||
});
|
||||
|
||||
test('Includes hours when duration >= 1 hour and zero-pads minutes when needed', () => {
|
||||
const mdi = new MediaDurationInfo();
|
||||
mdi.update(1 * 3600 + 2 * 60 + 3);
|
||||
expect(mdi.toString()).toBe('1:02:03');
|
||||
expect(mdi.ariaLabel()).toBe('1 hours, 2 minutes, 3 seconds');
|
||||
expect(mdi.ISO8601()).toBe('P0Y0M0DT1H2M3S');
|
||||
});
|
||||
|
||||
test('Accumulates hours when days are present (e.g., 1 day + 2:03:04 => 26:03:04)', () => {
|
||||
const mdi = new MediaDurationInfo();
|
||||
const seconds = 1 * 86400 + 2 * 3600 + 3 * 60 + 4; // 1d 2:03:04 => 26:03:04
|
||||
mdi.update(seconds);
|
||||
expect(mdi.toString()).toBe('26:03:04');
|
||||
expect(mdi.ariaLabel()).toBe('26 hours, 3 minutes, 4 seconds');
|
||||
expect(mdi.ISO8601()).toBe('P0Y0M0DT26H3M4S');
|
||||
});
|
||||
|
||||
test('Large durations: multiple days correctly mapped into hours', () => {
|
||||
const mdi = new MediaDurationInfo();
|
||||
const seconds = 3 * 86400 + 10 * 3600 + 15 * 60 + 9; // 3d 10:15:09 => 82:15:09
|
||||
mdi.update(seconds);
|
||||
expect(mdi.toString()).toBe('82:15:09');
|
||||
expect(mdi.ariaLabel()).toBe('82 hours, 15 minutes, 9 seconds');
|
||||
expect(mdi.ISO8601()).toBe('P0Y0M0DT82H15M9S');
|
||||
});
|
||||
|
||||
test('Caching: toString and ariaLabel recompute only after update()', () => {
|
||||
const mdi = new MediaDurationInfo(59);
|
||||
const firstToString = mdi.toString();
|
||||
const firstAria = mdi.ariaLabel();
|
||||
expect(firstToString).toBe('0:59');
|
||||
expect(firstAria).toBe('59 seconds');
|
||||
|
||||
// Call again to hit cached path
|
||||
expect(mdi.toString()).toBe(firstToString);
|
||||
expect(mdi.ariaLabel()).toBe(firstAria);
|
||||
|
||||
// Update and ensure cache invalidates
|
||||
mdi.update(60);
|
||||
expect(mdi.toString()).toBe('1:00');
|
||||
expect(mdi.ariaLabel()).toBe('1 minutes');
|
||||
});
|
||||
|
||||
test('Ignores invalid (non-positive integer or zero) updates, retaining previous value', () => {
|
||||
const mdi = new MediaDurationInfo(10);
|
||||
expect(mdi.toString()).toBe('0:10');
|
||||
|
||||
mdi.update(1.23);
|
||||
expect(mdi.toString()).toBe('0:10');
|
||||
|
||||
mdi.update(-5);
|
||||
expect(mdi.toString()).toBe('0:10');
|
||||
|
||||
mdi.update('x');
|
||||
expect(mdi.toString()).toBe('0:10');
|
||||
});
|
||||
|
||||
test('Boundary conditions around a minute and an hour', () => {
|
||||
const mdi = new MediaDurationInfo();
|
||||
|
||||
mdi.update(59);
|
||||
expect(mdi.toString()).toBe('0:59');
|
||||
|
||||
mdi.update(60);
|
||||
expect(mdi.toString()).toBe('1:00');
|
||||
|
||||
mdi.update(3599);
|
||||
expect(mdi.toString()).toBe('59:59');
|
||||
|
||||
mdi.update(3600);
|
||||
expect(mdi.toString()).toBe('1:00:00');
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Constructs without initial seconds', () => {
|
||||
const mdi = new MediaDurationInfo();
|
||||
expect(typeof mdi.toString()).toBe('function');
|
||||
expect(mdi.ariaLabel()).toBe('');
|
||||
expect(mdi.ISO8601()).toBe('P0Y0M0DTundefinedHundefinedMundefinedS');
|
||||
});
|
||||
});
|
||||
});
|
||||
102
frontend/tests/utils/classes/UpNextLoaderView.test.ts
Normal file
102
frontend/tests/utils/classes/UpNextLoaderView.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { UpNextLoaderView } from '../../../src/static/js/utils/classes/UpNextLoaderView';
|
||||
|
||||
// Minimal helpers mocks used by UpNextLoaderView
|
||||
jest.mock('../../../src/static/js/utils/helpers/', () => ({
|
||||
addClassname: jest.fn((el: any, cn: string) => el && el.classList && el.classList.add(cn)),
|
||||
removeClassname: jest.fn((el: any, cn: string) => el && el.classList && el.classList.remove(cn)),
|
||||
translateString: (s: string) => s,
|
||||
}));
|
||||
|
||||
const { addClassname, removeClassname } = jest.requireMock('../../../src/static/js/utils/helpers/');
|
||||
|
||||
const makeNextItem = () => ({
|
||||
url: '/next-url',
|
||||
title: 'Next title',
|
||||
author_name: 'Jane Doe',
|
||||
thumbnail_url: 'https://example.com/thumb.jpg',
|
||||
});
|
||||
|
||||
describe('utils/classes', () => {
|
||||
describe('UpNextLoaderView', () => {
|
||||
test('html() builds structure with expected classes and content', () => {
|
||||
const v = new UpNextLoaderView(makeNextItem());
|
||||
|
||||
const root = v.html();
|
||||
|
||||
expect(root).toBeInstanceOf(HTMLElement);
|
||||
expect(root.querySelector('.up-next-loader-inner')).not.toBeNull();
|
||||
expect(root.querySelector('.up-next-label')!.textContent).toBe('Up Next');
|
||||
expect(root.querySelector('.next-media-title')!.textContent).toBe('Next title');
|
||||
expect(root.querySelector('.next-media-author')!.textContent).toBe('Jane Doe');
|
||||
|
||||
// poster background
|
||||
const poster = root.querySelector('.next-media-poster') as HTMLElement;
|
||||
expect(poster.style.backgroundImage).toContain('thumb.jpg');
|
||||
|
||||
// go-next link points to next url
|
||||
const link = root.querySelector('.go-next a') as HTMLAnchorElement;
|
||||
expect(link.getAttribute('href')).toBe('/next-url');
|
||||
});
|
||||
|
||||
test('setVideoJsPlayerElem marks player with vjs-mediacms-has-up-next-view class', () => {
|
||||
const v = new UpNextLoaderView(makeNextItem());
|
||||
const player = document.createElement('div');
|
||||
|
||||
v.setVideoJsPlayerElem(player);
|
||||
|
||||
expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-has-up-next-view');
|
||||
expect(v.vjsPlayerElem).toBe(player);
|
||||
});
|
||||
|
||||
test('startTimer shows view, registers scroll, and navigates after 10s', () => {
|
||||
const next = makeNextItem();
|
||||
const v = new UpNextLoaderView(next);
|
||||
const player = document.createElement('div');
|
||||
|
||||
v.setVideoJsPlayerElem(player);
|
||||
v.startTimer();
|
||||
|
||||
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-up-next-hidden');
|
||||
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
|
||||
});
|
||||
|
||||
test('cancelTimer clears timeout, stops scroll, and marks canceled', () => {
|
||||
const v = new UpNextLoaderView(makeNextItem());
|
||||
const player = document.createElement('div');
|
||||
|
||||
v.setVideoJsPlayerElem(player);
|
||||
|
||||
v.startTimer();
|
||||
v.cancelTimer();
|
||||
|
||||
expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
|
||||
});
|
||||
|
||||
test('Cancel button click hides the view and cancels timer', () => {
|
||||
const v = new UpNextLoaderView(makeNextItem());
|
||||
const player = document.createElement('div');
|
||||
v.setVideoJsPlayerElem(player);
|
||||
|
||||
v.startTimer();
|
||||
const root = v.html();
|
||||
const cancelBtn = root.querySelector('.up-next-cancel button') as HTMLButtonElement;
|
||||
cancelBtn.click();
|
||||
|
||||
expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
|
||||
});
|
||||
|
||||
test('showTimerView shows or starts timer based on flag', () => {
|
||||
const v = new UpNextLoaderView(makeNextItem());
|
||||
const player = document.createElement('div');
|
||||
v.setVideoJsPlayerElem(player);
|
||||
|
||||
// beginTimer=false -> just show view
|
||||
v.showTimerView(false);
|
||||
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-up-next-hidden');
|
||||
|
||||
// beginTimer=true -> starts timer
|
||||
v.showTimerView(true);
|
||||
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
|
||||
});
|
||||
});
|
||||
});
|
||||
749
frontend/tests/utils/hooks/useBulkActions.test.tsx
Normal file
749
frontend/tests/utils/hooks/useBulkActions.test.tsx
Normal file
@@ -0,0 +1,749 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent, act } from '@testing-library/react';
|
||||
import { useBulkActions } from '../../../src/static/js/utils/hooks/useBulkActions';
|
||||
|
||||
// Mock translateString to return the input for easier assertions
|
||||
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||
translateString: (s: string) => s,
|
||||
}));
|
||||
|
||||
// Component that exposes hook state/handlers to DOM for testing
|
||||
function HookConsumer() {
|
||||
const hook = useBulkActions();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="selected-count">{Array.from(hook.selectedMedia).length}</div>
|
||||
<div data-testid="available-count">{hook.availableMediaIds.length}</div>
|
||||
<div data-testid="show-confirm">{String(hook.showConfirmModal)}</div>
|
||||
<div data-testid="confirm-message">{hook.confirmMessage}</div>
|
||||
<div data-testid="list-key">{hook.listKey}</div>
|
||||
<div data-testid="notification-message">{hook.notificationMessage}</div>
|
||||
<div data-testid="show-notification">{String(hook.showNotification)}</div>
|
||||
|
||||
{/* @todo: It doesn't used */}
|
||||
{/* <div data-testid="notification-type">{hook.notificationType}</div> */}
|
||||
|
||||
<div data-testid="show-permission">{String(hook.showPermissionModal)}</div>
|
||||
<div data-testid="permission-type">{hook.permissionType || ''}</div>
|
||||
<div data-testid="show-playlist">{String(hook.showPlaylistModal)}</div>
|
||||
<div data-testid="show-change-owner">{String(hook.showChangeOwnerModal)}</div>
|
||||
<div data-testid="show-publish-state">{String(hook.showPublishStateModal)}</div>
|
||||
<div data-testid="show-category">{String(hook.showCategoryModal)}</div>
|
||||
<div data-testid="show-tag">{String(hook.showTagModal)}</div>
|
||||
|
||||
<button data-testid="btn-handle-media-select" onClick={() => hook.handleMediaSelection('m1', true)} />
|
||||
<button data-testid="btn-handle-media-deselect" onClick={() => hook.handleMediaSelection('m1', false)} />
|
||||
<button
|
||||
data-testid="btn-handle-items-update"
|
||||
onClick={() => hook.handleItemsUpdate([{ id: 'a' }, { uid: 'b' }, { friendly_token: 'c' }])}
|
||||
/>
|
||||
<button data-testid="btn-select-all" onClick={() => hook.handleSelectAll()} />
|
||||
<button data-testid="btn-deselect-all" onClick={() => hook.handleDeselectAll()} />
|
||||
<button data-testid="btn-clear-selection" onClick={() => hook.clearSelection()} />
|
||||
<button data-testid="btn-clear-refresh" onClick={() => hook.clearSelectionAndRefresh()} />
|
||||
|
||||
<button data-testid="btn-bulk-delete" onClick={() => hook.handleBulkAction('delete-media')} />
|
||||
<button data-testid="btn-bulk-enable-comments" onClick={() => hook.handleBulkAction('enable-comments')} />
|
||||
<button data-testid="btn-bulk-disable-comments" onClick={() => hook.handleBulkAction('disable-comments')} />
|
||||
<button data-testid="btn-bulk-enable-download" onClick={() => hook.handleBulkAction('enable-download')} />
|
||||
<button data-testid="btn-bulk-disable-download" onClick={() => hook.handleBulkAction('disable-download')} />
|
||||
<button data-testid="btn-bulk-copy" onClick={() => hook.handleBulkAction('copy-media')} />
|
||||
<button data-testid="btn-bulk-perm-viewer" onClick={() => hook.handleBulkAction('add-remove-coviewers')} />
|
||||
<button data-testid="btn-bulk-perm-editor" onClick={() => hook.handleBulkAction('add-remove-coeditors')} />
|
||||
<button data-testid="btn-bulk-perm-owner" onClick={() => hook.handleBulkAction('add-remove-coowners')} />
|
||||
<button data-testid="btn-bulk-playlist" onClick={() => hook.handleBulkAction('add-remove-playlist')} />
|
||||
<button data-testid="btn-bulk-change-owner" onClick={() => hook.handleBulkAction('change-owner')} />
|
||||
<button data-testid="btn-bulk-publish" onClick={() => hook.handleBulkAction('publish-state')} />
|
||||
<button data-testid="btn-bulk-category" onClick={() => hook.handleBulkAction('add-remove-category')} />
|
||||
<button data-testid="btn-bulk-tag" onClick={() => hook.handleBulkAction('add-remove-tags')} />
|
||||
<button data-testid="btn-bulk-unknown" onClick={() => hook.handleBulkAction('unknown-action')} />
|
||||
|
||||
<button data-testid="btn-confirm-proceed" onClick={() => hook.handleConfirmProceed()} />
|
||||
<button data-testid="btn-confirm-cancel" onClick={() => hook.handleConfirmCancel()} />
|
||||
<button data-testid="btn-perm-cancel" onClick={() => hook.handlePermissionModalCancel()} />
|
||||
|
||||
<button data-testid="btn-perm-success" onClick={() => hook.handlePermissionModalSuccess('perm ok')} />
|
||||
<button data-testid="btn-perm-error" onClick={() => hook.handlePermissionModalError('perm err')} />
|
||||
<button data-testid="btn-playlist-cancel" onClick={() => hook.handlePlaylistModalCancel()} />
|
||||
|
||||
<button data-testid="btn-playlist-success" onClick={() => hook.handlePlaylistModalSuccess('pl ok')} />
|
||||
<button data-testid="btn-playlist-error" onClick={() => hook.handlePlaylistModalError('pl err')} />
|
||||
<button data-testid="btn-change-owner-cancel" onClick={() => hook.handleChangeOwnerModalCancel()} />
|
||||
|
||||
<button
|
||||
data-testid="btn-change-owner-success"
|
||||
onClick={() => hook.handleChangeOwnerModalSuccess('owner ok')}
|
||||
/>
|
||||
<button
|
||||
data-testid="btn-change-owner-error"
|
||||
onClick={() => hook.handleChangeOwnerModalError('owner err')}
|
||||
/>
|
||||
<button data-testid="btn-publish-cancel" onClick={() => hook.handlePublishStateModalCancel()} />
|
||||
|
||||
<button data-testid="btn-publish-success" onClick={() => hook.handlePublishStateModalSuccess('pub ok')} />
|
||||
<button data-testid="btn-publish-error" onClick={() => hook.handlePublishStateModalError('pub err')} />
|
||||
<button data-testid="btn-category-cancel" onClick={() => hook.handleCategoryModalCancel()} />
|
||||
|
||||
<button data-testid="btn-category-success" onClick={() => hook.handleCategoryModalSuccess('cat ok')} />
|
||||
<button data-testid="btn-category-error" onClick={() => hook.handleCategoryModalError('cat err')} />
|
||||
<button data-testid="btn-tag-cancel" onClick={() => hook.handleTagModalCancel()} />
|
||||
|
||||
<button data-testid="btn-tag-success" onClick={() => hook.handleTagModalSuccess('tag ok')} />
|
||||
<button data-testid="btn-tag-error" onClick={() => hook.handleTagModalError('tag err')} />
|
||||
|
||||
<div data-testid="csrf">{String(hook.getCsrfToken())}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useBulkActions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
document.cookie.split(';').forEach((c) => {
|
||||
document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/');
|
||||
});
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Utility Functions', () => {
|
||||
test('getCsrfToken reads csrftoken from cookies', () => {
|
||||
document.cookie = 'csrftoken=abc123';
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
expect(getByTestId('csrf').textContent).toBe('abc123');
|
||||
});
|
||||
|
||||
test('getCsrfToken returns null when csrftoken is not present', () => {
|
||||
// No cookie set, should return null
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
expect(getByTestId('csrf').textContent).toBe('null');
|
||||
});
|
||||
|
||||
test('getCsrfToken returns null when document.cookie is empty', () => {
|
||||
// Even if we try to set empty cookie, it should return null if no csrftoken
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
expect(getByTestId('csrf').textContent).toBe('null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Selection Management', () => {
|
||||
test('handleMediaSelection toggles selected media', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
expect(getByTestId('selected-count').textContent).toBe('1');
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-deselect'));
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
test('handleItemsUpdate extracts ids correctly from items with different id types', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-items-update'));
|
||||
expect(getByTestId('available-count').textContent).toBe('3');
|
||||
});
|
||||
|
||||
test('handleSelectAll selects all available items', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-items-update'));
|
||||
fireEvent.click(getByTestId('btn-select-all'));
|
||||
expect(getByTestId('selected-count').textContent).toBe('3');
|
||||
});
|
||||
|
||||
test('handleDeselectAll deselects all items', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-items-update'));
|
||||
fireEvent.click(getByTestId('btn-select-all'));
|
||||
fireEvent.click(getByTestId('btn-deselect-all'));
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
test('clearSelection clears all selected media', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
expect(getByTestId('selected-count').textContent).toBe('1');
|
||||
|
||||
fireEvent.click(getByTestId('btn-clear-selection'));
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
test('clearSelectionAndRefresh clears selection and increments listKey', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-items-update'));
|
||||
fireEvent.click(getByTestId('btn-select-all'));
|
||||
expect(getByTestId('list-key').textContent).toBe('0');
|
||||
|
||||
fireEvent.click(getByTestId('btn-clear-refresh'));
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
expect(getByTestId('list-key').textContent).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk Actions - Modal Opening', () => {
|
||||
test('handleBulkAction does nothing when no selection', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||
expect(getByTestId('show-confirm').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handleBulkAction opens confirm modal for delete, enable/disable comments and download, copy', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
|
||||
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
|
||||
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-enable-download'));
|
||||
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-disable-download'));
|
||||
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-copy'));
|
||||
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||
});
|
||||
|
||||
test('handleBulkAction opens permission modals with correct types', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-perm-viewer'));
|
||||
expect(getByTestId('show-permission').textContent).toBe('true');
|
||||
expect(getByTestId('permission-type').textContent).toBe('viewer');
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-perm-editor'));
|
||||
expect(getByTestId('permission-type').textContent).toBe('editor');
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-perm-owner'));
|
||||
expect(getByTestId('permission-type').textContent).toBe('owner');
|
||||
});
|
||||
|
||||
test('handleBulkAction opens other modals', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-playlist'));
|
||||
expect(getByTestId('show-playlist').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-change-owner'));
|
||||
expect(getByTestId('show-change-owner').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-publish'));
|
||||
expect(getByTestId('show-publish-state').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-category'));
|
||||
expect(getByTestId('show-category').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-bulk-tag'));
|
||||
expect(getByTestId('show-tag').textContent).toBe('true');
|
||||
});
|
||||
|
||||
test('handleBulkAction with unknown action does nothing', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-unknown'));
|
||||
expect(getByTestId('show-confirm').textContent).toBe('false');
|
||||
expect(getByTestId('show-permission').textContent).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Confirm Modal Handlers', () => {
|
||||
test('handleConfirmCancel closes confirm modal and resets state', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-confirm-cancel'));
|
||||
expect(getByTestId('show-confirm').textContent).toBe('false');
|
||||
expect(getByTestId('confirm-message').textContent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete Media Execution', () => {
|
||||
test('executeDeleteMedia success with notification', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('The media was deleted successfully');
|
||||
expect(getByTestId('show-notification').textContent).toBe('true');
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(getByTestId('show-notification').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('executeDeleteMedia handles response.ok = false', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to delete media');
|
||||
});
|
||||
|
||||
test('executeDeleteMedia handles fetch rejection exception', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to delete media');
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comments Management Execution', () => {
|
||||
test('executeEnableComments success', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Successfully Enabled comments');
|
||||
});
|
||||
|
||||
test('executeEnableComments handles response.ok = false', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to enable comments');
|
||||
});
|
||||
|
||||
test('executeEnableComments handles fetch rejection exception', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to enable comments');
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
test('executeDisableComments success', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Successfully Disabled comments');
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
test('executeDisableComments handles response.ok = false', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to disable comments');
|
||||
});
|
||||
|
||||
test('executeDisableComments handles fetch rejection exception', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to disable comments');
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download Management Execution', () => {
|
||||
test('executeEnableDownload success', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-enable-download'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Successfully Enabled Download');
|
||||
});
|
||||
|
||||
test('executeEnableDownload handles response.ok = false', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-enable-download'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to enable download');
|
||||
});
|
||||
|
||||
test('executeEnableDownload handles fetch rejection exception', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-enable-download'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to enable download');
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
test('executeDisableDownload success', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-disable-download'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Successfully Disabled Download');
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
test('executeDisableDownload handles response.ok = false', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-disable-download'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to disable download');
|
||||
});
|
||||
|
||||
test('executeDisableDownload handles fetch rejection exception', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-disable-download'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to disable download');
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copy Media Execution', () => {
|
||||
test('executeCopyMedia success', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-copy'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Successfully Copied');
|
||||
});
|
||||
|
||||
test('executeCopyMedia handles response.ok = false', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-copy'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to copy media');
|
||||
});
|
||||
|
||||
test('executeCopyMedia handles fetch rejection exception', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-copy'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getByTestId('notification-message').textContent).toContain('Failed to copy media');
|
||||
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Modal Handlers', () => {
|
||||
test('handlePermissionModalCancel closes permission modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-perm-viewer'));
|
||||
expect(getByTestId('show-permission').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-perm-cancel'));
|
||||
expect(getByTestId('show-permission').textContent).toBe('false');
|
||||
expect(getByTestId('permission-type').textContent).toBe('');
|
||||
});
|
||||
|
||||
test('handlePermissionModalSuccess shows notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-perm-success'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('perm ok');
|
||||
expect(getByTestId('show-permission').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handlePermissionModalError shows error notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-perm-error'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('perm err');
|
||||
expect(getByTestId('show-permission').textContent).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Playlist Modal Handlers', () => {
|
||||
test('handlePlaylistModalCancel closes playlist modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-playlist'));
|
||||
expect(getByTestId('show-playlist').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-playlist-cancel'));
|
||||
expect(getByTestId('show-playlist').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handlePlaylistModalSuccess shows notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-playlist-success'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('pl ok');
|
||||
expect(getByTestId('show-playlist').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handlePlaylistModalError shows error notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-playlist-error'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('pl err');
|
||||
expect(getByTestId('show-playlist').textContent).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Change Owner Modal Handlers', () => {
|
||||
test('handleChangeOwnerModalCancel closes change owner modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-change-owner'));
|
||||
expect(getByTestId('show-change-owner').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-change-owner-cancel'));
|
||||
expect(getByTestId('show-change-owner').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handleChangeOwnerModalSuccess shows notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-change-owner-success'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('owner ok');
|
||||
expect(getByTestId('show-change-owner').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handleChangeOwnerModalError shows error notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-change-owner-error'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('owner err');
|
||||
expect(getByTestId('show-change-owner').textContent).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Publish State Modal Handlers', () => {
|
||||
test('handlePublishStateModalCancel closes publish state modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-publish'));
|
||||
expect(getByTestId('show-publish-state').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-publish-cancel'));
|
||||
expect(getByTestId('show-publish-state').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handlePublishStateModalSuccess shows notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-publish-success'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('pub ok');
|
||||
expect(getByTestId('show-publish-state').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handlePublishStateModalError shows error notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-publish-error'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('pub err');
|
||||
expect(getByTestId('show-publish-state').textContent).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Category Modal Handlers', () => {
|
||||
test('handleCategoryModalCancel closes category modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-category'));
|
||||
expect(getByTestId('show-category').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-category-cancel'));
|
||||
expect(getByTestId('show-category').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handleCategoryModalSuccess shows notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-category-success'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('cat ok');
|
||||
expect(getByTestId('show-category').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handleCategoryModalError shows error notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-category-error'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('cat err');
|
||||
expect(getByTestId('show-category').textContent).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tag Modal Handlers', () => {
|
||||
test('handleTagModalCancel closes tag modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||
fireEvent.click(getByTestId('btn-bulk-tag'));
|
||||
expect(getByTestId('show-tag').textContent).toBe('true');
|
||||
|
||||
fireEvent.click(getByTestId('btn-tag-cancel'));
|
||||
expect(getByTestId('show-tag').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handleTagModalSuccess shows notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-tag-success'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('tag ok');
|
||||
expect(getByTestId('show-tag').textContent).toBe('false');
|
||||
});
|
||||
|
||||
test('handleTagModalError shows error notification and closes modal', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
|
||||
fireEvent.click(getByTestId('btn-tag-error'));
|
||||
expect(getByTestId('notification-message').textContent).toBe('tag err');
|
||||
expect(getByTestId('show-tag').textContent).toBe('false');
|
||||
});
|
||||
});
|
||||
});
|
||||
380
frontend/tests/utils/hooks/useItem.test.tsx
Normal file
380
frontend/tests/utils/hooks/useItem.test.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { useItem } from '../../../src/static/js/utils/hooks/useItem';
|
||||
|
||||
// Mock the item components
|
||||
jest.mock('../../../src/static/js/components/list-item/includes/items', () => ({
|
||||
ItemDescription: ({ description }: { description: string }) => (
|
||||
<div data-testid="item-description">{description}</div>
|
||||
),
|
||||
ItemMain: ({ children }: { children: React.ReactNode }) => <div data-testid="item-main">{children}</div>,
|
||||
ItemMainInLink: ({ children, link, title }: { children: React.ReactNode; link: string; title: string }) => (
|
||||
<div data-testid="item-main-in-link" data-link={link} data-title={title}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
ItemTitle: ({ title, ariaLabel }: { title: string; ariaLabel: string }) => (
|
||||
<h3 data-testid="item-title" data-aria-label={ariaLabel}>
|
||||
{title}
|
||||
</h3>
|
||||
),
|
||||
ItemTitleLink: ({ title, link, ariaLabel }: { title: string; link: string; ariaLabel: string }) => (
|
||||
<h3 data-testid="item-title-link" data-link={link} data-aria-label={ariaLabel}>
|
||||
{title}
|
||||
</h3>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock PageStore
|
||||
jest.mock('../../../src/static/js/utils/stores/PageStore.js', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: (key: string) => (key === 'config-site' ? { url: 'https://example.com' } : null),
|
||||
},
|
||||
}));
|
||||
|
||||
// HookConsumer component to test the hook
|
||||
function HookConsumer(props: any) {
|
||||
const { titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper } = useItem(props);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="title">{titleComponent()}</div>
|
||||
<div data-testid="description">{descriptionComponent()}</div>
|
||||
<div data-testid="thumbnail-url">{thumbnailUrl || 'null'}</div>
|
||||
<div data-testid="wrapper-type">{(UnderThumbWrapper as any).name}</div>
|
||||
<div data-testid="wrapper-component">
|
||||
<div>Wrapper content</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrapper consumer to test wrapper selection
|
||||
function WrapperTest(props: any) {
|
||||
const { UnderThumbWrapper } = useItem(props);
|
||||
|
||||
return (
|
||||
<UnderThumbWrapper link={props.link} title={props.title} data-testid="wrapper-test">
|
||||
<span data-testid="wrapper-content">Content</span>
|
||||
</UnderThumbWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
describe('useItem', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('titleComponent Rendering', () => {
|
||||
test('Renders ItemTitle when singleLinkContent is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description="Test Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
singleLinkContent={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByTestId('title').querySelector('[data-testid="item-title"]')).toBeTruthy();
|
||||
expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('Renders ItemTitleLink when singleLinkContent is false', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description="Test Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
singleLinkContent={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByTestId('title').querySelector('[data-testid="item-title"]')).toBeFalsy();
|
||||
expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Renders with default link when singleLinkContent is not provided', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer title="Test Title" description="Test Description" link="/media/test" thumbnail="" />
|
||||
);
|
||||
|
||||
// Default is false for singleLinkContent
|
||||
expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('descriptionComponent Rendering', () => {
|
||||
test('Renders single ItemDescription when hasMediaViewer is false', () => {
|
||||
const { getByTestId, queryAllByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description="My Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
hasMediaViewer={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptions = queryAllByTestId('item-description');
|
||||
expect(descriptions.length).toBe(1);
|
||||
expect(descriptions[0].textContent).toBe('My Description');
|
||||
});
|
||||
|
||||
test('Renders single ItemDescription when hasMediaViewerDescr is false', () => {
|
||||
const { getByTestId, queryAllByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description="My Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
hasMediaViewer={true}
|
||||
hasMediaViewerDescr={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptions = queryAllByTestId('item-description');
|
||||
expect(descriptions.length).toBe(1);
|
||||
expect(descriptions[0].textContent).toBe('My Description');
|
||||
});
|
||||
|
||||
test('Renders two ItemDescriptions when hasMediaViewer and hasMediaViewerDescr are both true', () => {
|
||||
const { queryAllByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description="Main Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
hasMediaViewer={true}
|
||||
hasMediaViewerDescr={true}
|
||||
meta_description="Meta Description"
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptions = queryAllByTestId('item-description');
|
||||
expect(descriptions.length).toBe(2);
|
||||
expect(descriptions[0].textContent).toBe('Meta Description');
|
||||
expect(descriptions[1].textContent).toBe('Main Description');
|
||||
});
|
||||
|
||||
test('Trims description text', () => {
|
||||
const { queryAllByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description=" Description with spaces "
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(queryAllByTestId('item-description')[0].textContent).toBe('Description with spaces');
|
||||
});
|
||||
|
||||
test('Trims meta_description text', () => {
|
||||
const { queryAllByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description="Main Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
hasMediaViewer={true}
|
||||
hasMediaViewerDescr={true}
|
||||
meta_description=" Meta with spaces "
|
||||
/>
|
||||
);
|
||||
|
||||
expect(queryAllByTestId('item-description')[0].textContent).toBe('Meta with spaces');
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbnailUrl', () => {
|
||||
test('Returns null when thumbnail is empty string', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description="Test Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByTestId('thumbnail-url').textContent).toBe('null');
|
||||
});
|
||||
|
||||
test('Returns formatted URL when thumbnail has value', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description="Test Description"
|
||||
link="https://example.com"
|
||||
thumbnail="/media/thumbnail.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByTestId('thumbnail-url').textContent).toBe('https://example.com/media/thumbnail.jpg');
|
||||
});
|
||||
|
||||
test('Handles absolute URLs as thumbnails', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description="Test Description"
|
||||
link="https://example.com"
|
||||
thumbnail="https://cdn.example.com/image.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
// formatInnerLink should preserve absolute URLs
|
||||
expect(getByTestId('thumbnail-url').textContent).toBe('https://cdn.example.com/image.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnderThumbWrapper', () => {
|
||||
test('Uses ItemMainInLink when singleLinkContent is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<WrapperTest
|
||||
title="Test Title"
|
||||
description="Test Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
singleLinkContent={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// When singleLinkContent is true, UnderThumbWrapper should be ItemMainInLink
|
||||
expect(getByTestId('item-main-in-link')).toBeTruthy();
|
||||
expect(getByTestId('item-main-in-link').getAttribute('data-link')).toBe('https://example.com');
|
||||
expect(getByTestId('item-main-in-link').getAttribute('data-title')).toBe('Test Title');
|
||||
});
|
||||
|
||||
test('Uses ItemMain when singleLinkContent is false', () => {
|
||||
const { getByTestId } = render(
|
||||
<WrapperTest
|
||||
title="Test Title"
|
||||
description="Test Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
singleLinkContent={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// When singleLinkContent is false, UnderThumbWrapper should be ItemMain
|
||||
expect(getByTestId('item-main')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Uses ItemMain by default when singleLinkContent is not provided', () => {
|
||||
const { getByTestId } = render(
|
||||
<WrapperTest
|
||||
title="Test Title"
|
||||
description="Test Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
/>
|
||||
);
|
||||
|
||||
// Default is singleLinkContent=false, so ItemMain
|
||||
expect(getByTestId('item-main')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMount callback', () => {
|
||||
test('Calls onMount callback when component mounts', () => {
|
||||
const onMountCallback = jest.fn();
|
||||
|
||||
render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description="Test Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
onMount={onMountCallback}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(onMountCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Calls onMount only once on initial mount', () => {
|
||||
const onMountCallback = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<HookConsumer
|
||||
title="Test Title"
|
||||
description="Test Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
onMount={onMountCallback}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(onMountCallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<HookConsumer
|
||||
title="Updated Title"
|
||||
description="Updated Description"
|
||||
link="https://example.com"
|
||||
thumbnail=""
|
||||
onMount={onMountCallback}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should still be called only once (useEffect with empty dependency array)
|
||||
expect(onMountCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration tests', () => {
|
||||
test('Complete rendering with all props', () => {
|
||||
const onMount = jest.fn();
|
||||
const { getByTestId, queryAllByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Complete Test"
|
||||
description="Complete Description"
|
||||
link="/media/complete"
|
||||
thumbnail="/img/thumb.jpg"
|
||||
type="media"
|
||||
hasMediaViewer={true}
|
||||
hasMediaViewerDescr={true}
|
||||
meta_description="Complete Meta"
|
||||
singleLinkContent={false}
|
||||
onMount={onMount}
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptions = queryAllByTestId('item-description');
|
||||
expect(descriptions.length).toBe(2);
|
||||
expect(onMount).toHaveBeenCalledTimes(1);
|
||||
expect(getByTestId('thumbnail-url').textContent).toBe('https://example.com/img/thumb.jpg');
|
||||
});
|
||||
|
||||
test('Minimal props required', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer title="Title" description="Description" link="/link" thumbnail="" />
|
||||
);
|
||||
|
||||
expect(getByTestId('title')).toBeTruthy();
|
||||
expect(getByTestId('description')).toBeTruthy();
|
||||
expect(getByTestId('thumbnail-url').textContent).toBe('null');
|
||||
});
|
||||
|
||||
test('Renders with special characters in title and description', () => {
|
||||
const { queryAllByTestId } = render(
|
||||
<HookConsumer
|
||||
title="Title with & < > special chars"
|
||||
description={`Description with 'quotes' and "double quotes"`}
|
||||
link="/media"
|
||||
thumbnail=""
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptions = queryAllByTestId('item-description');
|
||||
expect(descriptions[0].textContent).toContain('Description with');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
124
frontend/tests/utils/hooks/useItemList.test.tsx
Normal file
124
frontend/tests/utils/hooks/useItemList.test.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { createRef } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
// Stub style imports used by the hook so Jest doesn't try to parse SCSS
|
||||
jest.mock('../../../src/static/js/components/item-list/ItemList.scss', () => ({}), { virtual: true });
|
||||
|
||||
jest.mock('../../../src/static/js/components/item-list/includes/itemLists/initItemsList', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn((_lists: any[]) => [{ appendItems: jest.fn() }]),
|
||||
}));
|
||||
|
||||
import initItemsList from '../../../src/static/js/components/item-list/includes/itemLists/initItemsList';
|
||||
import { useItemList } from '../../../src/static/js/utils/hooks/useItemList';
|
||||
|
||||
function HookConsumer(props: any) {
|
||||
const listRef = createRef<HTMLDivElement>();
|
||||
const [items, countedItems, listHandler, setListHandler, onItemsLoad, onItemsCount, addListItems] = useItemList(
|
||||
props,
|
||||
listRef
|
||||
) as any[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={listRef} data-testid="list" className="list">
|
||||
{(items as any[]).map((_, idx) => (
|
||||
<div key={idx} className="item" data-testid={`itm-${idx}`} />
|
||||
))}
|
||||
</div>
|
||||
<div data-testid="counted">{String(countedItems)}</div>
|
||||
<div data-testid="len">{items.length}</div>
|
||||
<button data-testid="load-call" onClick={() => onItemsLoad([1, 2])} />
|
||||
<button data-testid="count-call" onClick={() => onItemsCount(5)} />
|
||||
<button data-testid="add-call" onClick={() => addListItems()} />
|
||||
<button data-testid="set-handler" onClick={() => setListHandler({ foo: 'bar' })} />
|
||||
<div data-testid="has-handler">{listHandler ? 'yes' : 'no'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
describe('useItemList', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Initial state: empty items and not counted', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
expect(getByTestId('counted').textContent).toBe('false');
|
||||
expect(getByTestId('len').textContent).toBe('0');
|
||||
expect(getByTestId('has-handler').textContent).toBe('no');
|
||||
});
|
||||
|
||||
test('onItemsLoad updates items and renders item nodes', () => {
|
||||
const { getByTestId, getByTestId: $ } = render(<HookConsumer />);
|
||||
(getByTestId('load-call') as HTMLButtonElement).click();
|
||||
expect(getByTestId('len').textContent).toBe('2');
|
||||
expect($('itm-0')).toBeTruthy();
|
||||
expect($('itm-1')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('onItemsCount marks countedItems true and triggers callback if provided', () => {
|
||||
const cb = jest.fn();
|
||||
const { getByTestId } = render(<HookConsumer itemsCountCallback={cb} />);
|
||||
(getByTestId('count-call') as HTMLButtonElement).click();
|
||||
expect(getByTestId('counted').textContent).toBe('true');
|
||||
expect(cb).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
test('addListItems initializes itemsListInstance and appends only new items', () => {
|
||||
const mockInit = initItemsList as jest.Mock;
|
||||
|
||||
const { getByTestId, rerender } = render(<HookConsumer />);
|
||||
|
||||
const itemsLen = getByTestId('len') as HTMLDivElement;
|
||||
const addBtn = getByTestId('add-call') as HTMLButtonElement;
|
||||
const loadBtn = getByTestId('load-call') as HTMLButtonElement;
|
||||
|
||||
expect(itemsLen.textContent).toBe('0');
|
||||
loadBtn.click();
|
||||
expect(itemsLen.textContent).toBe('2');
|
||||
|
||||
expect(mockInit).toHaveBeenCalledTimes(0);
|
||||
addBtn.click();
|
||||
expect(mockInit).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockInit.mock.results[0].value[0].appendItems).toHaveBeenCalledTimes(2);
|
||||
|
||||
loadBtn.click();
|
||||
expect(itemsLen.textContent).toBe('2');
|
||||
|
||||
addBtn.click();
|
||||
expect(mockInit).toHaveBeenCalledTimes(2);
|
||||
expect(mockInit.mock.results[1].value[0].appendItems).toHaveBeenCalledTimes(2);
|
||||
|
||||
rerender(<HookConsumer />);
|
||||
|
||||
addBtn.click();
|
||||
expect(mockInit).toHaveBeenCalledTimes(3);
|
||||
expect(mockInit.mock.results[2].value[0].appendItems).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('addListItems does nothing when there are no .item elements in the ref', () => {
|
||||
// Render, do not call onItemsLoad, then call addListItems
|
||||
const mockInit = initItemsList as jest.Mock;
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
(getByTestId('add-call') as HTMLButtonElement).click();
|
||||
expect(mockInit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('itemsLoadCallback is invoked when items change', () => {
|
||||
const itemsLoadCallback = jest.fn();
|
||||
const { getByTestId } = render(<HookConsumer itemsLoadCallback={itemsLoadCallback} />);
|
||||
(getByTestId('load-call') as HTMLButtonElement).click();
|
||||
expect(itemsLoadCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('setListHandler updates listHandler', () => {
|
||||
const { getByTestId } = render(<HookConsumer />);
|
||||
expect(getByTestId('has-handler').textContent).toBe('no');
|
||||
(getByTestId('set-handler') as HTMLButtonElement).click();
|
||||
expect(getByTestId('has-handler').textContent).toBe('yes');
|
||||
});
|
||||
});
|
||||
});
|
||||
346
frontend/tests/utils/hooks/useItemListInlineSlider.test.tsx
Normal file
346
frontend/tests/utils/hooks/useItemListInlineSlider.test.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent, act } from '@testing-library/react';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/helpers/', () => ({
|
||||
addClassname: jest.fn(),
|
||||
removeClassname: jest.fn(),
|
||||
}));
|
||||
|
||||
let mockListHandler: any;
|
||||
let mockInlineSliderInstance: any;
|
||||
let addListItemsSpy = jest.fn();
|
||||
|
||||
jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
|
||||
useItemList: (props: any, _ref: any) => {
|
||||
mockListHandler = {
|
||||
loadItems: jest.fn(),
|
||||
totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
|
||||
loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
|
||||
};
|
||||
return [
|
||||
props.__items ?? [], // items
|
||||
props.__countedItems ?? 0, // countedItems
|
||||
mockListHandler, // listHandler
|
||||
jest.fn(), // setListHandler
|
||||
jest.fn(), // onItemsLoad
|
||||
jest.fn(), // onItemsCount
|
||||
addListItemsSpy, // addListItems
|
||||
];
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/components/item-list/includes/itemLists/ItemsInlineSlider', () =>
|
||||
jest.fn().mockImplementation(() => {
|
||||
mockInlineSliderInstance = {
|
||||
updateDataStateOnResize: jest.fn(),
|
||||
updateDataState: jest.fn(),
|
||||
scrollToCurrentSlide: jest.fn(),
|
||||
nextSlide: jest.fn(),
|
||||
previousSlide: jest.fn(),
|
||||
hasNextSlide: jest.fn().mockReturnValue(true),
|
||||
hasPreviousSlide: jest.fn().mockReturnValue(true),
|
||||
loadItemsToFit: jest.fn().mockReturnValue(false),
|
||||
loadMoreItems: jest.fn().mockReturnValue(false),
|
||||
itemsFit: jest.fn().mockReturnValue(3),
|
||||
};
|
||||
return mockInlineSliderInstance;
|
||||
})
|
||||
);
|
||||
|
||||
jest.mock('../../../src/static/js/components/_shared', () => ({
|
||||
CircleIconButton: ({ children, onClick }: any) => (
|
||||
<button data-testid="circle-icon-button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { useItemListInlineSlider } from '../../../src/static/js/utils/hooks/useItemListInlineSlider';
|
||||
|
||||
function HookConsumer(props: any) {
|
||||
const tuple = useItemListInlineSlider(props);
|
||||
const [
|
||||
_items,
|
||||
_countedItems,
|
||||
_listHandler,
|
||||
classname,
|
||||
_setListHandler,
|
||||
_onItemsCount,
|
||||
_onItemsLoad,
|
||||
_winResizeListener,
|
||||
_sidebarVisibilityChangeListener,
|
||||
itemsListWrapperRef,
|
||||
_itemsListRef,
|
||||
renderBeforeListWrap,
|
||||
renderAfterListWrap,
|
||||
] = tuple as any;
|
||||
|
||||
return (
|
||||
<div ref={itemsListWrapperRef}>
|
||||
<div data-testid="class-list">{classname.list}</div>
|
||||
<div data-testid="class-outer">{classname.listOuter}</div>
|
||||
<div data-testid="render-before">{renderBeforeListWrap()}</div>
|
||||
<div data-testid="render-after">{renderAfterListWrap()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
describe('useItemListInlineSlider', () => {
|
||||
beforeEach(() => {
|
||||
addListItemsSpy = jest.fn();
|
||||
mockInlineSliderInstance = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Returns correct tuple of values from hook', () => {
|
||||
const TestComponent = (props: any) => {
|
||||
const tuple = useItemListInlineSlider(props);
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="tuple-length">{tuple.length}</div>
|
||||
<div data-testid="has-items">{tuple[0] ? 'yes' : 'no'}</div>
|
||||
<div data-testid="has-classname">{tuple[3] ? 'yes' : 'no'}</div>
|
||||
<div data-testid="has-listeners">{typeof tuple[7] === 'function' ? 'yes' : 'no'}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByTestId } = render(<TestComponent __items={[1, 2, 3]} />);
|
||||
|
||||
expect(getByTestId('tuple-length').textContent).toBe('13');
|
||||
expect(getByTestId('has-classname').textContent).toBe('yes');
|
||||
expect(getByTestId('has-listeners').textContent).toBe('yes');
|
||||
});
|
||||
|
||||
test('Computes classname.list and classname.listOuter with optional className prop', () => {
|
||||
const { getByTestId, rerender } = render(<HookConsumer className=" extra " />);
|
||||
|
||||
expect(getByTestId('class-outer').textContent).toBe('items-list-outer list-inline list-slider extra ');
|
||||
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||
|
||||
rerender(<HookConsumer />);
|
||||
|
||||
expect(getByTestId('class-outer').textContent).toBe('items-list-outer list-inline list-slider');
|
||||
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||
});
|
||||
|
||||
test('Invokes addListItems when items change', () => {
|
||||
const { rerender } = render(<HookConsumer __items={[]} />);
|
||||
expect(addListItemsSpy).toHaveBeenCalledTimes(1);
|
||||
rerender(<HookConsumer __items={[1]} />);
|
||||
expect(addListItemsSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('nextSlide loads more items when loadMoreItems returns true and not all items loaded', () => {
|
||||
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={false} />);
|
||||
|
||||
mockInlineSliderInstance.loadMoreItems.mockReturnValue(true);
|
||||
|
||||
const renderAfter = getByTestId('render-after');
|
||||
const nextButton = renderAfter.querySelector('button[data-testid="circle-icon-button"]');
|
||||
|
||||
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(nextButton!);
|
||||
|
||||
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
|
||||
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
|
||||
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('nextSlide does not load items when all items already loaded', () => {
|
||||
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={true} />);
|
||||
|
||||
mockInlineSliderInstance.loadMoreItems.mockReturnValue(false);
|
||||
|
||||
const renderAfter = getByTestId('render-after');
|
||||
const nextButton = renderAfter.querySelector('button[data-testid="circle-icon-button"]');
|
||||
|
||||
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(nextButton!);
|
||||
|
||||
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
|
||||
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
|
||||
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('prevSlide calls inlineSlider.previousSlide and updates button view', () => {
|
||||
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={false} />);
|
||||
|
||||
mockInlineSliderInstance.loadMoreItems.mockReturnValue(true);
|
||||
|
||||
const renderBefore = getByTestId('render-before');
|
||||
const prevButton = renderBefore.querySelector('button[data-testid="circle-icon-button"]');
|
||||
|
||||
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(prevButton!);
|
||||
|
||||
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
|
||||
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
|
||||
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('prevSlide always scrolls to current slide regardless of item load state', () => {
|
||||
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={true} />);
|
||||
|
||||
mockInlineSliderInstance.loadMoreItems.mockReturnValue(false);
|
||||
|
||||
const renderBefore = getByTestId('render-before');
|
||||
const prevButton = renderBefore.querySelector('button[data-testid="circle-icon-button"]');
|
||||
|
||||
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(prevButton!);
|
||||
|
||||
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
|
||||
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
|
||||
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('Button state updates based on hasNextSlide and hasPreviousSlide', () => {
|
||||
const { getByTestId, rerender } = render(<HookConsumer __items={[1, 2, 3]} />);
|
||||
|
||||
const renderBefore = getByTestId('render-before');
|
||||
const renderAfter = getByTestId('render-after');
|
||||
|
||||
// Initially should show buttons (default mock returns true)
|
||||
expect(renderBefore.querySelector('button')).toBeTruthy();
|
||||
expect(renderAfter.querySelector('button')).toBeTruthy();
|
||||
|
||||
// Now set hasNextSlide and hasPreviousSlide to false
|
||||
mockInlineSliderInstance.hasNextSlide.mockReturnValue(false);
|
||||
mockInlineSliderInstance.hasPreviousSlide.mockReturnValue(false);
|
||||
|
||||
// Trigger re-render by changing items
|
||||
rerender(<HookConsumer __items={[1, 2, 3, 4]} />);
|
||||
|
||||
// The next and previous buttons should not be rendered now
|
||||
const newRenderAfter = getByTestId('render-after');
|
||||
const newRenderBefore = getByTestId('render-before');
|
||||
expect(newRenderAfter.querySelector('button')).toBeNull();
|
||||
expect(newRenderBefore.querySelector('button')).toBeNull();
|
||||
});
|
||||
|
||||
test('winResizeListener and sidebarVisibilityChangeListener are returned as callable functions', () => {
|
||||
const TestComponentWithListeners = (props: any) => {
|
||||
const tuple = useItemListInlineSlider(props);
|
||||
|
||||
const winResizeListener = tuple[7]; // winResizeListener
|
||||
const sidebarListener = tuple[8]; // sidebarVisibilityChangeListener
|
||||
const wrapperRef = tuple[9]; // itemsListWrapperRef
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef as any} data-testid="wrapper">
|
||||
<button data-testid="trigger-resize" onClick={winResizeListener as any}>
|
||||
Trigger Resize
|
||||
</button>
|
||||
<button data-testid="trigger-sidebar" onClick={sidebarListener as any}>
|
||||
Trigger Sidebars
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByTestId } = render(<TestComponentWithListeners __items={[1, 2, 3]} />);
|
||||
|
||||
// Should not throw when called
|
||||
const resizeButton = getByTestId('trigger-resize');
|
||||
const sidebarButton = getByTestId('trigger-sidebar');
|
||||
|
||||
expect(() => fireEvent.click(resizeButton)).not.toThrow();
|
||||
expect(() => fireEvent.click(sidebarButton)).not.toThrow();
|
||||
});
|
||||
|
||||
test('winResizeListener updates resizeDate state triggering resize effect', () => {
|
||||
const TestComponent = (props: any) => {
|
||||
const tuple = useItemListInlineSlider(props) as any;
|
||||
const winResizeListener = tuple[7];
|
||||
const wrapperRef = tuple[9];
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} data-testid="wrapper">
|
||||
<button data-testid="trigger-resize" onClick={winResizeListener}>
|
||||
Trigger Resize
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByTestId } = render(<TestComponent __items={[1, 2, 3]} />);
|
||||
|
||||
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||
expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(0);
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
fireEvent.click(getByTestId('trigger-resize'));
|
||||
|
||||
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
|
||||
expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(200);
|
||||
|
||||
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(3);
|
||||
expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(2);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
190
frontend/tests/utils/hooks/useItemListLazyLoad.test.tsx
Normal file
190
frontend/tests/utils/hooks/useItemListLazyLoad.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
let mockListHandler: any;
|
||||
let addListItemsSpy = jest.fn();
|
||||
const mockRemoveListener = jest.fn();
|
||||
|
||||
jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
|
||||
useItemList: (props: any, _ref: any) => {
|
||||
mockListHandler = {
|
||||
loadItems: jest.fn(),
|
||||
totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
|
||||
loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
|
||||
};
|
||||
return [
|
||||
props.__items ?? [], // items
|
||||
props.__countedItems ?? 0, // countedItems
|
||||
mockListHandler, // listHandler
|
||||
jest.fn(), // setListHandler
|
||||
jest.fn(), // onItemsLoad
|
||||
jest.fn(), // onItemsCount
|
||||
addListItemsSpy, // addListItems
|
||||
];
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/stores/', () => ({
|
||||
PageStore: {
|
||||
removeListener: mockRemoveListener,
|
||||
},
|
||||
}));
|
||||
|
||||
import { useItemListLazyLoad } from '../../../src/static/js/utils/hooks/useItemListLazyLoad';
|
||||
|
||||
function HookConsumer(props: any) {
|
||||
const tuple = useItemListLazyLoad(props);
|
||||
|
||||
const [
|
||||
_items,
|
||||
_countedItems,
|
||||
_listHandler,
|
||||
_setListHandler,
|
||||
classname,
|
||||
_onItemsCount,
|
||||
_onItemsLoad,
|
||||
_onWindowScroll,
|
||||
_onDocumentVisibilityChange,
|
||||
_itemsListWrapperRef,
|
||||
_itemsListRef,
|
||||
renderBeforeListWrap,
|
||||
renderAfterListWrap,
|
||||
] = tuple as any;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="class-list">{classname.list}</div>
|
||||
<div data-testid="class-outer">{classname.listOuter}</div>
|
||||
<div data-testid="render-before">{renderBeforeListWrap()}</div>
|
||||
<div data-testid="render-after">{renderAfterListWrap()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HookConsumerWithRefs(props: any) {
|
||||
const tuple = useItemListLazyLoad(props);
|
||||
const [
|
||||
_items,
|
||||
_countedItems,
|
||||
_listHandler,
|
||||
_setListHandler,
|
||||
classname,
|
||||
_onItemsCount,
|
||||
_onItemsLoad,
|
||||
onWindowScroll,
|
||||
onDocumentVisibilityChange,
|
||||
itemsListWrapperRef,
|
||||
itemsListRef,
|
||||
renderBeforeListWrap,
|
||||
renderAfterListWrap,
|
||||
] = tuple as any;
|
||||
|
||||
return (
|
||||
<div ref={itemsListWrapperRef}>
|
||||
<div data-testid="class-list">{classname.list}</div>
|
||||
<div data-testid="class-outer">{classname.listOuter}</div>
|
||||
<div ref={itemsListRef} data-testid="list-ref-node" />
|
||||
<div data-testid="render-before">{renderBeforeListWrap()}</div>
|
||||
<div data-testid="render-after">{renderAfterListWrap()}</div>
|
||||
<button data-testid="trigger-visibility" onClick={onDocumentVisibilityChange} type="button">
|
||||
visibility
|
||||
</button>
|
||||
<button data-testid="trigger-scroll" onClick={onWindowScroll} type="button">
|
||||
scroll
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
describe('useItemListLazyLoad', () => {
|
||||
beforeEach(() => {
|
||||
addListItemsSpy = jest.fn();
|
||||
mockRemoveListener.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Computes classname.list and classname.listOuter with optional className prop', () => {
|
||||
const { getByTestId, rerender } = render(<HookConsumer className=" extra " />);
|
||||
expect(getByTestId('class-outer').textContent).toBe('items-list-outer extra');
|
||||
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||
rerender(<HookConsumer />);
|
||||
expect(getByTestId('class-outer').textContent).toBe('items-list-outer');
|
||||
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||
});
|
||||
|
||||
test('Invokes addListItems when items change', () => {
|
||||
const { rerender } = render(<HookConsumer __items={[]} />);
|
||||
expect(addListItemsSpy).toHaveBeenCalledTimes(1);
|
||||
rerender(<HookConsumer __items={[1]} />);
|
||||
expect(addListItemsSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('Renders nothing in renderBeforeListWrap and renderAfterListWrap', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer __items={[1]} __countedItems={1} __totalPages={3} __loadedAll={false} />
|
||||
);
|
||||
expect(getByTestId('render-before').textContent).toBe('');
|
||||
expect(getByTestId('render-after').textContent).toBe('');
|
||||
});
|
||||
|
||||
test('Does not call listHandler.loadItems when refs are not attached', () => {
|
||||
render(<HookConsumer __items={[1]} />);
|
||||
expect(mockListHandler.loadItems).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Calls listHandler.loadItems when refs are set and scroll threshold is reached', async () => {
|
||||
render(<HookConsumerWithRefs __items={[1]} __loadedAll={false} />);
|
||||
await waitFor(() => {
|
||||
expect(mockListHandler.loadItems).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('Calls PageStore.removeListener when refs are set and loadedAllItems is true', () => {
|
||||
render(<HookConsumerWithRefs __items={[1]} __loadedAll={true} />);
|
||||
expect(mockRemoveListener).toHaveBeenCalledWith('window_scroll', expect.any(Function));
|
||||
});
|
||||
|
||||
test('onDocumentVisibilityChange schedules onWindowScroll when document is visible', () => {
|
||||
jest.useFakeTimers();
|
||||
const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout');
|
||||
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
|
||||
|
||||
const { getByTestId } = render(<HookConsumerWithRefs __items={[1]} />);
|
||||
fireEvent.click(getByTestId('trigger-visibility'));
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10);
|
||||
|
||||
setTimeoutSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('onDocumentVisibilityChange does nothing when document is hidden', () => {
|
||||
jest.useFakeTimers();
|
||||
const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout');
|
||||
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
|
||||
|
||||
const { getByTestId } = render(<HookConsumerWithRefs __items={[1]} />);
|
||||
fireEvent.click(getByTestId('trigger-visibility'));
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
setTimeoutSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
156
frontend/tests/utils/hooks/useItemListSync.test.tsx
Normal file
156
frontend/tests/utils/hooks/useItemListSync.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/helpers/', () => ({
|
||||
translateString: (s: string) => s,
|
||||
}));
|
||||
|
||||
let mockListHandler: any;
|
||||
let mockOnItemsLoad = jest.fn();
|
||||
let mockOnItemsCount = jest.fn();
|
||||
let addListItemsSpy = jest.fn();
|
||||
|
||||
// Mock useItemList to control items, counts, and listHandler
|
||||
jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
|
||||
useItemList: (props: any, _ref: any) => {
|
||||
mockListHandler = {
|
||||
loadItems: jest.fn(),
|
||||
totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
|
||||
loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
|
||||
};
|
||||
return [
|
||||
props.__items ?? [], // items
|
||||
props.__countedItems ?? 0, // countedItems
|
||||
mockListHandler, // listHandler
|
||||
jest.fn(), // setListHandler
|
||||
mockOnItemsLoad, // onItemsLoad
|
||||
mockOnItemsCount, // onItemsCount
|
||||
addListItemsSpy, // addListItems
|
||||
];
|
||||
},
|
||||
}));
|
||||
|
||||
import { useItemListSync } from '../../../src/static/js/utils/hooks/useItemListSync';
|
||||
|
||||
function HookConsumer(props: any) {
|
||||
const tuple = useItemListSync(props);
|
||||
|
||||
const [
|
||||
_countedItems,
|
||||
_items,
|
||||
_listHandler,
|
||||
_setListHandler,
|
||||
classname,
|
||||
_itemsListWrapperRef,
|
||||
_itemsListRef,
|
||||
_onItemsCount,
|
||||
_onItemsLoad,
|
||||
renderBeforeListWrap,
|
||||
renderAfterListWrap,
|
||||
] = tuple as any;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* <div data-testid="counted">{String(countedItems)}</div> */}
|
||||
{/* <div data-testid="items">{Array.isArray(items) ? items.length : 0}</div> */}
|
||||
<div data-testid="class-list">{classname.list}</div>
|
||||
<div data-testid="class-outer">{classname.listOuter}</div>
|
||||
{/* <div data-testid="has-handler">{listHandler ? 'yes' : 'no'}</div> */}
|
||||
{/* <div data-testid="wrapper-ref">{itemsListWrapperRef.current ? 'set' : 'unset'}</div> */}
|
||||
{/* <div data-testid="list-ref">{itemsListRef.current ? 'set' : 'unset'}</div> */}
|
||||
<div data-testid="render-before">{renderBeforeListWrap()}</div>
|
||||
<div data-testid="render-after">{renderAfterListWrap()}</div>
|
||||
{/* <button data-testid="call-on-load" onClick={() => onItemsLoad([])} /> */}
|
||||
{/* <button data-testid="call-on-count" onClick={() => onItemsCount(0)} /> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
describe('useItemListSync', () => {
|
||||
beforeEach(() => {
|
||||
mockOnItemsLoad = jest.fn();
|
||||
mockOnItemsCount = jest.fn();
|
||||
addListItemsSpy = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Classname Management', () => {
|
||||
test('Computes classname.listOuter with optional className prop', () => {
|
||||
const { getByTestId, rerender } = render(<HookConsumer className=" extra " />);
|
||||
expect(getByTestId('class-outer').textContent).toBe('items-list-outer extra');
|
||||
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||
rerender(<HookConsumer />);
|
||||
expect(getByTestId('class-outer').textContent).toBe('items-list-outer');
|
||||
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Items Management', () => {
|
||||
test('Invokes addListItems and afterItemsLoad when items change', () => {
|
||||
const { rerender } = render(<HookConsumer __items={[]} />);
|
||||
expect(addListItemsSpy).toHaveBeenCalledTimes(1);
|
||||
rerender(<HookConsumer __items={[1]} />);
|
||||
// useEffect runs again due to items change
|
||||
expect(addListItemsSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Load More Button Rendering', () => {
|
||||
test('Renders SHOW MORE button when more pages exist and not loaded all', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer __items={[1]} __countedItems={1} __totalPages={3} __loadedAll={false} />
|
||||
);
|
||||
const btn = getByTestId('render-after').querySelector('button.load-more') as HTMLButtonElement;
|
||||
expect(btn).toBeTruthy();
|
||||
expect(btn.textContent).toBe('SHOW MORE');
|
||||
fireEvent.click(btn);
|
||||
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Hides SHOW MORE when totalPages <= 1', () => {
|
||||
const { getByTestId } = render(
|
||||
// With totalPages=1 the hook should not render the button regardless of loadedAll
|
||||
<HookConsumer __items={[1, 2]} __countedItems={2} __totalPages={1} __loadedAll={true} />
|
||||
);
|
||||
expect(getByTestId('render-after').textContent).toBe('');
|
||||
});
|
||||
|
||||
test('Hides SHOW MORE when loadedAllItems is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer __items={[1, 2, 3]} __countedItems={3} __totalPages={5} __loadedAll={true} />
|
||||
);
|
||||
expect(getByTestId('render-after').textContent).toBe('');
|
||||
});
|
||||
|
||||
test('Shows SHOW MORE when loadedAllItems is false even with totalPages > 1', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer __items={[1, 2]} __countedItems={2} __totalPages={2} __loadedAll={false} />
|
||||
);
|
||||
const btn = getByTestId('render-after').querySelector('button.load-more');
|
||||
expect(btn).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Returns null from renderBeforeListWrap', () => {
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer __items={[1]} __countedItems={1} __totalPages={3} __loadedAll={false} />
|
||||
);
|
||||
expect(getByTestId('render-before').textContent).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
118
frontend/tests/utils/hooks/useLayout.test.tsx
Normal file
118
frontend/tests/utils/hooks/useLayout.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
|
||||
import { useLayout } from '../../../src/static/js/utils/hooks/useLayout';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||
get: (key: string) => {
|
||||
let result: any = undefined;
|
||||
switch (key) {
|
||||
case 'visible-sidebar':
|
||||
result = true;
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
set: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/dispatcher.js', () => ({
|
||||
register: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||
}));
|
||||
|
||||
import { LayoutProvider } from '../../../src/static/js/utils/contexts';
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
describe('useLayout', () => {
|
||||
test('Returns default value', () => {
|
||||
let received: ReturnType<typeof useLayout> | undefined;
|
||||
|
||||
const Comp: React.FC = () => {
|
||||
received = useLayout();
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<LayoutProvider>
|
||||
<Comp />
|
||||
</LayoutProvider>
|
||||
);
|
||||
|
||||
expect(received).toStrictEqual({
|
||||
enabledSidebar: false,
|
||||
visibleSidebar: true,
|
||||
visibleMobileSearch: false,
|
||||
setVisibleSidebar: expect.any(Function),
|
||||
toggleMobileSearch: expect.any(Function),
|
||||
toggleSidebar: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
test('Returns undefined value when used without a Provider', () => {
|
||||
let received: any = 'init';
|
||||
|
||||
const Comp: React.FC = () => {
|
||||
received = useLayout();
|
||||
return null;
|
||||
};
|
||||
|
||||
render(<Comp />);
|
||||
|
||||
expect(received).toBe(undefined);
|
||||
});
|
||||
|
||||
test('Toggle sidebar', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
let received: ReturnType<typeof useLayout> | undefined;
|
||||
|
||||
const Comp: React.FC = () => {
|
||||
received = useLayout();
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<LayoutProvider>
|
||||
<Comp />
|
||||
</LayoutProvider>
|
||||
);
|
||||
|
||||
act(() => received?.toggleSidebar());
|
||||
jest.advanceTimersByTime(241);
|
||||
expect(received?.visibleSidebar).toBe(false);
|
||||
|
||||
act(() => received?.toggleSidebar());
|
||||
jest.advanceTimersByTime(241);
|
||||
expect(received?.visibleSidebar).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('Toggle mobile search', () => {
|
||||
let received: ReturnType<typeof useLayout> | undefined;
|
||||
|
||||
const Comp: React.FC = () => {
|
||||
received = useLayout();
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<LayoutProvider>
|
||||
<Comp />
|
||||
</LayoutProvider>
|
||||
);
|
||||
|
||||
act(() => received?.toggleMobileSearch());
|
||||
expect(received?.visibleMobileSearch).toBe(true);
|
||||
|
||||
act(() => received?.toggleMobileSearch());
|
||||
expect(received?.visibleMobileSearch).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
134
frontend/tests/utils/hooks/useManagementTableHeader.test.tsx
Normal file
134
frontend/tests/utils/hooks/useManagementTableHeader.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { useManagementTableHeader } from '../../../src/static/js/utils/hooks/useManagementTableHeader';
|
||||
|
||||
function HookConsumer(props: {
|
||||
order: 'asc' | 'desc';
|
||||
selected: boolean;
|
||||
sort: string;
|
||||
type: 'comments' | 'media' | 'users';
|
||||
onCheckAllRows?: (newSort: string, newOrder: 'asc' | 'desc') => void;
|
||||
onClickColumnSort?: (newSelected: boolean, newType: 'comments' | 'media' | 'users') => void;
|
||||
}) {
|
||||
const tuple = useManagementTableHeader(props) as [
|
||||
string,
|
||||
'asc' | 'desc',
|
||||
boolean,
|
||||
React.MouseEventHandler,
|
||||
() => void,
|
||||
];
|
||||
|
||||
const [sort, order, isSelected, sortByColumn, checkAll] = tuple;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="sort">{sort}</div>
|
||||
<div data-testid="order">{order}</div>
|
||||
<div data-testid="selected">{String(isSelected)}</div>
|
||||
<button id="title" data-testid="col-title" onClick={sortByColumn} />
|
||||
<button id="views" data-testid="col-views" onClick={sortByColumn} />
|
||||
<button data-testid="check-all" onClick={checkAll} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
describe('useManagementTableHeader', () => {
|
||||
test('Returns a 5-tuple in expected order and reflects initial props', () => {
|
||||
let tuple: any;
|
||||
|
||||
const Comp: React.FC = () => {
|
||||
tuple = useManagementTableHeader({ sort: 'title', order: 'asc', selected: false });
|
||||
return null;
|
||||
};
|
||||
|
||||
render(<Comp />);
|
||||
|
||||
expect(Array.isArray(tuple)).toBe(true);
|
||||
expect(tuple).toHaveLength(5);
|
||||
|
||||
const [sort, order, isSelected] = tuple;
|
||||
|
||||
expect(sort).toBe('title');
|
||||
expect(order).toBe('asc');
|
||||
expect(isSelected).toBe(false);
|
||||
});
|
||||
|
||||
test('sortByColumn toggles order when clicking same column and updates sort when clicking different column', () => {
|
||||
const onClickColumnSort = jest.fn();
|
||||
|
||||
const { getByTestId, rerender } = render(
|
||||
<HookConsumer
|
||||
sort="title"
|
||||
order="desc"
|
||||
type="media"
|
||||
selected={false}
|
||||
onClickColumnSort={onClickColumnSort}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initial state
|
||||
expect(getByTestId('sort').textContent).toBe('title');
|
||||
expect(getByTestId('order').textContent).toBe('desc');
|
||||
|
||||
// Click same column -> toggle order to asc
|
||||
fireEvent.click(getByTestId('col-title'));
|
||||
expect(onClickColumnSort).toHaveBeenLastCalledWith('title', 'asc');
|
||||
|
||||
// Rerender to ensure state settled in testing DOM
|
||||
rerender(
|
||||
<HookConsumer
|
||||
sort="title"
|
||||
order="asc"
|
||||
type="media"
|
||||
selected={false}
|
||||
onClickColumnSort={onClickColumnSort}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click same column -> toggle order to desc
|
||||
fireEvent.click(getByTestId('col-title'));
|
||||
expect(onClickColumnSort).toHaveBeenLastCalledWith('title', 'desc');
|
||||
|
||||
// Click different column -> set sort to that column and default order desc
|
||||
fireEvent.click(getByTestId('col-views'));
|
||||
expect(onClickColumnSort).toHaveBeenLastCalledWith('views', 'desc');
|
||||
});
|
||||
|
||||
test('checkAll inverts current selection and invokes callback with newSelected and type', () => {
|
||||
const onCheckAllRows = jest.fn();
|
||||
|
||||
const { getByTestId } = render(
|
||||
<HookConsumer sort="title" order="asc" selected={false} type="media" onCheckAllRows={onCheckAllRows} />
|
||||
);
|
||||
|
||||
expect(getByTestId('selected').textContent).toBe('false');
|
||||
fireEvent.click(getByTestId('check-all'));
|
||||
|
||||
// newSelected computed as !isSelected -> true
|
||||
expect(onCheckAllRows).toHaveBeenCalledWith(true, 'media');
|
||||
});
|
||||
|
||||
test('Effects update internal state when props change', () => {
|
||||
const { getByTestId, rerender } = render(
|
||||
<HookConsumer sort="title" order="asc" type="media" selected={false} />
|
||||
);
|
||||
|
||||
expect(getByTestId('sort').textContent).toBe('title');
|
||||
expect(getByTestId('order').textContent).toBe('asc');
|
||||
expect(getByTestId('selected').textContent).toBe('false');
|
||||
|
||||
rerender(<HookConsumer sort="views" order="desc" type="media" selected={true} />);
|
||||
|
||||
expect(getByTestId('sort').textContent).toBe('views');
|
||||
expect(getByTestId('order').textContent).toBe('desc');
|
||||
expect(getByTestId('selected').textContent).toBe('true');
|
||||
});
|
||||
|
||||
test('Does not throw when optional callbacks are not provided', () => {
|
||||
const { getByTestId } = render(<HookConsumer sort="x" order="desc" type="media" selected={false} />);
|
||||
expect(() => fireEvent.click(getByTestId('col-title'))).not.toThrow();
|
||||
expect(() => fireEvent.click(getByTestId('check-all'))).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
119
frontend/tests/utils/hooks/useMediaFilter.test.tsx
Normal file
119
frontend/tests/utils/hooks/useMediaFilter.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { useMediaFilter } from '../../../src/static/js/utils/hooks/useMediaFilter';
|
||||
|
||||
jest.mock('../../../src/static/js/components/_shared/popup/PopupContent', () => ({
|
||||
PopupContent: (props: any) => React.createElement('div', props, props.children),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/components/_shared/popup/PopupTrigger', () => ({
|
||||
PopupTrigger: (props: any) => React.createElement('div', props, props.children),
|
||||
}));
|
||||
|
||||
function HookConsumer({ initial }: { initial: string }) {
|
||||
const tuple = useMediaFilter(initial) as [
|
||||
React.RefObject<any>,
|
||||
string,
|
||||
React.Dispatch<React.SetStateAction<string>>,
|
||||
React.RefObject<any>,
|
||||
React.ReactNode,
|
||||
React.ReactNode,
|
||||
];
|
||||
|
||||
const [containerRef, value, setValue, popupContentRef, PopupContent, PopupTrigger] = tuple;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="container-ref">{containerRef && typeof containerRef === 'object' ? 'ok' : 'bad'}</div>
|
||||
<div data-testid="value">{value}</div>
|
||||
<button data-testid="set" onClick={() => setValue('updated')} />
|
||||
<div data-testid="popup-ref">{popupContentRef && typeof popupContentRef === 'object' ? 'ok' : 'bad'}</div>
|
||||
{typeof PopupContent === 'function' ? React.createElement(PopupContent, null, 'c') : null}
|
||||
{typeof PopupTrigger === 'function' ? React.createElement(PopupTrigger, null, 't') : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
describe('useMediaFilter', () => {
|
||||
test('Returns a 6-tuple in expected order', () => {
|
||||
let tuple: any;
|
||||
|
||||
const Comp: React.FC = () => {
|
||||
tuple = useMediaFilter('init');
|
||||
return null;
|
||||
};
|
||||
|
||||
render(<Comp />);
|
||||
|
||||
expect(Array.isArray(tuple)).toBe(true);
|
||||
expect(tuple).toHaveLength(6);
|
||||
|
||||
const [containerRef, value, setValue, popupContentRef, PopupContent, PopupTrigger] = tuple;
|
||||
|
||||
expect(containerRef).toBeDefined();
|
||||
expect(containerRef.current).toBe(null);
|
||||
expect(value).toBe('init');
|
||||
expect(typeof setValue).toBe('function');
|
||||
expect(popupContentRef).toBeDefined();
|
||||
expect(typeof PopupContent).toBe('function');
|
||||
expect(typeof PopupTrigger).toBe('function');
|
||||
});
|
||||
|
||||
test('Initial value is respected and can be updated via setter', () => {
|
||||
const { getByTestId } = render(<HookConsumer initial="first" />);
|
||||
expect(getByTestId('value').textContent).toBe('first');
|
||||
getByTestId('set').click();
|
||||
expect(getByTestId('value').textContent).toBe('updated');
|
||||
});
|
||||
|
||||
test('containerRef and popupContentRef are mutable ref objects', () => {
|
||||
let data: any;
|
||||
|
||||
const Comp: React.FC = () => {
|
||||
data = useMediaFilter('x');
|
||||
return null;
|
||||
};
|
||||
|
||||
render(<Comp />);
|
||||
|
||||
const [containerRef, _value, _setValue, popupContentRef] = data;
|
||||
|
||||
expect(containerRef.current).toBe(null);
|
||||
expect(popupContentRef.current).toBe(null);
|
||||
});
|
||||
|
||||
test('PopupContent and PopupTrigger are stable functions', () => {
|
||||
let first: any;
|
||||
let second: any;
|
||||
|
||||
const First: React.FC = () => {
|
||||
first = useMediaFilter('a');
|
||||
return null;
|
||||
};
|
||||
|
||||
const Second: React.FC = () => {
|
||||
second = useMediaFilter('b');
|
||||
return null;
|
||||
};
|
||||
|
||||
const Parent: React.FC = () => (
|
||||
<>
|
||||
<First />
|
||||
<Second />
|
||||
</>
|
||||
);
|
||||
|
||||
render(<Parent />);
|
||||
|
||||
const [, , , , PopupContent1, PopupTrigger1] = first;
|
||||
const [, , , , PopupContent2, PopupTrigger2] = second;
|
||||
|
||||
expect(typeof PopupContent1).toBe('function');
|
||||
expect(typeof PopupTrigger1).toBe('function');
|
||||
|
||||
expect(PopupContent1).toBe(PopupContent2);
|
||||
expect(PopupTrigger1).toBe(PopupTrigger2);
|
||||
});
|
||||
});
|
||||
});
|
||||
289
frontend/tests/utils/hooks/useMediaItem.test.tsx
Normal file
289
frontend/tests/utils/hooks/useMediaItem.test.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { useMediaItem, itemClassname } from '../../../src/static/js/utils/hooks/useMediaItem';
|
||||
|
||||
// Mock dependencies used by useMediaItem
|
||||
|
||||
// @todo: Revisit this
|
||||
jest.mock('../../../src/static/js/utils/stores/', () => ({
|
||||
PageStore: { get: (_: string) => ({ url: 'https://example.com' }) },
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/components/list-item/includes/items', () => ({
|
||||
MediaItemAuthor: ({ name }: any) => <div data-testid="author" data-name={name} />,
|
||||
MediaItemAuthorLink: ({ name, link }: any) => (
|
||||
<a data-testid="author-link" data-name={name} href={link || undefined} />
|
||||
),
|
||||
MediaItemMetaViews: ({ views }: any) => <span data-testid="views" data-views={views} />,
|
||||
MediaItemMetaDate: ({ time, dateTime, text }: any) => (
|
||||
<time data-testid="date" data-time={String(time)} data-datetime={String(dateTime)}>
|
||||
{text}
|
||||
</time>
|
||||
),
|
||||
MediaItemEditLink: ({ link }: any) => <a data-testid="edit" href={link} />,
|
||||
MediaItemViewLink: ({ link }: any) => <a data-testid="view" href={link} />,
|
||||
}));
|
||||
|
||||
// @todo: Revisit this
|
||||
// useItem returns titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper
|
||||
jest.mock('../../../src/static/js/utils/hooks/useItem', () => ({
|
||||
useItem: (props: any) => ({
|
||||
titleComponent: () => <h3 data-testid="title">{props.title || 'title'}</h3>,
|
||||
descriptionComponent: () => <p data-testid="desc">{props.description || 'desc'}</p>,
|
||||
thumbnailUrl: props.thumb || 'thumb.jpg',
|
||||
UnderThumbWrapper: ({ children }: any) => <div data-testid="under-thumb">{children}</div>,
|
||||
}),
|
||||
}));
|
||||
|
||||
function HookConsumer(props: any) {
|
||||
const [TitleComp, DescComp, thumbUrl, UnderThumbComp, EditComp, MetaComp, ViewComp] = useMediaItem(props);
|
||||
// The hook returns functions/components/values. To satisfy TS, render using React.createElement
|
||||
return (
|
||||
<div>
|
||||
{typeof TitleComp === 'function' ? React.createElement(TitleComp) : null}
|
||||
{typeof DescComp === 'function' ? React.createElement(DescComp) : null}
|
||||
<div data-testid="thumb">{typeof thumbUrl === 'string' ? thumbUrl : ''}</div>
|
||||
{typeof UnderThumbComp === 'function'
|
||||
? React.createElement(
|
||||
UnderThumbComp,
|
||||
null,
|
||||
typeof EditComp === 'function' ? React.createElement(EditComp) : null,
|
||||
typeof MetaComp === 'function' ? React.createElement(MetaComp) : null,
|
||||
typeof ViewComp === 'function' ? React.createElement(ViewComp) : null
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
describe('useMediaItem', () => {
|
||||
describe('itemClassname utility function', () => {
|
||||
test('Returns default classname when no modifications', () => {
|
||||
expect(itemClassname('base', '', false)).toBe('base');
|
||||
});
|
||||
|
||||
test('Appends inherited classname when provided', () => {
|
||||
expect(itemClassname('base', 'extra', false)).toBe('base extra');
|
||||
});
|
||||
|
||||
test('Appends pl-active-item when isActiveInPlaylistPlayback is true', () => {
|
||||
expect(itemClassname('base', '', true)).toBe('base pl-active-item');
|
||||
});
|
||||
|
||||
test('Appends both inherited classname and active state', () => {
|
||||
expect(itemClassname('base', 'extra', true)).toBe('base extra pl-active-item');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
test('Renders basic components from useItem and edit/view links', () => {
|
||||
// @todo: Revisit this
|
||||
const props = {
|
||||
title: 'My Title',
|
||||
description: 'My Desc',
|
||||
thumbnail: 'thumb.jpg',
|
||||
link: '/watch/1',
|
||||
singleLinkContent: true,
|
||||
// hasMediaViewer:...
|
||||
// hasMediaViewerDescr:...
|
||||
// meta_description:...
|
||||
// onMount:...
|
||||
// type:...
|
||||
// ------------------------------
|
||||
editLink: '/edit/1',
|
||||
showSelection: true,
|
||||
// publishLink: ...
|
||||
// hideAuthor:...
|
||||
author_name: 'Author',
|
||||
author_link: '/u/author',
|
||||
// hideViews:...
|
||||
views: 10,
|
||||
// hideDate:...
|
||||
publish_date: '2020-01-01T00:00:00Z',
|
||||
// hideAllMeta:...
|
||||
};
|
||||
|
||||
const { getByTestId, queryByTestId } = render(<HookConsumer {...props} />);
|
||||
|
||||
expect(getByTestId('title').textContent).toBe(props.title);
|
||||
expect(getByTestId('desc').textContent).toBe(props.description);
|
||||
expect(getByTestId('thumb').textContent).toBe('thumb.jpg');
|
||||
|
||||
expect(getByTestId('edit').getAttribute('href')).toBe(props.editLink);
|
||||
|
||||
expect(getByTestId('views').getAttribute('data-views')).toBe(props.views.toString());
|
||||
expect(getByTestId('date')).toBeTruthy();
|
||||
expect(getByTestId('view').getAttribute('href')).toBe(props.link);
|
||||
expect(queryByTestId('author')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('View Link Selection', () => {
|
||||
test('Uses publishLink when provided and showSelection=true', () => {
|
||||
const props = {
|
||||
editLink: '/edit/2',
|
||||
link: '/watch/2',
|
||||
publishLink: '/publish/2',
|
||||
showSelection: true,
|
||||
singleLinkContent: true,
|
||||
author_name: 'A',
|
||||
author_link: '',
|
||||
views: 0,
|
||||
publish_date: 0,
|
||||
};
|
||||
|
||||
const { getByTestId } = render(<HookConsumer {...props} />);
|
||||
|
||||
expect(getByTestId('view').getAttribute('href')).toBe(props.publishLink);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visibility Controls', () => {
|
||||
test('Hides author, views, and date based on props', () => {
|
||||
const props = {
|
||||
editLink: '/e',
|
||||
link: '/l',
|
||||
showSelection: true,
|
||||
hideAuthor: true,
|
||||
hideViews: true,
|
||||
hideDate: true,
|
||||
publish_date: '2020-01-01T00:00:00Z',
|
||||
views: 5,
|
||||
author_name: 'Hidden',
|
||||
author_link: '/u/x',
|
||||
};
|
||||
|
||||
const { queryByTestId } = render(<HookConsumer {...props} />);
|
||||
|
||||
expect(queryByTestId('author')).toBeNull();
|
||||
expect(queryByTestId('views')).toBeNull();
|
||||
expect(queryByTestId('date')).toBeNull();
|
||||
});
|
||||
|
||||
test('Author link resolves using formatInnerLink and PageStore base url when singleLinkContent=false', () => {
|
||||
const props = {
|
||||
editLink: '/e',
|
||||
link: '/l',
|
||||
showSelection: true,
|
||||
singleLinkContent: false,
|
||||
hideAuthor: false,
|
||||
author_name: 'John',
|
||||
author_link: '/u/john',
|
||||
publish_date: '2020-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const { container } = render(<HookConsumer {...props} />);
|
||||
|
||||
const a = container.querySelector('[data-testid="author-link"]') as HTMLAnchorElement;
|
||||
|
||||
expect(a).toBeTruthy();
|
||||
expect(a.getAttribute('href')).toBe(`https://example.com${props.author_link}`);
|
||||
expect(a.getAttribute('data-name')).toBe(props.author_name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meta Visibility', () => {
|
||||
test('Meta wrapper hidden when hideAllMeta=true', () => {
|
||||
const props = {
|
||||
editLink: '/e',
|
||||
link: '/l',
|
||||
showSelection: true,
|
||||
hideAllMeta: true,
|
||||
publish_date: '2020-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const { queryByTestId } = render(<HookConsumer {...props} />);
|
||||
|
||||
expect(queryByTestId('author')).toBeNull();
|
||||
expect(queryByTestId('views')).toBeNull();
|
||||
expect(queryByTestId('date')).toBeNull();
|
||||
});
|
||||
|
||||
test('Meta wrapper hidden individually by hideAuthor, hideViews, hideDate', () => {
|
||||
const props = {
|
||||
editLink: '/e',
|
||||
link: '/l',
|
||||
showSelection: true,
|
||||
hideAuthor: true,
|
||||
hideViews: false,
|
||||
hideDate: false,
|
||||
publish_date: '2020-01-01T00:00:00Z',
|
||||
views: 5,
|
||||
author_name: 'Test',
|
||||
author_link: '/u/test',
|
||||
};
|
||||
|
||||
const { queryByTestId } = render(<HookConsumer {...props} />);
|
||||
|
||||
expect(queryByTestId('author')).toBeNull();
|
||||
expect(queryByTestId('views')).toBeTruthy();
|
||||
expect(queryByTestId('date')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases & Date Handling', () => {
|
||||
test('Handles views when hideViews is false', () => {
|
||||
const props = {
|
||||
editLink: '/e',
|
||||
link: '/l',
|
||||
showSelection: true,
|
||||
hideViews: false,
|
||||
views: 100,
|
||||
publish_date: '2020-01-01T00:00:00Z',
|
||||
author_name: 'A',
|
||||
author_link: '/u/a',
|
||||
};
|
||||
|
||||
const { getByTestId } = render(<HookConsumer {...props} />);
|
||||
expect(getByTestId('views')).toBeTruthy();
|
||||
expect(getByTestId('views').getAttribute('data-views')).toBe('100');
|
||||
});
|
||||
|
||||
test('Renders without showSelection', () => {
|
||||
const props = {
|
||||
editLink: '/e',
|
||||
link: '/l',
|
||||
showSelection: false,
|
||||
publish_date: '2020-01-01T00:00:00Z',
|
||||
author_name: 'A',
|
||||
author_link: '/u/a',
|
||||
};
|
||||
|
||||
const { queryByTestId } = render(<HookConsumer {...props} />);
|
||||
expect(queryByTestId('view')).toBeNull();
|
||||
});
|
||||
|
||||
test('Handles numeric publish_date correctly', () => {
|
||||
const props = {
|
||||
editLink: '/e',
|
||||
link: '/l',
|
||||
showSelection: true,
|
||||
publish_date: 1577836800000, // 2020-01-01 as timestamp
|
||||
author_name: 'A',
|
||||
author_link: '/u/a',
|
||||
};
|
||||
|
||||
const { getByTestId } = render(<HookConsumer {...props} />);
|
||||
expect(getByTestId('date')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Handles empty author_link by setting it to null', () => {
|
||||
const props = {
|
||||
editLink: '/e',
|
||||
link: '/l',
|
||||
showSelection: true,
|
||||
singleLinkContent: false,
|
||||
author_name: 'Anonymous',
|
||||
author_link: '', // Empty link
|
||||
publish_date: '2020-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const { container } = render(<HookConsumer {...props} />);
|
||||
const authorLink = container.querySelector('[data-testid="author-link"]') as HTMLAnchorElement;
|
||||
expect(authorLink).toBeTruthy();
|
||||
expect(authorLink.getAttribute('href')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
68
frontend/tests/utils/hooks/usePopup.test.tsx
Normal file
68
frontend/tests/utils/hooks/usePopup.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
// Mock popup components to avoid SCSS imports breaking Jest
|
||||
jest.mock('../../../src/static/js/components/_shared/popup/Popup.jsx', () => {
|
||||
const React = require('react');
|
||||
const Popup = React.forwardRef((props: any, _ref: any) => React.createElement('div', props, props.children));
|
||||
return { __esModule: true, default: Popup };
|
||||
});
|
||||
|
||||
jest.mock('../../../src/static/js/components/_shared/popup/PopupContent.jsx', () => ({
|
||||
PopupContent: (props: any) => React.createElement('div', props, props.children),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/components/_shared/popup/PopupTrigger.jsx', () => ({
|
||||
PopupTrigger: (props: any) => React.createElement('div', props, props.children),
|
||||
}));
|
||||
|
||||
import { usePopup } from '../../../src/static/js/utils/hooks/usePopup';
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
describe('usePopup', () => {
|
||||
test('Returns a 3-tuple: [ref, PopupContent, PopupTrigger]', () => {
|
||||
let value: any;
|
||||
|
||||
const Comp: React.FC = () => {
|
||||
value = usePopup();
|
||||
return null;
|
||||
};
|
||||
|
||||
render(<Comp />);
|
||||
|
||||
expect(Array.isArray(value)).toBe(true);
|
||||
expect(value).toHaveLength(3);
|
||||
|
||||
const [ref, PopupContent, PopupTrigger] = value;
|
||||
|
||||
expect(ref).toBeDefined();
|
||||
expect(ref.current).toBe(null);
|
||||
|
||||
expect(typeof PopupContent).toBe('function');
|
||||
expect(typeof PopupTrigger).toBe('function');
|
||||
});
|
||||
|
||||
test('Tuple components are stable functions and refs are unique per call', () => {
|
||||
let results: any[] = [];
|
||||
|
||||
const Comp: React.FC = () => {
|
||||
results.push(usePopup());
|
||||
results.push(usePopup());
|
||||
return null;
|
||||
};
|
||||
|
||||
render(<Comp />);
|
||||
|
||||
const [ref1, PopupContent1, PopupTrigger1] = results[0];
|
||||
const [ref2, PopupContent2, PopupTrigger2] = results[1];
|
||||
|
||||
expect(typeof PopupContent1).toBe('function');
|
||||
expect(typeof PopupTrigger1).toBe('function');
|
||||
|
||||
expect(PopupContent1).toBe(PopupContent2);
|
||||
expect(PopupTrigger1).toBe(PopupTrigger2);
|
||||
|
||||
expect(ref1).not.toBe(ref2);
|
||||
});
|
||||
});
|
||||
});
|
||||
100
frontend/tests/utils/hooks/useTheme.test.tsx
Normal file
100
frontend/tests/utils/hooks/useTheme.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
|
||||
import { useTheme as useThemeHook } from '../../../src/static/js/utils/hooks/useTheme';
|
||||
|
||||
import { sampleMediaCMSConfig } from '../../tests-constants';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/dispatcher.js', () => ({
|
||||
register: jest.fn(),
|
||||
}));
|
||||
|
||||
function getRenderers(ThemeProvider: React.FC<{ children: React.ReactNode }>, useTheme: typeof useThemeHook) {
|
||||
const data: { current: any } = { current: undefined };
|
||||
|
||||
const Comp: React.FC = () => {
|
||||
data.current = useTheme();
|
||||
return null;
|
||||
};
|
||||
|
||||
const wrapper: typeof ThemeProvider = ({ children }) => <ThemeProvider>{children}</ThemeProvider>;
|
||||
|
||||
return { Comp, wrapper, data };
|
||||
}
|
||||
|
||||
function getThemeConfig(override?: {
|
||||
logo?: Partial<(typeof sampleMediaCMSConfig.theme)['logo']>;
|
||||
mode?: (typeof sampleMediaCMSConfig.theme)['mode'];
|
||||
switch?: Partial<(typeof sampleMediaCMSConfig.theme)['switch']>;
|
||||
}) {
|
||||
const { logo, mode, switch: sw } = override ?? {};
|
||||
const { lightMode, darkMode } = logo ?? {};
|
||||
|
||||
const config = {
|
||||
logo: {
|
||||
lightMode: { img: lightMode?.img ?? '/img/light.png', svg: lightMode?.svg ?? '/img/light.svg' },
|
||||
darkMode: { img: darkMode?.img ?? '/img/dark.png', svg: darkMode?.svg ?? '/img/dark.svg' },
|
||||
},
|
||||
mode: mode ?? 'dark',
|
||||
switch: { enabled: sw?.enabled ?? true, position: sw?.position ?? 'sidebar' },
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useTheme', () => {
|
||||
const themeConfig = getThemeConfig();
|
||||
const darkThemeConfig = getThemeConfig({ mode: 'dark' });
|
||||
|
||||
// @todo: Revisit this
|
||||
test.each([
|
||||
[
|
||||
darkThemeConfig,
|
||||
{
|
||||
logo: darkThemeConfig.logo.darkMode.svg,
|
||||
currentThemeMode: darkThemeConfig.mode,
|
||||
changeThemeMode: expect.any(Function),
|
||||
themeModeSwitcher: themeConfig.switch,
|
||||
},
|
||||
],
|
||||
])('Validate value', async (theme, expectedResult) => {
|
||||
jest.doMock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => ({ ...jest.requireActual('../../tests-constants').sampleMediaCMSConfig, theme })),
|
||||
}));
|
||||
|
||||
const { ThemeProvider } = await import('../../../src/static/js/utils/contexts/ThemeContext');
|
||||
const { useTheme } = await import('../../../src/static/js/utils/hooks/useTheme');
|
||||
|
||||
const { Comp, wrapper, data } = getRenderers(ThemeProvider, useTheme);
|
||||
|
||||
render(<Comp />, { wrapper });
|
||||
|
||||
expect(data.current).toStrictEqual(expectedResult);
|
||||
|
||||
act(() => data.current.changeThemeMode());
|
||||
|
||||
const newThemeMode = 'light' === expectedResult.currentThemeMode ? 'dark' : 'light';
|
||||
const newThemeLogo =
|
||||
'light' === newThemeMode ? themeConfig.logo.lightMode.svg : themeConfig.logo.darkMode.svg;
|
||||
|
||||
expect(data.current).toStrictEqual({
|
||||
...expectedResult,
|
||||
logo: newThemeLogo,
|
||||
currentThemeMode: newThemeMode,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
45
frontend/tests/utils/hooks/useUser.test.tsx
Normal file
45
frontend/tests/utils/hooks/useUser.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { UserProvider } from '../../../src/static/js/utils/contexts/UserContext';
|
||||
import { useUser } from '../../../src/static/js/utils/hooks/useUser';
|
||||
import { sampleMediaCMSConfig } from '../../tests-constants';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||
}));
|
||||
|
||||
function getRenderers() {
|
||||
const data: { current: any } = { current: undefined };
|
||||
|
||||
const Comp: React.FC = () => {
|
||||
data.current = useUser();
|
||||
return null;
|
||||
};
|
||||
|
||||
const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => <UserProvider>{children}</UserProvider>;
|
||||
|
||||
return { Comp, wrapper, data };
|
||||
}
|
||||
|
||||
describe('utils/hooks', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useUser', () => {
|
||||
test('Validate value', () => {
|
||||
const { Comp, wrapper, data } = getRenderers();
|
||||
|
||||
render(<Comp />, { wrapper });
|
||||
|
||||
expect(data.current).toStrictEqual({
|
||||
isAnonymous: sampleMediaCMSConfig.member.is.anonymous,
|
||||
username: sampleMediaCMSConfig.member.username,
|
||||
thumbnail: sampleMediaCMSConfig.member.thumbnail,
|
||||
userCan: sampleMediaCMSConfig.member.can,
|
||||
pages: sampleMediaCMSConfig.member.pages,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
739
frontend/tests/utils/stores/MediaPageStore.test.ts
Normal file
739
frontend/tests/utils/stores/MediaPageStore.test.ts
Normal file
@@ -0,0 +1,739 @@
|
||||
import { csrfToken, deleteRequest, getRequest, postRequest, putRequest } from '../../../src/static/js/utils/helpers';
|
||||
|
||||
const MEDIA_ID = 'MEDIA_ID';
|
||||
const PLAYLIST_ID = 'PLAYLIST_ID';
|
||||
|
||||
window.history.pushState({}, '', `/?m=${MEDIA_ID}&pl=${PLAYLIST_ID}`);
|
||||
|
||||
import store from '../../../src/static/js/utils/stores/MediaPageStore';
|
||||
|
||||
import { sampleGlobalMediaCMS, sampleMediaCMSConfig } from '../../tests-constants';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||
BrowserEvents: jest.fn().mockImplementation(() => ({
|
||||
doc: jest.fn(),
|
||||
win: jest.fn(),
|
||||
})),
|
||||
csrfToken: jest.fn(),
|
||||
deleteRequest: jest.fn(),
|
||||
exportStore: jest.fn((store) => store),
|
||||
getRequest: jest.fn(),
|
||||
postRequest: jest.fn(),
|
||||
putRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('utils/store', () => {
|
||||
describe('MediaPageStore', () => {
|
||||
const handler = store.actions_handler.bind(store);
|
||||
|
||||
const onLoadedViewerPlaylistData = jest.fn();
|
||||
const onLoadedPagePlaylistData = jest.fn();
|
||||
const onLoadedViewerPlaylistError = jest.fn();
|
||||
const onLoadedVideoData = jest.fn();
|
||||
const onLoadedImageData = jest.fn();
|
||||
const onLoadedMediaData = jest.fn();
|
||||
const onLoadedMediaError = jest.fn();
|
||||
const onCommentsLoad = jest.fn();
|
||||
const onUsersLoad = jest.fn();
|
||||
const onPlaylistsLoad = jest.fn();
|
||||
const onLikedMediaFailedRequest = jest.fn();
|
||||
const onLikedMedia = jest.fn();
|
||||
const onDislikedMediaFailedRequest = jest.fn();
|
||||
const onDislikedMedia = jest.fn();
|
||||
const onReportedMedia = jest.fn();
|
||||
const onPlaylistCreationCompleted = jest.fn();
|
||||
const onPlaylistCreationFailed = jest.fn();
|
||||
const onMediaPlaylistAdditionCompleted = jest.fn();
|
||||
const onMediaPlaylistAdditionFailed = jest.fn();
|
||||
const onMediaPlaylistRemovalCompleted = jest.fn();
|
||||
const onMediaPlaylistRemovalFailed = jest.fn();
|
||||
const onCopiedMediaLink = jest.fn();
|
||||
const onCopiedEmbedMediaCode = jest.fn();
|
||||
const onMediaDelete = jest.fn();
|
||||
const onMediaDeleteFail = jest.fn();
|
||||
const onCommentDeleteFail = jest.fn();
|
||||
const onCommentDelete = jest.fn();
|
||||
const onCommentSubmitFail = jest.fn();
|
||||
const onCommentSubmit = jest.fn();
|
||||
|
||||
store.on('loaded_viewer_playlist_data', onLoadedViewerPlaylistData);
|
||||
store.on('loaded_page_playlist_data', onLoadedPagePlaylistData);
|
||||
store.on('loaded_viewer_playlist_error', onLoadedViewerPlaylistError);
|
||||
store.on('loaded_video_data', onLoadedVideoData);
|
||||
store.on('loaded_image_data', onLoadedImageData);
|
||||
store.on('loaded_media_data', onLoadedMediaData);
|
||||
store.on('loaded_media_error', onLoadedMediaError);
|
||||
store.on('comments_load', onCommentsLoad);
|
||||
store.on('users_load', onUsersLoad);
|
||||
store.on('playlists_load', onPlaylistsLoad);
|
||||
store.on('liked_media_failed_request', onLikedMediaFailedRequest);
|
||||
store.on('liked_media', onLikedMedia);
|
||||
store.on('disliked_media_failed_request', onDislikedMediaFailedRequest);
|
||||
store.on('disliked_media', onDislikedMedia);
|
||||
store.on('reported_media', onReportedMedia);
|
||||
store.on('playlist_creation_completed', onPlaylistCreationCompleted);
|
||||
store.on('playlist_creation_failed', onPlaylistCreationFailed);
|
||||
store.on('media_playlist_addition_completed', onMediaPlaylistAdditionCompleted);
|
||||
store.on('media_playlist_addition_failed', onMediaPlaylistAdditionFailed);
|
||||
store.on('media_playlist_removal_completed', onMediaPlaylistRemovalCompleted);
|
||||
store.on('media_playlist_removal_failed', onMediaPlaylistRemovalFailed);
|
||||
store.on('copied_media_link', onCopiedMediaLink);
|
||||
store.on('copied_embed_media_code', onCopiedEmbedMediaCode);
|
||||
store.on('media_delete', onMediaDelete);
|
||||
store.on('media_delete_fail', onMediaDeleteFail);
|
||||
store.on('comment_delete_fail', onCommentDeleteFail);
|
||||
store.on('comment_delete', onCommentDelete);
|
||||
store.on('comment_submit_fail', onCommentSubmitFail);
|
||||
store.on('comment_submit', onCommentSubmit);
|
||||
|
||||
beforeAll(() => {
|
||||
(globalThis as any).window.MediaCMS = {
|
||||
// mediaId: MEDIA_ID, // @note: It doesn't belong in 'sampleGlobalMediaCMS, but it could be used
|
||||
features: sampleGlobalMediaCMS.features,
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete (globalThis as any).window.MediaCMS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Validate initial values', () => {
|
||||
expect(store.get('users')).toStrictEqual([]);
|
||||
expect(store.get('playlists')).toStrictEqual([]);
|
||||
expect(store.get('media-load-error-type')).toBe(null);
|
||||
expect(store.get('media-load-error-message')).toBe(null);
|
||||
expect(store.get('media-comments')).toStrictEqual([]);
|
||||
expect(store.get('media-data')).toBe(null);
|
||||
expect(store.get('media-id')).toBe(MEDIA_ID);
|
||||
expect(store.get('media-url')).toBe('N/A');
|
||||
expect(store.get('media-edit-subtitle-url')).toBe(null);
|
||||
expect(store.get('media-likes')).toBe('N/A');
|
||||
expect(store.get('media-dislikes')).toBe('N/A');
|
||||
expect(store.get('media-summary')).toBe(null);
|
||||
expect(store.get('media-categories')).toStrictEqual([]);
|
||||
expect(store.get('media-tags')).toStrictEqual([]);
|
||||
expect(store.get('media-type')).toBe(null);
|
||||
expect(store.get('media-original-url')).toBe(null);
|
||||
expect(store.get('media-thumbnail-url')).toBe(null);
|
||||
expect(store.get('user-liked-media')).toBe(false);
|
||||
expect(store.get('user-disliked-media')).toBe(false);
|
||||
expect(store.get('media-author-thumbnail-url')).toBe(null);
|
||||
expect(store.get('playlist-data')).toBe(null);
|
||||
expect(store.get('playlist-id')).toBe(null);
|
||||
expect(store.get('playlist-next-media-url')).toBe(null);
|
||||
expect(store.get('playlist-previous-media-url')).toBe(null);
|
||||
});
|
||||
|
||||
describe('Trigger and validate actions behavior', () => {
|
||||
const MEDIA_DATA = {
|
||||
add_subtitle_url: '/MEDIA_DATA_ADD_SUBTITLE_URL',
|
||||
author_thumbnail: 'MEDIA_DATA_AUTHOR_THUMBNAIL',
|
||||
categories_info: [
|
||||
{ title: 'Art', url: '/search?c=Art' },
|
||||
{ title: 'Documentary', url: '/search?c=Documentary' },
|
||||
],
|
||||
likes: 12,
|
||||
dislikes: 4,
|
||||
media_type: 'video',
|
||||
original_media_url: 'MEDIA_DATA_ORIGINAL_MEDIA_URL',
|
||||
reported_times: 0,
|
||||
summary: 'MEDIA_DATA_SUMMARY',
|
||||
tags_info: [
|
||||
{ title: 'and', url: '/search?t=and' },
|
||||
{ title: 'behavior', url: '/search?t=behavior' },
|
||||
],
|
||||
thumbnail_url: 'MEDIA_DATA_THUMBNAIL_URL',
|
||||
url: '/MEDIA_DATA_URL',
|
||||
};
|
||||
const PLAYLIST_DATA = {
|
||||
playlist_media: [
|
||||
{ friendly_token: `${MEDIA_ID}_2`, url: '/PLAYLIT_MEDIA_URL_2' },
|
||||
{ friendly_token: MEDIA_ID, url: '/PLAYLIT_MEDIA_URL_1' },
|
||||
{ friendly_token: `${MEDIA_ID}_3`, url: '/PLAYLIT_MEDIA_URL_3' },
|
||||
],
|
||||
};
|
||||
const USER_PLAYLIST_DATA = { playlist_media: [{ url: 'm=PLAYLIST_MEDIA_ID' }] };
|
||||
|
||||
test('Action type: "LOAD_MEDIA_DATA"', () => {
|
||||
const MEDIA_API_URL = `${sampleMediaCMSConfig.api.media}/${MEDIA_ID}`;
|
||||
const MEDIA_COMMENTS_API_URL = `${sampleMediaCMSConfig.api.media}/${MEDIA_ID}/comments`;
|
||||
const PLAYLIST_API_URL = `${sampleMediaCMSConfig.api.playlists}/${PLAYLIST_ID}`;
|
||||
const USERS_API_URL = sampleMediaCMSConfig.api.users;
|
||||
const USER_PLAYLISTS_API_URL = `${sampleMediaCMSConfig.api.user.playlists}${sampleMediaCMSConfig.member.username}`;
|
||||
const USER_PLAYLIST_API_URL = `${sampleMediaCMSConfig.site.url}/${'PLAYLIST_API_URL'.replace(/^\//g, '')}`;
|
||||
|
||||
const MEDIA_COMMENTS_RESULTS = ['COMMENT_ID_1'];
|
||||
const USERS_RESULTS = ['USER_ID_1'];
|
||||
const USER_PLAYLISTS_RESULTS = [
|
||||
{
|
||||
url: `/${PLAYLIST_ID}`,
|
||||
user: sampleMediaCMSConfig.member.username,
|
||||
title: 'PLAYLIST_TITLE',
|
||||
description: 'PLAYLIST_DECRIPTION',
|
||||
add_date: 'PLAYLIST_ADD_DATE',
|
||||
api_url: 'PLAYLIST_API_URL',
|
||||
},
|
||||
];
|
||||
|
||||
(getRequest as jest.Mock).mockImplementation((url, _cache, successCallback, _failCallback) => {
|
||||
if (url === PLAYLIST_API_URL) {
|
||||
return successCallback({ data: PLAYLIST_DATA });
|
||||
}
|
||||
|
||||
if (url === USER_PLAYLIST_API_URL) {
|
||||
return successCallback({ data: USER_PLAYLIST_DATA });
|
||||
}
|
||||
|
||||
if (url === MEDIA_API_URL) {
|
||||
return successCallback({ data: MEDIA_DATA });
|
||||
}
|
||||
|
||||
if (url === USERS_API_URL) {
|
||||
return successCallback({ data: { count: USERS_RESULTS.length, results: USERS_RESULTS } });
|
||||
}
|
||||
|
||||
if (url === MEDIA_COMMENTS_API_URL) {
|
||||
return successCallback({
|
||||
data: { count: MEDIA_COMMENTS_RESULTS.length, results: MEDIA_COMMENTS_RESULTS },
|
||||
});
|
||||
}
|
||||
|
||||
if (url === USER_PLAYLISTS_API_URL) {
|
||||
return successCallback({
|
||||
data: { count: USER_PLAYLISTS_RESULTS.length, results: USER_PLAYLISTS_RESULTS },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
handler({ type: 'LOAD_MEDIA_DATA' });
|
||||
|
||||
expect(getRequest).toHaveBeenCalledTimes(6);
|
||||
|
||||
expect(getRequest).toHaveBeenCalledWith(
|
||||
PLAYLIST_API_URL,
|
||||
false,
|
||||
store.playlistDataResponse,
|
||||
store.playlistDataErrorResponse
|
||||
);
|
||||
expect(getRequest).toHaveBeenCalledWith(
|
||||
MEDIA_API_URL,
|
||||
false,
|
||||
store.dataResponse,
|
||||
store.dataErrorResponse
|
||||
);
|
||||
expect(getRequest).toHaveBeenCalledWith(MEDIA_COMMENTS_API_URL, false, store.commentsResponse);
|
||||
expect(getRequest).toHaveBeenCalledWith(USERS_API_URL, false, store.usersResponse);
|
||||
expect(getRequest).toHaveBeenCalledWith(USER_PLAYLISTS_API_URL, false, store.playlistsResponse);
|
||||
expect(getRequest).toHaveBeenCalledWith(USER_PLAYLIST_API_URL, false, expect.any(Function));
|
||||
|
||||
expect(onLoadedViewerPlaylistData).toHaveBeenCalledTimes(1);
|
||||
expect(onLoadedPagePlaylistData).toHaveBeenCalledTimes(1);
|
||||
expect(onLoadedViewerPlaylistError).toHaveBeenCalledTimes(0);
|
||||
expect(onLoadedVideoData).toHaveBeenCalledTimes(1);
|
||||
expect(onLoadedImageData).toHaveBeenCalledTimes(0);
|
||||
expect(onLoadedMediaData).toHaveBeenCalledTimes(1);
|
||||
expect(onLoadedMediaError).toHaveBeenCalledTimes(0);
|
||||
expect(onCommentsLoad).toHaveBeenCalledTimes(1);
|
||||
expect(onUsersLoad).toHaveBeenCalledTimes(1);
|
||||
expect(onPlaylistsLoad).toHaveBeenCalledTimes(1);
|
||||
expect(onLikedMediaFailedRequest).toHaveBeenCalledTimes(0);
|
||||
expect(onLikedMedia).toHaveBeenCalledTimes(0);
|
||||
expect(onDislikedMediaFailedRequest).toHaveBeenCalledTimes(0);
|
||||
expect(onDislikedMedia).toHaveBeenCalledTimes(0);
|
||||
expect(onReportedMedia).toHaveBeenCalledTimes(0);
|
||||
expect(onPlaylistCreationCompleted).toHaveBeenCalledTimes(0);
|
||||
expect(onPlaylistCreationFailed).toHaveBeenCalledTimes(0);
|
||||
expect(onMediaPlaylistAdditionCompleted).toHaveBeenCalledTimes(0);
|
||||
expect(onMediaPlaylistAdditionFailed).toHaveBeenCalledTimes(0);
|
||||
expect(onMediaPlaylistRemovalCompleted).toHaveBeenCalledTimes(0);
|
||||
expect(onMediaPlaylistRemovalFailed).toHaveBeenCalledTimes(0);
|
||||
expect(onCopiedMediaLink).toHaveBeenCalledTimes(0);
|
||||
expect(onCopiedEmbedMediaCode).toHaveBeenCalledTimes(0);
|
||||
expect(onMediaDelete).toHaveBeenCalledTimes(0);
|
||||
expect(onMediaDeleteFail).toHaveBeenCalledTimes(0);
|
||||
expect(onCommentDeleteFail).toHaveBeenCalledTimes(0);
|
||||
expect(onCommentDelete).toHaveBeenCalledTimes(0);
|
||||
expect(onCommentSubmitFail).toHaveBeenCalledTimes(0);
|
||||
expect(onCommentSubmit).toHaveBeenCalledTimes(0);
|
||||
|
||||
expect(store.isVideo()).toBeTruthy();
|
||||
|
||||
expect(store.get('users')).toStrictEqual(USERS_RESULTS);
|
||||
expect(store.get('playlists')).toStrictEqual([
|
||||
{
|
||||
playlist_id: PLAYLIST_ID,
|
||||
title: 'PLAYLIST_TITLE',
|
||||
description: 'PLAYLIST_DECRIPTION',
|
||||
add_date: 'PLAYLIST_ADD_DATE',
|
||||
media_list: ['PLAYLIST_MEDIA_ID'],
|
||||
},
|
||||
]);
|
||||
expect(store.get('media-load-error-type')).toBe(null);
|
||||
expect(store.get('media-load-error-message')).toBe(null);
|
||||
expect(store.get('media-comments')).toStrictEqual(MEDIA_COMMENTS_RESULTS);
|
||||
expect(store.get('media-data')).toBe(MEDIA_DATA);
|
||||
expect(store.get('media-id')).toBe(MEDIA_ID);
|
||||
expect(store.get('media-url')).toBe(MEDIA_DATA.url);
|
||||
expect(store.get('media-edit-subtitle-url')).toBe(MEDIA_DATA.add_subtitle_url);
|
||||
expect(store.get('media-likes')).toBe(MEDIA_DATA.likes);
|
||||
expect(store.get('media-dislikes')).toBe(MEDIA_DATA.dislikes);
|
||||
expect(store.get('media-summary')).toBe(MEDIA_DATA.summary);
|
||||
expect(store.get('media-categories')).toStrictEqual(MEDIA_DATA.categories_info);
|
||||
expect(store.get('media-tags')).toStrictEqual(MEDIA_DATA.tags_info);
|
||||
expect(store.get('media-type')).toBe(MEDIA_DATA.media_type);
|
||||
expect(store.get('media-original-url')).toBe(MEDIA_DATA.original_media_url);
|
||||
expect(store.get('media-thumbnail-url')).toBe(MEDIA_DATA.thumbnail_url);
|
||||
expect(store.get('user-liked-media')).toBe(false);
|
||||
expect(store.get('user-disliked-media')).toBe(false);
|
||||
expect(store.get('media-author-thumbnail-url')).toBe(`/${MEDIA_DATA.author_thumbnail}`);
|
||||
expect(store.get('playlist-data')).toBe(PLAYLIST_DATA);
|
||||
expect(store.get('playlist-id')).toBe(PLAYLIST_ID);
|
||||
expect(store.get('playlist-next-media-url')).toBe(
|
||||
`${PLAYLIST_DATA.playlist_media[2].url}&pl=${PLAYLIST_ID}`
|
||||
);
|
||||
expect(store.get('playlist-previous-media-url')).toBe(
|
||||
`${PLAYLIST_DATA.playlist_media[0].url}&pl=${PLAYLIST_ID}`
|
||||
);
|
||||
});
|
||||
|
||||
test('Action type: "LIKE_MEDIA"', () => {
|
||||
// Mock the CSRF token
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
// Mock post request
|
||||
(postRequest as jest.Mock).mockImplementation(
|
||||
(_url, _postData, _configData, _sync, successCallback, _failCallback) =>
|
||||
successCallback({ data: {} })
|
||||
);
|
||||
|
||||
handler({ type: 'LIKE_MEDIA' });
|
||||
|
||||
// Verify postRequest was called with correct parameters
|
||||
expect(postRequest).toHaveBeenCalledWith(
|
||||
`${sampleMediaCMSConfig.api.media}/${MEDIA_ID}/actions`,
|
||||
{ type: 'like' },
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
store.likeActionResponse,
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
expect(onLikedMedia).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(store.get('media-likes')).toBe(MEDIA_DATA.likes + 1);
|
||||
expect(store.get('media-dislikes')).toBe(MEDIA_DATA.dislikes);
|
||||
expect(store.get('user-liked-media')).toBe(true);
|
||||
expect(store.get('user-disliked-media')).toBe(false);
|
||||
});
|
||||
|
||||
test('Action type: "DISLIKE_MEDIA"', () => {
|
||||
handler({ type: 'DISLIKE_MEDIA' });
|
||||
|
||||
expect(postRequest).toHaveBeenCalledTimes(0);
|
||||
expect(onDislikedMedia).toHaveBeenCalledTimes(0);
|
||||
|
||||
expect(store.get('media-likes')).toBe(MEDIA_DATA.likes + 1);
|
||||
expect(store.get('media-dislikes')).toBe(MEDIA_DATA.dislikes);
|
||||
expect(store.get('user-liked-media')).toBe(true);
|
||||
expect(store.get('user-disliked-media')).toBe(false);
|
||||
});
|
||||
|
||||
test('Action type: "REPORT_MEDIA"', () => {
|
||||
const REPORT_DESCRIPTION = 'REPORT_DESCRIPTION';
|
||||
|
||||
// Mock the CSRF token
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
// Mock post request
|
||||
(postRequest as jest.Mock).mockImplementation(
|
||||
(_url, _postData, _configData, _sync, successCallback, _failCallback) =>
|
||||
successCallback({ data: {} })
|
||||
);
|
||||
|
||||
handler({ type: 'REPORT_MEDIA', reportDescription: REPORT_DESCRIPTION });
|
||||
|
||||
// Verify postRequest was called with correct parameters
|
||||
expect(postRequest).toHaveBeenCalledWith(
|
||||
`${sampleMediaCMSConfig.api.media}/${MEDIA_ID}/actions`,
|
||||
{ type: 'report', extra_info: REPORT_DESCRIPTION },
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
store.reportActionResponse,
|
||||
store.reportActionResponse
|
||||
);
|
||||
|
||||
expect(onReportedMedia).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Action type: "COPY_SHARE_LINK"', () => {
|
||||
document.execCommand = jest.fn(); // @deprecated
|
||||
const inputElement = document.createElement('input');
|
||||
handler({ type: 'COPY_SHARE_LINK', inputElement });
|
||||
expect(onCopiedMediaLink).toHaveBeenCalledTimes(1);
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||
});
|
||||
|
||||
test('Action type: "COPY_EMBED_MEDIA_CODE"', () => {
|
||||
document.execCommand = jest.fn(); // @deprecated
|
||||
const inputElement = document.createElement('input');
|
||||
handler({ type: 'COPY_EMBED_MEDIA_CODE', inputElement });
|
||||
expect(onCopiedEmbedMediaCode).toHaveBeenCalledTimes(1);
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||
});
|
||||
|
||||
describe('Action type: "REMOVE_MEDIA"', () => {
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock the CSRF token
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Verify deleteRequest was called with correct parameters
|
||||
expect(deleteRequest).toHaveBeenCalledWith(
|
||||
`${sampleMediaCMSConfig.api.media}/${MEDIA_ID}`,
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
store.removeMediaResponse,
|
||||
store.removeMediaFail
|
||||
);
|
||||
|
||||
// Fast-forward time
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('Successful', () => {
|
||||
// Mock delete request
|
||||
(deleteRequest as jest.Mock).mockImplementation(
|
||||
(_url, _configData, _sync, successCallback, _failCallback) => successCallback({ status: 204 })
|
||||
);
|
||||
|
||||
handler({ type: 'REMOVE_MEDIA' });
|
||||
|
||||
expect(onMediaDelete).toHaveBeenCalledTimes(1);
|
||||
expect(onMediaDelete).toHaveBeenCalledWith(MEDIA_ID);
|
||||
});
|
||||
|
||||
test('Failed', () => {
|
||||
// Mock delete request
|
||||
(deleteRequest as jest.Mock).mockImplementation(
|
||||
(_url, _configData, _sync, _successCallback, failCallback) => failCallback({})
|
||||
);
|
||||
|
||||
handler({ type: 'REMOVE_MEDIA' });
|
||||
|
||||
expect(onMediaDeleteFail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Action type: "SUBMIT_COMMENT"', () => {
|
||||
const COMMENT_TEXT = 'COMMENT_TEXT';
|
||||
const COMMENT_UID = 'COMMENT_UID';
|
||||
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock the CSRF token
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Verify postRequest was called with correct parameters
|
||||
expect(postRequest).toHaveBeenCalledWith(
|
||||
`${sampleMediaCMSConfig.api.media}/${MEDIA_ID}/comments`,
|
||||
{ text: COMMENT_TEXT },
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
store.submitCommentResponse,
|
||||
store.submitCommentFail
|
||||
);
|
||||
|
||||
// Fast-forward time
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('Successful', () => {
|
||||
// Mock post request
|
||||
(postRequest as jest.Mock).mockImplementation(
|
||||
(_url, _postData, _configData, _sync, successCallback, _failCallback) =>
|
||||
successCallback({ data: { uid: COMMENT_UID }, status: 201 })
|
||||
);
|
||||
|
||||
handler({ type: 'SUBMIT_COMMENT', commentText: COMMENT_TEXT });
|
||||
|
||||
expect(onCommentSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onCommentSubmit).toHaveBeenCalledWith(COMMENT_UID);
|
||||
});
|
||||
|
||||
test('Failed', () => {
|
||||
// Mock post request
|
||||
(postRequest as jest.Mock).mockImplementation(
|
||||
(_url, _postData, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||
);
|
||||
|
||||
handler({ type: 'SUBMIT_COMMENT', commentText: COMMENT_TEXT });
|
||||
|
||||
expect(onCommentSubmitFail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Action type: "DELETE_COMMENT"', () => {
|
||||
const COMMENT_ID = 'COMMENT_ID';
|
||||
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock the CSRF token
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Verify deleteRequest was called with correct parameters
|
||||
expect(deleteRequest).toHaveBeenCalledWith(
|
||||
`${sampleMediaCMSConfig.api.media}/${MEDIA_ID}/comments/${COMMENT_ID}`,
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
store.removeCommentResponse,
|
||||
store.removeCommentFail
|
||||
);
|
||||
|
||||
// Fast-forward time
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('Successful', () => {
|
||||
// Mock delete request
|
||||
(deleteRequest as jest.Mock).mockImplementation(
|
||||
(_url, _configData, _sync, successCallback, _failCallback) => successCallback({ status: 204 })
|
||||
);
|
||||
|
||||
handler({ type: 'DELETE_COMMENT', commentId: COMMENT_ID });
|
||||
|
||||
expect(onCommentDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Failed', () => {
|
||||
// Mock delete request
|
||||
(deleteRequest as jest.Mock).mockImplementation(
|
||||
(_url, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||
);
|
||||
|
||||
handler({ type: 'DELETE_COMMENT', commentId: COMMENT_ID });
|
||||
|
||||
expect(onCommentDeleteFail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Action type: "CREATE_PLAYLIST"', () => {
|
||||
const NEW_PLAYLIST_DATA = {
|
||||
title: 'NEW_PLAYLIST_DATA_TITLE',
|
||||
description: 'NEW_PLAYLIST_DATA_DESCRIPTION',
|
||||
};
|
||||
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock the CSRF token
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Verify postRequest was called with correct parameters
|
||||
expect(postRequest).toHaveBeenCalledWith(
|
||||
sampleMediaCMSConfig.api.playlists,
|
||||
NEW_PLAYLIST_DATA,
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
test('Successful', () => {
|
||||
const NEW_PLAYLIST_RESPONSE_DATA = { uid: 'COMMENT_UID' };
|
||||
|
||||
// Mock post request
|
||||
(postRequest as jest.Mock).mockImplementation(
|
||||
(_url, _postData, _configData, _sync, successCallback, _failCallback) =>
|
||||
successCallback({ data: NEW_PLAYLIST_RESPONSE_DATA, status: 201 })
|
||||
);
|
||||
|
||||
handler({ type: 'CREATE_PLAYLIST', playlist_data: NEW_PLAYLIST_DATA });
|
||||
|
||||
// Verify postRequest was called with correct parameters
|
||||
expect(postRequest).toHaveBeenCalledWith(
|
||||
sampleMediaCMSConfig.api.playlists,
|
||||
NEW_PLAYLIST_DATA,
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
expect(onPlaylistCreationCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(onPlaylistCreationCompleted).toHaveBeenCalledWith(NEW_PLAYLIST_RESPONSE_DATA);
|
||||
});
|
||||
|
||||
test('Failed', () => {
|
||||
// Mock post request
|
||||
(postRequest as jest.Mock).mockImplementation(
|
||||
(_url, _postData, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||
);
|
||||
|
||||
handler({ type: 'CREATE_PLAYLIST', playlist_data: NEW_PLAYLIST_DATA });
|
||||
|
||||
expect(onPlaylistCreationFailed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Action type: "ADD_MEDIA_TO_PLAYLIST"', () => {
|
||||
const NEW_PLAYLIST_MEDIA_ID = 'NEW_PLAYLIST_MEDIA_ID';
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock the CSRF token
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Verify postRequest was called with correct parameters
|
||||
expect(putRequest).toHaveBeenCalledWith(
|
||||
`${sampleMediaCMSConfig.api.playlists}/${PLAYLIST_ID}`,
|
||||
{ type: 'add', media_friendly_token: NEW_PLAYLIST_MEDIA_ID },
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
test('Successful', () => {
|
||||
// Mock put request
|
||||
(putRequest as jest.Mock).mockImplementation(
|
||||
(_url, _putData, _configData, _sync, successCallback, _failCallback) =>
|
||||
successCallback({ data: {} })
|
||||
);
|
||||
|
||||
handler({
|
||||
type: 'ADD_MEDIA_TO_PLAYLIST',
|
||||
playlist_id: PLAYLIST_ID,
|
||||
media_id: NEW_PLAYLIST_MEDIA_ID,
|
||||
});
|
||||
|
||||
expect(onMediaPlaylistAdditionCompleted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Failed', () => {
|
||||
// Mock put request
|
||||
(putRequest as jest.Mock).mockImplementation(
|
||||
(_url, _putData, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||
);
|
||||
|
||||
handler({
|
||||
type: 'ADD_MEDIA_TO_PLAYLIST',
|
||||
playlist_id: PLAYLIST_ID,
|
||||
media_id: NEW_PLAYLIST_MEDIA_ID,
|
||||
});
|
||||
|
||||
expect(onMediaPlaylistAdditionFailed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Action type: "REMOVE_MEDIA_FROM_PLAYLIST"', () => {
|
||||
// Mock the CSRF token
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
afterEach(() => {
|
||||
// Verify postRequest was called with correct parameters
|
||||
expect(putRequest).toHaveBeenCalledWith(
|
||||
`${sampleMediaCMSConfig.api.playlists}/${PLAYLIST_ID}`,
|
||||
{ type: 'remove', media_friendly_token: MEDIA_ID },
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
test('Successful', () => {
|
||||
// Mock put request
|
||||
(putRequest as jest.Mock).mockImplementation(
|
||||
(_url, _putData, _configData, _sync, successCallback, _failCallback) =>
|
||||
successCallback({ data: {} })
|
||||
);
|
||||
|
||||
handler({ type: 'REMOVE_MEDIA_FROM_PLAYLIST', playlist_id: PLAYLIST_ID, media_id: MEDIA_ID });
|
||||
|
||||
expect(onMediaPlaylistRemovalCompleted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Failed', () => {
|
||||
// Mock put request
|
||||
(putRequest as jest.Mock).mockImplementation(
|
||||
(_url, _putData, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||
);
|
||||
|
||||
handler({ type: 'REMOVE_MEDIA_FROM_PLAYLIST', playlist_id: PLAYLIST_ID, media_id: MEDIA_ID });
|
||||
|
||||
expect(onMediaPlaylistRemovalFailed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('Action type: "APPEND_NEW_PLAYLIST"', () => {
|
||||
const NEW_USER_PLAYLIST = {
|
||||
add_date: 'PLAYLIST_ADD_DATE_2',
|
||||
description: 'PLAYLIST_DECRIPTION_2',
|
||||
media_list: ['PLAYLIST_MEDIA_ID'],
|
||||
playlist_id: 'PLAYLIST_ID',
|
||||
title: 'PLAYLIST_TITLE_2',
|
||||
};
|
||||
|
||||
handler({ type: 'APPEND_NEW_PLAYLIST', playlist_data: NEW_USER_PLAYLIST });
|
||||
|
||||
expect(onPlaylistsLoad).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(store.get('playlists')).toStrictEqual([
|
||||
{
|
||||
add_date: 'PLAYLIST_ADD_DATE',
|
||||
description: 'PLAYLIST_DECRIPTION',
|
||||
media_list: ['PLAYLIST_MEDIA_ID'],
|
||||
playlist_id: PLAYLIST_ID,
|
||||
title: 'PLAYLIST_TITLE',
|
||||
},
|
||||
NEW_USER_PLAYLIST,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
162
frontend/tests/utils/stores/PageStore.test.ts
Normal file
162
frontend/tests/utils/stores/PageStore.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { BrowserCache } from '../../../src/static/js/utils/classes';
|
||||
import store from '../../../src/static/js/utils/stores/PageStore';
|
||||
|
||||
import { sampleMediaCMSConfig } from '../../tests-constants';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||
get: (key: string) => (key === 'media-auto-play' ? false : undefined),
|
||||
set: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||
BrowserEvents: jest.fn().mockImplementation(() => ({
|
||||
doc: jest.fn(),
|
||||
win: jest.fn(),
|
||||
})),
|
||||
exportStore: jest.fn((store) => store),
|
||||
}));
|
||||
|
||||
describe('utils/store', () => {
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PageStore', () => {
|
||||
const handler = store.actions_handler.bind(store);
|
||||
|
||||
const onInit = jest.fn();
|
||||
const onToggleAutoPlay = jest.fn();
|
||||
const onAddNotification = jest.fn();
|
||||
|
||||
store.on('page_init', onInit);
|
||||
store.on('switched_media_auto_play', onToggleAutoPlay);
|
||||
store.on('added_notification', onAddNotification);
|
||||
|
||||
test('Validate initial values', () => {
|
||||
// BrowserCache mock
|
||||
expect(store.get('browser-cache').get('media-auto-play')).toBe(false);
|
||||
expect(store.get('browser-cache').get('ANY')).toBe(undefined);
|
||||
|
||||
// Autoplay media files
|
||||
expect(store.get('media-auto-play')).toBe(false);
|
||||
|
||||
// Configuration
|
||||
expect(store.get('config-contents')).toStrictEqual(sampleMediaCMSConfig.contents);
|
||||
expect(store.get('config-enabled')).toStrictEqual(sampleMediaCMSConfig.enabled);
|
||||
expect(store.get('config-media-item')).toStrictEqual(sampleMediaCMSConfig.media.item);
|
||||
expect(store.get('config-options')).toStrictEqual(sampleMediaCMSConfig.options);
|
||||
expect(store.get('config-site')).toStrictEqual(sampleMediaCMSConfig.site);
|
||||
|
||||
// Playlists API path
|
||||
expect(store.get('api-playlists')).toStrictEqual(sampleMediaCMSConfig.api.playlists);
|
||||
|
||||
// Notifications
|
||||
expect(store.get('notifications')).toStrictEqual([]);
|
||||
expect(store.get('notifications-size')).toBe(0);
|
||||
|
||||
expect(store.get('current-page')).toBe(undefined);
|
||||
});
|
||||
|
||||
test('Trigger and validate browser events behavior', () => {
|
||||
const docVisChange = jest.fn();
|
||||
const winScroll = jest.fn();
|
||||
const winResize = jest.fn();
|
||||
|
||||
store.on('document_visibility_change', docVisChange);
|
||||
store.on('window_scroll', winScroll);
|
||||
store.on('window_resize', winResize);
|
||||
|
||||
store.onDocumentVisibilityChange();
|
||||
store.onWindowScroll();
|
||||
store.onWindowResize();
|
||||
|
||||
expect(docVisChange).toHaveBeenCalled();
|
||||
expect(winScroll).toHaveBeenCalled();
|
||||
expect(winResize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('Trigger and validate actions behavior', () => {
|
||||
test('Action type: "INIT_PAGE"', () => {
|
||||
handler({ type: 'INIT_PAGE', page: 'home' });
|
||||
expect(onInit).toHaveBeenCalledTimes(1);
|
||||
expect(store.get('current-page')).toBe('home');
|
||||
|
||||
handler({ type: 'INIT_PAGE', page: 'about' });
|
||||
expect(onInit).toHaveBeenCalledTimes(2);
|
||||
expect(store.get('current-page')).toBe('about');
|
||||
|
||||
handler({ type: 'INIT_PAGE', page: 'profile' });
|
||||
expect(onInit).toHaveBeenCalledTimes(3);
|
||||
expect(store.get('current-page')).toBe('profile');
|
||||
|
||||
expect(onInit).toHaveBeenCalledWith();
|
||||
|
||||
expect(onToggleAutoPlay).toHaveBeenCalledTimes(0);
|
||||
expect(onAddNotification).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('Action type: "TOGGLE_AUTO_PLAY"', () => {
|
||||
const browserCacheInstance = (BrowserCache as jest.Mock).mock.results[0].value;
|
||||
const browserCacheSetSpy = browserCacheInstance.set;
|
||||
|
||||
const initialValue = store.get('media-auto-play');
|
||||
|
||||
handler({ type: 'TOGGLE_AUTO_PLAY' });
|
||||
|
||||
expect(onToggleAutoPlay).toHaveBeenCalledWith();
|
||||
expect(onToggleAutoPlay).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(store.get('media-auto-play')).toBe(!initialValue);
|
||||
expect(browserCacheSetSpy).toHaveBeenCalledWith('media-auto-play', !initialValue);
|
||||
|
||||
browserCacheSetSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Action type: "ADD_NOTIFICATION"', () => {
|
||||
const notificationMsg1 = 'NOTIFICATION_MSG_1';
|
||||
const notificationMsg2 = 'NOTIFICATION_MSG_2';
|
||||
const invalidNotification = 44;
|
||||
|
||||
// Add notification
|
||||
handler({ type: 'ADD_NOTIFICATION', notification: notificationMsg1 });
|
||||
expect(onAddNotification).toHaveBeenCalledWith();
|
||||
expect(onAddNotification).toHaveBeenCalledTimes(1);
|
||||
expect(store.get('notifications-size')).toBe(1);
|
||||
|
||||
const currentNotifications = store.get('notifications');
|
||||
expect(currentNotifications.length).toBe(1);
|
||||
expect(typeof currentNotifications[0][0]).toBe('string');
|
||||
expect(currentNotifications[0][1]).toBe(notificationMsg1);
|
||||
|
||||
expect(store.get('notifications-size')).toBe(0);
|
||||
expect(store.get('notifications')).toStrictEqual([]);
|
||||
|
||||
// Add another notification
|
||||
handler({ type: 'ADD_NOTIFICATION', notification: notificationMsg2 });
|
||||
|
||||
expect(onAddNotification).toHaveBeenCalledWith();
|
||||
expect(onAddNotification).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(store.get('notifications-size')).toBe(1);
|
||||
expect(store.get('notifications')[0][1]).toBe(notificationMsg2);
|
||||
|
||||
expect(store.get('notifications-size')).toBe(0);
|
||||
expect(store.get('notifications')).toStrictEqual([]);
|
||||
|
||||
// Add invalid notification
|
||||
handler({ type: 'ADD_NOTIFICATION', notification: invalidNotification });
|
||||
expect(onAddNotification).toHaveBeenCalledWith();
|
||||
expect(onAddNotification).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(store.get('notifications-size')).toBe(0);
|
||||
expect(store.get('notifications')).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
390
frontend/tests/utils/stores/PlaylistPageStore.test.ts
Normal file
390
frontend/tests/utils/stores/PlaylistPageStore.test.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import {
|
||||
publishedOnDate,
|
||||
getRequest,
|
||||
postRequest,
|
||||
deleteRequest,
|
||||
csrfToken,
|
||||
} from '../../../src/static/js/utils/helpers';
|
||||
import store from '../../../src/static/js/utils/stores/PlaylistPageStore';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||
publishedOnDate: jest.fn(),
|
||||
exportStore: jest.fn((store) => store),
|
||||
getRequest: jest.fn(),
|
||||
postRequest: jest.fn(),
|
||||
deleteRequest: jest.fn(),
|
||||
csrfToken: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('utils/store', () => {
|
||||
beforeAll(() => {
|
||||
(globalThis as any).window.MediaCMS = { playlistId: null };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete (globalThis as any).window.MediaCMS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PlaylistPageStore', () => {
|
||||
const handler = store.actions_handler.bind(store);
|
||||
|
||||
const onLoadedPlaylistData = jest.fn();
|
||||
const onLoadedPlaylistEerror = jest.fn();
|
||||
const onLoadedMediaError = jest.fn();
|
||||
const onPlaylistUpdateCompleted = jest.fn();
|
||||
const onPlaylistUpdateFailed = jest.fn();
|
||||
const onPlaylistRemovalCompleted = jest.fn();
|
||||
const onPlaylistRemovalFailed = jest.fn();
|
||||
const onSavedUpdated = jest.fn();
|
||||
const onReorderedMediaInPlaylist = jest.fn();
|
||||
const onRemovedMediaFromPlaylist = jest.fn();
|
||||
|
||||
store.on('loaded_playlist_data', onLoadedPlaylistData);
|
||||
store.on('loaded_playlist_error', onLoadedPlaylistEerror);
|
||||
store.on('loaded_media_error', onLoadedMediaError); // @todo: It doesn't get called
|
||||
store.on('playlist_update_completed', onPlaylistUpdateCompleted);
|
||||
store.on('playlist_update_failed', onPlaylistUpdateFailed);
|
||||
store.on('playlist_removal_completed', onPlaylistRemovalCompleted);
|
||||
store.on('playlist_removal_failed', onPlaylistRemovalFailed);
|
||||
store.on('saved-updated', onSavedUpdated);
|
||||
store.on('reordered_media_in_playlist', onReorderedMediaInPlaylist);
|
||||
store.on('removed_media_from_playlist', onRemovedMediaFromPlaylist);
|
||||
|
||||
test('Validate initial values', () => {
|
||||
expect(store.get('INVALID_TYPE')).toBe(null);
|
||||
expect(store.get('playlistId')).toBe(null);
|
||||
expect(store.get('logged-in-user-playlist')).toBe(false);
|
||||
expect(store.get('playlist-media')).toStrictEqual([]);
|
||||
expect(store.get('visibility')).toBe('public');
|
||||
expect(store.get('visibility-icon')).toBe(null);
|
||||
// // expect(store.get('total-items')).toBe(0); // @todo: It throws error
|
||||
expect(store.get('views-count')).toBe('N/A');
|
||||
expect(store.get('title')).toBe(null);
|
||||
expect(store.get('edit-link')).toBe('#');
|
||||
expect(store.get('thumb')).toBe(null);
|
||||
expect(store.get('description')).toBe(null);
|
||||
expect(store.get('author-username')).toBe(null);
|
||||
expect(store.get('author-name')).toBe(null);
|
||||
expect(store.get('author-link')).toBe(null);
|
||||
expect(store.get('author-thumb')).toBe(null);
|
||||
expect(store.get('saved-playlist')).toBe(false);
|
||||
expect(store.get('date-label')).toBe(null);
|
||||
});
|
||||
|
||||
describe('Trigger and validate actions behavior', () => {
|
||||
test('Action type: "LOAD_PLAYLIST_DATA" - failed', () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const loadDataSpy = jest.spyOn(store, 'loadData');
|
||||
|
||||
handler({ type: 'LOAD_PLAYLIST_DATA' });
|
||||
|
||||
expect(loadDataSpy).toHaveBeenCalledTimes(1);
|
||||
expect(loadDataSpy).toHaveReturnedWith(false);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith('Invalid playlist id:', '');
|
||||
|
||||
expect(store.get('playlistId')).toBe(null);
|
||||
|
||||
loadDataSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Action type: "LOAD_PLAYLIST_DATA" - completed successful', () => {
|
||||
const playlistId = 'PLAYLIST_ID_1';
|
||||
window.history.pushState({}, '', `/playlists/${playlistId}`);
|
||||
|
||||
// Mock get request
|
||||
const mockGetRequestResponse = {
|
||||
data: {
|
||||
add_date: Date.now(),
|
||||
description: 'DESCRIPTION',
|
||||
playlist_media: [],
|
||||
title: 'TITLE',
|
||||
user: 'USER',
|
||||
user_thumbnail_url: 'USER_THUMB_URL',
|
||||
},
|
||||
};
|
||||
|
||||
(getRequest as jest.Mock).mockImplementation((_url, _cache, successCallback, _failCallback) =>
|
||||
successCallback(mockGetRequestResponse)
|
||||
);
|
||||
|
||||
const loadDataSpy = jest.spyOn(store, 'loadData');
|
||||
const dataResponseSpy = jest.spyOn(store, 'dataResponse');
|
||||
|
||||
handler({ type: 'LOAD_PLAYLIST_DATA' });
|
||||
|
||||
expect(store.get('playlistId')).toBe(playlistId);
|
||||
expect(store.get('author-name')).toBe(mockGetRequestResponse.data.user);
|
||||
expect(store.get('author-link')).toBe(`/user/${mockGetRequestResponse.data.user}`);
|
||||
expect(store.get('author-thumb')).toBe(`/${mockGetRequestResponse.data.user_thumbnail_url}`);
|
||||
|
||||
expect(store.get('date-label')).toBe('Created on undefined');
|
||||
expect(publishedOnDate).toHaveBeenCalledWith(new Date(mockGetRequestResponse.data.add_date), 3);
|
||||
|
||||
expect(loadDataSpy).toHaveBeenCalledTimes(1);
|
||||
expect(loadDataSpy).toHaveReturnedWith(undefined);
|
||||
|
||||
expect(dataResponseSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dataResponseSpy).toHaveBeenCalledWith(mockGetRequestResponse);
|
||||
|
||||
// Verify getRequest was called with correct parameters
|
||||
expect(getRequest).toHaveBeenCalledWith(
|
||||
store.playlistAPIUrl,
|
||||
false,
|
||||
store.dataResponse,
|
||||
store.dataErrorResponse
|
||||
);
|
||||
|
||||
expect(onLoadedPlaylistData).toHaveBeenCalledTimes(1);
|
||||
expect(onLoadedPlaylistData).toHaveBeenCalledWith();
|
||||
|
||||
loadDataSpy.mockRestore();
|
||||
dataResponseSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Action type: "LOAD_PLAYLIST_DATA" - completed with error', () => {
|
||||
const playlistId = 'PLAYLIST_ID_2';
|
||||
window.history.pushState({}, '', `/playlists/${playlistId}`);
|
||||
|
||||
// Mock get request
|
||||
const mockGetRequestResponse = { type: 'private' };
|
||||
(getRequest as jest.Mock).mockImplementation((_url, _cache, _successCallback, failCallback) =>
|
||||
failCallback(mockGetRequestResponse)
|
||||
);
|
||||
|
||||
const loadDataSpy = jest.spyOn(store, 'loadData');
|
||||
const dataErrorResponseSpy = jest.spyOn(store, 'dataErrorResponse');
|
||||
|
||||
handler({ type: 'LOAD_PLAYLIST_DATA' });
|
||||
|
||||
expect(store.get('playlistId')).toBe(playlistId);
|
||||
|
||||
expect(loadDataSpy).toHaveBeenCalledTimes(1);
|
||||
expect(loadDataSpy).toHaveReturnedWith(undefined);
|
||||
|
||||
expect(dataErrorResponseSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dataErrorResponseSpy).toHaveBeenCalledWith(mockGetRequestResponse);
|
||||
|
||||
// Verify getRequest was called with correct parameters
|
||||
expect(getRequest).toHaveBeenCalledWith(
|
||||
store.playlistAPIUrl,
|
||||
false,
|
||||
store.dataResponse,
|
||||
store.dataErrorResponse
|
||||
);
|
||||
|
||||
expect(onLoadedPlaylistEerror).toHaveBeenCalledTimes(1);
|
||||
expect(onLoadedPlaylistEerror).toHaveBeenCalledWith();
|
||||
|
||||
loadDataSpy.mockRestore();
|
||||
dataErrorResponseSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Action type: "TOGGLE_SAVE"', () => {
|
||||
const initialValue = store.get('saved-playlist');
|
||||
|
||||
handler({ type: 'TOGGLE_SAVE' });
|
||||
|
||||
expect(onSavedUpdated).toHaveBeenCalledTimes(1);
|
||||
expect(onSavedUpdated).toHaveBeenCalledWith();
|
||||
|
||||
expect(store.get('saved-playlist')).toBe(!initialValue);
|
||||
});
|
||||
|
||||
test('Action type: "UPDATE_PLAYLIST" - failed', () => {
|
||||
// Mock (updated) playlist data
|
||||
const mockPlaylistData = { title: 'PLAYLIST_TITLE', description: 'PLAYLIST_DESCRIPTION' };
|
||||
|
||||
// Mock the CSRF token
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
// Mock post request
|
||||
(postRequest as jest.Mock).mockImplementation(
|
||||
(_url, _postData, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||
);
|
||||
|
||||
const initialStoreData = {
|
||||
title: store.get('title'),
|
||||
description: store.get('description'),
|
||||
};
|
||||
|
||||
expect(store.get('title')).toBe(initialStoreData.title);
|
||||
expect(store.get('description')).toBe(initialStoreData.description);
|
||||
|
||||
handler({ type: 'UPDATE_PLAYLIST', playlist_data: mockPlaylistData });
|
||||
|
||||
expect(store.get('title')).toBe(initialStoreData.title);
|
||||
expect(store.get('description')).toBe(initialStoreData.description);
|
||||
|
||||
// Verify postRequest was called with correct parameters
|
||||
expect(postRequest).toHaveBeenCalledWith(
|
||||
store.playlistAPIUrl,
|
||||
mockPlaylistData,
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
store.onPlaylistUpdateCompleted,
|
||||
store.onPlaylistUpdateFailed
|
||||
);
|
||||
|
||||
expect(onPlaylistUpdateFailed).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('Action type: "UPDATE_PLAYLIST" - successful', () => {
|
||||
// Mock (updated) playlist data
|
||||
const mockPlaylistData = { title: 'PLAYLIST_TITLE', description: 'PLAYLIST_DESCRIPTION' };
|
||||
|
||||
// Mock the CSRF token
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
// Mock post request
|
||||
(postRequest as jest.Mock).mockImplementation(
|
||||
(_url, _postData, _configData, _sync, successCallback, _failCallback) =>
|
||||
successCallback({ data: mockPlaylistData })
|
||||
);
|
||||
|
||||
const initialStoreData = {
|
||||
title: store.get('title'),
|
||||
description: store.get('description'),
|
||||
};
|
||||
|
||||
expect(store.get('title')).toBe(initialStoreData.title);
|
||||
expect(store.get('description')).toBe(initialStoreData.description);
|
||||
|
||||
handler({ type: 'UPDATE_PLAYLIST', playlist_data: mockPlaylistData });
|
||||
|
||||
expect(store.get('title')).toBe(mockPlaylistData.title);
|
||||
expect(store.get('description')).toBe(mockPlaylistData.description);
|
||||
|
||||
// Verify postRequest was called with correct parameters
|
||||
expect(postRequest).toHaveBeenCalledWith(
|
||||
store.playlistAPIUrl,
|
||||
mockPlaylistData,
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
store.onPlaylistUpdateCompleted,
|
||||
store.onPlaylistUpdateFailed
|
||||
);
|
||||
|
||||
expect(onPlaylistUpdateCompleted).toHaveBeenCalledWith(mockPlaylistData);
|
||||
});
|
||||
|
||||
test('Action type: "REMOVE_PLAYLIST" - failed', () => {
|
||||
// Mock the CSRF token
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
// Mock delete request
|
||||
(deleteRequest as jest.Mock).mockImplementation(
|
||||
(_url, _config, _sync, _successCallback, failCallback) => failCallback()
|
||||
);
|
||||
|
||||
handler({ type: 'REMOVE_PLAYLIST' });
|
||||
|
||||
// Verify deleteRequest was called with correct parameters
|
||||
expect(deleteRequest).toHaveBeenCalledWith(
|
||||
store.playlistAPIUrl,
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
store.onPlaylistRemovalCompleted,
|
||||
store.onPlaylistRemovalFailed
|
||||
);
|
||||
|
||||
expect(onPlaylistRemovalFailed).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('Action type: "REMOVE_PLAYLIST" - completed successful', () => {
|
||||
// Mock the CSRF token
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
// Mock delete request
|
||||
const deleteRequestResponse = { status: 204 };
|
||||
(deleteRequest as jest.Mock).mockImplementation(
|
||||
(_url, _config, _sync, successCallback, _failCallback) => successCallback(deleteRequestResponse)
|
||||
);
|
||||
|
||||
handler({ type: 'REMOVE_PLAYLIST' });
|
||||
|
||||
// Verify deleteRequest was called with correct parameters
|
||||
expect(deleteRequest).toHaveBeenCalledWith(
|
||||
store.playlistAPIUrl,
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
store.onPlaylistRemovalCompleted,
|
||||
store.onPlaylistRemovalFailed
|
||||
);
|
||||
|
||||
expect(onPlaylistRemovalCompleted).toHaveBeenCalledWith(deleteRequestResponse);
|
||||
});
|
||||
|
||||
test('Action type: "REMOVE_PLAYLIST" - completed with invalid status code', () => {
|
||||
// Mock the CSRF token
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
// Mock delete request
|
||||
const deleteRequestResponse = { status: 403 };
|
||||
(deleteRequest as jest.Mock).mockImplementation(
|
||||
(_url, _config, _sync, successCallback, _failCallback) => successCallback(deleteRequestResponse)
|
||||
);
|
||||
|
||||
handler({ type: 'REMOVE_PLAYLIST' });
|
||||
|
||||
// Verify deleteRequest was called with correct parameters
|
||||
expect(deleteRequest).toHaveBeenCalledWith(
|
||||
store.playlistAPIUrl,
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
store.onPlaylistRemovalCompleted,
|
||||
store.onPlaylistRemovalFailed
|
||||
);
|
||||
|
||||
expect(onPlaylistRemovalFailed).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('Action type: "PLAYLIST_MEDIA_REORDERED"', () => {
|
||||
// Mock playlist media data
|
||||
const mockPlaylistMedia = [
|
||||
{ thumbnail_url: 'THUMB_URL_1', url: '?id=MEDIA_ID_1' },
|
||||
{ thumbnail_url: 'THUMB_URL_2', url: '?id=MEDIA_ID_2' },
|
||||
];
|
||||
|
||||
handler({ type: 'PLAYLIST_MEDIA_REORDERED', playlist_media: mockPlaylistMedia });
|
||||
|
||||
expect(onReorderedMediaInPlaylist).toHaveBeenCalledWith();
|
||||
|
||||
expect(store.get('playlist-media')).toStrictEqual(mockPlaylistMedia);
|
||||
expect(store.get('thumb')).toBe(mockPlaylistMedia[0].thumbnail_url);
|
||||
expect(store.get('total-items')).toBe(mockPlaylistMedia.length);
|
||||
});
|
||||
|
||||
test('Action type: "MEDIA_REMOVED_FROM_PLAYLIST"', () => {
|
||||
// Mock playlist media data
|
||||
const mockPlaylistMedia = [
|
||||
{ thumbnail_url: 'THUMB_URL_1', url: '?id=MEDIA_ID_1' },
|
||||
{ thumbnail_url: 'THUMB_URL_2', url: '?id=MEDIA_ID_2' },
|
||||
];
|
||||
|
||||
handler({ type: 'PLAYLIST_MEDIA_REORDERED', playlist_media: mockPlaylistMedia });
|
||||
|
||||
handler({ type: 'MEDIA_REMOVED_FROM_PLAYLIST', media_id: 'MEDIA_ID_2' });
|
||||
|
||||
expect(store.get('playlist-media')).toStrictEqual([mockPlaylistMedia[0]]);
|
||||
expect(store.get('thumb')).toBe(mockPlaylistMedia[0].thumbnail_url);
|
||||
expect(store.get('total-items')).toBe(mockPlaylistMedia.length - 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
83
frontend/tests/utils/stores/PlaylistViewStore.test.ts
Normal file
83
frontend/tests/utils/stores/PlaylistViewStore.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { BrowserCache } from '../../../src/static/js/utils/classes/';
|
||||
import store from '../../../src/static/js/utils/stores/PlaylistViewStore';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||
BrowserEvents: jest.fn().mockImplementation(() => ({
|
||||
doc: jest.fn(),
|
||||
win: jest.fn(),
|
||||
})),
|
||||
exportStore: jest.fn((store) => store),
|
||||
}));
|
||||
|
||||
describe('utils/store', () => {
|
||||
describe('PlaylistViewStore', () => {
|
||||
const browserCacheInstance = (BrowserCache as jest.Mock).mock.results[0].value;
|
||||
const browserCacheSetSpy = browserCacheInstance.set;
|
||||
|
||||
const handler = store.actions_handler.bind(store);
|
||||
|
||||
const onLoopRepeatUpdated = jest.fn();
|
||||
const onShuffleUpdated = jest.fn();
|
||||
const onSavedUpdated = jest.fn();
|
||||
|
||||
store.on('loop-repeat-updated', onLoopRepeatUpdated);
|
||||
store.on('shuffle-updated', onShuffleUpdated);
|
||||
store.on('saved-updated', onSavedUpdated);
|
||||
|
||||
test('Validate initial values', () => {
|
||||
expect(store.get('INVALID_TYPE')).toBe(null);
|
||||
expect(store.get('logged-in-user-playlist')).toBe(false);
|
||||
expect(store.get('enabled-loop')).toBe(undefined);
|
||||
expect(store.get('enabled-shuffle')).toBe(undefined);
|
||||
expect(store.get('saved-playlist')).toBe(false);
|
||||
});
|
||||
|
||||
describe('Trigger and validate actions behavior', () => {
|
||||
// @todo: Revisit the behavior of this action
|
||||
test('Action type: "TOGGLE_LOOP"', () => {
|
||||
handler({ type: 'TOGGLE_LOOP' });
|
||||
|
||||
expect(onLoopRepeatUpdated).toHaveBeenCalledTimes(1);
|
||||
expect(onLoopRepeatUpdated).toHaveBeenCalledWith();
|
||||
|
||||
expect(store.get('enabled-loop')).toBe(undefined);
|
||||
|
||||
expect(browserCacheSetSpy).toHaveBeenCalledWith('loopPlaylist[null]', true);
|
||||
});
|
||||
|
||||
// @todo: Revisit the behavior of this action
|
||||
test('Action type: "TOGGLE_SHUFFLE"', () => {
|
||||
handler({ type: 'TOGGLE_SHUFFLE' });
|
||||
|
||||
expect(onShuffleUpdated).toHaveBeenCalledTimes(1);
|
||||
expect(onShuffleUpdated).toHaveBeenCalledWith();
|
||||
|
||||
expect(store.get('enabled-shuffle')).toBe(undefined);
|
||||
|
||||
expect(browserCacheSetSpy).toHaveBeenCalledWith('shufflePlaylist[null]', true);
|
||||
});
|
||||
|
||||
test('Action type: "TOGGLE_SAVE"', () => {
|
||||
const initialValue = store.get('saved-playlist');
|
||||
|
||||
handler({ type: 'TOGGLE_SAVE' });
|
||||
|
||||
expect(onSavedUpdated).toHaveBeenCalledTimes(1);
|
||||
expect(onSavedUpdated).toHaveBeenCalledWith();
|
||||
|
||||
expect(store.get('saved-playlist')).toBe(!initialValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
168
frontend/tests/utils/stores/ProfilePageStore.test.ts
Normal file
168
frontend/tests/utils/stores/ProfilePageStore.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { getRequest, deleteRequest, csrfToken } from '../../../src/static/js/utils/helpers';
|
||||
import store from '../../../src/static/js/utils/stores/ProfilePageStore';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => ({
|
||||
...jest.requireActual('../../tests-constants').sampleMediaCMSConfig,
|
||||
api: { ...jest.requireActual('../../tests-constants').sampleMediaCMSConfig.api, users: '' },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||
getRequest: jest.fn(),
|
||||
deleteRequest: jest.fn(),
|
||||
csrfToken: jest.fn(),
|
||||
exportStore: jest.fn((store) => store),
|
||||
}));
|
||||
|
||||
describe('utils/store', () => {
|
||||
const mockAuthorData = { username: 'testuser', name: 'Test User' };
|
||||
|
||||
beforeAll(() => {
|
||||
(globalThis as any).window.MediaCMS = { profileId: mockAuthorData.username };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete (globalThis as any).window.MediaCMS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ProfilePageStore', () => {
|
||||
const handler = store.actions_handler.bind(store);
|
||||
|
||||
const onProfileDelete = jest.fn();
|
||||
const onProfileDeleteFail = jest.fn();
|
||||
const onLoadAuthorData = jest.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
store.on('profile_delete', onProfileDelete);
|
||||
store.on('profile_delete_fail', onProfileDeleteFail);
|
||||
store.on('load-author-data', onLoadAuthorData);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset store state
|
||||
store.authorData = null;
|
||||
store.removingProfile = false;
|
||||
store.authorQuery = undefined;
|
||||
});
|
||||
|
||||
describe('Trigger and validate actions behavior', () => {
|
||||
test('Action type: "REMOVE_PROFILE" - successful deletion', async () => {
|
||||
// Set up author data
|
||||
store.authorData = mockAuthorData;
|
||||
|
||||
// Mock the CSRF token
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
// Mock delete request
|
||||
(deleteRequest as jest.Mock).mockImplementation(
|
||||
(_url, _config, _sync, successCallback, _failCallback) => successCallback({ status: 204 })
|
||||
);
|
||||
|
||||
handler({ type: 'REMOVE_PROFILE' });
|
||||
|
||||
// Verify deleteRequest was called with correct parameters
|
||||
expect(deleteRequest).toHaveBeenCalledWith(
|
||||
'/testuser', // API URL constructed from config + username
|
||||
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||
false,
|
||||
store.removeProfileResponse,
|
||||
store.removeProfileFail
|
||||
);
|
||||
|
||||
// Verify event was emitted
|
||||
expect(onProfileDelete).toHaveBeenCalledWith(mockAuthorData.username);
|
||||
expect(onProfileDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Action type: "REMOVE_PROFILE" - deletion failure', async () => {
|
||||
// Set up author data
|
||||
store.authorData = mockAuthorData;
|
||||
|
||||
// Mock the CSRF token
|
||||
const mockCSRFtoken = 'test-csrf-token';
|
||||
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||
|
||||
// Mock delete request
|
||||
(deleteRequest as jest.Mock).mockImplementation(
|
||||
(_url, _config, _sync, _successCallback, failCallback) => failCallback.call(store)
|
||||
);
|
||||
|
||||
handler({ type: 'REMOVE_PROFILE' });
|
||||
|
||||
// Wait for the setTimeout in removeProfileFail
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
// Verify event was emitted
|
||||
expect(onProfileDeleteFail).toHaveBeenCalledWith(mockAuthorData.username);
|
||||
expect(onProfileDeleteFail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Action type: "REMOVE_PROFILE" - prevents duplicate calls while removing', () => {
|
||||
// Set up author data
|
||||
store.authorData = mockAuthorData;
|
||||
|
||||
handler({ type: 'REMOVE_PROFILE' });
|
||||
expect(deleteRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.removingProfile = true;
|
||||
handler({ type: 'REMOVE_PROFILE' });
|
||||
expect(deleteRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.removingProfile = false;
|
||||
handler({ type: 'REMOVE_PROFILE' });
|
||||
expect(deleteRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('Action type: "LOAD_AUTHOR_DATA"', async () => {
|
||||
(getRequest as jest.Mock).mockImplementation((_url, _cache, successCallback, _failCallback) =>
|
||||
successCallback({ data: mockAuthorData })
|
||||
);
|
||||
|
||||
handler({ type: 'LOAD_AUTHOR_DATA' });
|
||||
|
||||
// Verify getRequest was called with correct parameters
|
||||
expect(getRequest).toHaveBeenCalledWith('/testuser', false, store.onDataLoad, store.onDataLoadFail);
|
||||
|
||||
// Verify event was emitted
|
||||
expect(onLoadAuthorData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify author data was processed correctly
|
||||
expect(store.get('author-data')).toStrictEqual(mockAuthorData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Getter methods', () => {
|
||||
test('Validate initial values', () => {
|
||||
expect(store.get('INVALID_TYPE')).toBe(undefined);
|
||||
expect(store.get('author-data')).toBe(null);
|
||||
expect(store.get('author-query')).toBe(null);
|
||||
});
|
||||
|
||||
test('get("author-data") returns authorData', () => {
|
||||
store.authorData = mockAuthorData;
|
||||
expect(store.get('author-data')).toBe(mockAuthorData);
|
||||
});
|
||||
|
||||
test('get("author-query") - without "aq" parameter in URL', () => {
|
||||
window.history.pushState({}, '', '/path');
|
||||
expect(store.get('author-query')).toBe(null);
|
||||
});
|
||||
|
||||
test('get("author-query") - with "aq" parameter in URL', () => {
|
||||
window.history.pushState({}, '', '/path?aq=AUTHOR_QUERY');
|
||||
expect(store.get('author-query')).toBe('AUTHOR_QUERY');
|
||||
});
|
||||
|
||||
test('get("author-query") - empty search string', () => {
|
||||
window.history.pushState({}, '', '/path?aq');
|
||||
expect(store.get('author-query')).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
64
frontend/tests/utils/stores/SearchFieldStore.test.ts
Normal file
64
frontend/tests/utils/stores/SearchFieldStore.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
const urlParams = { q: 'search_query', c: 'category_1', t: 'tag_1' };
|
||||
window.history.pushState({}, '', `/?q=${urlParams.q}&c=${urlParams.c}&t=${urlParams.t}`);
|
||||
|
||||
import store from '../../../src/static/js/utils/stores/SearchFieldStore';
|
||||
import { getRequest } from '../../../src/static/js/utils/helpers';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||
exportStore: jest.fn((store) => store),
|
||||
getRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('utils/store', () => {
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('SearchFieldStore', () => {
|
||||
const handler = store.actions_handler.bind(store);
|
||||
|
||||
const onLoadPredictions = jest.fn();
|
||||
|
||||
store.on('load_predictions', onLoadPredictions);
|
||||
|
||||
test('Validate initial values based on URL params', async () => {
|
||||
expect(store.get('INVALID_TYPE')).toBe(null);
|
||||
expect(store.get('search-query')).toBe(urlParams.q);
|
||||
expect(store.get('search-categories')).toBe(urlParams.c);
|
||||
expect(store.get('search-tags')).toBe(urlParams.t);
|
||||
});
|
||||
|
||||
test('Action type: "Action type: "TOGGLE_VIEWER_MODE"', async () => {
|
||||
const predictionsQuery_1 = 'predictions_query_1';
|
||||
const predictionsQuery_2 = 'predictions_query_2';
|
||||
|
||||
const response_1 = { data: [{ title: 'Prediction 1' }, { title: 'Prediction 2' }] };
|
||||
const response_2 = { data: [{ title: 'Prediction 3' }, { title: 'Prediction 4' }] };
|
||||
|
||||
(getRequest as jest.Mock)
|
||||
.mockImplementationOnce((_url, _cache, successCallback, _failCallback) => successCallback(response_1))
|
||||
.mockImplementationOnce((_url, _cache, successCallback, _failCallback) => successCallback(response_2));
|
||||
|
||||
handler({ type: 'REQUEST_PREDICTIONS', query: predictionsQuery_1 });
|
||||
handler({ type: 'REQUEST_PREDICTIONS', query: predictionsQuery_2 });
|
||||
|
||||
expect(onLoadPredictions).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(onLoadPredictions).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
predictionsQuery_1,
|
||||
response_1.data.map(({ title }) => title)
|
||||
);
|
||||
|
||||
expect(onLoadPredictions).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
predictionsQuery_2,
|
||||
response_2.data.map(({ title }) => title)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
147
frontend/tests/utils/stores/VideoViewerStore.test.ts
Normal file
147
frontend/tests/utils/stores/VideoViewerStore.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { BrowserCache } from '../../../src/static/js/utils/classes/';
|
||||
import store from '../../../src/static/js/utils/stores/VideoViewerStore';
|
||||
|
||||
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||
get: (key: string) => {
|
||||
let result: any = undefined;
|
||||
switch (key) {
|
||||
case 'player-volume':
|
||||
result = 0.6;
|
||||
break;
|
||||
case 'player-sound-muted':
|
||||
result = false;
|
||||
break;
|
||||
case 'in-theater-mode':
|
||||
result = true;
|
||||
break;
|
||||
case 'video-quality':
|
||||
result = 720;
|
||||
break;
|
||||
case 'video-playback-speed':
|
||||
result = 2;
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
set: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||
BrowserEvents: jest.fn().mockImplementation(() => ({
|
||||
doc: jest.fn(),
|
||||
win: jest.fn(),
|
||||
})),
|
||||
exportStore: jest.fn((store) => store),
|
||||
}));
|
||||
|
||||
describe('utils/store', () => {
|
||||
describe('VideoViewerStore', () => {
|
||||
const browserCacheInstance = (BrowserCache as jest.Mock).mock.results[0].value;
|
||||
const browserCacheSetSpy = browserCacheInstance.set;
|
||||
|
||||
const handler = store.actions_handler.bind(store);
|
||||
|
||||
const onChangedViewerMode = jest.fn();
|
||||
const onChangedPlayerVolume = jest.fn();
|
||||
const onChangedPlayerSoundMuted = jest.fn();
|
||||
const onChangedVideoQuality = jest.fn();
|
||||
const onChangedVideoPlaybackSpeed = jest.fn();
|
||||
|
||||
store.on('changed_viewer_mode', onChangedViewerMode);
|
||||
store.on('changed_player_volume', onChangedPlayerVolume);
|
||||
store.on('changed_player_sound_muted', onChangedPlayerSoundMuted);
|
||||
store.on('changed_video_quality', onChangedVideoQuality);
|
||||
store.on('changed_video_playback_speed', onChangedVideoPlaybackSpeed);
|
||||
|
||||
test('Validate initial values', () => {
|
||||
expect(store.get('player-volume')).toBe(0.6);
|
||||
expect(store.get('player-sound-muted')).toBe(false);
|
||||
expect(store.get('in-theater-mode')).toBe(true);
|
||||
expect(store.get('video-data')).toBe(undefined); // @todo: Revisit this behavior
|
||||
expect(store.get('video-quality')).toBe(720);
|
||||
expect(store.get('video-playback-speed')).toBe(2);
|
||||
});
|
||||
|
||||
describe('Trigger and validate actions behavior', () => {
|
||||
test('Action type: "TOGGLE_VIEWER_MODE"', () => {
|
||||
const initialValue = store.get('in-theater-mode');
|
||||
|
||||
handler({ type: 'TOGGLE_VIEWER_MODE' });
|
||||
|
||||
expect(onChangedViewerMode).toHaveBeenCalledWith();
|
||||
expect(onChangedViewerMode).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(store.get('in-theater-mode')).toBe(!initialValue);
|
||||
expect(browserCacheSetSpy).toHaveBeenCalledWith('in-theater-mode', !initialValue);
|
||||
});
|
||||
|
||||
test('Action type: "SET_VIEWER_MODE"', () => {
|
||||
const initialValue = store.get('in-theater-mode');
|
||||
const newValue = !initialValue;
|
||||
|
||||
handler({ type: 'SET_VIEWER_MODE', inTheaterMode: newValue });
|
||||
|
||||
expect(onChangedViewerMode).toHaveBeenCalledWith();
|
||||
expect(onChangedViewerMode).toHaveBeenCalledTimes(2); // The first time called by 'TOGGLE_VIEWER_MODE' action.
|
||||
|
||||
expect(store.get('in-theater-mode')).toBe(newValue);
|
||||
expect(browserCacheSetSpy).toHaveBeenCalledWith('in-theater-mode', newValue);
|
||||
});
|
||||
|
||||
test('Action type: "SET_PLAYER_VOLUME"', () => {
|
||||
const newValue = 0.3;
|
||||
|
||||
handler({ type: 'SET_PLAYER_VOLUME', playerVolume: newValue });
|
||||
|
||||
expect(onChangedPlayerVolume).toHaveBeenCalledWith();
|
||||
expect(onChangedPlayerVolume).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(store.get('player-volume')).toBe(newValue);
|
||||
expect(browserCacheSetSpy).toHaveBeenCalledWith('player-volume', newValue);
|
||||
});
|
||||
|
||||
test('Action type: "SET_PLAYER_SOUND_MUTED"', () => {
|
||||
const initialValue = store.get('player-sound-muted');
|
||||
const newValue = !initialValue;
|
||||
|
||||
handler({ type: 'SET_PLAYER_SOUND_MUTED', playerSoundMuted: newValue });
|
||||
|
||||
expect(onChangedPlayerSoundMuted).toHaveBeenCalledWith();
|
||||
expect(onChangedPlayerSoundMuted).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(store.get('player-sound-muted')).toBe(newValue);
|
||||
expect(browserCacheSetSpy).toHaveBeenCalledWith('player-sound-muted', newValue);
|
||||
});
|
||||
|
||||
test('Action type: "SET_VIDEO_QUALITY"', () => {
|
||||
const newValue = 1080;
|
||||
|
||||
handler({ type: 'SET_VIDEO_QUALITY', quality: newValue });
|
||||
|
||||
expect(onChangedVideoQuality).toHaveBeenCalledWith();
|
||||
expect(onChangedVideoQuality).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(store.get('video-quality')).toBe(newValue);
|
||||
expect(browserCacheSetSpy).toHaveBeenCalledWith('video-quality', newValue);
|
||||
});
|
||||
|
||||
test('Action type: "SET_VIDEO_PLAYBACK_SPEED"', () => {
|
||||
const newValue = 1.5;
|
||||
|
||||
handler({ type: 'SET_VIDEO_PLAYBACK_SPEED', playbackSpeed: newValue });
|
||||
|
||||
expect(onChangedVideoPlaybackSpeed).toHaveBeenCalledWith();
|
||||
expect(onChangedVideoPlaybackSpeed).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(store.get('video-playback-speed')).toBe(newValue);
|
||||
expect(browserCacheSetSpy).toHaveBeenCalledWith('video-playback-speed', newValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1937
frontend/yarn.lock
1937
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
302
install-rhel.sh
Normal file
302
install-rhel.sh
Normal file
@@ -0,0 +1,302 @@
|
||||
#!/bin/bash
|
||||
# should be run as root on a rhel8-like system
|
||||
|
||||
function update_permissions
|
||||
{
|
||||
# fix permissions of /srv/mediacms directory
|
||||
chown -R nginx:root $1
|
||||
}
|
||||
|
||||
echo "Welcome to the MediacMS installation!";
|
||||
|
||||
if [ `id -u` -ne 0 ]; then
|
||||
echo "Please run as root user"
|
||||
exit
|
||||
fi
|
||||
|
||||
|
||||
while true; do
|
||||
read -p "
|
||||
This script will attempt to perform a system update, install required dependencies, and configure PostgreSQL, NGINX, Redis and a few other utilities.
|
||||
It is expected to run on a new system **with no running instances of any these services**. Make sure you check the script before you continue. Then enter y or n
|
||||
" yn
|
||||
case $yn in
|
||||
[Yy]* ) echo "OK!"; break;;
|
||||
[Nn]* ) echo "Have a great day"; exit;;
|
||||
* ) echo "Please answer y or n.";;
|
||||
esac
|
||||
done
|
||||
|
||||
# update configuration files
|
||||
|
||||
sed -i 's/\/home\/mediacms\.io\/mediacms\/Bento4-SDK-1-6-0-637\.x86_64-unknown-linux\/bin\/mp4hls/\/srv\/mediacms\/bento4\/bin\/mp4hls/g' cms/settings.py
|
||||
sed -i 's/www-data/nginx/g;s/\/home\/mediacms\.io\/mediacms\/logs/\/var\/log\/mediacms/g;s/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g;s/\/home\/mediacms\.io\/bin/\/srv\/mediacms\/virtualenv\/bin/g' deploy/local_install/celery_*.service
|
||||
sed -i 's/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g' deploy/local_install/mediacms.io
|
||||
sed -i 's/\/home\/mediacms\.io\/bin/\/srv\/mediacms\/virtualenv\/bin/g;s/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g' deploy/local_install/mediacms.service
|
||||
sed -i 's/\/home\/mediacms\.io\/mediacms/\/var\/log\/mediacms/g' deploy/local_install/mediacms_logrorate
|
||||
sed -i 's/www-data/nginx/g' deploy/local_install/nginx.conf
|
||||
sed -i 's/www-data/nginx/g;s/\/home\/mediacms\.io\/mediacms\/logs/\/var\/log\/mediacms/g;s/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g;s/\/home\/mediacms\.io/\/srv\/mediacms\/virtualenv/g' deploy/local_install/uwsgi.ini
|
||||
|
||||
osVersion=
|
||||
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
osVersion=$(grep ^ID /etc/os-release)
|
||||
fi
|
||||
|
||||
if [[ $osVersion == *"fedora"* ]] || [[ $osVersion == *"rhel"* ]] || [[ $osVersion == *"centos"* ]] || [[ *"rocky"* ]]; then
|
||||
dnf install -y epel-release https://mirrors.rpmfusion.org/free/el/rpmfusion-free-release-8.noarch.rpm yum-utils
|
||||
yum-config-manager --enable powertools
|
||||
dnf install -y python3-virtualenv python39-devel redis postgresql postgresql-server nginx git gcc vim unzip ImageMagick python3-certbot-nginx certbot wget xz ffmpeg policycoreutils-devel cmake gcc gcc-c++ wget git bsdtar
|
||||
else
|
||||
echo "unsupported or unknown os"
|
||||
exit -1
|
||||
fi
|
||||
|
||||
# fix permissions of /srv/mediacms directory
|
||||
update_permissions /srv/mediacms/
|
||||
|
||||
read -p "Enter portal URL, or press enter for localhost : " FRONTEND_HOST
|
||||
read -p "Enter portal name, or press enter for 'MediaCMS : " PORTAL_NAME
|
||||
|
||||
[ -z "$PORTAL_NAME" ] && PORTAL_NAME='MediaCMS'
|
||||
[ -z "$FRONTEND_HOST" ] && FRONTEND_HOST='localhost'
|
||||
|
||||
echo "Configuring postgres"
|
||||
if [ ! command -v postgresql-setup > /dev/null 2>&1 ]; then
|
||||
echo "Something went wrong, the command 'postgresql-setup' was not found in the system path."
|
||||
exit -1
|
||||
fi
|
||||
|
||||
postgresql-setup --initdb
|
||||
|
||||
# set authentication method for mediacms user to scram-sha-256
|
||||
sed -i 's/.*password_encryption.*/password_encryption = scram-sha-256/' /var/lib/pgsql/data/postgresql.conf
|
||||
sed -i '/# IPv4 local connections:/a host\tmediacms\tmediacms\t127.0.0.1/32\tscram-sha-256' /var/lib/pgsql/data/pg_hba.conf
|
||||
|
||||
systemctl enable postgresql.service --now
|
||||
|
||||
su -c "psql -c \"CREATE DATABASE mediacms\"" postgres
|
||||
su -c "psql -c \"CREATE USER mediacms WITH ENCRYPTED PASSWORD 'mediacms'\"" postgres
|
||||
su -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE mediacms TO mediacms\"" postgres
|
||||
|
||||
echo 'Creating python virtualenv on /srv/mediacms/virtualenv/'
|
||||
|
||||
mkdir /srv/mediacms/virtualenv/
|
||||
cd /srv/mediacms/virtualenv/
|
||||
virtualenv . --python=python3
|
||||
source /srv/mediacms/virtualenv/bin/activate
|
||||
cd /srv/mediacms/
|
||||
pip install -r requirements.txt
|
||||
|
||||
systemctl enable redis.service --now
|
||||
|
||||
SECRET_KEY=`python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'`
|
||||
|
||||
# remove http or https prefix
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/http:\/\///g'`
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/https:\/\///g'`
|
||||
|
||||
FRONTEND_HOST_HTTP_PREFIX='http://'$FRONTEND_HOST
|
||||
|
||||
echo 'FRONTEND_HOST='\'"$FRONTEND_HOST_HTTP_PREFIX"\' >> cms/local_settings.py
|
||||
echo 'PORTAL_NAME='\'"$PORTAL_NAME"\' >> cms/local_settings.py
|
||||
echo "SSL_FRONTEND_HOST = FRONTEND_HOST.replace('http', 'https')" >> cms/local_settings.py
|
||||
|
||||
echo 'SECRET_KEY='\'"$SECRET_KEY"\' >> cms/local_settings.py
|
||||
echo "LOCAL_INSTALL = True" >> cms/local_settings.py
|
||||
|
||||
mkdir /var/log/mediacms/
|
||||
mkdir pids
|
||||
|
||||
update_permissions /var/log/mediacms/
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py loaddata fixtures/encoding_profiles.json
|
||||
python manage.py loaddata fixtures/categories.json
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
ADMIN_PASS=`python -c "import secrets;chars = 'abcdefghijklmnopqrstuvwxyz0123456789';print(''.join(secrets.choice(chars) for i in range(10)))"`
|
||||
echo "from users.models import User; User.objects.create_superuser('admin', 'admin@example.com', '$ADMIN_PASS')" | python manage.py shell
|
||||
|
||||
echo "from django.contrib.sites.models import Site; Site.objects.update(name='$FRONTEND_HOST', domain='$FRONTEND_HOST')" | python manage.py shell
|
||||
|
||||
update_permissions /srv/mediacms/
|
||||
|
||||
cp deploy/local_install/celery_long.service /etc/systemd/system/celery_long.service
|
||||
cp deploy/local_install/celery_short.service /etc/systemd/system/celery_short.service
|
||||
cp deploy/local_install/celery_beat.service /etc/systemd/system/celery_beat.service
|
||||
cp deploy/local_install/mediacms.service /etc/systemd/system/mediacms.service
|
||||
|
||||
mkdir -p /etc/letsencrypt/live/$FRONTEND_HOST
|
||||
mkdir -p /etc/nginx/sites-enabled
|
||||
mkdir -p /etc/nginx/sites-available
|
||||
mkdir -p /etc/nginx/dhparams/
|
||||
rm -rf /etc/nginx/conf.d/default.conf
|
||||
rm -rf /etc/nginx/sites-enabled/default
|
||||
cp deploy/local_install/mediacms.io_fullchain.pem /etc/letsencrypt/live/$FRONTEND_HOST/fullchain.pem
|
||||
cp deploy/local_install/mediacms.io_privkey.pem /etc/letsencrypt/live/$FRONTEND_HOST/privkey.pem
|
||||
cp deploy/local_install/mediacms.io /etc/nginx/sites-available/mediacms.io
|
||||
ln -s /etc/nginx/sites-available/mediacms.io /etc/nginx/sites-enabled/mediacms.io
|
||||
cp deploy/local_install/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
|
||||
cp deploy/local_install/nginx.conf /etc/nginx/
|
||||
|
||||
# attempt to get a valid certificate for specified domain
|
||||
while true ; do
|
||||
echo "Would you like to run [c]ertbot, or [s]kip?"
|
||||
read -p " : " certbotConfig
|
||||
|
||||
case $certbotConfig in
|
||||
[cC*] )
|
||||
if [ "$FRONTEND_HOST" != "localhost" ]; then
|
||||
systemctl start
|
||||
echo 'attempt to get a valid certificate for specified url $FRONTEND_HOST'
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
# unfortunately for some reason it needs to be run two times in order to create the entries
|
||||
# and directory structure!!!
|
||||
systemctl stop nginx
|
||||
|
||||
# Generate individual DH params
|
||||
openssl dhparam -out /etc/nginx/dhparams/dhparams.pem 4096
|
||||
fi
|
||||
|
||||
break
|
||||
;;
|
||||
[sS*] )
|
||||
echo "will not call certbot utility to update ssl certificate for url 'localhost', using default ssl certificate"
|
||||
cp deploy/local_install/dhparams.pem /etc/nginx/dhparams/dhparams.pem
|
||||
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $certbotConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# configure bento4 utility installation, for HLS
|
||||
while true ; do
|
||||
echo "Configuring Bento4"
|
||||
echo "Would you like to [d]ownload a pre-compiled bento4 binary, or [b]uild it now?"
|
||||
read -p "b/d : " bentoConfig
|
||||
|
||||
case $bentoConfig in
|
||||
[bB*] )
|
||||
echo "Building bento4 from source"
|
||||
git clone -b v1.6.0-640 https://github.com/axiomatic-systems/Bento4 /srv/mediacms/bento4
|
||||
cd /srv/mediacms/bento4/
|
||||
mkdir bin
|
||||
cd /srv/mediacms/bento4/bin/
|
||||
cmake -DCMAKE_BUILD_TYPE=Release ..
|
||||
make -j$(nproc)
|
||||
|
||||
chmod +x ../Source/Python/utils/mp4-hls.py
|
||||
|
||||
echo -e '#!/bin/bash' >> mp4hls
|
||||
echo -e 'BASEDIR=$(pwd)' >> mp4hls
|
||||
echo -e 'exec python3 "$BASEDIR/../Source/Python/utils/mp4-hls.py"' >> mp4hls
|
||||
|
||||
chmod +x mp4hls
|
||||
|
||||
break
|
||||
;;
|
||||
[dD*] )
|
||||
cd /srv/mediacms/
|
||||
wget http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
bsdtar -xf Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip -s '/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/bento4/'
|
||||
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $bentoConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir /srv/mediacms/media_files/hls
|
||||
|
||||
# update permissions
|
||||
|
||||
update_permissions /srv/mediacms/
|
||||
|
||||
# configure selinux
|
||||
|
||||
while true ; do
|
||||
echo "Configuring SELinux"
|
||||
echo "Would you like to [d]isable SELinux until next reboot, [c]onfigure our SELinux module, or [s]kip and not do any SELinux confgiguration?"
|
||||
read -p "d/c/s : " seConfig
|
||||
|
||||
case $seConfig in
|
||||
[Dd]* )
|
||||
echo "Disabling SELinux until next reboot"
|
||||
break
|
||||
;;
|
||||
[Cc]* )
|
||||
echo "Configuring custom mediacms selinux module"
|
||||
|
||||
semanage fcontext -a -t bin_t /srv/mediacms/virtualenv/bin/
|
||||
semanage fcontext -a -t httpd_sys_content_t "/srv/mediacms(/.*)?"
|
||||
restorecon -FRv /srv/mediacms/
|
||||
|
||||
sebools=(httpd_can_network_connect httpd_graceful_shutdown httpd_can_network_relay nis_enabled httpd_setrlimit domain_can_mmap_files)
|
||||
|
||||
for bool in "${sebools[@]}"
|
||||
do
|
||||
setsebool -P $bool 1
|
||||
done
|
||||
|
||||
cd /srv/mediacms/deploy/local_install/
|
||||
make -f /usr/share/selinux/devel/Makefile selinux-mediacms.pp
|
||||
semodule -i selinux-mediacms.pp
|
||||
|
||||
break
|
||||
;;
|
||||
[Ss]* )
|
||||
echo "Skipping SELinux configuration"
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $seConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# configure firewall
|
||||
if command -v firewall-cmd > /dev/null 2>&1 ; then
|
||||
while true ; do
|
||||
echo "Configuring firewall"
|
||||
echo "Would you like to configure http, https, or skip and not do any firewall configuration?"
|
||||
read -p "http/https/skip : " fwConfig
|
||||
|
||||
case $fwConfig in
|
||||
http )
|
||||
echo "Opening port 80 until next reboot"
|
||||
firewall-cmd --add-port=80/tcp
|
||||
break
|
||||
;;
|
||||
https )
|
||||
echo "Opening port 443 permanently"
|
||||
firewall-cmd --add-port=443/tcp --permanent
|
||||
firewall-cmd --reload
|
||||
break
|
||||
;;
|
||||
skip )
|
||||
echo "Skipping firewall configuration"
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $fwConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl start celery_long.service
|
||||
systemctl start celery_short.service
|
||||
systemctl start celery_beat.service
|
||||
systemctl start mediacms.service
|
||||
systemctl start nginx.service
|
||||
|
||||
echo 'MediaCMS installation completed, open browser on http://'"$FRONTEND_HOST"' and login with user admin and password '"$ADMIN_PASS"''
|
||||
140
install.sh
Normal file
140
install.sh
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/bin/bash
|
||||
# should be run as root and only on Ubuntu 20/22, Debian 10/11 (Buster/Bullseye) versions!
|
||||
echo "Welcome to the MediacMS installation!";
|
||||
|
||||
if [ `id -u` -ne 0 ]
|
||||
then echo "Please run as root"
|
||||
exit
|
||||
fi
|
||||
|
||||
|
||||
while true; do
|
||||
read -p "
|
||||
This script will attempt to perform a system update and install services including PostgreSQL, nginx and Django.
|
||||
It is expected to run on a new system **with no running instances of any these services**.
|
||||
This has been tested only in Ubuntu Linux 22 and 24. Make sure you check the script before you continue. Then enter yes or no
|
||||
" yn
|
||||
case $yn in
|
||||
[Yy]* ) echo "OK!"; break;;
|
||||
[Nn]* ) echo "Have a great day"; exit;;
|
||||
* ) echo "Please answer yes or no.";;
|
||||
esac
|
||||
done
|
||||
|
||||
apt-get update && apt-get -y upgrade && apt-get install pkg-config python3-venv python3-dev virtualenv redis-server postgresql nginx git gcc vim unzip imagemagick procps libxml2-dev libxmlsec1-dev libxmlsec1-openssl python3-certbot-nginx certbot wget xz-utils -y
|
||||
|
||||
# install ffmpeg
|
||||
echo "Downloading and installing ffmpeg"
|
||||
wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
||||
mkdir -p tmp
|
||||
tar -xf ffmpeg-release-amd64-static.tar.xz --strip-components 1 -C tmp
|
||||
cp -v tmp/{ffmpeg,ffprobe,qt-faststart} /usr/local/bin
|
||||
rm -rf tmp ffmpeg-release-amd64-static.tar.xz
|
||||
echo "ffmpeg installed to /usr/local/bin"
|
||||
|
||||
read -p "Enter portal URL, or press enter for localhost : " FRONTEND_HOST
|
||||
read -p "Enter portal name, or press enter for 'MediaCMS : " PORTAL_NAME
|
||||
|
||||
[ -z "$PORTAL_NAME" ] && PORTAL_NAME='MediaCMS'
|
||||
[ -z "$FRONTEND_HOST" ] && FRONTEND_HOST='localhost'
|
||||
|
||||
echo 'Creating database to be used in MediaCMS'
|
||||
|
||||
su -c "psql -c \"CREATE DATABASE mediacms\"" postgres
|
||||
su -c "psql -c \"CREATE USER mediacms WITH ENCRYPTED PASSWORD 'mediacms'\"" postgres
|
||||
su -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE mediacms TO mediacms\"" postgres
|
||||
su -c "psql -d mediacms -c \"GRANT CREATE, USAGE ON SCHEMA public TO mediacms\"" postgres
|
||||
|
||||
echo 'Creating python virtualenv on /home/mediacms.io'
|
||||
|
||||
cd /home/mediacms.io
|
||||
virtualenv . --python=python3
|
||||
source /home/mediacms.io/bin/activate
|
||||
cd mediacms
|
||||
pip install --no-binary lxml,xmlsec -r requirements.txt
|
||||
|
||||
SECRET_KEY=`python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'`
|
||||
|
||||
# remove http or https prefix
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/http:\/\///g'`
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/https:\/\///g'`
|
||||
|
||||
sed -i s/localhost/$FRONTEND_HOST/g deploy/local_install/mediacms.io
|
||||
|
||||
FRONTEND_HOST_HTTP_PREFIX='http://'$FRONTEND_HOST
|
||||
|
||||
echo 'FRONTEND_HOST='\'"$FRONTEND_HOST_HTTP_PREFIX"\' >> cms/local_settings.py
|
||||
echo 'PORTAL_NAME='\'"$PORTAL_NAME"\' >> cms/local_settings.py
|
||||
echo "SSL_FRONTEND_HOST = FRONTEND_HOST.replace('http', 'https')" >> cms/local_settings.py
|
||||
|
||||
echo 'SECRET_KEY='\'"$SECRET_KEY"\' >> cms/local_settings.py
|
||||
echo "LOCAL_INSTALL = True" >> cms/local_settings.py
|
||||
|
||||
mkdir logs
|
||||
mkdir pids
|
||||
python manage.py migrate
|
||||
python manage.py loaddata fixtures/encoding_profiles.json
|
||||
python manage.py loaddata fixtures/categories.json
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
ADMIN_PASS=`python -c "import secrets;chars = 'abcdefghijklmnopqrstuvwxyz0123456789';print(''.join(secrets.choice(chars) for i in range(10)))"`
|
||||
echo "from users.models import User; User.objects.create_superuser('admin', 'admin@example.com', '$ADMIN_PASS')" | python manage.py shell
|
||||
|
||||
echo "from django.contrib.sites.models import Site; Site.objects.update(name='$FRONTEND_HOST', domain='$FRONTEND_HOST')" | python manage.py shell
|
||||
|
||||
chown -R www-data. /home/mediacms.io/
|
||||
cp deploy/local_install/celery_long.service /etc/systemd/system/celery_long.service && systemctl enable celery_long && systemctl start celery_long
|
||||
cp deploy/local_install/celery_short.service /etc/systemd/system/celery_short.service && systemctl enable celery_short && systemctl start celery_short
|
||||
cp deploy/local_install/celery_beat.service /etc/systemd/system/celery_beat.service && systemctl enable celery_beat &&systemctl start celery_beat
|
||||
cp deploy/local_install/mediacms.service /etc/systemd/system/mediacms.service && systemctl enable mediacms.service && systemctl start mediacms.service
|
||||
|
||||
mkdir -p /etc/letsencrypt/live/mediacms.io/
|
||||
mkdir -p /etc/letsencrypt/live/$FRONTEND_HOST
|
||||
mkdir -p /etc/nginx/sites-enabled
|
||||
mkdir -p /etc/nginx/sites-available
|
||||
mkdir -p /etc/nginx/dhparams/
|
||||
rm -rf /etc/nginx/conf.d/default.conf
|
||||
rm -rf /etc/nginx/sites-enabled/default
|
||||
cp deploy/local_install/mediacms.io_fullchain.pem /etc/letsencrypt/live/$FRONTEND_HOST/fullchain.pem
|
||||
cp deploy/local_install/mediacms.io_privkey.pem /etc/letsencrypt/live/$FRONTEND_HOST/privkey.pem
|
||||
cp deploy/local_install/dhparams.pem /etc/nginx/dhparams/dhparams.pem
|
||||
cp deploy/local_install/mediacms.io /etc/nginx/sites-available/mediacms.io
|
||||
ln -s /etc/nginx/sites-available/mediacms.io /etc/nginx/sites-enabled/mediacms.io
|
||||
cp deploy/local_install/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
|
||||
cp deploy/local_install/nginx.conf /etc/nginx/
|
||||
systemctl stop nginx
|
||||
systemctl start nginx
|
||||
|
||||
# attempt to get a valid certificate for specified domain
|
||||
|
||||
if [ "$FRONTEND_HOST" != "localhost" ]; then
|
||||
echo 'attempt to get a valid certificate for specified url $FRONTEND_HOST'
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
# unfortunately for some reason it needs to be run two times in order to create the entries
|
||||
# and directory structure!!!
|
||||
systemctl restart nginx
|
||||
else
|
||||
echo "will not call certbot utility to update ssl certificate for url 'localhost', using default ssl certificate"
|
||||
fi
|
||||
|
||||
# Generate individual DH params
|
||||
if [ "$FRONTEND_HOST" != "localhost" ]; then
|
||||
# Only generate new DH params when using "real" certificates.
|
||||
openssl dhparam -out /etc/nginx/dhparams/dhparams.pem 4096
|
||||
systemctl restart nginx
|
||||
else
|
||||
echo "will not generate new DH params for url 'localhost', using default DH params"
|
||||
fi
|
||||
|
||||
# Bento4 utility installation, for HLS
|
||||
|
||||
cd /home/mediacms.io/mediacms
|
||||
wget http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
unzip Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
mkdir /home/mediacms.io/mediacms/media_files/hls
|
||||
|
||||
# last, set default owner
|
||||
chown -R www-data. /home/mediacms.io/
|
||||
|
||||
echo 'MediaCMS installation completed, open browser on http://'"$FRONTEND_HOST"' and login with user admin and password '"$ADMIN_PASS"''
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user