diff --git a/.env b/.env index 25331ca93..8f36e198c 100755 --- a/.env +++ b/.env @@ -20,7 +20,7 @@ FUEL_ENV=production BOOL_SEND_EMAILS=false #SYSTEM_EMAIL= #FUEL_LOCAL=en_US.UTF-8 -#FUEL_ALWAYS_LOAD_PACKAGES="orm,auth,materiaauth,ltiauth" +# FUEL_ALWAYS_LOAD_PACKAGES="orm,auth,materiaauth,ltiauth" #FUEL_ALWAYS_LOAD_MODULES="" #GOOGLE_ANALYTICS_ID=xxx diff --git a/.github/workflows/publish_widget_dependencies.yml b/.github/workflows/publish_widget_dependencies.yml new file mode 100644 index 000000000..9a17c46ef --- /dev/null +++ b/.github/workflows/publish_widget_dependencies.yml @@ -0,0 +1,17 @@ +name: Upload Widget Dependencies Package +on: + workflow_dispatch: +jobs: + checkout-and-install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '18.13.0' + registry-url: 'https://registry.npmjs.org' + - run: yarn install + - run: yarn build + - run: yarn publish public/dist + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index d28a1e877..b6a6609ea 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -53,7 +53,7 @@ jobs: cd docker && ./run_build_github_release_package.sh ghcr.io/${{ github.repository_owner }}/materia:app-${{ steps.tag_name.outputs.GIT_TAG }} - name: Upload to Release - if: ${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-alpha') }} + if: ${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-alpha') && !contains(github.ref, '-rc') }} uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} @@ -63,7 +63,7 @@ jobs: overwrite: true - name: Upload to Pre-Release - if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-alpha') }} + if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-alpha') && contains(github.ref, '-rc') }} uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 3c37457ee..605b7ac7c 100644 --- a/.gitignore +++ b/.gitignore @@ -115,4 +115,10 @@ licenses/LICENSES_NPM materia-pkg.zip materia-pkg-build-info.yml + yarn-error.log + +public/dist/* +!public/dist/package.json +!public/dist/path.js +!public/dist/README.md diff --git a/README.md b/README.md index 88c493a5f..1493b290e 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,25 @@ # Materia +Materia is a platform and ecosystem for small, self-contained, customizable e-learning applications called _widgets_, designed to enhance digital course content. Widgets in the catalog can be customized by instructors to suit their instructional goals and objectives, then shared with students directly or embedded in an LMS through LTI. + +Materia and its associated library of widgets is an open-source project of the University of Central Florida's Center for Distributed Learning. + View the [Materia Docs](http://ucfopen.github.io/Materia-Docs/) for info on installing, using, and developing Materia and widgets. [Join UCF Open Slack Discussions](https://dl.ucf.edu/join-ucfopen/) [![Join UCF Open Slack Discussions](https://badgen.net/badge/icon/ucfopen?icon=slack&label=slack&color=e01563)](https://dl.ucf.edu/join-ucfopen/) # Installation -# Docker +Materia is configured to use Docker containers in production environments, orchestrated through docker compose, though other orchestration frameworks could potentially be used instead. While it may be possible to deploy Materia without Docker, we **do not recommend doing so**. + +## Docker Deployment We publish production ready docker and nginx containers in the [Materia Docker repository](https://github.com/orgs/ucfopen/packages/container/package/materia). For more info on using Docker in Production, read the [Materia Docker Readme](docker/README.md) +## Configuration + +Visit the [Server Variables](https://ucfopen.github.io/Materia-Docs/admin/server-variables.html) page on our docs site for information about configuration through environment variables. + # Development ## Local Dev with Docker @@ -25,11 +35,15 @@ cd Materia/docker ``` More info about Materia Docker can be found in the [Materia Docker Readme](docker/README.md) +## Creating additional users + +See the wiki page for [Creating a local user](https://github.com/ucfopen/Materia/wiki#creating-a-local-user). + ## Running Tests Tests run in the docker environment to maintain consistency. View the `run_tests_*.sh` scripts in the docker directory for options. -### Running A single test group +### Running A Single Test Group Inspect the actual test command in `/.run_tests.sh` for guidance, but as of the time of writing this, you can run a subset of the tests in the docker environment to save time. @@ -41,328 +55,15 @@ The following command will run just the **Oauth** tests rather quickly: ## Git Hooks -There is a pre-commit hook available to ensure your code follows our linting standards. Check out the comments contained inside the hook files (in the githooks directory) to install it, you'll need a few dependencies installed to get linting working. - -## Configuring - -Configuration settings are handled through environment variables. There are several ways to accomplish changing these settings (yay, flexibility!). Pick the one that best suits your deployment. - -> Note: It is crucial that you don't expose phpinfo() pages to the web. It will display your secrets! -> In development mode, Materia exposes `/dev/php-info`!). - -### Configure Using .env - -This is the most common approach. Simply copy `.env` to `.env.local` and edit the copy. You'll want to keep a backup of this file! - -> Note: Take extra care to make sure .env.local file is not accessable from the web. - -### Configure Using NGINX - -You can set php environment variables in your NGINX config using the fastcgi_param option. - -``` -location ~ ^/index.php$ { - fastcgi_pass 127.0.0.1:9000; - #... clip ... - - # HERE! - fastcgi_param FUEL_ENV development; - - # OR in this file - include fastcgi_params; -} -``` - -### Configure Using Apache - -In your virtual host block, use `SetEnv` - -``` -SetEnv MY_ENV_VAR_1 'value1' -``` - -### Configure Using PHPFPM - -PHPFPM allows you to add env vars using its config. For example, your config may be located in `/etc/php/fpm/pool.d/www.conf`. Uncomment `clear_env = no` and add your environment variables to the config. A restart of php-fpm will be required. - -``` -clear_env = no -env[MY_ENV_VAR_1] = 'value1' -env[MY_ENV_VAR_2] = 'value2' -``` - -### Manual Override - -All of the environment variables are converted into regular FuelPHP configuration options in the files in fuel/app/config. If you prefer, you can edit those files directly, skipping the environment settings all together. The config path to each setting is in our configuration key below. - -### Environment Variables - -The config key below shows all of the available environment variables. - -> Note: only `BOOL_` options become boolean values. And ONLY `true` evaluates to true. - -``` -# GENERAL =================== - -# always use production when exposed to the web! -FUEL_ENV=production - -#db.default.connection -# All your mysql/mariadb connection information in one variable -# format: "mysql://user:pass@host/database" -#DATABASE_URL= - -#materia.send_emails -# Should Materia send emails (mostly used to notify about sharing widgets) -# Note we use FuelPHP's email classes, additional customization is possible using config/email.php -# [true|false] (default: false) -BOOL_SEND_EMAILS=false - -#materia.system_email -# set the from address that materia will send from -#SYSTEM_EMAIL= - -#config.locale (default: en_US.UTF-8) -# google `php setlocale` for more info -#FUEL_LOCAL=en_US.UTF-8 - -#config.always_load.packages -# list of fuelphp packages to always load at startup. Custom auth modules may need to be registred here -# default: ("orm,auth,materiaauth,ltiauth") -# comma separated -#FUEL_ALWAYS_LOAD_PACKAGES="orm,auth,materiaauth,ltiauth" - -#config.always_load.modules -# default:("") -# comma separated -#FUEL_ALWAYS_LOAD_MODULES="" +There is a pre-commit hook available to ensure your code follows our linting standards. Check out the comments contained inside the hook files (in the githooks directory) to install it, you may need a few dependencies installed to get linting working. -# LOGGING =================== +# Authentication -#config.log_threshold -# Threshold for for which types of logs get written -# [0|99|100|200|300|400] -# (default:300) -# L_NONE=0, L_ALL=99, L_DEBUG=100, L_INFO=200, L_WARNING=300, L_ERROR=400 -#FUEL_LOG_THRESHOLD=300 +Materia supports two forms of authentication: -#config.log_handler -# Optionaly set the log handler to stdout (like when you're using docker) -# [STDOUT|DEFAULT] -#LOG_HANDLER=DEFAULT +- Direct authentication through direct logins. Note that Materia does not provide an out-of-the-box tool for user generation. If your goal is to connect to an external identity management platform or service, you will need to author an authentication module to support this. Review FuelPHP's [Auth package and Login driver](https://fuelphp.com/docs/packages/auth/types/login.html) documentation, as well as the `ltiauth` and `materiaauth` packages located in `fuel/packages` to get started. +- Authentication over LTI. This is the more out-of-the-box solution for user management and authentication. In fact, you can disable direct authentication altogether through the `BOOL_LTI_RESTRICT_LOGINS_TO_LAUNCHES` environment variable, making LTI authentication the only way to access Materia. Visit our [LTI Integration Overview](https://ucfopen.github.io/Materia-Docs/develop/lti-integrations.html) page on the docs site for more information. -# ASSETS =================== +# Asset Storage -#materia.urls.static -# Highly suggested, but not required, second domain to host static assets from. -# 1: it provides a cross-domain boundry to protect Materia Server from widget javascript -# 2: you can use this to load those assets via a CDN - speed! -# default is dynamic - \Uri::create() -#URLS_STATIC= - -#materia.urls.engines -# Highly suggested, but not required, second domain to host static assets from. -# 1: it provides a cross-domain boundry to protect Materia Server from widget javascript -# 2: you can use this to load those assets via a CDN - speed! -# default is dynamic - \Uri::create('widget/') -#URLS_ENGINES= - -#materia.enabled_admin_uploader -# This will allow admin users to install & update widgets by uploading them in the admin interface -# You may wish to disable this if you prefer more control, or use a CI/CD pipeline to install them -# [true|false] -# (default: true) -#BOOL_ADMIN_UPLOADER_ENABLE=true - -#materia.asset_storage_driver -# Where to store author uploaded media? file is easy. db is easy & works when running multiple Materia servers. s3 is harder to set up, but efficient and multi-server friendly. Do not use file on Heroku. -# [file|db|s3] -# (default: file) -#ASSET_STORAGE_DRIVER=file - -#materia.asset_storage.s3.region -# Which s3 region should be used to upload -# (default: us-east-1) -#ASSET_STORAGE_S3_REGION=us-east-1 - -#materia.asset_storage.s3.bucket -# Which bucket should assets be uploaded to - it will need to be public! -#ASSET_STORAGE_S3_BUCKET= - -#materia.asset_storage.s3.subdir (default: media) -# Basepath to add to uploaded media. Unlikely you'll need to change this -#ASSET_STORAGE_S3_BASEPATH= - -#materia.asset_storage.s3.key -# AWS Access Key, suggest creating a key with very restricted access! -#ASSET_STORAGE_S3_KEY= - -#materia.asset_storage.s3.secret_key -# Secret for the above Access Key. -#ASSET_STORAGE_S3_SECRET= - -# SESSION & CACHE =================== -# Where to store app cache. file is an easy default if you don't have a memcached server. file is OK on Heroku. -#cache.driver: [memcached|file] (default: file) -#CACHE_DRIVER=file - -# cache.memcached.servers.default.host & session.memcached.servers.default.host -# if you're using memcached for cache or sessions, what domain is it accessible from? -# (default: localhost) -#MEMCACHED_HOST=localhost - -# cache.memcached.servers.default.port & session.memcached.servers.default.port -# if you're using memcached for cache or sessions, what port is it accessible from? -# (default: 11211) -#MEMCACHED_PORT=11211 - -#session.driver -# Where to keep user sessions? file = easy, db = multi-server support, memcached = fast! & multiserver! Do not use file on Heroku. -# [db|file|memcached] -# (default: file) -#SESSION_DRIVER=file - -#session.expiration_time -# how long before sessions expire? Comment out for unlimited. -# (default: unlimited) -SESSION_EXPIRATION=21600 - -# THEME =================== - -#theme.active -# Custom themes can be provided to override how Materia looks using FuelPHP's themes -# (default: ) -# see github.com/ucfopen/Materia-Theme-UCF for example -#THEME_ACTIVE= - -#theme.custom_path (default: ) -# Tell FuelPHP where your custom themes are located -#THEME_PACKAGE= - -# AUTH =================== - -#auth.drivers: -# Register custom auth drivers (They can be used to enable SAML or external database lookups) -# (default: Materiaauth) -# comma separated, no spaces -#AUTH_DRIVERS=Materiaauth - -#auth.salt: -# A string used to salt older Materia Servers -# Upgrades from Materia 7.0.1 or earlier: copy from existing fuel/app/config/auth.php -# Create one: `docker-compose run --rm app php -r "echo(sodium_bin2hex(random_bytes(SODIUM_CRYPTO_STREAM_KEYBYTES)));"` -#AUTH_SALT= - -#simpleauth.login_hash_salt -# A string used to salt older Materia Servers -# Upgrades from Materia 7.0.1 or earlier: copy from existing fuel/app/config/crypt.php -# Create one for new installs: `docker-compose run --rm app php -r "echo(sodium_bin2hex(random_bytes(SODIUM_CRYPTO_STREAM_KEYBYTES)));"` -#AUTH_SIMPLEAUTH_SALT= - -# DEFAULT USERS =================== - -# materia.default_users[0].password -# By default materia creates 3 default users. This is a system user that must be created -# (default: random) -#USER_SYSTEM_PASSWORD - -# materia.default_users[1].password -# By default materia creates 3 default users. This is a sample instructor -# (default: random) -#USER_INSTRUCTOR_PASSWORD - -# materia.default_users[2].password -# By default materia creates 3 default users. This is a sample student -# (default: random) -#USER_STUDENT_PASSWORD - -# CRYPTO =================== - -#crypto.key -# A string used to salt older Materia Servers -# Upgrades from Materia 7.0.1 or earlier: copy from existing fuel/app/config/crypt.php -# Create one: `docker-compose run --rm app php -r "echo(sodium_bin2hex(random_bytes(SODIUM_CRYPTO_STREAM_KEYBYTES)));"` -#CRYPTO_KEY= - -#crypto.iv -# A string used to salt older Materia Servers -# Upgrades from Materia 7.0.1 or earlier: copy from existing fuel/app/config/crypt.php -# Create one: see crypto.key instructions -#CRYPTO_IV= - -#crypto.hmac -# A string used to salt older Materia Servers -# Upgrades from Materia 7.0.1 or earlier: copy from existing fuel/app/config/crypt.php -# Create one: see crypto.key instructions -#CRYPTO_HMAC= - -#crypto.sodium.cipherkey -# A special cipher used to encrypt newer fuelphp data using lib sodium. -# Create one: see crypto.key instructions -#CIPHER_KEY= - -# LTI =================== - -#auth.restrict_logins_to_lti_single_sign_on -# If set to true, users will ONLY be able to log in through your LMS. -# [true|false] -# (default: false) -#BOOL_LTI_RESTRICT_LOGINS_TO_LAUNCHES=false - -#lti.tool_consumer_instance_guid -# see `tool_consumer_instance_guid` http://www.imsglobal.org/specs/ltiv1p1/implementation-guide -#LTI_GUID= - -#lti.consumers.default.tool_id -# see `tool_id` https://canvas.instructure.com/doc/api/file.lti_dev_key_config.html -#LTI_TOOL_ID= - -#lti.consumers.default.course_nav_default -# Should Materia show up in the course nav by default? -# [true|false] -# (default: false) -#BOOL_LTI_COURSE_NAV_DEFAULT=false - -#lti.consumers.default.key -# What is the LTI Public Key for your LMS Integration -LTI_KEY="materia-production-lti-key" - -#lti.consumers.default.secret -# What is the LTI Secret For your LMS Integration -#LTI_SECRET= - -#lti.consumers.default.remote_username -# Which one of the LTI Launch paramaters do you want to use as a username in Materia? -# See https://canvas.instructure.com/doc/api/file.tools_xml.html -# pick any lti launch param -# (default: lis_person_sourcedid) -#LTI_REMOTE_USERNAME= - -#lti.consumers.default.remote_identifier -# Which one of the LTI Launch paramaters do you want to use as a username in Materia? -# See https://canvas.instructure.com/doc/api/file.tools_xml.html -# (default: lis_person_sourcedid) -#LTI_REMOTE_IDENTIFIER= - -#lti.consumers.default.creates_users -# Should LTI launches new users create new users? -# [true|false] -# (default: true) -#BOOL_LTI_CREATE_USERS=true - -#lti.consumers.default.use_launch_roles -# Should LTI launches change the user's role for Instructors & Students? -# [true|false] -# (default: true) -#BOOL_LTI_USE_LAUNCH_ROLES=true - - -#lti.graceful_fallback_to_default -# If an lti configuration for a specific provider isn't present, should Materia use the default configuration? -# The default is set in lti.consumers.default. If false, a consumer must be defined that matches the lti launch param 'tool_consumer_info_product_family_code'. For example: if the family code is 'canvas', a config must exist for lti.consumers.canvas. -# Using default is a nice option for simplicity, but it's advisable to use a different key and secret for each family_code. -# [true|false] -# (default: true) -#BOOL_LTI_GRACEFUL_CONFIG_FALLBACK=true - -``` +Materia enables users to upload media assets for their widgets, including images and audio. There are two asset storage drivers available out of the box: `file` and `db`. `file` is the default asset storage driver, which can be explicitly set via the `ASSET_STORAGE_DRIVER` environment variable. \ No newline at end of file diff --git a/babel.config.json b/babel.config.json new file mode 100644 index 000000000..0ad1b4930 --- /dev/null +++ b/babel.config.json @@ -0,0 +1,17 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "edge": "17", + "firefox": "60", + "chrome": "67", + "safari": "11.1" + }, + "useBuiltIns": "usage", + "corejs": "3" + } + ], "@babel/preset-react" + ] +} \ No newline at end of file diff --git a/composer.json b/composer.json index d611f0004..6232c425f 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "ext-pdo": "*", "ext-gd": "*", "ext-mbstring": "*", - "composer/installers": "~1.0", + "composer/installers": "1.*", "fuel/core": "dev-1.9/develop", "fuel/auth": "dev-1.9/develop", "fuel/email": "dev-1.9/develop", @@ -51,7 +51,7 @@ "eher/oauth": "1.0.7", "aws/aws-sdk-php": "3.67.17", "symfony/dotenv": "^5.1", - "ucfopen/materia-theme-ucf": "1.2.1" + "ucfopen/materia-theme-ucf": "2.0.1" }, "suggest": { "ext-memcached": "*" @@ -64,7 +64,10 @@ }, "config": { "vendor-dir": "fuel/vendor", - "optimize-autoloader": true + "optimize-autoloader": true, + "allow-plugins": { + "composer/installers": true + } }, "extra": { "installer-paths": { @@ -94,9 +97,9 @@ "package": { "name": "ucfopen/materia-theme-ucf", "type": "fuel-package", - "version": "1.2.1", + "version": "2.0.1", "dist": { - "url": "https://github.com/ucfopen/Materia-Theme-UCF/archive/refs/tags/v1.2.1.zip", + "url": "https://github.com/ucfopen/Materia-Theme-UCF/archive/refs/tags/v2.0.1.zip", "type": "zip" }, "source": { diff --git a/composer.lock b/composer.lock index eace7843c..dd0ada3be 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d71a4c647a0eae503daf308c37a3798b", + "content-hash": "6cd12e58a2eadc64a08ed0a8f5d0cf24", "packages": [ { "name": "aws/aws-sdk-php", @@ -84,6 +84,11 @@ "s3", "sdk" ], + "support": { + "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.67.17" + }, "time": "2018-09-21T20:29:41+00:00" }, { @@ -217,6 +222,10 @@ "zend", "zikula" ], + "support": { + "issues": "https://github.com/composer/installers/issues", + "source": "https://github.com/composer/installers/tree/v1.12.0" + }, "funding": [ { "url": "https://packagist.com", @@ -264,6 +273,10 @@ "BSD-3-Clause" ], "description": "OAuth 1 PHP Library", + "support": { + "issues": "https://github.com/EHER/OAuth/issues", + "source": "https://github.com/EHER/OAuth/tree/1.0.7" + }, "time": "2012-12-13T23:48:10+00:00" }, { @@ -272,17 +285,18 @@ "source": { "type": "git", "url": "https://github.com/fuel/auth.git", - "reference": "4ac41cd52e911405e1ba81a7d604f6332b8e9bf9" + "reference": "d6ad8342a01c0aba376a00fdf9e7ee42ecf04b4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fuel/auth/zipball/4ac41cd52e911405e1ba81a7d604f6332b8e9bf9", - "reference": "4ac41cd52e911405e1ba81a7d604f6332b8e9bf9", + "url": "https://api.github.com/repos/fuel/auth/zipball/d6ad8342a01c0aba376a00fdf9e7ee42ecf04b4a", + "reference": "d6ad8342a01c0aba376a00fdf9e7ee42ecf04b4a", "shasum": "" }, "require": { "composer/installers": "~1.0" }, + "default-branch": true, "type": "fuel-package", "notification-url": "https://packagist.org/downloads/", "license": [ @@ -296,7 +310,11 @@ ], "description": "FuelPHP 1.x Auth Package", "homepage": "https://github.com/fuel/auth", - "time": "2022-08-22T14:42:04+00:00" + "support": { + "issues": "https://github.com/fuel/auth/issues", + "source": "https://github.com/fuel/auth/tree/1.9/develop" + }, + "time": "2023-08-15T19:28:35+00:00" }, { "name": "fuel/core", @@ -304,12 +322,12 @@ "source": { "type": "git", "url": "https://github.com/fuel/core.git", - "reference": "7c37c7f701bb28986cf92e002dcf4db59388512e" + "reference": "0e6444a7b2903aa4df438dbd953be94a22bd3418" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fuel/core/zipball/7c37c7f701bb28986cf92e002dcf4db59388512e", - "reference": "7c37c7f701bb28986cf92e002dcf4db59388512e", + "url": "https://api.github.com/repos/fuel/core/zipball/0e6444a7b2903aa4df438dbd953be94a22bd3418", + "reference": "0e6444a7b2903aa4df438dbd953be94a22bd3418", "shasum": "" }, "require": { @@ -319,6 +337,7 @@ "paragonie/sodium_compat": "^1.6", "phpseclib/phpseclib": "~2.0" }, + "default-branch": true, "type": "fuel-package", "notification-url": "https://packagist.org/downloads/", "license": [ @@ -332,7 +351,11 @@ ], "description": "FuelPHP 1.x Core", "homepage": "https://github.com/fuel/core", - "time": "2022-10-20T00:05:40+00:00" + "support": { + "issues": "https://github.com/fuel/core/issues", + "source": "https://github.com/fuel/core/tree/1.9/develop" + }, + "time": "2023-09-28T21:45:24+00:00" }, { "name": "fuel/email", @@ -340,17 +363,18 @@ "source": { "type": "git", "url": "https://github.com/fuel/email.git", - "reference": "abf60db5b9813de3cb2759f51daa98e23d82fbf1" + "reference": "07dd69dcad1998cab6a1be6130f7932c25ad03fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fuel/email/zipball/abf60db5b9813de3cb2759f51daa98e23d82fbf1", - "reference": "abf60db5b9813de3cb2759f51daa98e23d82fbf1", + "url": "https://api.github.com/repos/fuel/email/zipball/07dd69dcad1998cab6a1be6130f7932c25ad03fb", + "reference": "07dd69dcad1998cab6a1be6130f7932c25ad03fb", "shasum": "" }, "require": { "composer/installers": "~1.0" }, + "default-branch": true, "type": "fuel-package", "notification-url": "https://packagist.org/downloads/", "license": [ @@ -364,7 +388,11 @@ ], "description": "FuelPHP 1.x Email Package", "homepage": "https://github.com/fuel/email", - "time": "2020-11-18T12:55:52+00:00" + "support": { + "issues": "https://github.com/fuel/email/issues", + "source": "https://github.com/fuel/email/tree/1.9/develop" + }, + "time": "2023-08-22T18:54:43+00:00" }, { "name": "fuel/oil", @@ -372,17 +400,18 @@ "source": { "type": "git", "url": "https://github.com/fuel/oil.git", - "reference": "793f53bce56a1bf6e9e9088ace57ef1868f29cfa" + "reference": "987062cd90b870ac921627f7713b1bfda03dfdab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fuel/oil/zipball/793f53bce56a1bf6e9e9088ace57ef1868f29cfa", - "reference": "793f53bce56a1bf6e9e9088ace57ef1868f29cfa", + "url": "https://api.github.com/repos/fuel/oil/zipball/987062cd90b870ac921627f7713b1bfda03dfdab", + "reference": "987062cd90b870ac921627f7713b1bfda03dfdab", "shasum": "" }, "require": { "composer/installers": "~1.0" }, + "default-branch": true, "type": "fuel-package", "notification-url": "https://packagist.org/downloads/", "license": [ @@ -396,7 +425,11 @@ ], "description": "FuelPHP 1.x Oil Package", "homepage": "https://github.com/fuel/oil", - "time": "2020-11-06T17:38:34+00:00" + "support": { + "issues": "https://github.com/fuel/oil/issues", + "source": "https://github.com/fuel/oil/tree/1.9/develop" + }, + "time": "2023-08-08T15:44:07+00:00" }, { "name": "fuel/orm", @@ -404,17 +437,18 @@ "source": { "type": "git", "url": "https://github.com/fuel/orm.git", - "reference": "9e1322f5634cbecc4aeb0a763cece134be9a6079" + "reference": "d70d7b532b849b4c8e797cb11e3112b083bb9774" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fuel/orm/zipball/9e1322f5634cbecc4aeb0a763cece134be9a6079", - "reference": "9e1322f5634cbecc4aeb0a763cece134be9a6079", + "url": "https://api.github.com/repos/fuel/orm/zipball/d70d7b532b849b4c8e797cb11e3112b083bb9774", + "reference": "d70d7b532b849b4c8e797cb11e3112b083bb9774", "shasum": "" }, "require": { "composer/installers": "~1.0" }, + "default-branch": true, "type": "fuel-package", "notification-url": "https://packagist.org/downloads/", "license": [ @@ -428,7 +462,11 @@ ], "description": "FuelPHP 1.x ORM Package", "homepage": "https://github.com/fuel/orm", - "time": "2022-08-03T14:20:19+00:00" + "support": { + "issues": "https://github.com/fuel/orm/issues", + "source": "https://github.com/fuel/orm/tree/1.9/develop" + }, + "time": "2023-04-16T21:39:15+00:00" }, { "name": "fuel/parser", @@ -436,17 +474,18 @@ "source": { "type": "git", "url": "https://github.com/fuel/parser.git", - "reference": "72a9495293e822b144575329bd7ced8c4cf41e54" + "reference": "0f258160964f2960dc2edce9f6abafc2233bb9be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fuel/parser/zipball/72a9495293e822b144575329bd7ced8c4cf41e54", - "reference": "72a9495293e822b144575329bd7ced8c4cf41e54", + "url": "https://api.github.com/repos/fuel/parser/zipball/0f258160964f2960dc2edce9f6abafc2233bb9be", + "reference": "0f258160964f2960dc2edce9f6abafc2233bb9be", "shasum": "" }, "require": { "composer/installers": "~1.0" }, + "default-branch": true, "type": "fuel-package", "notification-url": "https://packagist.org/downloads/", "license": [ @@ -460,7 +499,11 @@ ], "description": "FuelPHP 1.x Parser Package", "homepage": "https://github.com/fuel/parser", - "time": "2022-08-16T13:15:04+00:00" + "support": { + "issues": "https://github.com/fuel/parser/issues", + "source": "https://github.com/fuel/parser/tree/1.9/develop" + }, + "time": "2023-01-31T00:03:36+00:00" }, { "name": "fuelphp/upload", @@ -510,6 +553,10 @@ "file uploads", "upload" ], + "support": { + "issues": "https://github.com/fuelphp/upload/issues", + "source": "https://github.com/fuelphp/upload/tree/2.0.7" + }, "time": "2022-05-10T20:42:04+00:00" }, { @@ -607,6 +654,10 @@ "rest", "web service" ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/6.5.8" + }, "funding": [ { "url": "https://github.com/GrahamCampbell", @@ -625,16 +676,16 @@ }, { "name": "guzzlehttp/promises", - "version": "1.5.2", + "version": "1.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "b94b2807d85443f9719887892882d0329d1e2598" + "reference": "67ab6e18aaa14d753cc148911d273f6e6cb6721e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/b94b2807d85443f9719887892882d0329d1e2598", - "reference": "b94b2807d85443f9719887892882d0329d1e2598", + "url": "https://api.github.com/repos/guzzle/promises/zipball/67ab6e18aaa14d753cc148911d273f6e6cb6721e", + "reference": "67ab6e18aaa14d753cc148911d273f6e6cb6721e", "shasum": "" }, "require": { @@ -644,11 +695,6 @@ "symfony/phpunit-bridge": "^4.4 || ^5.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.5-dev" - } - }, "autoload": { "files": [ "src/functions_include.php" @@ -687,6 +733,10 @@ "keywords": [ "promise" ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/1.5.3" + }, "funding": [ { "url": "https://github.com/GrahamCampbell", @@ -701,20 +751,20 @@ "type": "tidelift" } ], - "time": "2022-08-28T14:55:35+00:00" + "time": "2023-05-21T12:31:43+00:00" }, { "name": "guzzlehttp/psr7", - "version": "1.9.0", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "e98e3e6d4f86621a9b75f623996e6bbdeb4b9318" + "reference": "e4490cabc77465aaee90b20cfc9a770f8c04be6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/e98e3e6d4f86621a9b75f623996e6bbdeb4b9318", - "reference": "e98e3e6d4f86621a9b75f623996e6bbdeb4b9318", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/e4490cabc77465aaee90b20cfc9a770f8c04be6b", + "reference": "e4490cabc77465aaee90b20cfc9a770f8c04be6b", "shasum": "" }, "require": { @@ -733,11 +783,6 @@ "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.9-dev" - } - }, "autoload": { "files": [ "src/functions_include.php" @@ -793,6 +838,10 @@ "uri", "url" ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/1.9.1" + }, "funding": [ { "url": "https://github.com/GrahamCampbell", @@ -807,7 +856,7 @@ "type": "tidelift" } ], - "time": "2022-06-20T21:43:03+00:00" + "time": "2023-04-17T16:00:37+00:00" }, { "name": "iturgeon/qasset", @@ -854,6 +903,10 @@ "javascript", "qasset" ], + "support": { + "issues": "https://github.com/iturgeon/qAsset/issues", + "source": "https://github.com/iturgeon/qAsset/tree/master" + }, "time": "2017-07-17T03:18:14+00:00" }, { @@ -903,6 +956,10 @@ "keywords": [ "markdown" ], + "support": { + "issues": "https://github.com/michelf/php-markdown/issues", + "source": "https://github.com/michelf/php-markdown/tree/1.9.1" + }, "time": "2021-11-24T02:52:38+00:00" }, { @@ -981,29 +1038,33 @@ "logging", "psr-3" ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/1.18.2" + }, "time": "2016-04-02T13:12:58+00:00" }, { "name": "mtdowling/jmespath.php", - "version": "2.6.1", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/jmespath/jmespath.php.git", - "reference": "9b87907a81b87bc76d19a7fb2d61e61486ee9edb" + "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9b87907a81b87bc76d19a7fb2d61e61486ee9edb", - "reference": "9b87907a81b87bc76d19a7fb2d61e61486ee9edb", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/bbb69a935c2cbb0c03d7f481a238027430f6440b", + "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b", "shasum": "" }, "require": { - "php": "^5.4 || ^7.0 || ^8.0", + "php": "^7.2.5 || ^8.0", "symfony/polyfill-mbstring": "^1.17" }, "require-dev": { - "composer/xdebug-handler": "^1.4 || ^2.0", - "phpunit/phpunit": "^4.8.36 || ^7.5.15" + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" }, "bin": [ "bin/jp.php" @@ -1011,7 +1072,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.6-dev" + "dev-master": "2.7-dev" } }, "autoload": { @@ -1027,6 +1088,11 @@ "MIT" ], "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, { "name": "Michael Dowling", "email": "mtdowling@gmail.com", @@ -1038,7 +1104,11 @@ "json", "jsonpath" ], - "time": "2021-06-14T00:11:39+00:00" + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.7.0" + }, + "time": "2023-08-25T10:54:48+00:00" }, { "name": "paragonie/random_compat", @@ -1083,20 +1153,25 @@ "pseudorandom", "random" ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, "time": "2020-10-15T08:29:30+00:00" }, { "name": "paragonie/sodium_compat", - "version": "v1.19.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "cb15e403ecbe6a6cc515f855c310eb6b1872a933" + "reference": "e592a3e06d1fa0d43988c7c7d9948ca836f644b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/cb15e403ecbe6a6cc515f855c310eb6b1872a933", - "reference": "cb15e403ecbe6a6cc515f855c310eb6b1872a933", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/e592a3e06d1fa0d43988c7c7d9948ca836f644b6", + "reference": "e592a3e06d1fa0d43988c7c7d9948ca836f644b6", "shasum": "" }, "require": { @@ -1165,7 +1240,11 @@ "secret-key cryptography", "side-channel resistant" ], - "time": "2022-09-26T03:40:35+00:00" + "support": { + "issues": "https://github.com/paragonie/sodium_compat/issues", + "source": "https://github.com/paragonie/sodium_compat/tree/v1.20.0" + }, + "time": "2023-04-30T00:54:53+00:00" }, { "name": "phpseclib/phpseclib", @@ -1256,6 +1335,10 @@ "x.509", "x509" ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/2.0.31" + }, "funding": [ { "url": "https://github.com/terrafrost", @@ -1274,25 +1357,25 @@ }, { "name": "psr/http-message", - "version": "1.0.1", + "version": "1.1", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -1320,7 +1403,10 @@ "request", "response" ], - "time": "2016-08-06T14:39:51+00:00" + "support": { + "source": "https://github.com/php-fig/http-message/tree/1.1" + }, + "time": "2023-04-04T09:50:52+00:00" }, { "name": "psr/log", @@ -1367,6 +1453,9 @@ "psr", "psr-3" ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, "time": "2021-05-03T11:20:27+00:00" }, { @@ -1407,20 +1496,24 @@ } ], "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, "time": "2019-03-08T08:55:37+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.1.1", + "version": "v3.3.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "07f1b9cc2ffee6aaafcf4b710fbc38ff736bd918" + "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/07f1b9cc2ffee6aaafcf4b710fbc38ff736bd918", - "reference": "07f1b9cc2ffee6aaafcf4b710fbc38ff736bd918", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/7c3aff79d10325257a001fcf92d991f24fc967cf", + "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf", "shasum": "" }, "require": { @@ -1429,7 +1522,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "3.1-dev" + "dev-main": "3.4-dev" }, "thanks": { "name": "symfony/contracts", @@ -1457,6 +1550,9 @@ ], "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.3.0" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -1471,20 +1567,20 @@ "type": "tidelift" } ], - "time": "2022-02-25T11:15:52+00:00" + "time": "2023-05-23T14:45:45+00:00" }, { "name": "symfony/dotenv", - "version": "v5.4.5", + "version": "v5.4.22", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "83a2310904a4f5d4f42526227b5a578ac82232a9" + "reference": "77b7660bfcb85e8f28287d557d7af0046bcd2ca3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/83a2310904a4f5d4f42526227b5a578ac82232a9", - "reference": "83a2310904a4f5d4f42526227b5a578ac82232a9", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/77b7660bfcb85e8f28287d557d7af0046bcd2ca3", + "reference": "77b7660bfcb85e8f28287d557d7af0046bcd2ca3", "shasum": "" }, "require": { @@ -1525,6 +1621,9 @@ "env", "environment" ], + "support": { + "source": "https://github.com/symfony/dotenv/tree/v5.4.22" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -1539,21 +1638,20 @@ "type": "tidelift" } ], - "time": "2022-02-15T17:04:12+00:00" + "time": "2023-03-09T20:36:58+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "639084e360537a19f9ee352433b84ce831f3d2da" + "reference": "ecaafce9f77234a6a449d29e49267ba10499116d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/639084e360537a19f9ee352433b84ce831f3d2da", - "reference": "639084e360537a19f9ee352433b84ce831f3d2da", - + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/ecaafce9f77234a6a449d29e49267ba10499116d", + "reference": "ecaafce9f77234a6a449d29e49267ba10499116d", "shasum": "" }, "require": { @@ -1567,7 +1665,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -1610,6 +1708,9 @@ "portable", "shim" ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.28.0" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -1624,20 +1725,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:30:37+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "19bd1e4fcd5b91116f14d8533c57831ed00571b6" + "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/19bd1e4fcd5b91116f14d8533c57831ed00571b6", - "reference": "19bd1e4fcd5b91116f14d8533c57831ed00571b6", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", + "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", "shasum": "" }, "require": { @@ -1649,7 +1750,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -1691,6 +1792,9 @@ "portable", "shim" ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.28.0" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -1705,20 +1809,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:26:14+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.26.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e" + "reference": "42292d99c55abe617799667f454222c54c60e229" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", - "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229", + "reference": "42292d99c55abe617799667f454222c54c60e229", "shasum": "" }, "require": { @@ -1733,7 +1837,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.26-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -1771,6 +1875,9 @@ "portable", "shim" ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -1785,20 +1892,20 @@ "type": "tidelift" } ], - "time": "2022-05-24T11:49:31+00:00" + "time": "2023-07-28T09:04:16+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "869329b1e9894268a8a61dabb69153029b7a8c97" + "reference": "70f4aebd92afca2f865444d30a4d2151c13c3179" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/869329b1e9894268a8a61dabb69153029b7a8c97", - "reference": "869329b1e9894268a8a61dabb69153029b7a8c97", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/70f4aebd92afca2f865444d30a4d2151c13c3179", + "reference": "70f4aebd92afca2f865444d30a4d2151c13c3179", "shasum": "" }, "require": { @@ -1807,7 +1914,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -1844,6 +1951,9 @@ "portable", "shim" ], + "support": { + "source": "https://github.com/symfony/polyfill-php72/tree/v1.28.0" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -1858,11 +1968,11 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:26:14+00:00" }, { "name": "ucfopen/materia-theme-ucf", - "version": "1.2.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/ucfopen/Materia-Theme-UCF.git", @@ -1870,8 +1980,7 @@ }, "dist": { "type": "zip", - "url": "https://github.com/ucfopen/Materia-Theme-UCF/archive/refs/tags/v1.2.1.zip", - "reference": "master" + "url": "https://github.com/ucfopen/Materia-Theme-UCF/archive/refs/tags/v2.0.1.zip" }, "type": "fuel-package" } @@ -1879,30 +1988,30 @@ "packages-dev": [ { "name": "doctrine/instantiator", - "version": "1.4.1", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc" + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/10dcfce151b967d20fde1b34ae6640712c3891bc", - "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^9", + "doctrine/coding-standard": "^9 || ^11", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^0.16 || ^1", "phpstan/phpstan": "^1.4", "phpstan/phpstan-phpunit": "^1", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.22" + "vimeo/psalm": "^4.30 || ^5.4" }, "type": "library", "autoload": { @@ -1927,6 +2036,10 @@ "constructor", "instantiate" ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + }, "funding": [ { "url": "https://www.doctrine-project.org/sponsorship.html", @@ -1941,7 +2054,7 @@ "type": "tidelift" } ], - "time": "2022-03-03T08:28:38+00:00" + "time": "2022-12-30T00:15:36+00:00" }, { "name": "johnkary/phpunit-speedtrap", @@ -1989,20 +2102,24 @@ "profile", "slow" ], + "support": { + "issues": "https://github.com/johnkary/phpunit-speedtrap/issues", + "source": "https://github.com/johnkary/phpunit-speedtrap/tree/v4.0.1" + }, "time": "2022-10-17T00:56:56+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.11.0", + "version": "1.11.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614" + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/14daed4296fae74d9e3201d2c4925d1acb7aa614", - "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", "shasum": "" }, "require": { @@ -2038,26 +2155,30 @@ "object", "object graph" ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + }, "funding": [ { "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", "type": "tidelift" } ], - "time": "2022-03-03T13:19:32+00:00" + "time": "2023-03-08T13:26:56+00:00" }, { "name": "nikic/php-parser", - "version": "v4.15.1", + "version": "v4.17.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900" + "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/0ef6c55a3f47f89d7a374e6f835197a0b5fcf900", - "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", "shasum": "" }, "require": { @@ -2096,7 +2217,11 @@ "parser", "php" ], - "time": "2022-09-04T07:30:47+00:00" + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" + }, + "time": "2023-08-13T19:53:39+00:00" }, { "name": "phar-io/manifest", @@ -2152,6 +2277,10 @@ } ], "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.3" + }, "time": "2021-07-20T11:28:43+00:00" }, { @@ -2199,27 +2328,31 @@ } ], "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, "time": "2022-02-21T01:04:05+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.18", + "version": "9.2.29", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "12fddc491826940cf9b7e88ad9664cf51f0f6d0a" + "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/12fddc491826940cf9b7e88ad9664cf51f0f6d0a", - "reference": "12fddc491826940cf9b7e88ad9664cf51f0f6d0a", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6a3a87ac2bbe33b25042753df8195ba4aa534c76", + "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.14", + "nikic/php-parser": "^4.15", "php": ">=7.3", "phpunit/php-file-iterator": "^3.0.3", "phpunit/php-text-template": "^2.0.2", @@ -2234,8 +2367,8 @@ "phpunit/phpunit": "^9.3" }, "suggest": { - "ext-pcov": "*", - "ext-xdebug": "*" + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "type": "library", "extra": { @@ -2266,13 +2399,18 @@ "testing", "xunit" ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.29" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2022-10-27T13:35:33+00:00" + "time": "2023-09-19T04:57:46+00:00" }, { "name": "phpunit/php-file-iterator", @@ -2322,6 +2460,10 @@ "filesystem", "iterator" ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -2381,6 +2523,10 @@ "keywords": [ "process" ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -2436,6 +2582,10 @@ "keywords": [ "template" ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -2491,6 +2641,10 @@ "keywords": [ "timer" ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -2581,6 +2735,10 @@ "testing", "xunit" ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.25" + }, "funding": [ { "url": "https://phpunit.de/sponsors.html", @@ -2641,6 +2799,10 @@ ], "description": "Library for parsing CLI options", "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -2693,6 +2855,10 @@ ], "description": "Collection of value objects that represent the PHP code units", "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -2744,6 +2910,10 @@ ], "description": "Looks up which function or method a line of code belongs to", "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -2814,6 +2984,10 @@ "compare", "equality" ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -2867,6 +3041,10 @@ ], "description": "Library for calculating the complexity of PHP code units", "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -2877,16 +3055,16 @@ }, { "name": "sebastian/diff", - "version": "4.0.4", + "version": "4.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d" + "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d", - "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/74be17022044ebaaecfdf0c5cd504fc9cd5a7131", + "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131", "shasum": "" }, "require": { @@ -2929,26 +3107,30 @@ "unidiff", "unified diff" ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.5" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2020-10-26T13:10:38+00:00" + "time": "2023-05-07T05:35:17+00:00" }, { "name": "sebastian/environment", - "version": "5.1.4", + "version": "5.1.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7" + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/1b5dff7bb151a4db11d49d90e5408e4e938270f7", - "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", "shasum": "" }, "require": { @@ -2988,13 +3170,17 @@ "environment", "hhvm" ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2022-04-03T09:37:03+00:00" + "time": "2023-02-03T06:03:51+00:00" }, { "name": "sebastian/exporter", @@ -3061,6 +3247,10 @@ "export", "exporter" ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -3071,16 +3261,16 @@ }, { "name": "sebastian/global-state", - "version": "5.0.5", + "version": "5.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2" + "reference": "bde739e7565280bda77be70044ac1047bc007e34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2", - "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bde739e7565280bda77be70044ac1047bc007e34", + "reference": "bde739e7565280bda77be70044ac1047bc007e34", "shasum": "" }, "require": { @@ -3121,13 +3311,17 @@ "keywords": [ "global state" ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.6" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2022-02-14T08:28:10+00:00" + "time": "2023-08-02T09:26:13+00:00" }, { "name": "sebastian/lines-of-code", @@ -3174,6 +3368,10 @@ ], "description": "Library for counting the lines of code in PHP source code", "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -3227,6 +3425,10 @@ ], "description": "Traverses array structures and object graphs to enumerate all referenced objects", "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -3278,6 +3480,10 @@ ], "description": "Allows reflection of object attributes, including inherited and non-public ones", "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -3288,16 +3494,16 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.4", + "version": "4.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172" + "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172", - "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", "shasum": "" }, "require": { @@ -3336,14 +3542,18 @@ } ], "description": "Provides functionality to recursively process PHP variables", - "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2020-10-26T13:17:30+00:00" + "time": "2023-02-03T06:07:39+00:00" }, { "name": "sebastian/resource-operations", @@ -3388,6 +3598,10 @@ ], "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "issues": "https://github.com/sebastianbergmann/resource-operations/issues", + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -3398,16 +3612,16 @@ }, { "name": "sebastian/type", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e" + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e", - "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", "shasum": "" }, "require": { @@ -3440,13 +3654,17 @@ ], "description": "Collection of value objects that represent the types of the PHP type system", "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2022-09-12T14:47:03+00:00" + "time": "2023-02-03T06:13:03+00:00" }, { "name": "sebastian/version", @@ -3489,6 +3707,10 @@ ], "description": "Library that helps with managing the version number of Git-hosted PHP projects", "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", @@ -3546,83 +3768,12 @@ "phpcs", "standards" ], - "time": "2020-01-30T22:20:29+00:00" - }, - { - "name": "symfony/polyfill-ctype", - "version": "v1.22.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "c6c942b1ac76c82448322025e084cadc56048b4e" + "support": { + "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", + "source": "https://github.com/squizlabs/PHP_CodeSniffer", + "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/c6c942b1ac76c82448322025e084cadc56048b4e", - "reference": "c6c942b1ac76c82448322025e084cadc56048b4e", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "suggest": { - "ext-ctype": "For best performance" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.22-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for ctype functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-01-07T16:49:33+00:00" + "time": "2020-01-30T22:20:29+00:00" }, { "name": "theseer/tokenizer", @@ -3662,6 +3813,10 @@ } ], "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.1" + }, "funding": [ { "url": "https://github.com/theseer", @@ -3680,8 +3835,7 @@ }, "dist": { "type": "zip", - "url": "https://github.com/ucfcdl/fuelphp-phpcs/archive/v3.0.1.zip", - "reference": "v3.0.1" + "url": "https://github.com/ucfcdl/fuelphp-phpcs/archive/v3.0.1.zip" }, "type": "library" } @@ -3705,5 +3859,5 @@ "ext-mbstring": "*" }, "platform-dev": [], - "plugin-api-version": "1.1.0" + "plugin-api-version": "2.3.0" } diff --git a/docker/config/nginx/nginx-dev.conf b/docker/config/nginx/nginx-dev.conf index f589ffe56..bd0d3fc8a 100644 --- a/docker/config/nginx/nginx-dev.conf +++ b/docker/config/nginx/nginx-dev.conf @@ -152,6 +152,13 @@ http { listen *:8008 ssl; listen [::]:8008 ssl; + # In the dev environment, js and css assets are emitted to public/dist instead of public/ + # However, server pages will expect them to be in public/js or public/css instead + # Redirect requests for these assets to public/dist + location ~* ^\/(?:js|css)\/.+\.(?:js|css)$ { + proxy_pass https://$server_addr/dist$uri; + } + # @TODO: match only /js/* /css/* and /widget/* location ~* ^.+\.(html|ogv|svg|svgz|eot|otf|woff|mp4|ttf|rss|atom|jpg|jpeg|gif|png|ico|zip|tgz|gz|rar|bz2|doc|xls|exe|ppt|tar|mid|midi|wav|bmp|rtf|js|css|json|obj)$ { # pass all requests back to origin server transparently diff --git a/docker/docker-compose.override.test.yml b/docker/docker-compose.override.test.yml index abd42b3f1..2115b1a11 100644 --- a/docker/docker-compose.override.test.yml +++ b/docker/docker-compose.override.test.yml @@ -30,7 +30,7 @@ services: - MYSQL_ROOT_PASSWORD - MYSQL_USER - MYSQL_PASSWORD - - MYSQL_DATABASE=test + - MYSQL_DATABASE # this makes the unit tests much faster but it's a little weird jumping # back and forth between running the server and testing # tmpfs: diff --git a/docker/dockerfiles/wait-for-it.sh b/docker/dockerfiles/wait-for-it.sh index 92cbdbb3c..d990e0d36 100755 --- a/docker/dockerfiles/wait-for-it.sh +++ b/docker/dockerfiles/wait-for-it.sh @@ -36,7 +36,7 @@ wait_for() nc -z $WAITFORIT_HOST $WAITFORIT_PORT WAITFORIT_result=$? else - (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 WAITFORIT_result=$? fi if [[ $WAITFORIT_result -eq 0 ]]; then @@ -179,4 +179,4 @@ if [[ $WAITFORIT_CLI != "" ]]; then exec "${WAITFORIT_CLI[@]}" else exit $WAITFORIT_RESULT -fi \ No newline at end of file +fi diff --git a/docker/run_build_assets.sh b/docker/run_build_assets.sh index cc438cc64..f74a1a3ab 100755 --- a/docker/run_build_assets.sh +++ b/docker/run_build_assets.sh @@ -21,5 +21,5 @@ docker run \ --name materia-asset-build \ --mount type=bind,source="$(pwd)"/../,target=/build \ --mount source=materia-asset-build-vol,target=/build/node_modules \ - node:12.11.1-alpine \ - /bin/ash -c "apk add --no-cache git && cd build && yarn install --frozen-lockfile --non-interactive --production --silent --pure-lockfile --force" + node:18.13.0-alpine \ + /bin/ash -c "apk add --no-cache git && cd build && yarn install --frozen-lockfile --non-interactive --silent --pure-lockfile --force && npm run-script build" diff --git a/docker/run_build_github_release_package.sh b/docker/run_build_github_release_package.sh index 69e2aad5e..f101cc313 100755 --- a/docker/run_build_github_release_package.sh +++ b/docker/run_build_github_release_package.sh @@ -30,13 +30,13 @@ DOCKER_IMAGE=$1 # declare files that should have been created declare -a FILES_THAT_SHOULD_EXIST=( "public/js/materia.enginecore.js" - "public/css/widget-play.css" + "public/css/player-page.css" ) # declare files to omit from zip declare -a FILES_TO_EXCLUDE=( - ".git*" - ".gitignore" + "*.git*" + "*.gitignore" "app.json" "nginx_app.conf" "Procfile" @@ -58,7 +58,7 @@ declare -a FILES_TO_EXCLUDE=( EXCLUDE='' for i in "${FILES_TO_EXCLUDE[@]}" do - EXCLUDE="$EXCLUDE --exclude=\"./$i\"" + EXCLUDE="$EXCLUDE -x \"$i\"" done set -o xtrace @@ -78,12 +78,19 @@ GITREMOTE=$(git remote get-url origin) # remove .git dir for slightly faster copy rm -rf ./clean_build_clone/.git rm -rf ./clean_build_clone/public -rm -rf ./clean_build_clone/fuel/app/config/asset_hash.json - # copy the clean build clone into the container docker cp $(docker create --rm $DOCKER_IMAGE):/var/www/html/public ./clean_build_clone/public/ -docker cp $(docker create --rm $DOCKER_IMAGE):/var/www/html/fuel/app/config/asset_hash.json ./clean_build_clone/fuel/app/config/asset_hash.json + +# compile js & css assets into the public/dist directory +docker volume create materia-asset-build-vol +docker run \ + --rm \ + --name materia-asset-build \ + --mount type=bind,source="$(pwd)"/clean_build_clone/,target=/build \ + --mount source=materia-asset-build-vol,target=/build/node_modules \ + node:18.13.0-alpine \ + /bin/ash -c "apk add --no-cache git && cd build && yarn install --frozen-lockfile --non-interactive --pure-lockfile --force && npm run-script build-for-image" # verify all files we expect to be created exist for i in "${FILES_THAT_SHOULD_EXIST[@]}" @@ -93,7 +100,7 @@ done # zip, excluding some files cd ./clean_build_clone -zip -r $EXCLUDE ../../materia-pkg.zip ./ +eval "zip -r ../../materia-pkg.zip ./ $EXCLUDE" # calulate hashes MD5=$(md5sum ../../materia-pkg.zip | awk '{ print $1 }') @@ -110,4 +117,4 @@ echo "sha1: $SHA1" >> ../../materia-pkg-build-info.yml echo "sha256: $SHA256" >> ../../materia-pkg-build-info.yml echo "md5: $MD5" >> ../../materia-pkg-build-info.yml -rm -rf ./clean_build_clone +cd .. && rm -rf ./clean_build_clone diff --git a/docker/run_tests.sh b/docker/run_tests.sh index 63b5a5087..3e6660317 100755 --- a/docker/run_tests.sh +++ b/docker/run_tests.sh @@ -15,4 +15,4 @@ DCTEST="docker-compose -f docker-compose.yml -f docker-compose.override.test.yml set -e set -o xtrace -$DCTEST run --rm app /wait-for-it.sh mysql:3306 -t 20 -- composer run testci -- "$@" +$DCTEST run -T --rm app /wait-for-it.sh mysql:3306 -t 20 -- composer run testci -- "$@" diff --git a/docker/run_tests_ci.sh b/docker/run_tests_ci.sh index fbc330fb6..f65fa05d9 100755 --- a/docker/run_tests_ci.sh +++ b/docker/run_tests_ci.sh @@ -19,7 +19,7 @@ $DCTEST pull --ignore-pull-failures app fakes3 docker run --rm -v $(pwd)/../:/source alpine:latest chown -R 1000 /source # install php deps -$DCTEST run --rm --no-deps app composer install --no-progress +$DCTEST run -T --rm --no-deps app composer install --no-progress # run linter source run_tests_lint.sh diff --git a/docker/run_tests_coverage.sh b/docker/run_tests_coverage.sh index cc39fe45e..5b3d9c511 100755 --- a/docker/run_tests_coverage.sh +++ b/docker/run_tests_coverage.sh @@ -20,4 +20,4 @@ echo "If you have an issue with a broken widget, clear the widgets with:" echo "$DCTEST run --rm app bash -c -e 'rm /var/www/html/fuel/packages/materia/vendor/widget/test/*'" # store the docker compose command to shorten the following commands -$DCTEST run --rm app /wait-for-it.sh mysql:3306 -t 20 -- composer run coverageci -- "$@" +$DCTEST run -T --rm app /wait-for-it.sh mysql:3306 -t 20 -- composer run coverageci -- "$@" diff --git a/docker/run_tests_lint.sh b/docker/run_tests_lint.sh index 12117fd47..945ec6bb0 100755 --- a/docker/run_tests_lint.sh +++ b/docker/run_tests_lint.sh @@ -10,4 +10,4 @@ DCTEST="docker-compose -f docker-compose.yml -f docker-compose.override.test.yml set -e set -o xtrace -$DCTEST run --rm --no-deps app composer sniff-ci +$DCTEST run -T --rm --no-deps app composer sniff-ci diff --git a/fuel/app/classes/alwaysload.php b/fuel/app/classes/alwaysload.php index b3c013c79..10edfa5f5 100644 --- a/fuel/app/classes/alwaysload.php +++ b/fuel/app/classes/alwaysload.php @@ -9,76 +9,5 @@ class Alwaysload public static function _init() { \Config::load('materia', true); // Always load is loaded before configs listed in config.always_load.configs - self::build_asset_cache_buster_hashes(); } - - /** - * materia is dynamically building the asset urls based on web requests - * this means their full paths can only be determined in context of a server request - * This function makes sure hashes built by the materia-server-client-assets node module - * can be used to cache bust the dynamically created paths on the server - * npm makes "js/myfile.js": "xxxxxxx" - * this appends "https://materia.static.com/js/myfile.js": "xxxxxxxx" - * so when the server places the https://materia.static.com/js/myfile.js asset in the page - * it will have the hash appended - * it's important that the hash comes from a config file (so we cant md5 on the fly) - * and it's important that those routes be dynamic, to reduce configuration complexity - **/ - protected static function build_asset_cache_buster_hashes() - { - $hashes = \Config::load('asset_hash.json', true); - - // nothing loaded? - if ( ! is_array($hashes)) return; - - // already calculated? - if ( ! empty($hashes['static']) && $hashes['static'] == \Config::get('materia.urls.static')) return; - - // add in css - $css = \Config::load('css', true); - if (is_array($css)) $hashes = self::add_resolved_hash_paths($hashes, $css); - - // add in js - $js = \Config::load('js', true); - if (is_array($js)) $hashes = self::add_resolved_hash_paths($hashes, $js); - - // load in static - $hashes['static'] = \Config::get('materia.urls.static'); - - // save - \Config::save('asset_hash.json', $hashes); - } - - // digs through the hash file and a qasset config to add hashes it resolves - protected static function add_resolved_hash_paths(Array $hashes, Array $assets) - { - $keys = array_keys($hashes); - foreach ($assets['groups'] as $key => $value) - { - foreach ($value as $asset) - { - foreach ($keys as $hash_key) - { - if (self::ends_with($asset, $hash_key)) - { - $hashes[$asset] = $hashes[$hash_key]; - } - } - } - } - - return $hashes; - } - - // does a string end with another string? - protected static function ends_with($haystack, $needle) - { - $length = strlen($needle); - if ($length == 0) - { - return true; - } - - return (substr($haystack, -$length) === $needle); - } -} +} \ No newline at end of file diff --git a/fuel/app/classes/controller/admin.php b/fuel/app/classes/controller/admin.php index 5bab74622..aabac218e 100644 --- a/fuel/app/classes/controller/admin.php +++ b/fuel/app/classes/controller/admin.php @@ -6,7 +6,6 @@ class Controller_Admin extends Controller { - use Trait_CommonControllerTemplate { before as public common_before; } @@ -14,30 +13,40 @@ class Controller_Admin extends Controller public function before() { $this->common_before(); - if ( ! \Materia\Perm_Manager::is_super_user() ) throw new \HttpNotFoundException; - Css::push_group('admin'); - Js::push_group(['angular', 'materia', 'admin']); + if ( ! (\Materia\Perm_Manager::is_super_user() || \Materia\Perm_Manager::is_support_user()) ) throw new \HttpNotFoundException; parent::before(); } public function get_widget() { + if ( ! \Materia\Perm_Manager::is_super_user() ) throw new \HttpNotFoundException; + + Js::push_inline('var UPLOAD_ENABLED ="'.Config::get('materia.enable_admin_uploader').'";'); + Js::push_inline('var HEROKU_WARNING ="'.Config::get('materia.heroku_admin_warning').'";'); + Js::push_inline('var ACTION_LINK ="/admin/upload";'); + Js::push_inline('var UPLOAD_NOTICE = "'.Session::get_flash('upload_notice').'";'); + + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template()->set('title', 'Widget Admin'); - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/admin/widget') - ->set('upload_enabled', Config::get('materia.enable_admin_uploader', false)) - ->set('heroku_warning', Config::get('materia.heroku_admin_warning', false)); + + Css::push_group(['support']); + Js::push_group(['react', 'widget_admin']); } public function get_user() { + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template()->set('title', 'User Admin'); - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/admin/user'); + + Css::push_group(['user-admin']); + Js::push_group(['react', 'user_admin']); } public function post_upload() { + if ( ! \Materia\Perm_Manager::is_super_user() ) throw new \HttpNotFoundException; if (Config::get('materia.enable_admin_uploader', false) !== true) throw new HttpNotFoundException; // Custom configuration for this upload @@ -69,9 +78,24 @@ public function post_upload() } } } + + if ($failed) + { + throw new HttpServerErrorException; + } Session::set_flash('upload_notice', ($failed ? 'Failed' : 'Success') ); Response::redirect('admin/widget'); } + + public function get_instance() + { + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); + $this->theme->get_template()->set('title', 'Instance Admin'); + + Css::push_group(['support']); + Js::push_group(['react', 'support']); + } } diff --git a/fuel/app/classes/controller/api/admin.php b/fuel/app/classes/controller/api/admin.php index d9f58bc79..df6e1bb2b 100644 --- a/fuel/app/classes/controller/api/admin.php +++ b/fuel/app/classes/controller/api/admin.php @@ -3,14 +3,18 @@ * Materia * License outlined in licenses folder */ +//namespace Materia; +use \Materia\Msg; class Controller_Api_Admin extends Controller_Rest { + use \Trait_RateLimit; + protected $_supported_formats = ['json' => 'application/json']; public function before() { - if ( ! \Materia\Perm_Manager::is_super_user() ) throw new \HttpNotFoundException; + if ( ! (\Materia\Perm_Manager::is_super_user() || \Materia\Perm_Manager::is_support_user()) ) throw new \HttpNotFoundException; parent::before(); } @@ -64,4 +68,57 @@ public function post_user($user_id) unset($user->id); return \Service_User::update_user($user_id, $user); } + + public function get_widget_search(string $input) + { + $input = trim($input); + $input = urldecode($input); + //no need to search if for some reason an empty string is passed + if ($input == '') return []; + return \Materia\Widget_Instance_Manager::get_search($input); + } + + public function get_extra_attempts(string $inst_id) + { + $inst = \Materia\Widget_Instance_Manager::get($inst_id); + return $inst->get_all_extra_attempts(); + } + + public function post_extra_attempts(string $inst_id) + { + // Get POSTed json input + $extra_attempts = Input::json(); + + if ( ! is_string($inst_id)) return Msg::invalid_input($inst_id); + + $inst = \Materia\Widget_Instance_Manager::get($inst_id); + + if ( ! $inst) + { + return $this->response('Course ID not found.', 400); + } + + $attempts = []; + + // iterate thru each extra attempt and set it in the db + foreach ($extra_attempts as $value) + { + if ( ! is_int($value['user_id']) ) return $this->response('User ID must be type int', 400); + if ( ! is_int($value['extra_attempts']) ) return $this->response('Extra attempts must be type int', 400); + if ( ! is_string($value['context_id']) ) return $this->response('Context ID must be type string', 400); + if ( ! is_int($value['id']) ) return $this->response('Widget ID must be type int', 400); + + $attempts[] = $inst->set_extra_attempts($value['user_id'], $value['extra_attempts'], $value['context_id'], $value['id'] > 0 ? $value['id'] : null); + } + + return $attempts; + } + + public function post_widget_instance_undelete(string $inst_id) + { + if ( ! \Materia\Util_Validator::is_valid_hash($inst_id)) return Msg::invalid_input($inst_id); + if (\Service_User::verify_session() !== true) return Msg::no_login(); + if ( ! ($inst = \Materia\Widget_Instance_Manager::get($inst_id, false, false, true))) return new Msg(Msg::ERROR, 'Widget instance does not exist.'); + return $inst->db_undelete(); + } } diff --git a/fuel/app/classes/controller/api/asset.php b/fuel/app/classes/controller/api/asset.php new file mode 100644 index 000000000..45fc8afa9 --- /dev/null +++ b/fuel/app/classes/controller/api/asset.php @@ -0,0 +1,67 @@ +value('deleted_at', time()) + ->value('is_deleted', '1') + ->where('id', $asset_id) + ->execute(); + + return true; + } + catch (\Exception $th) + { + \Log::error('Error: In the deletion process'); + \Log::error($th); + + return new Msg(Msg::ERROR, 'Asset could not be deleted.'); + } + } + + public function post_restore($asset_id) + { + $user_id = \Model_User::find_current_id(); + if (\Service_User::verify_session() !== true) return Msg::no_login(); + + if ( ! Perm_Manager::user_has_any_perm_to($user_id, $asset_id, Perm::ASSET, Perm::FULL)) + return new Response('You do not have access to this asset', 401); + + try + { + \DB::update('asset') + ->value('deleted_at', '-1') + ->value('is_deleted', '0') + ->where('id', $asset_id) + ->execute(); + + return true; + } + catch (\Exception $th) + { + \Log::error('Error: In the deletion process'); + \Log::error($th); + + return new Msg(Msg::ERROR, 'Asset could not be restored.'); + } + } +} \ No newline at end of file diff --git a/fuel/app/classes/controller/api/instance.php b/fuel/app/classes/controller/api/instance.php index aabb947c8..ac2c9b09f 100644 --- a/fuel/app/classes/controller/api/instance.php +++ b/fuel/app/classes/controller/api/instance.php @@ -10,7 +10,7 @@ class Controller_Api_Instance extends Controller_Rest use Trait_Apiutils; protected $_supported_formats = ['json' => 'application/json']; - + /** * Requests all qsets for a given widget instance ID. * Current user must have author/collab access to the widget. @@ -23,7 +23,7 @@ public function get_history() if ( ! \Materia\Util_Validator::is_valid_hash($inst_id) ) return $this->response(\Materia\Msg::invalid_input($inst_id), 401); if ( ! ($inst = \Materia\Widget_Instance_Manager::get($inst_id))) return $this->response('Instance not found', 404); if ( ! \Materia\Perm_Manager::user_has_any_perm_to(\Model_User::find_current_id(), $inst_id, \Materia\Perm::INSTANCE, [\Materia\Perm::FULL])) return $this->response(\Materia\Msg::no_login(), 401); - + $history = $inst->get_qset_history($inst_id); return $this->response($history, 200); @@ -32,8 +32,22 @@ public function get_history() public function post_request_access() { $user_id = \Model_User::find_current_id(); - $inst_id = Input::post('inst_id', null); - $owner_id = Input::post('owner_id', null); - \Model_Notification::send_item_notification($user_id, $owner_id, \Materia\Perm::INSTANCE, $inst_id, 'access_request', \Materia\Perm::FULL); + + $inst_id = Input::json('inst_id', null); + $owner_id = Input::json('owner_id', null); + + if ( ! $inst_id) return $this->response('Requires an inst_id parameter', 401); + if ( ! $owner_id) return $this->response('Requires an owner_id parameter', 401); + + if ( ! \Model_User::find_by_id($owner_id)) return $this->response('Owner not found', 404); + if ( ! ($inst = \Materia\Widget_Instance_Manager::get($inst_id))) return $this->response('Instance not found', 404); + + if ( ! Materia\Perm_Manager::user_has_any_perm_to($owner_id, $inst_id, Materia\Perm::INSTANCE, [Materia\Perm::FULL, Materia\Perm::VISIBLE])) return $this->response('Owner does not own instance', 404); + + if ( ! \Materia\Util_Validator::is_valid_hash($inst_id) ) return $this->response(\Materia\Msg::invalid_input($inst_id), 401); + + $requested_access = \Model_Notification::send_item_notification($user_id, $owner_id, \Materia\Perm::INSTANCE, $inst_id, 'access_request', \Materia\Perm::FULL); + + return $this->response($requested_access, 200); } } diff --git a/fuel/app/classes/controller/api/user.php b/fuel/app/classes/controller/api/user.php index 837ce0569..0f5344008 100644 --- a/fuel/app/classes/controller/api/user.php +++ b/fuel/app/classes/controller/api/user.php @@ -39,4 +39,58 @@ public function post_settings() return $this->response($reply); } + + public function post_roles() + { + if (\Service_User::verify_session() !== true) return $this->response('Not logged in', 401); + // this endpoint is only available to superusers! + if ( ! \Materia\Perm_Manager::is_super_user()) return $this->response('Not authorized', 403); + + $success = false; + $user_id = Input::json('id', null); + $roles = [ + 'basic_author' => Input::json('author', false), + 'support_user' => Input::json('support_user', false) + ]; + + if ( ! $user_id) return $this->response('User ID not provided', 401); + + $current_roles = \Materia\Perm_Manager::get_user_roles($user_id); + $current_roles_condensed = array_map( fn($r) => $r->name, $current_roles); + + $roles_to_add = []; + $roles_to_revoke = []; + + foreach ($roles as $name => $val) + { + if ( ! in_array($name, $current_roles_condensed) && $val == true) array_push($roles_to_add, $name); + else if (in_array($name, $current_roles_condensed) && $val == false) array_push($roles_to_revoke, $name); + } + + $message = ''; + + if (count($roles_to_add) > 0) + { + $success = \Materia\Perm_Manager::add_users_to_roles([$user_id], $roles_to_add); + if ($success != true) return $this->response(['success' => false, 'status' => 'Failed to add roles']); + $message .= count($roles_to_add).' role(s) added.'; + } + + if (count($roles_to_revoke) > 0) + { + $success = \Materia\Perm_Manager::remove_users_from_roles([$user_id], $roles_to_revoke); + if ($success != true) return $this->response(['success' => false, 'status' => 'Failed to revoke roles']); + $message .= count($roles_to_revoke).' role(s) revoked.'; + } + + if (strlen($message) == 0) + { + $message .= 'No roles were changed.'; + } + + return $this->response([ + 'success' => true, + 'status' => $message + ]); + } } diff --git a/fuel/app/classes/controller/media.php b/fuel/app/classes/controller/media.php index ed3506046..b4a4beec7 100644 --- a/fuel/app/classes/controller/media.php +++ b/fuel/app/classes/controller/media.php @@ -43,19 +43,16 @@ public function get_import() // Validate Logged in if (\Service_User::verify_session() !== true) throw new HttpNotFoundException; - Css::push_group(['core', 'media_import']); - Js::push_group(['angular', 'jquery', 'materia', 'author', 'dataTables']); - $this->inject_common_js_constants(); $theme = Theme::instance(); - $theme->set_template('layouts/main'); + $theme->set_template('layouts/react'); $theme->get_template() ->set('title', 'Media Catalog') ->set('page_type', 'import'); - $theme->set_partial('footer', 'partials/angular_alert'); - $theme->set_partial('content', 'partials/catalog/media'); + Css::push_group(['media_import']); + Js::push_group(['react', 'media']); return Response::forge($theme->render()); } diff --git a/fuel/app/classes/controller/qsets.php b/fuel/app/classes/controller/qsets.php index 53500b7cf..b639486e0 100644 --- a/fuel/app/classes/controller/qsets.php +++ b/fuel/app/classes/controller/qsets.php @@ -12,47 +12,19 @@ public function action_import() // Validate Logged in if (\Service_User::verify_session() !== true ) throw new HttpNotFoundException; - - Css::push_group(['core', 'qset_history']); - Js::push_group(['angular', 'jquery', 'materia', 'author', 'dataTables']); - - Js::push_inline('var BASE_URL = "'.Uri::base().'";'); - Js::push_inline('var WIDGET_URL = "'.Config::get('materia.urls.engines').'";'); - Js::push_inline('var STATIC_CROSSDOMAIN = "'.Config::get('materia.urls.static').'";'); - $theme = Theme::instance(); - $theme->set_template('layouts/main'); + $theme->set_template('layouts/react'); $theme->get_template() ->set('title', 'QSet Catalog') ->set('page_type', 'import'); - $theme->set_partial('footer', 'partials/angular_alert'); - $theme->set_partial('content', 'partials/catalog/qset'); - - return Response::forge($theme->render()); - } - - public function action_confirm() - { - if (\Service_User::verify_session() !== true ) throw new HttpNotFoundException; - - Css::push_group(['core', 'rollback_dialog']); - Js::push_group(['angular', 'jquery', 'materia', 'author']); - Js::push_inline('var BASE_URL = "'.Uri::base().'";'); Js::push_inline('var WIDGET_URL = "'.Config::get('materia.urls.engines').'";'); Js::push_inline('var STATIC_CROSSDOMAIN = "'.Config::get('materia.urls.static').'";'); - $theme = Theme::instance(); - $theme->set_template('layouts/main'); - $theme->get_template() - ->set('title', 'Confirm Qset Import') - ->set('page_type', 'confirm'); - - $theme->set_partial('footer', 'partials/angular_alert'); - $theme->set_partial('content', 'partials/widget/rollback_confirm'); + Css::push_group(['qset_history']); + Js::push_group(['react', 'qset_history']); return Response::forge($theme->render()); - } } diff --git a/fuel/app/classes/controller/questions.php b/fuel/app/classes/controller/questions.php index 7944e343b..a75e4c24d 100644 --- a/fuel/app/classes/controller/questions.php +++ b/fuel/app/classes/controller/questions.php @@ -6,29 +6,24 @@ class Controller_Questions extends Controller { - - public function action_import() + public function get_import() { // Validate Logged in if (\Service_User::verify_session() !== true ) throw new HttpNotFoundException; - - Css::push_group(['core', 'question_import']); - Js::push_group(['angular', 'jquery', 'materia', 'author', 'dataTables']); + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); + $this->theme->get_template() + ->set('title', 'Question Catalog') + ->set('page_type', 'import'); Js::push_inline('var BASE_URL = "'.Uri::base().'";'); Js::push_inline('var WIDGET_URL = "'.Config::get('materia.urls.engines').'";'); Js::push_inline('var STATIC_CROSSDOMAIN = "'.Config::get('materia.urls.static').'";'); - $theme = Theme::instance(); - $theme->set_template('layouts/main'); - $theme->get_template() - ->set('title', 'Question Catalog') - ->set('page_type', 'import'); - - $theme->set_partial('footer', 'partials/angular_alert'); - $theme->set_partial('content', 'partials/catalog/question'); + Css::push_group(['core', 'questionimport']); + Js::push_group(['react', 'question-importer']); - return Response::forge($theme->render()); + return Response::forge($this->theme->render()); } } diff --git a/fuel/app/classes/controller/scores.php b/fuel/app/classes/controller/scores.php index a460629a2..aca7187c0 100644 --- a/fuel/app/classes/controller/scores.php +++ b/fuel/app/classes/controller/scores.php @@ -7,6 +7,7 @@ class Controller_Scores extends Controller { use Trait_CommonControllerTemplate; + use Trait_Supportinfo; // Allow LTI launches to score screens // In canvas, this is shown on the grade review @@ -51,6 +52,8 @@ public function get_show(string $inst_id, bool $is_embedded = false) $instances = Materia\Api::widget_instances_get([$inst_id]); if ( ! isset($instances[0])) throw new HttpNotFoundException; + $is_preview = ! ! preg_match('/preview/', URI::current()); + $inst = $instances[0]; // not allowed to play the widget if ( ! $inst->playable_by_current_user()) @@ -59,24 +62,26 @@ public function get_show(string $inst_id, bool $is_embedded = false) Response::redirect(Router::get('login').'?redirect='.urlencode(URI::current())); } - Css::push_group(['core', 'scores']); - - Js::push_group(['angular', 'materia', 'student', 'labjs']); - $token = \Input::get('token', false); if ($token) { Js::push_inline('var LAUNCH_TOKEN = "'.$token.'";'); } - if ($is_embedded) $this->_header = 'partials/header_empty'; + Js::push_inline('var IS_EMBEDDED = "'.$is_embedded.'";'); + Js::push_inline('var IS_PREVIEW = "'.$is_preview.'";'); + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', 'Score Results') ->set('page_type', 'scores'); - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/score/full'); + Css::push_group(['scores']); + + Js::push_group(['react', 'scores']); + + $this->add_inline_info(); } public function get_show_embedded(string $inst_id) diff --git a/fuel/app/classes/controller/site.php b/fuel/app/classes/controller/site.php index b1ed8714a..26b207062 100644 --- a/fuel/app/classes/controller/site.php +++ b/fuel/app/classes/controller/site.php @@ -7,6 +7,7 @@ class Controller_Site extends Controller { use Trait_CommonControllerTemplate; + use Trait_Supportinfo; /** * Handles the homepage @@ -14,51 +15,60 @@ class Controller_Site extends Controller */ public function action_index() { - Js::push_group(['angular', 'materia']); - - $this->theme->get_template() - ->set('title', 'Welcome to Materia') - ->set('page_type', 'store'); - - $spotlight = $this->theme->view('partials/spotlight'); - $this->theme->set_partial('content', 'partials/homepage') - ->set_safe('spotlight', $spotlight); + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); + $this->theme->get_template()->set('title', 'Welcome to Materia'); + Css::push_group(['homepage']); + Js::push_group(['react', 'homepage']); } public function action_permission_denied() { - Js::push_group(['angular', 'materia']); - + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', 'Permission Denied') ->set('page_type', ''); - $this->theme->set_partial('content', 'partials/nopermission'); + Js::push_group(['react', 'no_permission']); } public function action_help() { - Js::push_group(['angular', 'materia']); + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', 'Help') ->set('page_type', 'docs help'); - $this->theme->set_partial('content', 'partials/help/main'); - - Css::push_group('help'); + // check to see if a theme override exists for the help page + $theme_overrides = \Event::Trigger('before_help_page', '', 'array'); + + if ($theme_overrides) + { + Js::push_group(['react', $theme_overrides[0]['js']]); + Css::push_group($theme_overrides[0]['css']); + } + else + { + Js::push_group(['react', 'help']); + Css::push_group('help'); + } } public function action_403() { Css::push_group('errors'); + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', '403 Not Authorized') ->set('page_type', '404'); - $this->theme->set_partial('content', 'partials/404'); + Js::push_group(['react', '404']); Log::warning('403 URL: '.Uri::main()); @@ -72,13 +82,14 @@ public function action_403() */ public function action_404() { - Css::push_group('errors'); - + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() - ->set('title', '404 Page not Found') + ->set('title', '404 Page Not Found') ->set('page_type', '404'); - $this->theme->set_partial('content', 'partials/404'); + Js::push_group(['react', '404']); + Css::push_group('404'); Log::warning('404 URL: '.Uri::main()); @@ -92,16 +103,19 @@ public function action_404() */ public function action_500() { - Css::push_group('errors'); - + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', '500 Server Error') ->set('page_type', '500'); - $this->theme->set_partial('content', 'partials/500'); + Js::push_group(['react', '500']); Log::warning('500 URL: '.Uri::main()); + $this->add_inline_info(); + Css::push_group(['500']); + $response = \Response::forge(\Theme::instance()->render(), 500); return $response; diff --git a/fuel/app/classes/controller/users.php b/fuel/app/classes/controller/users.php index 911c92a9d..843c7fd4e 100644 --- a/fuel/app/classes/controller/users.php +++ b/fuel/app/classes/controller/users.php @@ -24,18 +24,49 @@ public function get_login() // already logged in Response::redirect($redirect); } + + Js::push_inline('var LOGIN_USER = "'.\Lang::get('login.user').'";'); + Js::push_inline('var LOGIN_PW = "'.\Lang::get('login.password').'";'); - Event::trigger('request_login', $direct_login); + // condense login links into a string with delimiters to be embedded as a JS global + $link_items = []; + foreach (\Lang::get('login.links') as $a) + { + $link_items[] = $a['href'].'***'.$a['title']; + } + $login_links = implode('@@@', $link_items); + Js::push_inline('var LOGIN_LINKS = "'.urlencode($login_links).'";'); + + // additional JS globals. Previously, these were rendered directly in the partial view. Now we have to hand them off + // to the React template to be rendered. + Js::push_inline('var ACTION_LOGIN = "'.\Router::get('login').'";'); + Js::push_inline('var ACTION_REDIRECT = "'.$redirect.'";'); + Js::push_inline('var ACTION_DIRECTLOGIN = "'.($direct_login ? 'true' : 'false').'";'); + + Js::push_inline('var BYPASS = "'.(Session::get_flash('bypass', false, false) ? 'true' : 'false').'";'); + + // conditionally add globals if there is an error or notice + if ($msg = Session::get_flash('login_error')) + { + Js::push_inline('var ERR_LOGIN = "'.$msg.'";'); + } + if ($notice = (array) Session::get_flash('notice')) + { + Js::push_inline('var NOTICE_LOGIN = "'.implode('

', $notice).'";'); + } + + Js::push_inline('var CONTEXT = "login";'); - Css::push_group(['core', 'login']); - Js::push_group(['angular', 'materia']); + Event::trigger('request_login', $direct_login); + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', 'Login') ->set('page_type', 'login'); - $this->theme->set_partial('content', 'partials/login') - ->set('redirect', urlencode($redirect)); + Css::push_group(['login']); + Js::push_group(['react', 'login']); } public function post_login() @@ -84,25 +115,18 @@ public function get_profile() { if (\Service_User::verify_session() !== true) { - Session::set_flash('notice', 'Please log in to view this page.'); - Response::redirect(Router::get('login').'?redirect='.URI::current()); + Session::set('redirect_url', URI::current()); + Session::set_flash('notice', 'Please log in to view your profile.'); + Response::redirect(Router::get('login')); + return; } - Css::push_group(['core', 'profile']); - - Js::push_group(['angular', 'materia', 'student']); - - // to properly fix the date display, we need to provide the raw server date for JS to access - $server_date = date_create('now', timezone_open('UTC'))->format('D, d M Y H:i:s'); - Js::push_inline("var DATE = '$server_date'"); + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); + $this->theme->get_template()->set('title', 'My Profile'); - $this->theme->get_template() - ->set('title', 'Profile') - ->set('page_type', 'user profile'); - - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/user/profile') - ->set('me', \Model_User::find_current()); + Css::push_group(['profile']); + Js::push_group(['react', 'profile']); } /** @@ -113,20 +137,18 @@ public function get_settings() { if (\Service_User::verify_session() !== true) { - Session::set_flash('notice', 'Please log in to view this page.'); - Response::redirect(Router::get('login').'?redirect='.URI::current()); + Session::set('redirect_url', URI::current()); + Session::set_flash('notice', 'Please log in to view your profile settings.'); + Response::redirect(Router::get('login')); + return; } - Css::push_group(['core', 'profile']); - Js::push_group(['angular', 'materia', 'student']); - - $this->theme->get_template() - ->set('title', 'Settings') - ->set('page_type', 'user profile settings'); + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); + $this->theme->get_template()->set('title', 'Settings'); - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/user/settings') - ->set('me', \Model_User::find_current()); + Css::push_group(['profile']); + Js::push_group(['react', 'settings']); } } diff --git a/fuel/app/classes/controller/widgets.php b/fuel/app/classes/controller/widgets.php index cbcfd4bc8..31ccf4e59 100644 --- a/fuel/app/classes/controller/widgets.php +++ b/fuel/app/classes/controller/widgets.php @@ -7,6 +7,7 @@ class Controller_Widgets extends Controller { use Trait_CommonControllerTemplate; + use Trait_Supportinfo; /** * Catalog page to show all the available widgets @@ -15,16 +16,13 @@ class Controller_Widgets extends Controller */ public function get_index() { - Css::push_group(['core', 'widget_catalog']); - - Js::push_group(['angular', 'ng-animate', 'materia']); - - $this->theme->get_template() - ->set('title', 'Widget Catalog') - ->set('page_type', 'catalog'); - - $this->theme->set_partial('content', 'partials/widget/catalog'); + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); + $this->theme->get_template()->set('title', 'Widget Catalog'); $this->theme->set_partial('meta', 'partials/responsive'); + + Css::push_group(['catalog']); + Js::push_group(['react', 'catalog']); } public function get_all() @@ -48,18 +46,17 @@ public function get_detail() $demo = $widget->meta_data['demo']; - Css::push_group(['widget_detail', 'core']); - Js::push_group(['angular', 'hammerjs', 'jquery', 'materia', 'student']); + Js::push_inline('var NO_AUTHOR = "'.\Materia\Perm_Manager::does_user_have_role(['no_author']).'";'); + Js::push_inline('var WIDGET_HEIGHT = "'.$widget->height.'";'); + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', 'Widget Details') ->set('page_type', 'widget'); - $this->theme->set_partial('content', 'partials/widget/detail'); - - $this->theme->set_partial('meta', 'partials/responsive'); - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->_disable_browser_cache = true; + Css::push_group(['detail']); + Js::push_group(['react', 'detail']); } /** @@ -117,20 +114,21 @@ public function get_guide(string $type) break; } - Css::push_group(['core', 'guide']); - Js::push_group(['angular', 'materia']); + Js::push_inline('var NAME = "'.$widget->name.'";'); + Js::push_inline('var TYPE = "'.$type.'";'); + Js::push_inline('var HAS_PLAYER_GUIDE = "'.( ! empty($widget->player_guide)).'";'); + Js::push_inline('var HAS_CREATOR_GUIDE = "'.( ! empty($widget->creator_guide)).'";'); + Js::push_inline('var DOC_PATH = "'.Config::get('materia.urls.engines').$widget->dir.$guide.'";'); + + + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', $title) ->set('page_type', 'guide'); - $this->theme->set_partial('meta', 'partials/responsive'); - - $this->theme->set_partial('content', 'partials/widget/guide_doc') - ->set('name', $widget->name) - ->set('type', $type) - ->set('has_player_guide', ! empty($widget->player_guide)) - ->set('has_creator_guide', ! empty($widget->creator_guide)) - ->set('doc_path', Config::get('materia.urls.engines').$widget->dir.$guide); + Css::push_group(['guide']); + Js::push_group(['react', 'guides']); } /** @@ -161,8 +159,10 @@ public function get_edit($inst_id) } /** - * Listing of all widgets i have rights to + * Listing of all widgets a given user has rights to */ + // this exists as an alternative to the next one for use with the React component + // once we're sure that is good enough, replace the action after this one with this action, rename it, remove unnecessary routes etc. public function get_mywidgets() { if (\Service_User::verify_session() !== true) @@ -170,19 +170,15 @@ public function get_mywidgets() Session::set('redirect_url', URI::current()); Session::set_flash('notice', 'Please log in to view your widgets.'); Response::redirect(Router::get('login')); + return; } - Css::push_group(['core', 'my_widgets']); - Js::push_group(['angular', 'jquery', 'materia', 'author', 'tablock', 'spinner', 'jqplot', 'my_widgets', 'dataTables']); - - Js::push_inline('var IS_STUDENT = '.(\Service_User::verify_session(['basic_author', 'super_user']) ? 'false;' : 'true;')); + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); + $this->theme->get_template()->set('title', 'My Widgets'); - $this->theme->get_template() - ->set('title', 'My Widgets') - ->set('page_type', 'my_widgets'); - - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/my_widgets'); + Css::push_group(['my_widgets']); + Js::push_group(['react', 'my_widgets']); } public function get_play_demo() @@ -201,6 +197,8 @@ public function action_play_widget($inst_id = false) public function action_play_embedded($inst_id = false) { + // if routed from the legacy LTI URL, the instance id is available as a GET parameter + if ( ! $inst_id && \Input::get('widget') ) $inst_id = \Input::get('widget'); // context_id isolates attempt count for an class so a user's attempt limit is reset per course Session::set('context_id', \Input::post('context_id')); return $this->_play_widget($inst_id, false, true); @@ -237,9 +235,6 @@ public function get_preview_widget($inst_id, $is_embedded = false) $this->display_widget($inst, false, $is_embedded); } } - - Css::push_group('widget_play'); - } /* ============================== PROTECTED ================================== */ @@ -248,103 +243,113 @@ public function get_preview_widget($inst_id, $is_embedded = false) protected function show_editor($title, $widget, $inst_id=null) { $this->_disable_browser_cache = true; - Css::push_group(['core', 'widget_create']); - Js::push_group(['angular', 'materia', 'author']); - if ( ! empty($widget->creator) && preg_match('/\.swf$/', $widget->creator)) - { - // add swfobject if it's needed - Js::push_group('swfobject'); - } + Js::push_inline('var WIDGET_HEIGHT = "'.$widget->height.'";'); + Js::push_inline('var WIDGET_WIDTH = "'.$widget->width.'";'); + + + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', $title) - ->set('page_type', 'create'); + ->set('page_type', 'widget'); - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/widget/create') - ->set('widget', $widget) - ->set('inst_id', $inst_id); + Css::push_group(['widget_create']); + Js::push_group(['react', 'createpage']); } protected function draft_not_playable() { $this->_disable_browser_cache = true; + + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', 'Draft Not Playable') ->set('page_type', ''); - $this->theme->set_partial('content', 'partials/widget/draft_not_playable'); - $this->theme->set_partial('footer', 'partials/angular_alert'); + Js::push_group(['react', 'draft_not_playable']); + Css::push_group(['login']); - Js::push_group(['angular', 'materia']); + $this->add_inline_info(); } - protected function retired() + protected function retired(bool $is_embedded = false) { + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', 'Retired Widget') ->set('page_type', ''); - $this->theme->set_partial('content', 'partials/widget/retired'); - $this->theme->set_partial('footer', 'partials/angular_alert'); + Js::push_group(['react', 'retired']); + Css::push_group(['login']); - Js::push_group(['angular', 'materia']); + Js::push_inline('var IS_EMBEDDED = "'.$is_embedded.'";'); } protected function no_attempts(object $inst, bool $is_embedded) { $this->_disable_browser_cache = true; + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', 'Widget Unavailable') ->set('page_type', 'login'); - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/widget/no_attempts') - ->set('classes', 'widget') - ->set('attempts', $inst->attempts) - ->set('scores_path', '/scores'.($is_embedded ? '/embed' : '').'/'.$inst->id) - - ->set('summary', $this->theme->view('partials/widget/summary') - ->set('type',$inst->widget->name) - ->set('name', $inst->name) - ->set('icon', Config::get('materia.urls.engines')."{$inst->widget->dir}img/icon-92.png")); + Js::push_inline('var ATTEMPTS = "'.$inst->attempts.'";'); + Js::push_inline('var WIDGET_ID = "'.$inst->id.'";'); + Js::push_inline('var IS_EMBEDDED = "'.$is_embedded.'";'); + Js::push_inline('var NAME = "'.$inst->name.'";'); + Js::push_inline('var ICON_DIR = "'.Config::get('materia.urls.engines').$inst->widget->dir.'";'); - Js::push_group(['angular', 'materia']); // The styles for this are in login, should probably be moved? Css::push_group('login'); + Js::push_group(['react', 'no_attempts']); } protected function no_permission() { $this->_disable_browser_cache = true; + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', 'Permission Denied') ->set('page_type', ''); - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/nopermission'); - - Js::push_group(['angular', 'materia']); + Js::push_group(['react', 'no_permission']); + Css::push_group('no_permission'); + $this->add_inline_info(); } protected function embedded_only($inst) { + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', 'Widget Unavailable') ->set('page_type', 'login'); - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/widget/embedded_only') - ->set('classes', 'widget') + $theme_overrides = \Event::Trigger('before_embedded_only', '', 'array'); + if ($theme_overrides) + { + $this->theme->set_template('layouts/react'); + $this->theme->get_template() + ->set('title', 'Login') + ->set('page_type', 'login'); - ->set('summary', $this->theme->view('partials/widget/summary') - ->set('type',$inst->widget->name) - ->set('name', $inst->name) - ->set('icon', Config::get('materia.urls.engines')."{$inst->widget->dir}img/icon-92.png")); + Css::push_group([$theme_overrides[0]['css']]); + Js::push_group(['react', $theme_overrides[0]['js']]); + } + else + { + Js::push_group(['react', 'embedded_only']); + // The styles for this are in login, should probably be moved? + Css::push_group('login'); + } - Js::push_group(['angular', 'materia']); - // The styles for this are in login, should probably be moved? - Css::push_group('login'); + Js::push_inline('var NAME = "'.$inst->name.'";'); + Js::push_inline('var ICON_DIR = "'.Config::get('materia.urls.engines').$inst->widget->dir.'";'); } protected function _play_widget($inst_id = false, $demo=false, $is_embedded=false) @@ -371,9 +376,6 @@ protected function _play_widget($inst_id = false, $demo=false, $is_embedded=fals $inst = Materia\Widget_Instance_Manager::get($inst_id); if ( ! $inst) throw new HttpNotFoundException; - // Disable header if embedded, prior to setting the widget view or any login/error screens - if ($is_embedded) $this->_header = 'partials/header_empty'; - if ( ! $is_embedded && $inst->embedded_only) return $this->embedded_only($inst); // display a login @@ -384,9 +386,9 @@ protected function _play_widget($inst_id = false, $demo=false, $is_embedded=fals $status = $inst->status($context_id); - if ( ! $status['open']) return $this->build_widget_login('Widget Unavailable', $inst_id); + if ( ! $status['open']) return $this->build_widget_login('Widget Unavailable', $inst_id, $is_embedded); if ( ! $demo && $inst->is_draft) return $this->draft_not_playable(); - if ( ! $demo && ! $inst->widget->is_playable) return $this->retired(); + if ( ! $demo && ! $inst->widget->is_playable) return $this->retired($is_embedded); if ( ! $status['has_attempts']) return $this->no_attempts($inst, $is_embedded); if (isset($_GET['autoplay']) && $_GET['autoplay'] === 'false') return $this->pre_embed_placeholder($inst); @@ -420,6 +422,7 @@ protected function build_widget_login($login_title = null, $inst_id = null, $is_ $server_date = date_create('now', timezone_open('UTC'))->format('D, d M Y H:i:s'); // ===================== RENDER ========================== + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', $login_title ?: 'Login') ->set('page_type', 'login'); @@ -429,39 +432,69 @@ protected function build_widget_login($login_title = null, $inst_id = null, $is_ if ($is_open) { // fire an event prior to deciding which theme to render - $alt = \Event::Trigger('before_widget_login'); // if something came back as a result of the event being triggered, use that instead of the default - $theme = $alt ?: 'partials/widget/login'; - $content = $this->theme->set_partial('content', $theme); - $content - ->set('user', __('user')) - ->set('pass', __('password')) - ->set('links', __('links')) - ->set('title', $login_title) - ->set('date', $server_date) - ->set('preview', $is_preview); + // theme_overrides object should include an array with a js and css index + // these specify a) the react page to render and b) its associated css + $theme_overrides = \Event::Trigger('before_widget_login', '', 'array'); + if ($theme_overrides) + { + $this->theme->set_template('layouts/react'); + $this->theme->get_template() + ->set('title', 'Login') + ->set('page_type', 'login'); + + Css::push_group([$theme_overrides[0]['css']]); + Js::push_group(['react', $theme_overrides[0]['js']]); + } + else + { + $this->theme->set_template('layouts/react'); + $this->theme->get_template() + ->set('title', 'Login') + ->set('page_type', 'login'); + + Css::push_group(['login']); + Js::push_group(['react', 'login']); + } + + Js::push_inline('var EMBEDDED = '.($is_embedded ? 'true' : 'false').';'); + Js::push_inline('var ACTION_LOGIN = "'.\Router::get('login').'";'); + Js::push_inline('var ACTION_REDIRECT = "'.urlencode(URI::current()).'";'); + Js::push_inline('var LOGIN_USER = "'.\Lang::get('login.user').'";'); + Js::push_inline('var LOGIN_PW = "'.\Lang::get('login.password').'";'); + Js::push_inline('var CONTEXT = "widget";'); + Js::push_inline('var NAME = "'.$inst->name.'";'); + Js::push_inline('var WIDGET_NAME = "'.$inst->widget->name.'";'); + Js::push_inline('var IS_PREVIEW = "'.$is_preview.'";'); + Js::push_inline('var ICON_DIR = "'.Config::get('materia.urls.engines').$inst->widget->dir.'";'); + + // condense login links into a string with delimiters to be embedded as a JS global + $link_items = []; + foreach (\Lang::get('login.links') as $a) + { + $link_items[] = $a['href'].'***'.$a['title']; + } + $login_links = implode('@@@', $link_items); + Js::push_inline('var LOGIN_LINKS = "'.urlencode($login_links).'";'); } else { - $content = $this->theme->set_partial('content', 'partials/widget/closed'); - $content - ->set('msg', __('user')) - ->set('date', $server_date) - ->set_safe('availability', $desc); - } + Js::push_inline('var IS_EMBEDDED = '.($is_embedded ? 'true' : 'false').';'); + Js::push_inline('var NAME = "'.$inst->name.'";'); + Js::push_inline('var WIDGET_NAME = "'.$inst->widget->name.'";'); + Js::push_inline('var ICON_DIR = "'.Config::get('materia.urls.engines').$inst->widget->dir.'";'); - // add widget summary - $content->set('classes', 'widget '.($is_preview ? 'preview' : '')) - ->set('summary', $this->theme->view('partials/widget/summary') - ->set('type',$inst->widget->name) - ->set('name', $inst->name) - ->set('icon', Config::get('materia.urls.engines')."{$inst->widget->dir}img/icon-92.png") - ->set_safe('avail', $summary)); + Js::push_inline('var SUMMARY = "'.$summary.'";'); + Js::push_inline('var DESC = "'.$desc.'";'); - if ($is_embedded) $this->_header = 'partials/header_empty'; + $this->theme->set_template('layouts/react'); + $this->theme->get_template() + ->set('title', 'Widget Unavailable') + ->set('page_type', 'login'); - Js::push_group(['angular', 'materia', 'student']); - Css::push_group('login'); + Css::push_group(['login']); + Js::push_group(['react', 'closed']); + } } protected function build_widget_login_messages($inst) @@ -473,13 +506,14 @@ protected function build_widget_login_messages($inst) // Build the open/close dates for display if ($status['opens']) { - $start_string = ''.date($format, (int) $inst->open_at).''; - $start_sec = '{{ time('.((int) $inst->open_at * 1000).') }}'; + // $start_string = ''.date($format, (int) $inst->open_at).''; + $start_string = date($format, (int) $inst->open_at); + $start_sec = date('h:i A', (int) $inst->open_at * 1000); } if ($status['closes']) { - $end_string = ''.date($format, (int) $inst->close_at).''; - $end_sec = '{{ time('.((int) $inst->close_at * 1000).') }}'; + $end_string = date($format, (int) $inst->close_at); + $end_sec = date('h:i A', (int) $inst->close_at * 1000); } // finish the actual messages to the user @@ -507,47 +541,44 @@ protected function build_widget_login_messages($inst) protected function display_widget(\Materia\Widget_Instance $inst, $play_id=false, $is_embedded=false) { - Css::push_group(['core', 'widget_play']); - Js::push_group(['angular', 'materia', 'student']); - if ($is_embedded) $this->_header = 'partials/header_empty'; - if ( ! empty($inst->widget->player) && preg_match('/\.swf$/', $inst->widget->player)) - { - // add swfobject if it's needed - Js::push_group('swfobject'); - } - Js::push_inline('var PLAY_ID = "'.$play_id.'";'); + Js::push_inline('var DEMO_ID = "'.$inst->id.'";'); + Js::push_inline('var WIDGET_HEIGHT = "'.$inst->widget->height.'";'); + Js::push_inline('var WIDGET_WIDTH = "'.$inst->widget->width.'";'); + Js::push_inline('var STATIC_CROSSDOMAIN = "'.Config::get('materia.urls.static').'";'); + Js::push_inline('var BASE_URL = "'.Uri::base().'";'); + Js::push_inline('var WIDGET_URL = "'.Config::get('materia.urls.engines').'";'); + Js::push_inline('var MEDIA_URL = "'.Config::get('materia.urls.media').'";'); + + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', $inst->name.' '.$inst->widget->name) ->set('page_type', 'widget') ->set('html_class', $is_embedded ? 'embedded' : '' ); - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/widget/play') - ->set('inst_id', $inst->id); + Css::push_group(['playpage']); + Js::push_group(['react', 'playpage']); } protected function pre_embed_placeholder($inst) { $this->_disable_browser_cache = true; + $this->theme = Theme::instance(); + $this->theme->set_template('layouts/react'); $this->theme->get_template() - ->set('title', 'Widget Unavailable') - ->set('page_type', 'login'); + ->set('title', $inst->name.' '.$inst->widget->name) + ->set('page_type', 'widget'); $uri = URI::current(); $context = strpos($uri, 'play/') != false ? 'play' : 'embed'; - $this->theme->set_partial('footer', 'partials/angular_alert'); - $this->theme->set_partial('content', 'partials/widget/pre_embed_placeholder') - ->set('classes', 'widget') - ->set('inst_id', $inst->id) - ->set('context', $context) - ->set('summary', $this->theme->view('partials/widget/summary') - ->set('type',$inst->widget->name) - ->set('name', $inst->name) - ->set('icon', Config::get('materia.urls.engines')."{$inst->widget->dir}img/icon-92.png")); - - Js::push_group(['angular', 'materia']); + Js::push_inline('var INST_ID = "'.$inst->id.'";'); + Js::push_inline('var CONTEXT = "'.$context.'";'); + Js::push_inline('var NAME = "'.$inst->name.'";'); + Js::push_inline('var ICON_DIR = "'.Config::get('materia.urls.engines').$inst->widget->dir.'";'); + + Js::push_group(['react', 'pre_embed']); Css::push_group(['login','pre_embed_placeholder']); } } diff --git a/fuel/app/classes/materia/api/v1.php b/fuel/app/classes/materia/api/v1.php index c20e3afb8..b173b7be3 100644 --- a/fuel/app/classes/materia/api/v1.php +++ b/fuel/app/classes/materia/api/v1.php @@ -10,7 +10,7 @@ - Verb Last - Use a underscore between the item and the verb EX: gameInstance_get, gameInstance_create, gameInstance_edit, gameInstance_copy -Availible Verbs: +Available Verbs: - get (retrive a value) - create (create/save a new value) - delete (remove a value) @@ -42,18 +42,33 @@ static public function widgets_get_by_type($type) return Widget_Manager::get_widgets([], $type); } - static public function widget_instances_get($inst_ids = null) + static public function widget_instances_get($inst_ids = null, bool $deleted = false, $load_qset = false) { // get all my instances - must be logged in if (empty($inst_ids)) { if (\Service_User::verify_session() !== true) return []; // shortcut to returning noting - return Widget_Instance_Manager::get_all_for_user(\Model_User::find_current_id()); + return Widget_Instance_Manager::get_all_for_user(\Model_User::find_current_id(), $load_qset); } // get specific instances - no log in required if ( ! is_array($inst_ids)) $inst_ids = [$inst_ids]; // convert string into array of items - return Widget_Instance_Manager::get_all($inst_ids); + return Widget_Instance_Manager::get_all($inst_ids, $load_qset, false, $deleted); + } + +/** + * Takes a page number, and returns objects containing the total_num_pages and + * widget instances that are visible to the user. + * + * @param page_number The page to be requested. By default it is set to 1. + * + * @return array of objects containing total_num_pages and widget instances that are visible to the user. + */ + static public function widget_paginate_instances_get($page_number = 0) + { + if (\Service_User::verify_session() !== true) return Msg::no_login(); + $data = Widget_Instance_Manager::get_paginated_for_user(\Model_User::find_current_id(), $page_number); + return $data; } /** @@ -63,7 +78,7 @@ static public function widget_instance_delete($inst_id) { if ( ! Util_Validator::is_valid_hash($inst_id)) return Msg::invalid_input($inst_id); if (\Service_User::verify_session() !== true) return Msg::no_login(); - if ( ! static::has_perms_to_inst($inst_id, [Perm::FULL])) return Msg::no_perm(); + if ( ! static::has_perms_to_inst($inst_id, [Perm::FULL]) && ! Perm_Manager::is_support_user()) return Msg::no_perm(); if ( ! ($inst = Widget_Instance_Manager::get($inst_id))) return false; return $inst->db_remove(); } @@ -123,7 +138,7 @@ static private function has_perms_to_inst($inst_id, $perms) static public function widget_instance_copy(string $inst_id, string $new_name, bool $copy_existing_perms = false) { if (\Service_User::verify_session() !== true) return Msg::no_login(); - if ( ! static::has_perms_to_inst($inst_id, [Perm::FULL])) return Msg::no_perm(); + if ( ! static::has_perms_to_inst($inst_id, [Perm::FULL]) && ! Perm_Manager::is_support_user()) return Msg::no_perm(); $inst = Widget_Instance_Manager::get($inst_id, true); try @@ -131,7 +146,7 @@ static public function widget_instance_copy(string $inst_id, string $new_name, b // retain access - if true, grant access to the copy to all original owners $current_user_id = \Model_User::find_current_id(); $duplicate = $inst->duplicate($current_user_id, $new_name, $copy_existing_perms); - return $duplicate->id; + return $duplicate; } catch (\Exception $e) { @@ -196,6 +211,7 @@ static public function widget_instance_new($widget_id=null, $name=null, $qset=nu * Save and existing instance * * @param int $inst_id + * @param string $name * @param object $qset * @param bool $is_draft Whether the widget is being saved as a draft * @param int $open_at @@ -229,6 +245,12 @@ static public function widget_instance_update($inst_id=null, $name=null, $qset=n { $inst->qset = $qset; } + else + { + // if the qset is not explicitly provided, assume it is not being updated + // if $inst->qset is populated it will be saved to the db as a new qset version - which isn't necessary + $inst->qset = (object) ['version' => null, 'data' => null]; + } if ( ! empty($name)) { if ($inst->name != $name) @@ -638,12 +660,14 @@ static public function guest_widget_instance_scores_get($inst_id, $play_id) * [quickStats] contains attempts, scores, currentPlayers, avScore, replays
* [playLogs] a log of all scores recoreded */ - static public function play_logs_get($inst_id, $semester = 'all', $year = 'all') + static public function play_logs_get($inst_id, $semester = 'all', $year = 'all', $page_number=1) { - if ( ! Util_Validator::is_valid_hash($inst_id)) return Msg::invalid_input($inst_id); + if ( ! Util_Validator::is_valid_hash($inst_id)) return Msg::invalid_input($inst_id); if (\Service_User::verify_session() !== true) return Msg::no_login(); if ( ! static::has_perms_to_inst($inst_id, [Perm::VISIBLE, Perm::FULL])) return Msg::no_perm(); - return Session_Play::get_by_inst_id($inst_id, $semester, $year); + + $data = Session_Play::get_by_inst_id_paginated($inst_id, $semester, $year, $page_number); + return $data; } /** @@ -817,6 +841,7 @@ static public function questions_get($ids=null, $type=null) // remote_getQuestio return Widget_Question_Manager::get_users_questions(\Model_User::find_current_id(), $type); } } + static public function play_storage_data_save($play_id, $data) { $inst = self::_get_instance_for_play_id($play_id); @@ -887,7 +912,9 @@ static public function user_get($user_ids = null) //no user ids provided, return current user if ($user_ids === null) { - $results = \Model_User::find_current(); + //$results = \Model_User::find_current(); + $me = \Model_User::find_current_id(); + $results = \Model_User::find($me); $results = $results->to_array(); } else @@ -895,6 +922,7 @@ static public function user_get($user_ids = null) if (empty($user_ids) || ! is_array($user_ids)) return Msg::invalid_input(); //user ids provided, get all of the users with the given ids $me = \Model_User::find_current_id(); + foreach ($user_ids as $id) { if (Util_Validator::is_pos_int($id)) @@ -1087,21 +1115,36 @@ static public function notifications_get() return $return_array; } - static public function notification_delete($note_id) + static public function notification_delete($note_id, $delete_all) { if ( ! \Service_User::verify_session()) return Msg::no_login(); $user = \Model_User::find_current(); - $note = \Model_Notification::query() + if ($delete_all) + { + $notes = \Model_Notification::query() + ->where('to_id', $user->id) + ->get(); + + foreach ($notes as $note) + { + $note->delete(); + } + return true; + } + if ($note_id) + { + $note = \Model_Notification::query() ->where('id', $note_id) ->where('to_id', $user->id) ->get(); - if ($note) - { - $note[$note_id]->delete(); - return true; + if ($note) + { + $note[$note_id]->delete(); + return true; + } } return false; } @@ -1122,7 +1165,7 @@ static private function _validate_play_id($play_id) { if ($play->get_by_id($play_id)) { - if ($play->is_valid == 1) + if (intval($play->is_valid) == 1) { $play->update_elapsed(); // update the elapsed time return $play; diff --git a/fuel/app/classes/materia/perm.php b/fuel/app/classes/materia/perm.php index 00c82a8c7..29af26de9 100644 --- a/fuel/app/classes/materia/perm.php +++ b/fuel/app/classes/materia/perm.php @@ -62,10 +62,10 @@ abstract class Perm const GIVE_SHARE = 75; // group rights only - /** @const Has rights to access manger interface */ - const AUTHORACCESS = 80; - /** @const Has rights to administer users */ - const ADMINISTRATOR = 85; - /** @const Has super user rights to do anything */ + /** @const Standard author access level. Can edit/publish/modify and share widgets without restrictions. */ + const BASICAUTHOR = 80; + /** @const Elevated access level. Grants access to instance and user admin interface. Can review user settings, ownership, and play history, and access/modify instances and their settings. */ + const SUPPORTUSER = 85; + /** @const Super-elevated access level. Can do anything. */ const SUPERUSER = 90; } diff --git a/fuel/app/classes/materia/perm/manager.php b/fuel/app/classes/materia/perm/manager.php index ee0349904..bf3e703da 100644 --- a/fuel/app/classes/materia/perm/manager.php +++ b/fuel/app/classes/materia/perm/manager.php @@ -34,16 +34,24 @@ static public function create_role($role_name = '') */ static public function is_super_user() { - $login_hash = \Session::get('login_hash'); - $key = 'is_super_user_'.$login_hash; - $has_role = (\Fuel::$is_cli === true && ! \Fuel::$is_test) || \Session::get($key, false); + // @TODO this was previously creating a local session object storing the value returned from this + // The session caching has been removed due to issues related to the cache when the role is added or revoked + // Ideally we can still find a way to cache this and make it more performant!! + return (\Fuel::$is_cli === true && ! \Fuel::$is_test) || self::does_user_have_role([\Materia\Perm_Role::SU]); + + } - if ( ! $has_role) - { - $has_role = self::does_user_have_role([\Materia\Perm_Role::SU]); - \Session::set($key, $has_role); - } - return $has_role; + /** + * Check if user is currently logged in as a support user + * + * @return boolean wheter or not the current user has the support role as defined by Perm_Role class + */ + static public function is_support_user(): bool + { + // @TODO this was previously creating a local session object storing the value returned from this + // The session caching has been removed due to issues related to the cache when the role is added or revoked + // Ideally we can still find a way to cache this and make it more performant!! + return (\Fuel::$is_cli === true && ! \Fuel::$is_test) || self::does_user_have_role([\Materia\Perm_Role::SUPPORT]); } /** @@ -261,12 +269,11 @@ static public function get_user_roles($user_id = 0) ->as_object(); $current_id = \Model_User::find_current_id(); + $roles = []; // return logged in user's roles if id is 0 or less, non su users can only use this method if ($user_id <= 0 || $user_id == $current_id) { - $roles = []; - $results = $q->where('m.user_id', $current_id) ->execute(); @@ -533,7 +540,10 @@ static public function clear_all_perms_for_object($object_id, $object_type) } /** - * Gets an array of object id's that a user has permissions for matching any of the requested permissions. + * Gets an array of object ids of a given type that a user has EXPLICIT permissions to that matches the perms provided. + * (!!!) NOTE: Previously, this method would also return IMPLICITLY available objects based on the user's role (if elevated). + * This is no longer the case. If IMPLICITLY available objects are required, use get_all_objects_for_elevated_user_role + * * If an object has any of the requested permissions, it will be returned. * Perm_Manager->get_all_objects_for_users($user->user_id, \Materia\Perm::INSTANCE, [\Materia\Perm::SHARE]); * @@ -551,43 +561,64 @@ static public function get_all_objects_for_user($user_id, $object_type, $perms) // WHERE id IN (5, 6) whould match ids that ***START*** with 5 or 6 foreach ($perms as &$value) $value = (string) $value; - // ====================== GET THE USERS ROLE PERMISSIONS ============================ - // build a subquery that gets any roles the user has - $subquery_role_ids = \DB::select('role_id') - ->from('perm_role_to_user') - ->where('user_id', $user_id); - - // get any perms that users roles have - $roles_perms = \DB::select('perm') - ->from('perm_role_to_perm') - ->where('role_id', 'IN', $subquery_role_ids) + // ==================== GET USER's EXPLICIT PERMISSSION ============================== + // get objects that the user has direct access to + $objects = \DB::select('object_id') + ->from('perm_object_to_user') + ->where('object_type', $object_type) + ->where('user_id', $user_id) ->where('perm', 'IN', $perms) - ->execute(); - - // Only super_user has role perm 30 -- get all assets/widgets - if ($roles_perms->count() != 0) - { - $objects = \DB::select('id') - ->from($object_type == Perm::ASSET ? 'asset' : 'widget_instance') - ->execute() - ->as_array('id', 'id'); - } - else - { - // ==================== GET USER's EXPLICIT PERMISSSION ============================== - // get objects that the user has direct access to - $objects = \DB::select('object_id') - ->from('perm_object_to_user') - ->where('object_type', $object_type) - ->where('user_id', $user_id) - ->where('perm', 'IN', $perms) - ->execute() - ->as_array('object_id', 'object_id'); - } + ->execute() + ->as_array('object_id', 'object_id'); return $objects; } } + /** + * Gets an array of object ids that a user has permissions to access EXCLUSIVELY based on an elevated role + * This requires the user has a role with elevated perms, and that the group rights associated with those perms are present in the perm_role_to_perm table + * Currently, the role must be Perm::SUPPORTUSER or Perm::SUPERUSER + * + * Perm_Manager->get_all_objects_for_users($user->user_id, \Materia\Perm::INSTANCE); + * + * @param int User ID the get permissions for + * @param int Object type as defined in Perm constants + */ + static public function get_all_objects_for_elevated_user_role($user_id, $object_type) + { + $objects = []; + $user_is_admin_or_su = false; + + // ====================== GET THE USERS ROLE PERMISSIONS ============================ + // build a subquery that gets any roles the user has + $subquery_role_ids = \DB::select('role_id') + ->from('perm_role_to_user') + ->where('user_id', $user_id); + + // get any perms that users roles have + $roles_perms = \DB::select('perm') + ->from('perm_role_to_perm') + ->where('role_id', 'IN', $subquery_role_ids) + ->execute(); + + + // verify that perms returned from perm_role_to_perm table are elevated + // this means either Perm::SUPPORTUSER (85) or Perm::SUPERUSER (90) + foreach ($roles_perms as $role) + { + if (in_array([Perm::SUPPORTUSER, Perm::SUPERUSER], $role['perm'])) $user_is_admin_or_su = true; + } + + if ($user_is_admin_or_su == true) + { + $objects = \DB::select('id') + ->from($object_type == Perm::ASSET ? 'asset' : 'widget_instance') + ->execute() + ->as_array('id', 'id'); + } + return $objects; + } + /** * Counts the number of users with perms to a given object * to an object (used by Widget_Asset_Manager.can_asset_be_deleted) @@ -646,9 +677,9 @@ static public function get_all_users_explicit_perms(string $object_id, int $obje $all_perms = self::get_all_users_with_perms_to($object_id, $object_type); - // if the current user is a super user, append their user's perms into the results - // super users don't get explicit perms to things set in the db, so we'll fake it here - $cur_user_perms = self::is_super_user() ? [Perm::SUPERUSER, null] : $all_perms[$current_user_id]; + // if the current user is a super user or support user, append their user's perms into the results + // super users and support users don't get explicit perms to objects set in the db, so we'll fake it here + $cur_user_perms = self::is_super_user() ? [Perm::SUPERUSER, null] : (self::is_support_user() ? [Perm::FULL, null] : $all_perms[$current_user_id]); return [ 'user_perms' => [$current_user_id => $cur_user_perms], diff --git a/fuel/app/classes/materia/perm/role.php b/fuel/app/classes/materia/perm/role.php index 05d24cb27..e9fc04b8e 100644 --- a/fuel/app/classes/materia/perm/role.php +++ b/fuel/app/classes/materia/perm/role.php @@ -29,6 +29,7 @@ class Perm_Role const NOAUTH = 'no_author'; const AUTHOR = 'basic_author'; const SU = 'super_user'; + const SUPPORT = 'support_user'; public $id; public $name; diff --git a/fuel/app/classes/materia/session/play.php b/fuel/app/classes/materia/session/play.php index 8c559990f..679da49ab 100644 --- a/fuel/app/classes/materia/session/play.php +++ b/fuel/app/classes/materia/session/play.php @@ -260,6 +260,20 @@ public static function get_by_inst_id($inst_id, $semester='all', $year='all') return $plays; } + public static function get_by_inst_id_paginated($inst_id, $semester='all', $year='all', $page_number=1) + { + $items_per_page = 100; + $data = self::get_by_inst_id($inst_id, $semester, $year); + $total_num_pages = ceil(sizeof($data) / $items_per_page); + $offset = $items_per_page * ($page_number - 1); + $page = array_slice($data, $offset, $items_per_page); + $data = [ + 'total_num_pages' => $total_num_pages, + 'pagination' => $page, + ]; + return $data; + } + /** * NEEDS DOCUMENTAION * diff --git a/fuel/app/classes/materia/session/playdataexporter.php b/fuel/app/classes/materia/session/playdataexporter.php index 9fd2ffffa..ab4bb9a6a 100644 --- a/fuel/app/classes/materia/session/playdataexporter.php +++ b/fuel/app/classes/materia/session/playdataexporter.php @@ -243,7 +243,7 @@ protected static function full_event_log($inst, $semesters) $semester, $play_event->type, $play_event->item_id, - str_replace(["\r","\n", ','], '', $play_event->text), // sanitize commas and newlines to keep CSV formatting intact + is_string($play_event->text) ? str_replace(["\r","\n", ','], '', $play_event->text) : '', // sanitize commas and newlines to keep CSV formatting intact $play_event->value, $play_event->game_time, $play_event->created_at @@ -307,7 +307,7 @@ protected static function full_event_log($inst, $semesters) foreach ($csv_questions as $question) { // Sanitize newlines and commas, as they break CSV formatting - $sanitized_question_text = str_replace(["\r","\n", ','], '', $question['text']); + $sanitized_question_text = is_string($question['text']) ? str_replace(["\r","\n", ','], '', $question['text']) : ''; $csv_question_text .= "\r\n{$question['question_id']},{$question['id']},{$sanitized_question_text}"; foreach ($options as $key) @@ -327,14 +327,14 @@ protected static function full_event_log($inst, $semesters) foreach ($csv_answers as $answer) { // Sanitize newlines and commas, as they break CSV formatting - $sanitized_answer_text = str_replace(["\r","\n", ','], '', $answer['text']); + $sanitized_answer_text = is_string($answer['text']) ? str_replace(["\r","\n", ','], '', $answer['text']) : ''; $csv_answer_text .= "\r\n{$answer['question_id']},{$answer['id']},{$sanitized_answer_text},{$answer['value']}"; } $tempname = tempnam('/tmp', 'materia_raw_log_csv'); $zip = new \ZipArchive(); - $zip->open($tempname); + $zip->open($tempname, \ZipArchive::OVERWRITE); $zip->addFromString('questions.csv', $csv_question_text); $zip->addFromString('answers.csv', $csv_answer_text); $zip->addFromString('logs.csv', $csv_playlog_text); @@ -362,12 +362,12 @@ protected static function questions_and_answers($inst, $semesters) foreach ($questions as $question) { - $sanitized_question = str_replace(["\r","\n", ','], '', $question->questions[0]['text']); + $sanitized_question = is_string($question->questions[0]['text']) ? str_replace(["\r","\n", ','], '', $question->questions[0]['text']) : ''; $sanitized_answers = []; foreach ($question->answers as $answer) { - $sanitized_answer = str_replace(["\r","\n", ','], '', $answer['text']); + $sanitized_answer = is_string($answer['text']) ? str_replace(["\r","\n", ','], '', $answer['text']) : ''; array_push($sanitized_answers, $sanitized_answer); } @@ -426,7 +426,7 @@ protected static function referrer_urls($inst, $semesters) $tempname = tempnam('/tmp', 'materia_raw_log_csv'); $zip = new \ZipArchive(); - $zip->open($tempname); + $zip->open($tempname, \ZipArchive::OVERWRITE); $zip->addFromString('individual_referrers.csv', $csv_i); $zip->addFromString('collective_referrers.csv', $csv_c); $zip->close(); diff --git a/fuel/app/classes/materia/widget/asset.php b/fuel/app/classes/materia/widget/asset.php index 9cf628cce..e831f25dc 100644 --- a/fuel/app/classes/materia/widget/asset.php +++ b/fuel/app/classes/materia/widget/asset.php @@ -16,11 +16,16 @@ class Widget_Asset const MAP_TYPE_QUESTION = '2'; protected const MIME_TYPE_TO_EXTENSION = [ - 'image/png' => 'png', - 'image/gif' => 'gif', - 'image/jpeg' => 'jpg', - 'audio/mpeg' => 'mp3', - 'text/plain' => 'obj', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/jpeg' => 'jpg', + 'audio/mp4' => 'm4a', + 'audio/x-m4a' => 'm4a', + 'audio/mpeg' => 'mp3', + 'audio/wav' => 'wav', + 'audio/wave' => 'wav', + 'audio/x-wav' => 'wav', + 'text/plain' => 'obj', ]; protected const MIME_TYPE_FROM_EXTENSION = [ @@ -28,8 +33,10 @@ class Widget_Asset 'gif' => 'image/gif', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', + 'm4a' => 'audio/mp4', 'mp3' => 'audio/mpeg', - 'obj' => 'text/plain', + 'wav' => 'audio/wav', + 'obj' => 'text/plain', ]; public $created_at = 0; @@ -39,6 +46,7 @@ class Widget_Asset public $file_size = ''; public $questions = []; public $type = ''; + public $is_deleted = 0; protected $_storage_driver; @@ -115,7 +123,9 @@ public function db_update() 'type' => $this->type, 'title' => $this->title, 'file_size' => $this->file_size, - 'created_at' => time() + 'created_at' => time(), + 'is_deleted' => $this->is_deleted + ]) ->where('id','=',$this->id) ->execute(); diff --git a/fuel/app/classes/materia/widget/instance.php b/fuel/app/classes/materia/widget/instance.php index f5a69c6b2..1291b2a8b 100644 --- a/fuel/app/classes/materia/widget/instance.php +++ b/fuel/app/classes/materia/widget/instance.php @@ -14,6 +14,7 @@ class Widget_Instance public $embed_url = ''; public $is_student_made = false; public $is_embedded = false; + public $is_deleted = false; public $embedded_only = false; public $student_access = false; public $guest_access = false; @@ -418,6 +419,35 @@ public function db_remove() return true; } + /** + * 'Undeletes' instance by updating is_deleted flag from 1 to 0 + * @return bool true if successfully undeleted, false if unable to restore + */ + public function db_undelete(): bool + { + if ( ! Util_Validator::is_valid_hash($this->id)) return false; + + $current_user_id = \Model_User::find_current_id(); + + \DB::update('widget_instance') + ->set(['is_deleted' => '0', 'updated_at' => time()]) + ->where('id', $this->id) + ->execute(); + + // store an activity log + $activity = new Session_Activity([ + 'user_id' => $current_user_id, + 'type' => Session_Activity::TYPE_EDIT_WIDGET, + 'item_id' => $this->id, + 'value_1' => $this->name, + 'value_2' => $this->widget->id + ]); + + $activity->db_store(); + + return true; + } + /** * Creates a duplicate widget instance and optionally makes the current user the owner. * @@ -434,7 +464,17 @@ public function duplicate(int $owner_id, string $new_name = null, bool $copy_exi $duplicate->id = 0; // mark as a new game $duplicate->user_id = $owner_id; // set current user as owner in instance table - if ( ! empty($new_name)) $duplicate->name = $new_name; // update name + // update name + if ( ! empty($new_name)) $duplicate->name = $new_name; + + // these values aren't saved to the db - but the frontend will make use of them + $duplicate->clean_name = \Inflector::friendly_title($duplicate->name, '-', true); + $base_url = "{$duplicate->id}/{$duplicate->clean_name}"; + $duplicate->preview_url = \Config::get('materia.urls.preview').$base_url; + $duplicate->play_url = $duplicate->is_draft === false ? \Config::get('materia.urls.play').$base_url : ''; + $duplicate->embed_url = $duplicate->is_draft === false ? \Config::get('materia.urls.embed').$base_url : ''; + + $duplicate->created_at = time(); // manually update created_at, the actual value saved to the db is created in db_store // if original widget is student made - verify if new owner is a student or not // if they have a basic_author role or above, turn off the is_student_made flag @@ -460,6 +500,9 @@ public function duplicate(int $owner_id, string $new_name = null, bool $copy_exi $owners = []; $viewers = []; + // Add current user + $owners[] = $owner_id; + foreach ($existing_perms['widget_user_perms'] as $user_id => $perm_obj) { list($perm) = $perm_obj; @@ -597,6 +640,85 @@ public function lti_associations() ->get(); } + public function get_all_extra_attempts(): array + { + $semester = Semester::get_current_semester(); + + $result = \DB::select('id', 'user_id', 'context_id','extra_attempts') + ->from('user_extra_attempts') + ->where('inst_id', $this->id) + ->where('semester', $semester) + ->execute() + ->as_array(); + + return $result; + } + + public function set_extra_attempts(int $user_id, int $extra_attempts, string $context_id, $id=null) + { + $semester = Semester::get_current_semester(); + + $result = [ 'user_id' => $user_id, 'success' => false ]; + + // we have an ID, update an existing row + if ($id != null) + { + if ($extra_attempts > 0) + { + \DB::update('user_extra_attempts') + ->value('extra_attempts', $extra_attempts) + ->value('context_id', $context_id) + ->where('id', '=', $id) + ->execute(); + + $result = [ + 'user_id' => $user_id, + 'extra_attempts' => $extra_attempts, + 'context_id' => $context_id, + 'success' => true + ]; + } + // delete existing row if attempts <= 0 + else + { + \DB::delete('user_extra_attempts') + ->where('id', $id) + ->execute(); + + $result = [ 'user_id' => $user_id, 'success' => true ]; + } + } + // no ID provided, add new row + else + { + // make sure extra attempts are > 0, otherwise no need to add + if ($extra_attempts > 0) + { + \DB::insert('user_extra_attempts') + ->set([ + 'inst_id' => $this->id, + 'semester' => $semester, + 'user_id' => $user_id, + 'extra_attempts' => $extra_attempts, + 'context_id' => $context_id, + 'created_at' => time() + ]) + ->execute(); + + $result = [ + 'inst_id' => $this->id, + 'semester' => $semester, + 'user_id' => $user_id, + 'extra_attempts' => $extra_attempts, + 'context_id' => $context_id, + 'success' => true + ]; + } + } + + return $result; + } + public function export() { } diff --git a/fuel/app/classes/materia/widget/instance/manager.php b/fuel/app/classes/materia/widget/instance/manager.php index 8a68ab141..d6ef5f140 100644 --- a/fuel/app/classes/materia/widget/instance/manager.php +++ b/fuel/app/classes/materia/widget/instance/manager.php @@ -6,13 +6,15 @@ class Widget_Instance_Manager { public $validate = true; - static public function get($inst_id, $load_qset=false, $timestamp=false) + // rolling back type expectations for now to resolve failing tests - this isn't a bad idea but it needs more focused attention + // static public function get(string $inst_id, bool $load_qset=false, $timestamp=false, bool $deleted=false) + static public function get($inst_id, $load_qset=false, $timestamp=false, $deleted=false) { - $instances = Widget_Instance_Manager::get_all([$inst_id], $load_qset, $timestamp); + $instances = Widget_Instance_Manager::get_all([$inst_id], $load_qset, $timestamp, $deleted); return count($instances) > 0 ? $instances[0] : false; } - static public function get_all(Array $inst_ids, $load_qset=false, $timestamp=false) + static public function get_all(Array $inst_ids, $load_qset=false, $timestamp=false, bool $deleted=false): array { if ( ! is_array($inst_ids) || count($inst_ids) < 1) return []; @@ -23,7 +25,7 @@ static public function get_all(Array $inst_ids, $load_qset=false, $timestamp=fal $results = \DB::select() ->from('widget_instance') ->where('id', 'IN', $inst_ids) - ->and_where('is_deleted', '=', '0') + ->and_where('is_deleted', '=', $deleted ? '1' : '0') ->order_by('created_at', 'desc') ->execute() ->as_array(); @@ -45,6 +47,7 @@ static public function get_all(Array $inst_ids, $load_qset=false, $timestamp=fal 'open_at' => $r['open_at'], 'close_at' => $r['close_at'], 'attempts' => $r['attempts'], + 'is_deleted' => (bool) $r['is_deleted'], 'embedded_only' => (bool) $r['embedded_only'], 'widget' => $widget, ]); @@ -56,14 +59,44 @@ static public function get_all(Array $inst_ids, $load_qset=false, $timestamp=fal return $instances; } - public static function get_all_for_user($user_id) + public static function get_all_for_user($user_id, $load_qset=false) { $inst_ids = Perm_Manager::get_all_objects_for_user($user_id, Perm::INSTANCE, [Perm::FULL, Perm::VISIBLE]); - if ( ! empty($inst_ids)) return Widget_Instance_Manager::get_all($inst_ids); + if ( ! empty($inst_ids)) return Widget_Instance_Manager::get_all($inst_ids, $load_qset); else return []; } +/** + * It takes a user ID and a page number, and returns an array of instances that the user has permission + * to see, along with the total number of pages + * + * @param user_id The user id of the user whose instances we want to get + * @param page_number The page number of the pagination. + * + * @return array of widget instances that are visible to the user. + */ + public static function get_paginated_for_user($user_id, $page_number = 0) + { + $inst_ids = Perm_Manager::get_all_objects_for_user($user_id, Perm::INSTANCE, [Perm::FULL, Perm::VISIBLE]); + $displayable_inst = self::get_all($inst_ids); + $widgets_per_page = 80; + $total_num_pages = ceil(sizeof($displayable_inst) / $widgets_per_page); + $offset = $widgets_per_page * $page_number; + $has_next_page = $offset + $widgets_per_page < sizeof($displayable_inst) ? true : false; + + // inst_ids corresponds to a single page's worth of instances + $displayable_inst = array_slice($displayable_inst, $offset, $widgets_per_page); + + $data = [ + 'pagination' => $displayable_inst, + ]; + + if ($has_next_page) $data['next_page'] = $page_number + 1; + + return $data; + } + /** * Checks to see if the given widget instance is locked by the current user. * @@ -101,4 +134,50 @@ public static function lock($inst_id) // true if the lock is mine return $locked_by == $me; } + + /** + * Gets all widget instances related to a given input, including id or name. + * + * @param input search input + * + * @return array of widget instances related to the given input + */ + public static function get_search(string $input): array + { + $results = \DB::select() + ->from('widget_instance') + ->where('id', 'LIKE', "%$input%") + ->or_where('name', 'LIKE', "%$input%") + ->order_by('created_at', 'desc') + ->execute() + ->as_array(); + + $instances = []; + foreach ($results as $r) + { + $widget = new Widget(); + $widget->get($r['widget_id']); + $student_access = Perm_Manager::accessible_by_students($r['id'], Perm::INSTANCE); + $inst = new Widget_Instance([ + 'id' => $r['id'], + 'user_id' => $r['user_id'], + 'name' => $r['name'], + 'is_student_made' => (bool) $r['is_student_made'], + 'student_access' => $student_access, + 'guest_access' => (bool) $r['guest_access'], + 'is_draft' => (bool) $r['is_draft'], + 'created_at' => $r['created_at'], + 'open_at' => $r['open_at'], + 'close_at' => $r['close_at'], + 'attempts' => $r['attempts'], + 'is_deleted' => (bool) $r['is_deleted'], + 'embedded_only' => (bool) $r['embedded_only'], + 'widget' => $widget, + ]); + + $instances[] = $inst; + } + + return $instances; + } } diff --git a/fuel/app/classes/model/notification.php b/fuel/app/classes/model/notification.php index 91942c021..1448caf52 100644 --- a/fuel/app/classes/model/notification.php +++ b/fuel/app/classes/model/notification.php @@ -74,7 +74,7 @@ public static function send_item_notification(int $from_user_id, int $to_user_id $inst->db_get($inst_id, false); $user_link = $from->first.' '.$from->last.' ('.$from->username.')'; - $widget_link = Html::anchor(\Config::get('materia.urls.root').'my-widgets#/'.$inst_id, $inst->name); + $widget_link = Html::anchor(\Config::get('materia.urls.root').'my-widgets#'.$inst_id, $inst->name); $widget_name = $inst->name; $widget_type = $inst->widget->name; @@ -94,23 +94,23 @@ public static function send_item_notification(int $from_user_id, int $to_user_id switch ($mode) { case 'disabled': - $subject = "$user_link is no longer sharing \"$widget_name\" with you."; + $subject = "$user_link is no longer sharing \"$widget_name\" with you."; break; case 'changed': - $subject = "$user_link changed your access to widget \"$widget_link\".
You now have $perm_string access."; + $subject = "$user_link changed your access to widget \"$widget_link\".
You now have $perm_string access."; break; case 'expired': - $subject = "Your access to \"$widget_name\" has automatically expired."; + $subject = "Your access to \"$widget_name\" has automatically expired."; break; case 'deleted': - $subject = "$user_link deleted $widget_type widget \"$widget_name\"."; + $subject = "$user_link deleted $widget_type widget \"$widget_name\"."; break; case 'access_request': - $subject = "$user_link is requesting access to your widget \"$widget_name\".
The widget is currently being used within a course in your LMS."; + $subject = "$user_link is requesting access to your widget \"$widget_name\".
The widget is currently being used within a course in your LMS."; $action = 'access_request'; break; @@ -134,7 +134,8 @@ public static function send_item_notification(int $from_user_id, int $to_user_id 'is_read' => '0', 'subject' => $subject, 'avatar' => \Materia\Utils::get_avatar(50), - 'action' => $action + 'action' => $action, + 'created_at' => time() ]); $notification->save(); diff --git a/fuel/app/classes/model/user.php b/fuel/app/classes/model/user.php index 50cf0cc4a..69d12fe28 100644 --- a/fuel/app/classes/model/user.php +++ b/fuel/app/classes/model/user.php @@ -94,6 +94,7 @@ static public function find_by_name_search($name) $user_table = \Model_User::table(); $matches = \DB::select() ->from($user_table) + // Do not return super users or the current user // << why? ->where($user_table.'.id', 'NOT', \DB::expr('IN('.\DB::select($user_table.'.id') ->from($user_table) ->join('perm_role_to_user', 'LEFT') @@ -158,6 +159,8 @@ public function to_array($custom = false, $recurse = false, $eav = false) $array = parent::to_array($custom, $recurse, $eav); $array['avatar'] = $avatar; $array['is_student'] = \Materia\Perm_Manager::is_student($this->id); + $array['is_support_user'] = \Materia\Perm_Manager::does_user_have_role([\Materia\Perm_Role::SUPPORT], $this->id); + if (\Materia\Perm_Manager::does_user_have_role([\Materia\Perm_Role::SU], $this->id)) $array['is_super_user'] = true; return $array; } diff --git a/fuel/app/classes/service/user.php b/fuel/app/classes/service/user.php index 49a2f0826..9feb169c2 100644 --- a/fuel/app/classes/service/user.php +++ b/fuel/app/classes/service/user.php @@ -109,7 +109,9 @@ public static function get_played_inst_info($user_id) 'p.created_at', 'p.elapsed', 'p.is_complete', - 'p.percent' + 'p.percent', + 'p.auth', + 'p.context_id' ) ->from(['log_play', 'p']) ->join(['widget_instance', 'i']) diff --git a/fuel/app/classes/trait/commoncontrollertemplate.php b/fuel/app/classes/trait/commoncontrollertemplate.php index c32f090a6..0b02488b0 100644 --- a/fuel/app/classes/trait/commoncontrollertemplate.php +++ b/fuel/app/classes/trait/commoncontrollertemplate.php @@ -8,13 +8,11 @@ trait Trait_CommonControllerTemplate { use Trait_Analytics; - protected $_header = 'partials/header'; protected $_disable_browser_cache = false; public function before() { $this->theme = Theme::instance(); - $this->theme->set_template('layouts/main'); } public function after($response) @@ -22,13 +20,7 @@ public function after($response) // If no response object was returned by the action, if (empty($response) or ! $response instanceof Response) { - // render the defined template - $me = Model_User::find_current(); - - $this->theme->set_partial('header', $this->_header)->set('me', $me); - $this->insert_analytics(); - $response = Response::forge(Theme::instance()->render()); } @@ -39,7 +31,6 @@ public function after($response) } $this->inject_common_js_constants(); - Css::push_group('core'); return parent::after($response); } diff --git a/fuel/app/classes/trait/supportinfo.php b/fuel/app/classes/trait/supportinfo.php new file mode 100644 index 000000000..84395d602 --- /dev/null +++ b/fuel/app/classes/trait/supportinfo.php @@ -0,0 +1,20 @@ + $_ENV['MEMCACHED_PORT'] ?? 11211, 'weight' => 100], ], - ], - -); + ] +]; diff --git a/fuel/app/config/config.php b/fuel/app/config/config.php index 2650e05f5..dcc197469 100644 --- a/fuel/app/config/config.php +++ b/fuel/app/config/config.php @@ -321,6 +321,7 @@ */ 'language' => array( 'login', + 'support' ), ), diff --git a/fuel/app/config/css.php b/fuel/app/config/css.php index cc8af545f..60a1c9000 100644 --- a/fuel/app/config/css.php +++ b/fuel/app/config/css.php @@ -1,7 +1,7 @@ [ - 'admin' => [ - $static_css.'admin.css' - ], - 'widget_play' => [ - $static_css.'widget-play.css', - $static_css.'ng-modal.css' - ], + 'homepage' => [$webpack.'css/homepage.css'], + 'user-admin' => [$webpack.'css/user-admin.css'], + 'support' => [$webpack.'css/support.css'], + 'catalog' => [$webpack.'css/catalog.css'], + 'detail' => [$webpack.'css/detail.css'], + 'playpage' => [$webpack.'css/player-page.css'], 'lti' => [ - $static_css.'util-lti-picker.css', - ], - 'my_widgets' => [ - $static_css.'my-widgets.css', - $cdnjs.'jqPlot/1.0.9/jquery.jqplot.min.css', - $static_css.'ui-lightness/jquery-ui-1.8.21.custom.css', - $static_css.'jquery.dataTables.css', - $static_css.'ng-modal.css' - ], - 'widget_create' => [ - $static_css.'widget-create.css', - $static_css.'ng-modal.css' - ], - 'widget_detail' => [ - $static_css.'widget-detail.css', - $static_css.'ng-modal.css' - ], - 'widget_catalog' => [ - $static_css.'widget-catalog.css', - ], - 'profile' => [ - $static_css.'profile.css', - ], - 'login' => [ - $static_css.'login.css', - ], - 'scores' => [ - $cdnjs.'jqPlot/1.0.9/jquery.jqplot.min.css', - $static_css.'scores.css', - ], - 'pre_embed_placeholder' => [ - $static_css.'widget-embed-placeholder.css' - ], - 'embed_scores' => [ - $static_css.'scores.css', - ], + $webpack.'css/lti.css', + $webpack.'css/lti-select-item.css', + $webpack.'css/lti-error.css', + ], + 'my_widgets' => [$webpack.'css/my-widgets.css'], + 'widget_create' => [$webpack.'css/creator-page.css'], + 'profile' => [$webpack.'css/profile.css'], + 'login' => [$webpack.'css/login.css'], + 'scores' => [$webpack.'css/scores.css'], + 'pre_embed_placeholder' => [$webpack.'css/pre-embed-common-styles.css'], + 'embed_scores' => [$webpack.'css/scores.css'], 'question_import' => [ - $static_css.'jquery.dataTables.css', - $static_css.'util-question-import.css', - ], - 'qset_history' => [ - $static_css.'util-qset-history.css', - ], - 'rollback_dialog' => [ - $static_css.'util-rollback-confirm.css' - ], - 'media_import' => [ - $static_css.'util-media-import.css' - ], - 'help' => [ - $static_css.'help.css', - ], - 'errors' => [ - $static_css.'errors.css', - ], - 'core' => [ - $static_css.'core.css', - ], + $vendor.'jquery.dataTables.min.css', + $webpack.'css/util-question-import.css', + $webpack.'css/question-importer.css', + ], + 'questionimport' => [$webpack.'css/question-importer.css'], + 'qset_history' => [$webpack.'css/qset-history.css'], + 'rollback_dialog' => [$webpack.'css/util-rollback-confirm.css'], + 'media_import' => [$webpack.'css/media.css'], + 'help' => [$webpack.'css/help.css'], 'fonts' => [ - $g_fonts.'css?family=Kameron:700&text=0123456789%25', - $g_fonts.'css?family=Lato:300,400,700,700italic,900&v2', - ], - 'guide' => [ - $static_css.'widget-guide.css', - ], + $g_fonts.'css2?family=Kameron:wght@700&display=block', + $g_fonts.'css2?family=Lato:ital,wght@0,300;0,400;0,700;0,900;1,700&display=block', + ], + 'guide' => [$webpack.'css/guides.css'], + 'draft-not-playable' => [$webpack.'css/draft-not-playable.css'], + '404' => [$webpack.'css/404.css'], + '500' => [$webpack.'css/500.css'], + 'no_permission' => [$webpack.'css/no-permission.css'] ], -]; +]; \ No newline at end of file diff --git a/fuel/app/config/development/materia.php b/fuel/app/config/development/materia.php new file mode 100644 index 000000000..cec4350ad --- /dev/null +++ b/fuel/app/config/development/materia.php @@ -0,0 +1,34 @@ + false, // disable email in dev + + 'urls' => [ + // append port 8008 for dev + // simulates loading from a pass-through cdn + // No port is specified so 8080 is picked by default + 'static' => $simulated_cdn_url, + 'engines' => $simulated_cdn_url.'widget/', + 'js_css' => $assets_exist ? $simulated_cdn_url : '//127.0.0.1:8080/', + ], + + /** + * Allow browser based widget uploads by administrators + */ + 'enable_admin_uploader' => true, + + // Storage driver can be overridden from env here + // s3 uses fakes3 on dev + 'asset_storage_driver' => 'file', + + 'asset_storage' => [ + 's3' => [ + 'endpoint' => 'http://fakes3:10001', + 'bucket' => 'fake_bucket', // bucket to store original user uploads + ], + ] +]; \ No newline at end of file diff --git a/fuel/app/config/development/routes.php b/fuel/app/config/development/routes.php index daecffa8e..91595862d 100644 --- a/fuel/app/config/development/routes.php +++ b/fuel/app/config/development/routes.php @@ -1,5 +1,10 @@ 'site/404', + '500' => 'site/500', + // Route for testing what Materia looks like using the embed code 'test/external/(:alnum)(/.*)?' => 'widgets/test/external/$1', diff --git a/fuel/app/config/file.php b/fuel/app/config/file.php index 831d47e2c..5f98baa65 100644 --- a/fuel/app/config/file.php +++ b/fuel/app/config/file.php @@ -12,13 +12,11 @@ 'basedir' => APPPATH, 'areas' => [ - - 'media' => [ + 'media' => [ 'basedir' => realpath(APPPATH.'media').DS, - 'extensions' => ['jpg', 'jpeg', 'png', 'gif', 'wav', 'mp3', 'obj'], + 'extensions' => ['jpg', 'jpeg', 'png', 'gif', 'wav', 'mp3', 'obj', 'm4a'], 'url' => DOCROOT . 'media', ] ], ); - diff --git a/fuel/app/config/js.php b/fuel/app/config/js.php index db1eb85d7..582187e15 100644 --- a/fuel/app/config/js.php +++ b/fuel/app/config/js.php @@ -1,38 +1,46 @@ 'asset_hash.js.json', - 'remove_group_duplicates' => true, - 'groups' => [ - 'materia' => [$static.'materia.js'], - 'angular' => [$cdnjs.'angular.js/1.8.0/angular.min.js'], - 'ng-animate' => [$cdnjs.'angular-animate/1.8.0/angular-animate.min.js'], - 'jquery' => [$cdnjs.'jquery/3.5.1/jquery.min.js'], - 'admin' => [$static.'admin.js'], - 'author' => [$static.'author.js'], - 'student' => [$static.'student.js'], - 'dataTables' => [$static.'vendor/datatables/jquery.dataTables.min.js'], - 'jquery_ui' => [$cdnjs.'jqueryui/1.12.1/jquery-ui.min.js'], - 'labjs' => [$static.'vendor/labjs/LAB.min.js'], - 'spinner' => [$static.'vendor/spin.min.js', $static.'spin.jquery.js'], - 'hammerjs' => [$static.'vendor/hammer.min.js'], - 'swfobject' => [$static.'vendor/swfobject/swfobject.js'], - - 'jqplot' => [ - $cdnjs.'jqPlot/1.0.9/jquery.jqplot.min.js', - $cdnjs.'jqPlot/1.0.9/plugins/jqplot.barRenderer.min.js', - $cdnjs.'jqPlot/1.0.9/plugins/jqplot.canvasTextRenderer.min.js', - $cdnjs.'jqPlot/1.0.9/plugins/jqplot.canvasAxisTickRenderer.min.js', - $cdnjs.'jqPlot/1.0.9/plugins/jqplot.categoryAxisRenderer.min.js', - $cdnjs.'jqPlot/1.0.9/plugins/jqplot.cursor.min.js', - $cdnjs.'jqPlot/1.0.9/plugins/jqplot.highlighter.min.js', + 'login' => [$webpack.'js/login.js'], + 'profile' => [$webpack.'js/profile.js'], + 'settings' => [$webpack.'js/settings.js'], + 'support' => [$webpack.'js/support.js'], + 'user_admin' => [$webpack.'js/user-admin.js'], + 'widget_admin' => [$webpack.'js/widget-admin.js'], + 'materia' => [$webpack.'js/materia.js'], + 'homepage' => [$webpack.'js/homepage.js'], + 'catalog' => [$webpack.'js/catalog.js'], + 'my_widgets' => [$webpack.'js/my-widgets.js'], + 'detail' => [$webpack.'js/detail.js'], + 'playpage' => [$webpack.'js/player-page.js'], + 'createpage' => [$webpack.'js/creator-page.js'], + 'scores' => [$webpack.'js/scores.js'], + 'guides' => [$webpack.'js/guides.js'], + 'retired' => [$webpack.'js/retired.js'], + 'no_attempts'=> [$webpack.'js/no-attempts.js'], + 'draft_not_playable' => [$webpack.'js/draft-not-playable.js'], + 'no_permission' => [$webpack.'js/no-permission.js'], + 'closed' => [$webpack.'js/closed.js'], + 'embedded_only' => [$webpack.'js/embedded-only.js'], + 'pre_embed' => [$webpack.'js/pre-embed-placeholder.js'], + 'help' => [$webpack.'js/help.js'], + '404' => [$webpack.'js/404.js'], + '500' => [$webpack.'js/500.js'], + 'media' => [$webpack.'js/media.js'], + 'qset_history' => [$webpack.'js/qset-history.js'], + 'post_login' => [$webpack.'js/lti-post-login.js'], + 'select_item' => [$webpack.'js/lti-select-item.js'], + 'open_preview' => [$webpack.'js/lti-open-preview.js'], + 'error_general' => [$webpack.'js/lti-error.js'], + 'react' => [ + '//unpkg.com/react@16.13.1/umd/react.development.js', + '//unpkg.com/react-dom@16.13.1/umd/react-dom.development.js', + $webpack.'js/include.js' ], - - 'my_widgets' => [ - $cdnjs.'jqueryui/1.12.1/jquery-ui.min.js' - ] - ], + 'question-importer' => [$webpack.'js/question-importer.js'] + ] ]; diff --git a/fuel/app/config/materia.php b/fuel/app/config/materia.php index ed7fd531c..0d8204309 100644 --- a/fuel/app/config/materia.php +++ b/fuel/app/config/materia.php @@ -16,7 +16,9 @@ /* * URLS throughout the system - * + * \Uri::create('') will create full urls + * If you're having issues with urls not being correct + * You may wish to simply hard code these values */ 'urls' => [ 'root' => \Uri::create(''), // root directory http:://siteurl.com/ @@ -27,6 +29,13 @@ 'preview' => \Uri::create('preview/'), // game preview urls http://siteurl.com/preview/3443 'static' => $_ENV['URLS_STATIC'] ?? \Uri::create(), // allows you to host another domain for static assets http://static.siteurl.com/ 'engines' => $_ENV['URLS_ENGINES'] ?? \Uri::create('widget/'), // widget file locations + // where are js and css assets hosted? + // DEFAULT: public/dist (hosted as as https://site.com/) + 'js_css' => \Uri::create('/'), + // CDN PASS-THROUGH: set up aws cloudfront cdn have it load data from the default url + //'js_css' => '//xxxxxxxx.cloudfront.net/dist/', + // CDN UNPKG.COM: load assets from npm module with the same release (version must match your version of materia) + // 'js_css' => '//unpkg.com/materia-server-client-assets@2.2.0/', ], @@ -48,7 +57,8 @@ // location of the lang files 'lang_path' => [ - 'login' => APPPATH.DS + 'login' => APPPATH.DS, + 'support' => APPPATH.DS ], 'default_users' => [ diff --git a/fuel/app/config/routes.php b/fuel/app/config/routes.php index 0c66e8a3b..35e9f41ba 100644 --- a/fuel/app/config/routes.php +++ b/fuel/app/config/routes.php @@ -11,7 +11,7 @@ '_404_' => 'site/404', '_500_' => 'site/500', '_root_' => 'site/index', // Homepage - 'permission-denied' => ['site/permission_denied', 'name' => 'nopermission'], + 'permission-denied' => ['site/permission_denied', 'name' => 'no_permission'], 'crossdomain' => 'site/crossdomain', 'help' => ['site/help', 'name' => 'help'], // The main docs page @@ -28,8 +28,6 @@ 'widgets/all' => 'widgets/all', // catalog page, with optional display option(s) 'widgets' => ['widgets/index', 'name' => 'catalog'], // catalog of all the widget engines 'my-widgets' => 'widgets/mywidgets/', - - 'edit/(:alnum)(/.*)?' => 'widgets/edit/$1', 'play/(:alnum)(/.*)?' => 'widgets/play_widget/$1', 'preview/(:alnum)(/.*)?' => 'widgets/preview_widget/$1', 'preview-embed/(:alnum)(/.*)?' => 'widgets/play_embedded_preview/$1', diff --git a/fuel/app/lang/en/support.php b/fuel/app/lang/en/support.php new file mode 100644 index 000000000..beeeec942 --- /dev/null +++ b/fuel/app/lang/en/support.php @@ -0,0 +1,11 @@ + [ + // 'helpdesk' => [ + // 'title' => 'Trouble Logging In?', + // 'subtitle' => 'Contact the Service Desk.', + // 'website' => 'service desk support website dot edu', + // ] + // ] +]; \ No newline at end of file diff --git a/fuel/app/migrations/052_add_support_user_role.php b/fuel/app/migrations/052_add_support_user_role.php new file mode 100644 index 000000000..4bf353373 --- /dev/null +++ b/fuel/app/migrations/052_add_support_user_role.php @@ -0,0 +1,21 @@ +where('name', 'support_user') + ->execute(); + } +} diff --git a/fuel/app/migrations/053_hide_asset_option.php b/fuel/app/migrations/053_hide_asset_option.php new file mode 100644 index 000000000..ce51c68e8 --- /dev/null +++ b/fuel/app/migrations/053_hide_asset_option.php @@ -0,0 +1,25 @@ + ['constraint' => 11, 'type' => 'int', 'default' => -1], + 'is_deleted' => ['type' => 'enum', 'constraint' => "'0','1'", 'default' => '0'], + ] + ); + } + + public function down() + { + \DBUtil::drop_fields( + 'asset', + ['deleted_at', 'is_deleted'] + ); + } +} \ No newline at end of file diff --git a/fuel/app/migrations/054_add_support_user_role_perms.php b/fuel/app/migrations/054_add_support_user_role_perms.php new file mode 100644 index 000000000..00c53f42b --- /dev/null +++ b/fuel/app/migrations/054_add_support_user_role_perms.php @@ -0,0 +1,50 @@ +from('perm_role_to_perm') + ->where('role_id', $support_role_id) + ->where('perm', \Materia\Perm::FULL) + ->execute(); + + // if not, grant support_user Perm::FULL + if ($pre_q->count() == 0) { + $q = \DB::query('INSERT INTO `perm_role_to_perm` (`role_id`, `perm`) values (:role_id, :perm) ON DUPLICATE KEY UPDATE `role_id` = :role_id, `perm` = :perm'); + $q->param('role_id', $support_role_id); + $q->param('perm', \Materia\Perm::FULL); + $q->execute(); + } + + // now check to see if support_user already has Perm::SUPPORTUSER + $pre_q = \DB::select('role_id','perm') + ->from('perm_role_to_perm') + ->where('role_id', $support_role_id) + ->where('perm', \Materia\Perm::SUPPORTUSER) + ->execute(); + + // if not, grant support_user Perm::SUPPORTUSER + if ($pre_q->count() == 0) { + $q = \DB::query('INSERT INTO `perm_role_to_perm` (`role_id`, `perm`) values (:role_id, :perm) ON DUPLICATE KEY UPDATE `role_id` = :role_id, `perm` = :perm'); + $q->param('role_id', $support_role_id); + $q->param('perm', \Materia\Perm::SUPPORTUSER); + $q->execute(); + } + } + + public function down() + { + $support_role_id = \Materia\Perm_Manager::get_role_id('support_user'); + + \DB::delete('perm_role_to_perm') + ->where('role_id', 'like', $support_role_id) + ->execute(); + } +} diff --git a/fuel/app/migrations/055_update_super_user_role_permissions.php b/fuel/app/migrations/055_update_super_user_role_permissions.php new file mode 100644 index 000000000..a9b95e0f9 --- /dev/null +++ b/fuel/app/migrations/055_update_super_user_role_permissions.php @@ -0,0 +1,28 @@ +param('role_id', $super_user_role_id) + ->param('old_perm', \Materia\Perm::BASICAUTHOR) // the OLD permission level (80) + ->param('new_perm', \Materia\Perm::SUPERUSER) // the NEW permission level (90) + ->execute(); + } + + public function down() + { + $super_user_role_id = \Materia\Perm_Manager::get_role_id('super_user'); + + \DB::QUERY('UPDATE `perm_role_to_perm` SET `perm` = :old_perm WHERE `role_id` = :role_id AND `perm` = :new_perm') + ->param('role_id', $super_user_role_id) + ->param('old_perm', \Materia\Perm::BASICAUTHOR) + ->param('new_perm', \Materia\Perm::SUPERUSER) + ->execute(); + } +} diff --git a/fuel/app/modules/lti/classes/controller/error.php b/fuel/app/modules/lti/classes/controller/error.php index 5511dc85e..b86f93acb 100644 --- a/fuel/app/modules/lti/classes/controller/error.php +++ b/fuel/app/modules/lti/classes/controller/error.php @@ -8,47 +8,55 @@ class Controller_Error extends \Controller { - use \Trait_Analytics; - protected $_content_partial = 'partials/error_general'; - protected $_message = 'There was a problem'; + use \Trait_CommonControllerTemplate; + use \Trait_Supportinfo; - public function after($response) + // overrides Trait_CommonControllerTemplate->before() + public function before() { - $msg = str_replace('_', ' ', \Input::param('message', $this->_message)); - $system = str_replace('_', ' ', \Input::param('system', 'the system')); - $this->theme = \Theme::instance(); - $this->theme->set_template('layouts/main'); - $this->theme->set_partial('header', 'partials/header_empty'); - $this->theme->get_template() - ->set('title', 'Error - '.$msg) - ->set('page_type', 'lti-error'); - - $this->theme->set_partial('content', $this->_content_partial ) - ->set('title', "Error - {$msg}") - ->set('system', $system); + } - $this->insert_analytics(); + protected $_message = 'There was a problem'; + protected $_type = 'error_general'; - \Js::push_group(['angular', 'materia']); + public function after($response) + { \Js::push_inline('var BASE_URL = "'.\Uri::base().'";'); + \Js::push_inline('var TITLE = "'.'Error - '.$this->_message.'";'); + \Js::push_inline('var ERROR_TYPE = "'.$this->_type.'";'); \Js::push_inline('var STATIC_CROSSDOMAIN = "'.\Config::get('materia.urls.static').'";'); + $this->add_inline_info(); + \Css::push_group('lti'); + $this->theme->set_template('layouts/react'); + $this->theme->get_template() + ->set('title', 'Error - '.$this->_message) + ->set('page_type', 'lti-error'); + + \Js::push_group(['react', 'error_general']); + return \Response::forge(\Theme::instance()->render()); } public function action_unknown_user() { - $this->_content_partial = 'partials/error_unknown_user'; + $this->_type = 'error_unknown_user'; $this->_message = 'Unknown User'; } public function action_unknown_assignment() { - $this->_content_partial = 'partials/error_unknown_assignment'; $this->_message = 'Unknown Assignment'; + $this->_type = 'error_unknown_assignment'; + } + + public function action_invalid_oauth_request() + { + $this->_message = 'Invalid OAuth Request'; + $this->_type = 'error_invalid_oauth_request'; } /** @@ -58,14 +66,14 @@ public function action_unknown_assignment() */ public function action_autoplay_misconfigured() { - $this->_content_partial = 'partials/error_autoplay_misconfigured'; $this->_message = 'Widget Misconfigured - Autoplay cannot be set to false for LTI assignment widgets'; + $this->_type = 'error_autoplay_misconfigured'; } public function action_guest_mode() { - $this->_content_partial = 'partials/error_lti_guest_mode'; $this->_message = 'Assignment has guest mode enabled'; + $this->_type = 'error_lti_guest_mode'; } public function action_index() diff --git a/fuel/app/modules/lti/classes/controller/lti.php b/fuel/app/modules/lti/classes/controller/lti.php index 177884c83..f14ac9f6d 100644 --- a/fuel/app/modules/lti/classes/controller/lti.php +++ b/fuel/app/modules/lti/classes/controller/lti.php @@ -8,8 +8,9 @@ class Controller_Lti extends \Controller { - use \Trait_Analytics; + use \Trait_CommonControllerTemplate; + // overrides Trait_CommonControllerTemplate->before() public function before() { $this->theme = \Theme::instance(); @@ -46,22 +47,18 @@ public function action_index() */ public function action_login() { - if ( ! Oauth::validate_post()) \Response::redirect('/lti/error?message=invalid_oauth_request'); + if ( ! Oauth::validate_post()) \Response::redirect('/lti/error/invalid_oauth_request'); $launch = LtiLaunch::from_request(); - if ( ! LtiUserManager::authenticate($launch)) \Response::redirect('/lti/error?message=invalid_oauth_request'); + if ( ! LtiUserManager::authenticate($launch)) \Response::redirect('/lti/error/invalid_oauth_request'); - $this->theme->set_template('layouts/main') + $this->theme->set_template('layouts/react'); + $this->theme->get_template() ->set('title', 'Materia') ->set('page_type', 'lti-login'); - $this->theme->set_partial('content', 'partials/post_login'); - $this->insert_analytics(); - - \Js::push_inline('var BASE_URL = "'.\Uri::base().'";'); - \Js::push_inline('var STATIC_CROSSDOMAIN = "'.\Config::get('materia.urls.static').'";'); - - \Css::push_group('core'); + \Js::push_group(['react', 'post_login']); + \Css::push_group(['lti']); return \Response::forge($this->theme->render()); } @@ -72,7 +69,7 @@ public function action_login() */ public function action_picker(bool $authenticate = true) { - if ( ! Oauth::validate_post()) \Response::redirect('/lti/error?message=invalid_oauth_request'); + if ( ! Oauth::validate_post()) \Response::redirect('/lti/error/invalid_oauth_request'); $launch = LtiLaunch::from_request(); if ($authenticate && ! LtiUserManager::authenticate($launch)) return \Response::redirect('/lti/error/unknown_user'); @@ -83,28 +80,23 @@ public function action_picker(bool $authenticate = true) \Materia\Log::profile(['action_picker', \Input::post('selection_directive'), $system, $is_selector_mode ? 'yes' : 'no', $return_url], 'lti'); - $this->theme->set_template('layouts/main'); - - \Js::push_group(['angular', 'materia', 'author']); \Js::push_inline('var BASE_URL = "'.\Uri::base().'";'); \Js::push_inline('var WIDGET_URL = "'.\Config::get('materia.urls.engines').'";'); \Js::push_inline('var STATIC_CROSSDOMAIN = "'.\Config::get('materia.urls.static').'";'); - \Js::push_inline($this->theme->view('partials/select_item_js') - ->set('system', $system)); - \Css::push_group(['core', 'lti']); + \Js::push_inline('var SYSTEM = "'.$system.'";'); + \Css::push_group(['lti']); if ($is_selector_mode && ! empty($return_url)) { \Js::push_inline('var RETURN_URL = "'.$return_url.'"'); } + $this->theme->set_template('layouts/react'); $this->theme->get_template() ->set('title', 'Select a Widget for Use in '.$system) ->set('page_type', 'lti-select'); - $this->theme->set_partial('content', 'partials/select_item'); - $this->theme->set_partial('header', 'partials/header_empty'); - $this->insert_analytics(); + \Js::push_group(['react', 'select_item']); return \Response::forge($this->theme->render()); } @@ -116,28 +108,33 @@ public function action_success(string $inst_id) // If the current user does not have ownership over the embedded widget, find all of the users who do $current_user_owns = \Materia\Perm_Manager::user_has_any_perm_to(\Model_User::find_current_id(), $inst_id, \Materia\Perm::INSTANCE, [\Materia\Perm::VISIBLE, \Materia\Perm::FULL]); - $instance_owner_list = $current_user_owns ? [] : $inst->get_owners(); - - $this->theme->set_template('layouts/main') - ->set('title', 'Widget Connected Successfully') - ->set('page_type', 'preview'); - - $this->theme->set_partial('content', 'partials/open_preview') - ->set('inst_name', $inst->name) - ->set('widget_name', $inst->widget->name) - ->set('preview_url', \Uri::create('/preview/'.$inst_id)) - ->set('icon', \Config::get('materia.urls.engines')."{$inst->widget->dir}img/icon-92.png") - ->set('preview_embed_url', \Uri::create('/preview-embed/'.$inst_id)) - ->set('current_user_owns', $current_user_owns) - ->set('instance_owner_list', $instance_owner_list); - $this->insert_analytics(); + $instance_owner_list = $current_user_owns ? [] : (array_map(function ($object) + { + return (object) [ + 'first' => $object->first, + 'last' => $object->last, + 'id' => $object->id + ]; + }, $inst->get_owners())); \Js::push_inline('var BASE_URL = "'.\Uri::base().'";'); - \Js::push_inline('var inst_id = "'.$inst_id.'";'); + \Js::push_inline('var PREVIEW_URL = "'.\Uri::create('/preview/'.$inst_id).'";'); + \Js::push_inline('var ICON_URL = "'.\Config::get('materia.urls.engines')."{$inst->widget->dir}img/icon-92.png".'";'); + \Js::push_inline('var PREVIEW_EMBED_URL = "'.\Uri::create('/preview-embed/'.$inst_id).'";'); + \Js::push_inline('var CURRENT_USER_OWNS = "'.$current_user_owns.'";'); \Js::push_inline('var STATIC_CROSSDOMAIN = "'.\Config::get('materia.urls.static').'";'); + \Js::push_inline('var OWNER_LIST = '.json_encode($instance_owner_list).';'); + \Js::push_inline('var USER_ID = "'.\Model_User::find_current_id().'";'); + + \Css::push_group(['lti']); + + $this->theme->set_template('layouts/react'); + $this->theme->get_template() + ->set('title', 'Widget Connected Successfully') + ->set('page_type', 'preview'); - \Css::push_group(['core', 'lti']); + \Js::push_group(['react', 'open_preview']); return \Response::forge($this->theme->render()); } diff --git a/fuel/app/modules/lti/classes/controller/test.php b/fuel/app/modules/lti/classes/controller/test.php index 24e977e30..5e9a58e42 100644 --- a/fuel/app/modules/lti/classes/controller/test.php +++ b/fuel/app/modules/lti/classes/controller/test.php @@ -16,7 +16,6 @@ public function before() trace('these tests are not availible in production mode'); throw new \HttpNotFoundException; } - \Js::push_group('jquery'); parent::before(); } diff --git a/fuel/app/modules/lti/classes/ltievents.php b/fuel/app/modules/lti/classes/ltievents.php index 7c9ca6ff0..217c2c63a 100644 --- a/fuel/app/modules/lti/classes/ltievents.php +++ b/fuel/app/modules/lti/classes/ltievents.php @@ -23,7 +23,7 @@ public static function on_before_single_score_review() $launch = LtiLaunch::from_request(); if ($launch) { - if ( ! Oauth::validate_post()) $result['redirect'] = '/lti/error?message=invalid_oauth_request'; + if ( ! Oauth::validate_post()) $result['redirect'] = '/lti/error/invalid_oauth_request'; elseif ( ! LtiUserManager::authenticate($launch)) $result['redirect'] = '/lti/error/unknown_user'; $result['is_embedded'] = true; } @@ -61,7 +61,7 @@ public static function on_before_play_start_event($payload) } } - if ( ! Oauth::validate_post()) $redirect = '/lti/error?message=invalid_oauth_request'; + if ( ! Oauth::validate_post()) $redirect = '/lti/error/invalid_oauth_request'; elseif ( ! LtiUserManager::authenticate($launch)) $redirect = '/lti/error/unknown_user'; elseif ( ! $inst_id || ! $inst) $redirect = '/lti/error/unknown_assignment'; elseif ($inst->guest_access) $redirect = '/lti/error/guest_mode'; diff --git a/fuel/app/modules/lti/classes/ltilaunch.php b/fuel/app/modules/lti/classes/ltilaunch.php index 6a11d9484..e963710fa 100644 --- a/fuel/app/modules/lti/classes/ltilaunch.php +++ b/fuel/app/modules/lti/classes/ltilaunch.php @@ -36,7 +36,7 @@ public static function from_request() 'last' => Utils::safeTrim(\Input::param('lis_person_name_family', '')), 'first' => Utils::safeTrim(\Input::param('lis_person_name_given', '')), 'fullname' => Utils::safeTrim(\Input::param('lis_person_name_full', '')), - 'outcome_ext' => Utils::safeTrim(\Input::param('ext_outcome_data_values_accepted'), ''), + 'outcome_ext' => Utils::safeTrim(\Input::param('ext_outcome_data_values_accepted', '')), 'roles' => $roles, 'remote_id' => Utils::safeTrim(\Input::param($remote_id_field)), 'username' => Utils::safeTrim(\Input::param($remote_user_field)), diff --git a/fuel/app/modules/lti/tests/ltievents.php b/fuel/app/modules/lti/tests/ltievents.php index d7fe8218d..2de8d8dfb 100644 --- a/fuel/app/modules/lti/tests/ltievents.php +++ b/fuel/app/modules/lti/tests/ltievents.php @@ -29,7 +29,7 @@ public function test_on_before_play_start_event_shows_error_for_bad_oauth_reques $this->assertArrayHasKey('redirect', $result); $this->assertCount(1, $result); - $this->assertEquals('/lti/error?message=invalid_oauth_request', $result['redirect']); + $this->assertEquals('/lti/error/invalid_oauth_request', $result['redirect']); } public function test_on_before_play_start_event_throws_unknown_user_exception_for_bad_user() diff --git a/fuel/app/tasks/admin.php b/fuel/app/tasks/admin.php index b3c8da572..628ba82ca 100644 --- a/fuel/app/tasks/admin.php +++ b/fuel/app/tasks/admin.php @@ -293,6 +293,7 @@ public static function populate_roles() if (\Materia\Perm_Manager::create_role('no_author')) $roles++; if (\Materia\Perm_Manager::create_role('basic_author')) $roles++; if (\Materia\Perm_Manager::create_role('super_user')) $roles++; + if (\Materia\Perm_Manager::create_role('support_user')) $roles++; if ($admin_role_id = \Materia\Perm_Manager::get_role_id('super_user')) { @@ -302,7 +303,20 @@ public static function populate_roles() $q->execute(); $q->param('role_id', $admin_role_id); - $q->param('perm', \Materia\Perm::AUTHORACCESS); + $q->param('perm', \Materia\Perm::SUPERUSER); + $q->execute(); + } + + if ($support_role_id = \Materia\Perm_Manager::get_role_id('support_user')) + { + $q = \DB::query('INSERT INTO `perm_role_to_perm` SET `role_id` = :role_id, `perm` = :perm ON DUPLICATE KEY UPDATE `perm` = :perm'); + + $q->param('role_id', $support_role_id); + $q->param('perm', \Materia\Perm::FULL); + $q->execute(); + + $q->param('role_id', $support_role_id); + $q->param('perm', \Materia\Perm::SUPPORTUSER); $q->execute(); } diff --git a/fuel/app/tests/api/v1.php b/fuel/app/tests/api/v1.php index 577b97529..42b524c6d 100644 --- a/fuel/app/tests/api/v1.php +++ b/fuel/app/tests/api/v1.php @@ -15,7 +15,7 @@ public function test_allPublicAPIMethodsHaveTests() $testMethods = get_class_methods($this); foreach ($apiMethods as $value) { - $this->assertContains('test_'.$value, $testMethods); + $this->assertContainsEquals('test_'.$value, $testMethods); } } @@ -105,11 +105,47 @@ public function test_widget_instance_access_perms_verify() public function test_widget_instances_get() { + // Create widget instance + $this->_as_author(); + $title = "My Test Widget"; + $question = 'What rhymes with harvest fests but are half as exciting (or tasty)'; + $answer = 'Tests'; + $qset = $this->create_new_qset($question, $answer); + $widget = $this->make_disposable_widget(); + + $instance = Api_V1::widget_instance_new($widget->id, $title, $qset, true); + // ======= AS NO ONE ======== + $this->_as_noauth(); + + // ----- returns empty array if not requesting a specific instance -------- $output = Api_V1::widget_instances_get(); $this->assertIsArray($output); $this->assertCount(0, $output); + // ----- loads specific instance without qset -------- + $output = Api_V1::widget_instances_get($instance->id); + $this->assertIsArray($output); + $this->assertCount(1, $output); + foreach ($output as $key => $value) + { + $this->assert_is_widget_instance($value, true); + $this->assertObjectHasAttribute('qset', $value); + $this->assertNull($value->qset->data); + $this->assertNull($value->qset->version); + } + + // ----- loads specific instance with qset -------- + $output = Api_V1::widget_instances_get($instance->id, false, true); + $this->assertIsArray($output); + $this->assertCount(1, $output); + foreach ($output as $key => $value) + { + $this->assert_is_widget_instance($value, true); + $this->assertObjectHasAttribute('qset', $value); + $this->assert_is_qset($value->qset); + } + // ======= STUDENT ======== $this->_as_student(); $output = Api_V1::widget_instances_get(); @@ -144,6 +180,11 @@ public function test_widget_instances_get() } + public function test_widget_paginate_instances_get() + { + + } + public function test_widget_instance_new() { $widget = $this->make_disposable_widget(); @@ -486,7 +527,7 @@ public function test_widget_instance_lock_for_another_user() // the lock is stored in a cache that expires // let's manually clear cache now, effectively removing the lock - \Cache::delete_all(''); + \Cache::delete('instance-lock.'.($inst->id)); $this->assertTrue(Api_V1::widget_instance_lock($inst->id)); // lock should be expired, i can edit it } @@ -516,9 +557,9 @@ public function test_widget_instance_copy() $output = Api_V1::widget_instance_copy($inst_id, 'Copied Widget'); - $this->assert_is_valid_id($output); + $this->assert_is_valid_id($output->id); - $insts = Api_V1::widget_instances_get($output); + $insts = Api_V1::widget_instances_get($output->id); $this->assert_is_widget_instance($insts[0], true); $this->assertEquals('Copied Widget', $insts[0]->name); $this->assertEquals(true, $insts[0]->is_draft); @@ -536,9 +577,9 @@ public function test_widget_instance_copy() $output = Api_V1::widget_instance_copy($inst_id, 'Copied Widget'); - $this->assert_is_valid_id($output); + $this->assert_is_valid_id($output->id); - $insts = Api_V1::widget_instances_get($output); + $insts = Api_V1::widget_instances_get($output->id); $this->assert_is_widget_instance($insts[0], true); $this->assertEquals('Copied Widget', $insts[0]->name); $this->assertEquals(true, $insts[0]->is_draft); @@ -983,6 +1024,10 @@ public function test_play_logs_get() } + public function test_paginated_play_logs_get() + { + } + public function test_score_summary_get() { // ======= AS NO ONE ======== @@ -1390,12 +1435,12 @@ public function test_notification_delete(){ $id = $widget->id; // ======= AS NO ONE ======== - $output = Api_V1::notification_delete(5); + $output = Api_V1::notification_delete(5, false); $this->assert_invalid_login_message($output); // ======= STUDENT ======== $this->_as_student(); - $output = Api_V1::notification_delete(5); + $output = Api_V1::notification_delete(5, false); $this->assertFalse($output); $author = $this->_as_author(); @@ -1422,17 +1467,24 @@ public function test_notification_delete(){ // try as someone author2 $this->_as_author_2(); - $output = Api_V1::notification_delete($notifications[0]['id']); + $output = Api_V1::notification_delete($notifications[0]['id'], false); $this->assertFalse($output); $this->_as_author(); - $output = Api_V1::notification_delete($notifications[0]['id']); + $output = Api_V1::notification_delete($notifications[0]['id'], false); $this->assertTrue($output); $this->_as_author(); $notifications = Api_V1::notifications_get(); $this->assertEquals($start_count, count($notifications)); + // try deleting all + $this->_as_author(); + $output = Api_V1::notification_delete(null, true); + $this->assertTrue($output); + + $notifications = Api_V1::notifications_get(); + $this->assertEquals(0, count($notifications)); } public function test_semester_get() @@ -1513,7 +1565,7 @@ protected function assert_is_semester_rage($semester) $this->assertArrayHasKey('year', $semester); $this->assertGreaterThan(0, $semester['year']); $this->assertArrayHasKey('semester', $semester); - $this->assertContains($semester['semester'], array('Spring', 'Summer', 'Fall') ); + $this->assertContainsEquals($semester['semester'], array('Spring', 'Summer', 'Fall') ); $this->assertArrayHasKey('start', $semester); $this->assertGreaterThan(0, $semester['start']); $this->assertArrayHasKey('end', $semester); diff --git a/fuel/app/tests/classes/materia/perm/manager.php b/fuel/app/tests/classes/materia/perm/manager.php index 9e00438c5..1049ada2b 100644 --- a/fuel/app/tests/classes/materia/perm/manager.php +++ b/fuel/app/tests/classes/materia/perm/manager.php @@ -66,14 +66,14 @@ public function test_get_user_ids_with_role() $newSuperUser = $this->make_random_super_user(); $superUserIds = Perm_Manager::get_user_ids_with_role('super_user'); - $this->assertContains((int)$newSuperUser->id, $superUserIds); + $this->assertContainsEquals($newSuperUser->id, $superUserIds); $newAuthorOne = $this->make_random_author(); $newAuthorTwo = $this->make_random_author(); $studentIds = Perm_Manager::get_user_ids_with_role('basic_author'); - $this->assertContains((int)$newAuthorOne->id, $studentIds); - $this->assertContains((int)$newAuthorTwo->id, $studentIds); + $this->assertContainsEquals($newAuthorOne->id, $studentIds); + $this->assertContainsEquals($newAuthorTwo->id, $studentIds); $this->assertCount(2, $studentIds); } diff --git a/fuel/app/tests/controller/api/instance.php b/fuel/app/tests/controller/api/instance.php index e01422a79..2bcf7e8b3 100644 --- a/fuel/app/tests/controller/api/instance.php +++ b/fuel/app/tests/controller/api/instance.php @@ -57,4 +57,89 @@ public function test_get_history() $this->assertTrue(is_array($output)); $this->assertCount(1, $output); } + + public function test_post_request_access() + { + $_SERVER['HTTP_ACCEPT'] = 'application/json'; + + // ======= NO INST ID PROVIDED ======== + $response = Request::forge('/api/instance/request_access') + ->set_method('POST') + ->execute() + ->response(); + + $this->assertEquals($response->status, 401); + $this->assertEquals($response->body, '"Requires an inst_id parameter"'); + + // ======= NO OWNER ID PROVIDED ======== + $response = Request::forge('/api/instance/request_access') + ->set_method('POST') + ->set_json('inst_id', 555) + ->execute() + ->response(); + + $this->assertEquals($response->status, 401); + $this->assertEquals($response->body, '"Requires an owner_id parameter"'); + + // == Now we're an author + $this->_as_author(); + + // == Make a widget instance + $widget = $this->make_disposable_widget(); + $title = "My Test Widget"; + $question = 'This is another word for test'; + $answer = 'Assert'; + $qset = $this->create_new_qset($question, $answer); + $instance = Api_V1::widget_instance_new($widget->id, $title, $qset, false); + $author_id = \Model_User::find_current_id(); + + // ======= NO INST ID FOUND ======== + $response = Request::forge('/api/instance/request_access') + ->set_method('POST') + ->set_json('inst_id', 555) + ->set_json('owner_id', $author_id) + ->execute() + ->response(); + + $this->assertEquals($response->body, '"Instance not found"'); + $this->assertEquals($response->status, 404); + + // ======= NO OWNER ID FOUND ======== + $response = Request::forge('/api/instance/request_access') + ->set_method('POST') + ->set_json('inst_id', $instance->id) + ->set_json('owner_id', 111) + ->execute() + ->response(); + + $this->assertEquals($response->status, 404); + $this->assertEquals($response->body, '"Owner not found"'); + + // ======= OWNER DOES NOT OWN INSTANCE ========= + // Switch users + $this->_as_student(); + + $response = Request::forge('/api/instance/request_access') + ->set_method('POST') + ->set_json('inst_id', $instance->id) + ->set_json('owner_id', \Model_User::find_current_id()) + ->execute() + ->response(); + + $this->assertEquals($response->status, 404); + $this->assertEquals($response->body, '"Owner does not own instance"'); + + // ======= SUCCESSFUL REQUEST ======== + $response = Request::forge('/api/instance/request_access') + ->set_method('POST') + ->set_json('inst_id', $instance->id) + ->set_json('owner_id', $author_id) + ->execute() + ->response(); + + // TODO: Test is_valid_hash + + $this->assertEquals($response->body, 'true'); + $this->assertEquals($response->status, 200); + } } \ No newline at end of file diff --git a/fuel/app/tests/model/user.php b/fuel/app/tests/model/user.php index 157fec2a3..4cd42d0c4 100644 --- a/fuel/app/tests/model/user.php +++ b/fuel/app/tests/model/user.php @@ -97,8 +97,8 @@ public function test_find_by_name_search_finds_multiple_matches() $ids = [$x[0]->id, $x[1]->id]; - self::assertContains((int)$user1->id, $ids); - self::assertContains((int)$user2->id, $ids); + self::assertContainsEquals($user1->id, $ids); + self::assertContainsEquals($user2->id, $ids); } } diff --git a/fuel/app/themes/default/layouts/main.php b/fuel/app/themes/default/layouts/main.php deleted file mode 100644 index cd97f08c3..000000000 --- a/fuel/app/themes/default/layouts/main.php +++ /dev/null @@ -1,16 +0,0 @@ - - - - - -<?= $title ?> | Materia - - - - - - - - - - diff --git a/fuel/app/themes/default/layouts/react.php b/fuel/app/themes/default/layouts/react.php new file mode 100644 index 000000000..6bd6b8093 --- /dev/null +++ b/fuel/app/themes/default/layouts/react.php @@ -0,0 +1,22 @@ + + + + + + + <?= $title ?? '' ?> | Materia + + + + + + + + + + +

+ + + + diff --git a/fuel/app/themes/default/lti/layouts/test_learner.php b/fuel/app/themes/default/lti/layouts/test_learner.php index c0b9012d6..6330f77a8 100644 --- a/fuel/app/themes/default/lti/layouts/test_learner.php +++ b/fuel/app/themes/default/lti/layouts/test_learner.php @@ -14,11 +14,15 @@ -
-
-
- <?= $widget_name ?> Type Widget Icon -
-
-
-

You don't own this widget!

-

You may contact one of the widget owners listed below to request access to this widget. Clicking the Request Access option will notify them and provide them the option to add you as a collaborator.

- -
- - -
-
-
- <?= $widget_name ?> Type Widget Icon -
-
-
-

Students will see the widget instead of this message. When supported, Materia will synchronize scores.

- Start Preview -
- -
-

Preview restricted by widget permissions in Materia.

-

To view the widget in Canvas as a student, view the assignment while in Student View.

-
- - - diff --git a/fuel/app/themes/default/lti/partials/post_login.php b/fuel/app/themes/default/lti/partials/post_login.php deleted file mode 100644 index b2dfbfba8..000000000 --- a/fuel/app/themes/default/lti/partials/post_login.php +++ /dev/null @@ -1,27 +0,0 @@ -
-
-

- Materia: Build, Create, & Share Your Widgets -

-
-
-

Make Your Own Widgets:

- -

- Materia features a growing library of customizable widgets. - Learn more about the available widgets and how to make your own - here. -

- Go to Materia -
- -
-

Embed Your Widgets:

- -

- Embedding the widgets you create into your Canvas courses as assignments - graded or not - is a quick and easy process. - Learn more about embedding your widgets. -

- Learn More -
-
diff --git a/fuel/app/themes/default/lti/partials/select_item.php b/fuel/app/themes/default/lti/partials/select_item.php deleted file mode 100644 index 11235dd2b..000000000 --- a/fuel/app/themes/default/lti/partials/select_item.php +++ /dev/null @@ -1,48 +0,0 @@ -
-
-

{{strHeader}}

- -
-
- - Refresh listing -
- -
-
- You don't have any widgets yet. Click this button to create a widget, then return to this tab/window and select your new widget. - Create a widget at Materia -
-
-
- Or, create a new widget at Materia - Cancel changing widget -
-
-
-

{{ selectedWidget.name }}

- -
-
- {{ !easterMode ? "Connecting your widget..." : "Reticulating splines..." }} -
-
-
-
-
-
Click to see your new widget
-
diff --git a/fuel/app/themes/default/lti/partials/select_item_js.php b/fuel/app/themes/default/lti/partials/select_item_js.php deleted file mode 100644 index 4e357c64f..000000000 --- a/fuel/app/themes/default/lti/partials/select_item_js.php +++ /dev/null @@ -1 +0,0 @@ -var system = ""; \ No newline at end of file diff --git a/fuel/app/themes/default/partials/404.php b/fuel/app/themes/default/partials/404.php deleted file mode 100644 index dfa0c8f85..000000000 --- a/fuel/app/themes/default/partials/404.php +++ /dev/null @@ -1,6 +0,0 @@ -
-
-

404

-

We may have lost the page you're looking for.

-
-
diff --git a/fuel/app/themes/default/partials/500.php b/fuel/app/themes/default/partials/500.php deleted file mode 100644 index fbd8ed145..000000000 --- a/fuel/app/themes/default/partials/500.php +++ /dev/null @@ -1,13 +0,0 @@ -
-
-

500

-

- Uh oh! Something's broken. Looks like an internal server error. - To get help with resolving this issue, contact support below. -

-
- -
- view('partials/help/support_info') ?> -
-
\ No newline at end of file diff --git a/fuel/app/themes/default/partials/admin/user.php b/fuel/app/themes/default/partials/admin/user.php deleted file mode 100644 index fea93ac93..000000000 --- a/fuel/app/themes/default/partials/admin/user.php +++ /dev/null @@ -1,253 +0,0 @@ -
-
-
-
-

User Admin

-
- Search: - -
-
-
- -
-
- {{match.first}} {{match.last}} -
-
- -
- No matches found. -

The person you're searching for may need to log in to create an account.

-
-
- Searching Users... - -
-
-
- -
-
diff --git a/fuel/app/themes/default/partials/admin/widget.php b/fuel/app/themes/default/partials/admin/widget.php deleted file mode 100644 index c979e72e9..000000000 --- a/fuel/app/themes/default/partials/admin/widget.php +++ /dev/null @@ -1,167 +0,0 @@ -
-
-
- -
-

-
- -
-

Install Widget

-
- -

- Upload a .wigt widget package file to install a new widget or upgrade an existing widget on Materia. -

-
- /> - - {{ selectedFileName }} -
-

Browse installable widgets on The Official Materia Widget Gallery

-

Browse features and more on The Official Materia Documentation Page

- -

Widget uploader is disabled.

-

To enable, alter the "enable_admin_uploader" configuration option in config/materia.php.

- -

- Note: On Heroku, installing widgets must happen during the Heroku build process. Read more at - - The Official Materia Documentation Page. - -

- - -
-
-
-
-
-

Widget List

-
-
    -
  • -
    - - - - {{widget.name}} -
    -
    -
    -
    - {{ error }} -
    -
    -
    -
    - - {{ widget.id }} - -
    -
    - - {{ widget.created_at * 1000 | date:yyyy-MM-dd }} - -
    -
    - - {{ widget.width }}w x {{ widget.height }}h - -
    -
    - - - -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    -
    -
    - - - -
    -
    - - - -
    -
    - - - -
    -
    - - - - -
      -
    • {{ feature }}
    • -
    -
    -
    -
    - - - - -
      -
    • {{ qtype }}
    • -
    -
    -
    -
    - - - - -
      -
    • {{ export }}
    • -
    -
    -
    - -
    -
    -
  • -
-
-
-
diff --git a/fuel/app/themes/default/partials/angular_alert.php b/fuel/app/themes/default/partials/angular_alert.php deleted file mode 100644 index 9ee9c5993..000000000 --- a/fuel/app/themes/default/partials/angular_alert.php +++ /dev/null @@ -1,12 +0,0 @@ -
- -

{{ alert.msg }}

- - -
-
diff --git a/fuel/app/themes/default/partials/catalog/media.php b/fuel/app/themes/default/partials/catalog/media.php deleted file mode 100644 index c36335874..000000000 --- a/fuel/app/themes/default/partials/catalog/media.php +++ /dev/null @@ -1,69 +0,0 @@ -
-
-
- Upload a new file -
-
-
- Drag a file here to upload -
-
- -
- -
-
- Pick from your library - - -
-
-
-
- {{option.name}} -
-
-
- -
-
- -
-
- No files available! -
-
- - - - - - {{file.name}} - - {{file.type}} - - - {{file.created}} - -
-
-
-
diff --git a/fuel/app/themes/default/partials/catalog/qset.php b/fuel/app/themes/default/partials/catalog/qset.php deleted file mode 100644 index 8b1c30eb2..000000000 --- a/fuel/app/themes/default/partials/catalog/qset.php +++ /dev/null @@ -1,26 +0,0 @@ -
- -
-

Save History

- - - - - - - - - - - -
Save CountSaved At
Save #{{saves.length - $index}}{{save.created_at}}
-
-
-

No previous saves for this widget.

- If you publish or a save a draft of your widget and then come back, you can view and restore previous saves from here. -
- -
- Cancel -
-
\ No newline at end of file diff --git a/fuel/app/themes/default/partials/catalog/question.php b/fuel/app/themes/default/partials/catalog/question.php deleted file mode 100644 index 786b53edf..000000000 --- a/fuel/app/themes/default/partials/catalog/question.php +++ /dev/null @@ -1,19 +0,0 @@ -
-
-

Question Catalog

- - - - - - - - - -
Question TextTypeDateUsed
-
- Cancel - -
-
-
diff --git a/fuel/app/themes/default/lti/partials/config_xml.php b/fuel/app/themes/default/partials/config_xml.php similarity index 100% rename from fuel/app/themes/default/lti/partials/config_xml.php rename to fuel/app/themes/default/partials/config_xml.php diff --git a/fuel/app/themes/default/partials/header.php b/fuel/app/themes/default/partials/header.php deleted file mode 100644 index 610a34fab..000000000 --- a/fuel/app/themes/default/partials/header.php +++ /dev/null @@ -1,82 +0,0 @@ - -
- - is_guest()): ?> - - - first} {$me->last}" ?>" - data-avatar="" - data-role="" - data-notify="profile_fields['notify'] ? 'true' : 'false' ?>" - > - - -

Materia

- - -

- - {{currentUser.name}} - - - - - Logout - - - - Login - -

-
- - - -
- -
-
-

-
-

- -
- -
-
-
-
- diff --git a/fuel/app/themes/default/partials/header_empty.php b/fuel/app/themes/default/partials/header_empty.php deleted file mode 100644 index e69de29bb..000000000 diff --git a/fuel/app/themes/default/partials/help/main.php b/fuel/app/themes/default/partials/help/main.php index aeb56cbe5..289435360 100644 --- a/fuel/app/themes/default/partials/help/main.php +++ b/fuel/app/themes/default/partials/help/main.php @@ -11,7 +11,7 @@

Materia requires that you use an up-to-date browser with javascript and cookies enabled.

-
+

Login Issues

In many cases, problems logging in are a result of one of the following:

@@ -21,7 +21,7 @@

Expired Password

You may need to reset your password.

-

User Account Doesn't exist

+

User Account Doesn't Exist

Your user account may not have been created yet.

diff --git a/fuel/app/themes/default/partials/homepage.php b/fuel/app/themes/default/partials/homepage.php deleted file mode 100644 index 57a1c4b36..000000000 --- a/fuel/app/themes/default/partials/homepage.php +++ /dev/null @@ -1,69 +0,0 @@ -
-
- - - -
-
-
-
- -
-

- Easily embed engaging apps in your online course. -

-

- Get Started -

-
- -
-
-
-

Engage Your Students

-

- Re-imagine your course filled with diverse and interesting experiences. It can bring life to content modules, practice, study activities, and even assessments. Engage students with game mechanics like: story-telling, competition, instant feedback, and instant reward systems. -

-
- screen shot of a labeling widget -
-

Create Quickly and Easily

-

- Materia's design philosophy is to be incredibly easy to use. Every step of customizing and delivering apps has been finely tuned to be as clear and simple as possible. Players are greeted with clean and simple interfaces. We aim to get out of the way so your content can engage with students as quickly and clearly as possible. -

-

- screen shot of creating a crossword widget -
-

Integrate with Your Course

-

- Materia integrates into Canvas seamlessly. As an assignment, student's scores can automatically sync to the grade book. Thanks to the magic of LTI, Students are logged in automatically! -

-
- screen shot of a widget score page -
- -
-

- Use Materia at your organization. -

-

- - Get Materia - (It's open source!) - -

-
- -
-

Built at UCF, for Everyone

-

- Materia is an open source project built by the University of Central Florida's Center for Distributed Learning. Our team is a truly unique group of experts working directly with faculty and students to build enjoyable tools for teaching and learning. -

-

- We're committed to building a better tomorrow through better learning tools, so our team is constantly improving and re-inventing Materia. If you have an idea for a new widget or simply would like to give us feedback, we'd love to hear from you on Github. -

- -
-
diff --git a/fuel/app/themes/default/partials/login.php b/fuel/app/themes/default/partials/login.php deleted file mode 100644 index 47d1fe96b..000000000 --- a/fuel/app/themes/default/partials/login.php +++ /dev/null @@ -1,40 +0,0 @@ -
-
-
- - Using your and -
- -
- -
-

-
- - -
-

', $notice) ?>

-
- -
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
- - - -
-
-
-
diff --git a/fuel/app/themes/default/partials/my_widgets.php b/fuel/app/themes/default/partials/my_widgets.php deleted file mode 100644 index fc1de58a5..000000000 --- a/fuel/app/themes/default/partials/my_widgets.php +++ /dev/null @@ -1,956 +0,0 @@ -
-
- Click here to start making a new widget! -
-
-
- - -
-

Editing a published widget may affect statistical analysis when comparing data collected prior to your edits.

-

Caution should be taken when:

-
    -
  • Students have already completed your widget
  • -
  • You make significant content changes
  • -
  • Edits change the difficulty level
  • -
  • Statistics will be used for research
  • -
- - - - Cancel - - - Edit Published Widget - - -
-
- - - -
-

This widget is restricted.

-

- You are not able to publish this widget or make any changes to it - after it has been published. -

- - - - Cancel - - -
-
- - - - -
-

- {{error}} -

-

You are viewing a limited version of this page due to your current role as a student. Students do not have permission to change certain settings like attempt limits or access levels.

-
    -
  • -

    Attempts

    -
    -
      -
    • - 1 -
    • -
    • - 2 -
    • -
    • - 3 -
    • -
    • - 4 -
    • -
    • - 5 -
    • -
    • - 10 -
    • -
    • - 15 -
    • -
    • - 20 -
    • -
    • - Unlimited -
    • -
    -
    -
    - Attempts are the number of times a student can complete a widget. - Only their highest score counts. -
    - Attempts are unlimited when Guest Mode is enabled. -
    -
    -
    -
  • -
      -
    • -

      {{available.header}}

      -
        -
      • - - -
      • -
      • - - - - at - - - am - - - pm - -
      • -
      -
    • - -
    • -

      Access

      -
        -
      • - - -
        - Only students and users who can log into Materia can - access this widget. If the widget collects scores, those - scores will be associated with the user. The widget can - be distributed via URL, embed code, or as an assignment - in your LMS. -
        -
      • -
      • - - -
        - Anyone with a link can play this widget without logging in. - All recorded scores will be anonymous. Can't use in an - external system. -
        Guest Mode is always on for widgets created by students.
        -
        -
      • -
      • - - -
        - This widget will not be playable outside of the classes - it is embedded within. -
        -
      • -
      - -
    • -
    -
- -
-
- - -
-
-

Export

- -

{{header || "No Semester Selected"}}

-
-

- Export Scores - provides a means of exporting student score information in .CSV - format, much like an excel spreadsheet. Teachers can use the scores - to analyze, compare, and gauge class performance. In addition, teachers - can also download a CSV containing a widget's question and answer - set by selecting the Questions and Answers option from the drop-down - menu. Download options may vary by widget, as some widgets - provide specialized export options. -

- -
- - -

- - - Download {{exportType}} - -

- -

- You don't need to export scores and import them into Canvas if you have - embedded a widget as a graded assignment. - - See how! - -

-
-
- -

- - Cancel - -

-
-
-

Semesters

-

Export which semesters?

-

- No semesters available -

-
    -
  • - - -
  • -
  • - - -
  • -
-
-
-
- - -
-
- - -
-
- - -

If checked, all users who have access to the original widget will continue to have access to the new copy. Note that the rules for sharing widgets with students will still apply.

-
- -
-
- -
-
-

- You do not have access to this widget or this widget does not exist. -

-
-
-
-

Your Widgets

-

Choose a widget from the list on the left.

-
-
-

You have no widgets!

-

Make a new widget in the widget catalog.

-
-
-
-

{{selected.widget.name}} Widget

-
-
-
- {{selected.widget.widget.name}} -
-
- -
    - -
  • - -
  • -
  • - -
  • -
-
- Are you sure you want to delete this widget? - -
-
-

Settings:

-
-
Attempts:
-
- {{ attemptText }} -
-
Available:
-
- - Anytime - - - Open until - {{ availability.end.date }} - at - {{ availability.end.time }} - - - Anytime after - {{ availability.start.date }} - at - {{ availability.start.time }} - - - From - {{ availability.start.date }} - at - {{ availability.start.time }} - until - {{ availability.end.date }} - at - {{ availability.end.time}} - -
-
Access:
-
- Staff and Students only - Guest Mode - No Login Required -
-
- - Edit settings... - -
-
- -
-
-

Student Activity

- - - Export Options - - -
-

{{semester.term}} {{semester.year}}

- -
- -

Select a student to view their scores.

-
-
- - - - - - -
- {{user.name}} -
-
-
-
- - - - - - - - -
{{score.date.substring(0, 10)}}{{ score.complete == "1" ? score.percent + "%" : "---" }}{{score.elapsed}}
-
-
-
-
-
-
-
-
- - Anonymize Download - - Download Table - -
-
-

Table: - {{tableNames[0]}} -

-
- -
-

- Showing only the first {{MAX_ROWS}} entries of this table. - Download the table to see all entries. -

- - - - - - - - - - - - - - - - - - - -
userfirstNamelastNametime{{name}}
{{row.play.user}}{{row.play.firstName}}{{row.play.lastName}}{{row.play.cleanTime}} - {{rowData}} -
-
-
-
    -
  • -

    Students

    -

    - {{semester.students}} -

    -
  • -
  • -

    Scores

    -

    {{semester.totalScores}}

    -
  • -
  • -

    Avg Final Score

    -

    {{semester.average}}

    -
  • -
- - Show older scores... - -
-
-
-
- -
-
diff --git a/fuel/app/themes/default/partials/noflash.php b/fuel/app/themes/default/partials/noflash.php deleted file mode 100644 index 8e8ab3664..000000000 --- a/fuel/app/themes/default/partials/noflash.php +++ /dev/null @@ -1,19 +0,0 @@ -
-
-

Flash Player Required

- -

Materia requires that you have the latest Flash Player plug-in installed.

- - - -

Other Solutions

-

If your Flash player is up to date, then you may be able to fix this problem by turning on Javascript in your browser.

- -

Should this not fix your problems, please visit the Help pages for more information.

-
-
diff --git a/fuel/app/themes/default/partials/nopermission.php b/fuel/app/themes/default/partials/nopermission.php deleted file mode 100644 index 8bcae1e95..000000000 --- a/fuel/app/themes/default/partials/nopermission.php +++ /dev/null @@ -1,13 +0,0 @@ -
-
-

You don't have permission to view this page.

-

You may need to:

-
    -
  • Make sure you own this item.
  • -
  • Ask the owner to share it with you.
  • -
  • Make sure the item you are trying to access exists.
  • -
- - view('partials/help/support_info') ?> -
-
diff --git a/fuel/app/themes/default/partials/score/full.php b/fuel/app/themes/default/partials/score/full.php deleted file mode 100644 index a0af9ce1e..000000000 --- a/fuel/app/themes/default/partials/score/full.php +++ /dev/null @@ -1,119 +0,0 @@ -
-
- - -

{{ widget.title }}

- - -
-
-
-

Incomplete Attempt

-
-

- This student didn't complete this attempt. - This score was not counted in any linked gradebooks and is only available for informational purposes. -

-
-
-

Attempt {{ attempt_num }} Score:

-

This Attempt Score:

- {{ overview.score }}% -
{{ classRankText }}
-
-
- - - - - - - -
{{ row.message }} - {{ row.value }}{{ (row.symbol == null) ? '%' : row.symbol }} -
-
-
- -
-
-
-
-
-
- - - -
-

{{ detail.title }}

- - - - - - - - - - - - - - - - -
{{ header }}
- -

{{ $index+1 }}

-
- - {{ row.score }}{{ row.symbol }} - -
{{ data }}
-
- -
-
- - Preview Again -
-
- -
-
- - -

You may need to:

-
    -
  • Make sure the score you're trying to access belongs to you or your student.
  • -
  • Try to access this score through your profile page.
  • -
  • Check out our documentation.
  • -
- - view('partials/help/support_info') ?> -
-
-
diff --git a/fuel/app/themes/default/partials/spotlight.php b/fuel/app/themes/default/partials/spotlight.php deleted file mode 100644 index b971024bb..000000000 --- a/fuel/app/themes/default/partials/spotlight.php +++ /dev/null @@ -1,47 +0,0 @@ -
-
-

Supercharged HTML 5 Widgets

- -

We're proud to introduce our new, updated HTML 5 catalog!

- -

We've been toiling away in the lab polishing, improving, and re-imagining every single widget in the catalog.

- -

Best of all, we now support phones and tablets for students on the go.

-
-
- -
-
-

Choose Your Own Adventure

- -

Say hello to our newest and most powerful widget.

-

Now, you can easily build branching scenarios and intricate experiences. Your students will navigate their own path through various situations and decision points that you can easily design.

- - Screenshots & more » -
-
- -
-
-

Enigma

- -

- Enigma is a fantastic way to prepare students for quizzes, exams, or simply to review course content in an entertaining way. With engaging visuals and a “feel good” interactive quality, Enigma is an enjoyable way to engross students in course materials. -

- - Screenshots & more » -
-
- -
-
-

Crossword

- -

- The Crossword Widget is a new spin on a classic favorite! Students will enjoy the ease of using a virtual board to challenge themselves academically and increase their problem solving skills. -

- - Screenshots & more » -
-
- diff --git a/fuel/app/themes/default/partials/user/profile.php b/fuel/app/themes/default/partials/user/profile.php deleted file mode 100644 index bd461ad7e..000000000 --- a/fuel/app/themes/default/partials/user/profile.php +++ /dev/null @@ -1,44 +0,0 @@ -
-
- - - -
- -
- -

Profile - {{user.name}} -

- -
    -
  • {{user.role}}
  • -
- -

Activity

- - - - Loading... Show more - -

You don't have any activity! Start doing stuff.

- -
-
diff --git a/fuel/app/themes/default/partials/user/settings.php b/fuel/app/themes/default/partials/user/settings.php deleted file mode 100644 index 8a8fed8eb..000000000 --- a/fuel/app/themes/default/partials/user/settings.php +++ /dev/null @@ -1,55 +0,0 @@ -
-
- - - -
- -
- -

AccountSettings

- -
- -

Notifications

- -
    -
  • - - -
    - -
  • -
- -

User Icon

- - -
-

Beard Mode

- -
- -

- -

- -
- -
-
diff --git a/fuel/app/themes/default/partials/widget/catalog.php b/fuel/app/themes/default/partials/widget/catalog.php deleted file mode 100644 index 60b904806..000000000 --- a/fuel/app/themes/default/partials/widget/catalog.php +++ /dev/null @@ -1,91 +0,0 @@ -
-
- -
-

Widget Catalog

- - -
- -
- -
- {{filter}}{{$last ? "" : ", "}} -
-
- -
- -
- -
-
- -
-
- -
- No widgets match the filters you set. - No Widgets Installed - Loading Widgets... -
- -
-

Featured Widgets

- -
- -
-
- -
-
- -
- {{totalWidgets - widgets.length }} hidden by filters. -
-
-
- - diff --git a/fuel/app/themes/default/partials/widget/closed.php b/fuel/app/themes/default/partials/widget/closed.php deleted file mode 100644 index 7249bd14a..000000000 --- a/fuel/app/themes/default/partials/widget/closed.php +++ /dev/null @@ -1,10 +0,0 @@ -
-
- -
- -
- " : '' ?> -
-
-
diff --git a/fuel/app/themes/default/partials/widget/create.php b/fuel/app/themes/default/partials/widget/create.php deleted file mode 100644 index ae995255a..000000000 --- a/fuel/app/themes/default/partials/widget/create.php +++ /dev/null @@ -1,81 +0,0 @@ -
-
-
-

Your browser blocked the preview popup, click below to preview the widget.

- -
- -
-

Update Widget

-

Updating this published widget will instantly allow your students to see your changes.

- - -
- - -
-

Publish Widget

-

Publishing removes the "Draft" status of a widget, which grants you the ability to use it in your course and collect student scores & data.

- -
- - -
-

Publish Restricted

-

Students are not allowed to publish this widget.

-

You can share the widget with a non-student who can publish it for you. Select "Save Draft" and add a non-student as a collaborator on the My Widgets page.

- -
- Cancel -
-
- -
-

Previewing Prior Save

-

Select Cancel to go back to the version you were working on. Select Keep to commit to using this version.

- - -
-
- ←Return to {{ returnPlace }} - Save History - Import Questions... - - -
- - -
-
- -
- -
-
- view('partials/noflash') ?> -
-
-
- - - - -
-
- view('partials/nopermission') ?> -
-
diff --git a/fuel/app/themes/default/partials/widget/detail.php b/fuel/app/themes/default/partials/widget/detail.php deleted file mode 100644 index 43072594c..000000000 --- a/fuel/app/themes/default/partials/widget/detail.php +++ /dev/null @@ -1,228 +0,0 @@ -
- - - -
-
- -

{{ widget.name }}

-

{{ widget.about }}

-
- -

{{ widget.about }}

- -
- - - -
-
-
- -
- -
-
-
-
-
-
-
- -
-
-
-
- view('partials/noflash') ?> -
-
-
-

{{!showDemoCover ? 'Playing ' : '' }}Demo

-
- -
- -
-

Screenshot {{$index + 1}} of {{numScreenshots}}

-
-
-
- -
- - -
-
- -
- - -
-

Want to use it in your course?

-

- - - - - - Create your widget - -

-
- - -
- Features: -
-
- {{ feature.text }} -
-
- {{ feature.description }} -
-
-
- -
- Supported Data: -
-
- {{ data.text }} -
-
- {{ data.description }} -
-
-
- - - - - {{ widget.name }} was last updated on {{ widget.created }} - -
-
-
diff --git a/fuel/app/themes/default/partials/widget/draft_not_playable.php b/fuel/app/themes/default/partials/widget/draft_not_playable.php deleted file mode 100644 index b37f55e11..000000000 --- a/fuel/app/themes/default/partials/widget/draft_not_playable.php +++ /dev/null @@ -1,17 +0,0 @@ -
-
- - - -

You probably need to:

-
    -
  • Preview instead of play.
  • -
  • Publish this widget to start collecting scores.
  • -
  • Check out our documentation.
  • -
  • Take a break, watch cat videos.
  • -
- - view('partials/help/support_info') ?> - -
-
diff --git a/fuel/app/themes/default/partials/widget/embedded_only.php b/fuel/app/themes/default/partials/widget/embedded_only.php deleted file mode 100644 index 10cfc6168..000000000 --- a/fuel/app/themes/default/partials/widget/embedded_only.php +++ /dev/null @@ -1,10 +0,0 @@ -
-
- - -
-

Not Playable Here

- Your instructor has not made this widget available outside of the LMS. -
-
-
diff --git a/fuel/app/themes/default/partials/widget/guide_doc.php b/fuel/app/themes/default/partials/widget/guide_doc.php deleted file mode 100644 index 5e2ee3f30..000000000 --- a/fuel/app/themes/default/partials/widget/guide_doc.php +++ /dev/null @@ -1,16 +0,0 @@ -
-
-

-
- - Player Guide - - - Creator Guide - -
-
-
- -
-
diff --git a/fuel/app/themes/default/partials/widget/login.php b/fuel/app/themes/default/partials/widget/login.php deleted file mode 100644 index 860227aa8..000000000 --- a/fuel/app/themes/default/partials/widget/login.php +++ /dev/null @@ -1,44 +0,0 @@ -
-
- " : '' ?> - - -
- - Using your and -
- -
- -
-

-
- - -
-

', $notice) ?>

-
- -
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
- -
-
- -
-
diff --git a/fuel/app/themes/default/partials/widget/no_attempts.php b/fuel/app/themes/default/partials/widget/no_attempts.php deleted file mode 100644 index 72dfc3c64..000000000 --- a/fuel/app/themes/default/partials/widget/no_attempts.php +++ /dev/null @@ -1,13 +0,0 @@ -
-
- - -
-

No remaining attempts

- You've used all available attempts. -

- Review previous scores -

-
-
-
diff --git a/fuel/app/themes/default/partials/widget/play.php b/fuel/app/themes/default/partials/widget/play.php deleted file mode 100644 index a3dfee5a1..000000000 --- a/fuel/app/themes/default/partials/widget/play.php +++ /dev/null @@ -1,10 +0,0 @@ -
-
-
- -
-
-
- view('partials/noflash') ?> -
-
diff --git a/fuel/app/themes/default/partials/widget/pre_embed_placeholder.php b/fuel/app/themes/default/partials/widget/pre_embed_placeholder.php deleted file mode 100644 index f5ddab013..000000000 --- a/fuel/app/themes/default/partials/widget/pre_embed_placeholder.php +++ /dev/null @@ -1,9 +0,0 @@ -
-
- - -
- Play -
-
-
\ No newline at end of file diff --git a/fuel/app/themes/default/partials/widget/retired.php b/fuel/app/themes/default/partials/widget/retired.php deleted file mode 100644 index 85942c5eb..000000000 --- a/fuel/app/themes/default/partials/widget/retired.php +++ /dev/null @@ -1,8 +0,0 @@ -
-
-
- -
-

This engine has been retired.

-
-
diff --git a/fuel/app/themes/default/partials/widget/summary.php b/fuel/app/themes/default/partials/widget/summary.php deleted file mode 100644 index 7f2c984ff..000000000 --- a/fuel/app/themes/default/partials/widget/summary.php +++ /dev/null @@ -1,9 +0,0 @@ -
-
- -
-
    - $name" : '' ?> - $avail" : '' ?> -
-
\ No newline at end of file diff --git a/materia-app.Dockerfile b/materia-app.Dockerfile index 5e7dd5374..776f6c291 100644 --- a/materia-app.Dockerfile +++ b/materia-app.Dockerfile @@ -6,8 +6,8 @@ FROM php:8.1.11-fpm-alpine3.16 AS base_stage ARG PHP_EXT="bcmath gd pdo_mysql xml zip opcache" ARG PHP_MEMCACHED_VERSION="v3.1.5" -ARG COMPOSER_VERSION="1.10.26" -ARG COMPOSER_INSTALLER_URL="https://raw.githubusercontent.com/composer/getcomposer.org/2e4127af2d638693670a33b1a63ee035c20277d7/web/installer" +ARG COMPOSER_VERSION="2.5.4" +ARG COMPOSER_INSTALLER_URL="https://raw.githubusercontent.com/composer/getcomposer.org/be31d0a5e5e835063c29bb45804bd94eefd4cf34/web/installer" ARG COMPOSER_INSTALLER_SHA="55ce33d7678c5a611085589f1f3ddf8b3c52d662cd01d4ba75c0ee0459970c2200a51f492d557530c71c15d8dba01eae" # os packages needed for php extensions @@ -61,20 +61,24 @@ COPY --chown=www-data:www-data ./oil /var/www/html/oil RUN composer install --no-cache --no-dev --no-progress --no-scripts --prefer-dist --optimize-autoloader # ===================================================================================================== -# Yarn stage buils js/css assets +# Yarn stage build js/css assets # ===================================================================================================== -FROM node:12.11.1-alpine AS yarn_stage +FROM node:18.13.0-alpine AS yarn_stage RUN apk add --no-cache git COPY ./public /build/public +# copy configs into /build. These are required for yarn and webpack COPY ./package.json /build/package.json -COPY ./process_assets.js /build/process_assets.js +COPY ./babel.config.json /build/babel.config.json +COPY ./webpack.prod.config.js /build/webpack.prod.config.js COPY ./yarn.lock /build/yarn.lock -# make sure the directory where asset_hash.json is generated exists +# these directories must be hoisted into /build in order for webpack to work on them +COPY ./src /build/src +COPY --from=composer_stage /var/www/html/fuel/packages /build/fuel/packages RUN mkdir -p /build/fuel/app/config/ -RUN cd build && yarn install --frozen-lockfile --non-interactive --production --silent --pure-lockfile --force - +# run yarn install and then the build script in the package.json (webpack --config webpack.prod.config.js) +RUN cd build && yarn install --frozen-lockfile --non-interactive --silent --pure-lockfile --force && npm run-script build-for-image # ===================================================================================================== # final stage creates the final deployable image @@ -87,5 +91,4 @@ COPY docker/config/php/materia.php.ini $PHP_INI_DIR/conf.d/materia.php.ini USER www-data # ======== COPY FINAL APP COPY --from=composer_stage --chown=www-data:www-data /var/www/html /var/www/html -COPY --from=yarn_stage --chown=www-data:www-data /build/public /var/www/html/public -COPY --from=yarn_stage --chown=www-data:www-data /build/fuel/app/config/asset_hash.json /var/www/html/fuel/app/config/asset_hash.json +COPY --from=yarn_stage --chown=www-data:www-data /build/public /var/www/html/public \ No newline at end of file diff --git a/package.json b/package.json index 4af6a2931..19889b109 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,72 @@ { "name": "@ucfopen/materia", + "version": "10.0.0", "license": "AGPL-3.0", + "engines": { + "node": ">=18.0.0" + }, "description": "Engage students with easily embedded apps for online courses.", "author": "University of Central Florida, Center for Distributed Learning", "homepage": "https://ucfopen.github.io/Materia-Docs", "repository": "https://github.com/ucfopen/Materia.git", "scripts": { - "build": "node process_assets.js", - "build:watch": "concurrently \"yarn build:watchmateria\" \"yarn build:watchclientassets\"", - "build:watchmateria": "nodemon -e js,css -w ./node_modules/materia-server-client-assets/dist process_assets.js", - "build:watchclientassets": "cd node_modules/materia-server-client-assets && yarn build-watch", - "postinstall": "napa && npm run build", - "devassets:git": "cd .. && git clone https://github.com/ucfopen/Materia-Server-Client-Assets.git Materia-Server-Client-Assets", - "devassets:link": "cd ../Materia-Server-Client-Assets && yarn install && yarn link && cd - && yarn link materia-server-client-assets", - "devassets:unlink": "yarn unlink materia-server-client-assets && cd ../Materia-Server-Client-Assets && yarn unlink && cd - && yarn install", + "dev": "webpack-dev-server", + "build": "webpack", + "build-for-image": "webpack --config webpack.prod.config.js", + "test": "TZ=Etc/UTC jest --verbose", + "test:dev": "TZ=Etc/UTC jest --verbose --watch --coverage", + "test:ci": "TZ=Etc/UTC CI=true jest --ci --useStderr --coverage --coverageReporters text-summary cobertura", "test:php": "echo 'Additional env setup needed to run on host. You probably want docker/run_tests.sh'; composer run testci", - "test:php:watch": "nodemon -e php -i node_modules/ -i coverage/ -i docker/ -i public/widget -i fuel/vendor/ -i fuel/core/ --exec 'composer run test || exit 1'" + "test:php:watch": "nodemon -e php -i node_modules/ -i coverage/ -i docker/ -i public/widget -i fuel/vendor/ -i fuel/core/ --exec 'composer run test || exit 1'", + "prettier:run": "prettier --write 'src/**/*.{js,scss}'", + "prettier:debug": "prettier -l 'src/**/*.{js,scss}'", + "prettier:detectchanges": "git diff --exit-code ./src || (echo '!! Prettier created files that need to be manually added.'; exit 1;)" }, "dependencies": { + "d3": "^7.2.0", "fs-extra": "^8.0.1", - "materia-server-client-assets": "2.4.2", - "napa": "^3.0.0" + "js-base64": "^3.7.2", + "react-datepicker": "^4.8.0", + "react-overlays": "^5.2.1", + "react-query": "^3.39.2", + "uuid": "^9.0.1" }, "devDependencies": { + "@babel/core": "^7.10.4", + "@babel/preset-env": "^7.10.4", + "@babel/preset-react": "^7.10.4", + "@testing-library/dom": "7.31.2", + "@testing-library/jest-dom": "^5.14.1", + "@testing-library/react": "11.2.7", + "@testing-library/user-event": "^13.1.9", + "autoprefixer": "^9.8.5", + "babel-jest": "^29.3.1", + "babel-loader": "^9.1.2", "concurrently": "^5.1.0", - "nodemon": "^2.0.2" + "core-js": "3", + "css-loader": "^6.7.3", + "enzyme": "^3.11.0", + "enzyme-adapter-react-16": "^1.15.6", + "enzyme-to-json": "^3.6.2", + "husky": "^4.2.5", + "jest": "^29.3.1", + "jest-environment-jsdom": "^29.4.3", + "jquery": "3.5.1", + "jquery-ui": "1.12.1", + "lint-staged": "^10.2.11", + "mini-css-extract-plugin": "^2.7.2", + "node-sass": "^8.0.0", + "nodemon": "^2.0.20", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-test-renderer": "^17.0.2", + "sass-loader": "^13.2.0", + "webpack": "^5.75.0", + "webpack-cli": "^5.0.1", + "webpack-dev-server": "^4.11.1", + "webpack-manifest-plugin": "^5.0.0", + "webpack-remove-empty-scripts": "1.0.1", + "webpack-strip-block": "^0.3.0" }, "nodemonConfig": { "delay": "500", @@ -32,12 +74,56 @@ ".git" ] }, - "napa": { - "datatables": "DataTables/DataTables#1.10.16", - "labjs": "getify/LABjs#2.0.3", - "swfobject": "swfobject/swfobject#2.2", - "hammerjs": "hammerjs/hammer.js#v2.0.8", - "spinjs": "https://github.com/fgnass/spin.js.git#1.2.8" + "prettier": { + "printWidth": 100, + "semi": false, + "useTabs": true, + "singleQuote": true + }, + "lint-staged": { + "src/**/*.{js,scss}": [ + "yarn prettier --write" + ] + }, + "husky": { + "hooks": { + "pre-commit": "yarn test:ci && yarn lint-staged" + } }, - "version": "9.0.3" + "browserslist": [ + "> 0.5%", + "not ie >= 0", + "not op_mini all" + ], + "jest": { + "moduleFileExtensions": [ + "js", + "jsx" + ], + "moduleNameMapper": { + "^.+\\.(css|less|scss)$": "babel-jest" + }, + "verbose": false, + "coverageReporters": [ + "text", + "lcov" + ], + "setupFilesAfterEnv": [ + "./src/testSetup.js" + ], + "collectCoverageFrom": [ + "src/components/**/*.{js,jsx}" + ], + "snapshotSerializers": [ + "enzyme-to-json/serializer" + ], + "coverageThreshold": { + "global": { + "statements": 43, + "branches": 32, + "functions": 48, + "lines": 43 + } + } + } } diff --git a/process_assets.js b/process_assets.js deleted file mode 100644 index d7ffaab54..000000000 --- a/process_assets.js +++ /dev/null @@ -1,118 +0,0 @@ -const path = require('path'); -const fs = require('fs'); -const fse = require('fs-extra') -const glob = require('glob') -const crypto = require('crypto'); - -const nodeModulesPath = path.join(__dirname, 'node_modules') -const srcPath = path.join(nodeModulesPath, 'materia-server-client-assets', 'dist') -const pubPath = path.join(__dirname, 'public') -const vendorPath = path.join(pubPath, 'js', 'vendor') -const filesFound = {} - -const copyList = [ - { - from: path.join(srcPath, 'css'), - to: path.join(pubPath, 'css') - }, - { - from: path.join(srcPath, 'js', 'materia.js'), - to: path.join(pubPath, 'js', 'materia.js') - }, - { - from: path.join(srcPath, 'js', 'student.js'), - to: path.join(pubPath, 'js', 'student.js') - }, - { - from: path.join(srcPath, 'js', 'author.js'), - to: path.join(pubPath, 'js', 'author.js') - }, - { - from: path.join(srcPath, 'js', 'admin.js'), - to: path.join(pubPath, 'js', 'admin.js') - }, - { - from: path.join(srcPath, 'js', 'materia.creatorcore.js'), - to: path.join(pubPath, 'js', 'materia.creatorcore.js') - }, - { - from: path.join(srcPath, 'js', 'materia.enginecore.js'), - to: path.join(pubPath, 'js', 'materia.enginecore.js') - }, - { - from: path.join(srcPath, 'js', 'materia.scorecore.js'), - to: path.join(pubPath, 'js', 'materia.scorecore.js') - }, - { - from: path.join(nodeModulesPath, 'datatables', 'media', 'js', 'jquery.dataTables.min.js'), - to: path.join(vendorPath, 'datatables', 'jquery.dataTables.min.js') - }, - { - from: path.join(nodeModulesPath, 'labjs', 'LAB.min.js'), - to: path.join(vendorPath, 'labjs', 'LAB.min.js') - }, - { - from: path.join(nodeModulesPath, 'swfobject', 'swfobject'), - to: path.join(vendorPath, 'swfobject') - }, - { - from: path.join(nodeModulesPath, 'hammerjs', 'hammer.min.js'), - to: path.join(vendorPath, 'hammer.min.js') - }, - { - from: path.join(nodeModulesPath, 'spinjs', 'dist', 'spin.min.js'), - to: path.join(vendorPath, 'spin.min.js') - } -] - -/* - fse.copy filter function applied to each item - we'll use it to build a list of all files - and make an md5 hash for each of them -*/ -const md5AllAssets = (src, dest) => { - let files = [] - let srcDir - let destDir - - if(fs.statSync(src).isDirectory()){ - // src is a directory - files = glob.sync(path.join(src, '**', '*')) - srcDir = src - destDir = dest - } - else{ - // src is a single file - files = [src] - srcDir = path.dirname(src) - destDir = path.dirname(dest) - } - - // md5 each file and keep track of it - files.forEach(f => { - if(fs.lstatSync(f).isDirectory()) return - count++ - let data = fse.readFileSync(f) - let outputName = f.replace(srcDir, destDir).replace(pubPath+'/', '') - filesFound[outputName] = crypto.createHash('md5').update(data).digest("hex") - }) - - return true -} - - -let count = 0; - -Promise.all(copyList.map(item => fse.copy(item.from, item.to, {filter: md5AllAssets}))) -.then(() => { - const hashPath = path.join(__dirname, 'fuel', 'app', 'config', 'asset_hash.json') - return fse.writeJson(hashPath, filesFound, {spaces: '\t'}) -}) -.then(() => { - console.log(` ${count} Assets installed!`) - process.exit() -}) -.catch(err => { - console.error(err) - process.exit(1) -}) diff --git a/public/css/blank.gif b/public/css/blank.gif deleted file mode 100644 index ae59fa25a..000000000 Binary files a/public/css/blank.gif and /dev/null differ diff --git a/public/css/jquery.dataTables.css b/public/css/jquery.dataTables.css deleted file mode 100644 index dfbdfc01d..000000000 --- a/public/css/jquery.dataTables.css +++ /dev/null @@ -1,245 +0,0 @@ - -/* - * Table - */ -table.dataTable { - margin: 0 auto; - clear: both; - width: 100%; - padding:0; - border-spacing:0px; - font-size:13px; -} - -table.dataTable thead th { - padding: 3px 18px 3px 10px; - border-bottom: 1px solid black; - font-weight: bold; - cursor: pointer; - *cursor: hand; -} - -table.dataTable tfoot th { - padding: 3px 18px 3px 10px; - border-top: 1px solid black; - font-weight: bold; -} - -table.dataTable td { - padding: 5px 10px; - margin:0; - position: relative; -} - -table.dataTable td.center, -table.dataTable td.dataTables_empty { - text-align: center; -} - -table.dataTable tr.odd { background-color: #EEE; } -table.dataTable tr.even { background-color: white; } - -/* - * Table wrapper - */ -.dataTables_wrapper { - position: relative; - clear: both; - *zoom: 1; -} - - -/* - * Page length menu - */ -.dataTables_length { - float: left; -} - - -/* - * Filter - */ -.dataTables_filter { - float: right; - text-align: right; -} - - -/* - * Table information - */ -.dataTables_info { - clear: both; - float: left; -} - - -/* - * Pagination - */ -.dataTables_paginate { - float: right; - text-align: right; -} - -/* Two button pagination - previous / next */ -.paginate_disabled_previous, -.paginate_enabled_previous, -.paginate_disabled_next, -.paginate_enabled_next { - height: 19px; - float: left; - cursor: pointer; - *cursor: hand; - color: #111 !important; -} -.paginate_disabled_previous:hover, -.paginate_enabled_previous:hover, -.paginate_disabled_next:hover, -.paginate_enabled_next:hover { - text-decoration: none !important; -} -.paginate_disabled_previous:active, -.paginate_enabled_previous:active, -.paginate_disabled_next:active, -.paginate_enabled_next:active { - outline: none; -} - -.paginate_disabled_previous, -.paginate_disabled_next { - color: #666 !important; -} -.paginate_disabled_previous, -.paginate_enabled_previous { - padding-left: 23px; -} -.paginate_disabled_next, -.paginate_enabled_next { - padding-right: 23px; - margin-left: 10px; -} - -.paginate_enabled_previous { background: url('../img/datatables/back_enabled.png') no-repeat top left; } -.paginate_enabled_previous:hover { background: url('../img/datatables/back_enabled_hover.png') no-repeat top left; } -.paginate_disabled_previous { background: url('../img/datatables/back_disabled.png') no-repeat top left; } - -.paginate_enabled_next { background: url('../img/datatables/forward_enabled.png') no-repeat top right; } -.paginate_enabled_next:hover { background: url('../img/datatables/forward_enabled_hover.png') no-repeat top right; } -.paginate_disabled_next { background: url('../img/datatables/forward_disabled.png') no-repeat top right; } - -/* Full number pagination */ -.paging_full_numbers { - height: 22px; - line-height: 22px; -} -.paging_full_numbers a:active { - outline: none -} -.paging_full_numbers a:hover { - text-decoration: none; -} - -.paging_full_numbers a.paginate_button, -.paging_full_numbers a.paginate_active { - border: 1px solid #aaa; - -webkit-border-radius: 5px; - -moz-border-radius: 5px; - border-radius: 5px; - padding: 2px 5px; - margin: 0 3px; - cursor: pointer; - *cursor: hand; - color: #333 !important; -} - -.paging_full_numbers a.paginate_button { - background-color: #ddd; -} - -.paging_full_numbers a.paginate_button:hover { - background-color: #ccc; - text-decoration: none !important; -} - -.paging_full_numbers a.paginate_active { - background-color: #99B3FF; -} - - -/* - * Processing indicator - */ -.dataTables_processing { - position: absolute; - top: 50%; - left: 50%; - width: 250px; - height: 30px; - margin-left: -125px; - margin-top: -15px; - padding: 14px 0 2px 0; - border: 1px solid #ddd; - text-align: center; - color: #999; - font-size: 14px; - background-color: white; -} - - -/* - * Sorting - */ - -/* -.sorting_asc { background: url('../img/datatables/sort_asc.png') no-repeat center right; } -.sorting_desc { background: url('../img/datatables/sort_desc.png') no-repeat center right; } - -.sorting_asc_disabled { background: url('../img/datatables/sort_asc_disabled.png') no-repeat center right; } -.sorting_desc_disabled { background: url('../img/datatables/sort_desc_disabled.png') no-repeat center right; } -*/ - -table.dataTable th:active { - outline: none; -} - - -/* - * Scrolling - */ -.dataTables_scroll { - clear: both; -} - -.dataTables_scrollBody { - *margin-top: -1px; -} - -body table.dataTable tr.row_selected, -body table.dataTable tr.row_selected td -{ - background-color:#ffcb68 !important; -} - -body table.dataTable tr:hover, -body table.dataTable tr td:hover -{ - background-color:#ffe0a5; -} - -table.dataTable tr td span.q{ - margin-left:30px; - display:block; -} - -table.dataTable tr td input[type="checkbox"] -{ - float:left; -} - -table.dataTable tr td .numeric -{ - float:right; - white-space:nowrap; -} \ No newline at end of file diff --git a/public/css/ui-lightness/images/ui-bg_diagonals-thick_18_b81900_40x40.png b/public/css/ui-lightness/images/ui-bg_diagonals-thick_18_b81900_40x40.png deleted file mode 100755 index 954e22dbd..000000000 Binary files a/public/css/ui-lightness/images/ui-bg_diagonals-thick_18_b81900_40x40.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-bg_diagonals-thick_20_666666_40x40.png b/public/css/ui-lightness/images/ui-bg_diagonals-thick_20_666666_40x40.png deleted file mode 100755 index 64ece5707..000000000 Binary files a/public/css/ui-lightness/images/ui-bg_diagonals-thick_20_666666_40x40.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-bg_flat_10_000000_40x100.png b/public/css/ui-lightness/images/ui-bg_flat_10_000000_40x100.png deleted file mode 100755 index abdc01082..000000000 Binary files a/public/css/ui-lightness/images/ui-bg_flat_10_000000_40x100.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-bg_glass_100_f6f6f6_1x400.png b/public/css/ui-lightness/images/ui-bg_glass_100_f6f6f6_1x400.png deleted file mode 100755 index 9b383f4d2..000000000 Binary files a/public/css/ui-lightness/images/ui-bg_glass_100_f6f6f6_1x400.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-bg_glass_100_fdf5ce_1x400.png b/public/css/ui-lightness/images/ui-bg_glass_100_fdf5ce_1x400.png deleted file mode 100755 index a23baad25..000000000 Binary files a/public/css/ui-lightness/images/ui-bg_glass_100_fdf5ce_1x400.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-bg_glass_65_ffffff_1x400.png b/public/css/ui-lightness/images/ui-bg_glass_65_ffffff_1x400.png deleted file mode 100755 index 42ccba269..000000000 Binary files a/public/css/ui-lightness/images/ui-bg_glass_65_ffffff_1x400.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-bg_gloss-wave_35_f6a828_500x100.png b/public/css/ui-lightness/images/ui-bg_gloss-wave_35_f6a828_500x100.png deleted file mode 100755 index 39d5824d6..000000000 Binary files a/public/css/ui-lightness/images/ui-bg_gloss-wave_35_f6a828_500x100.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-bg_highlight-soft_100_eeeeee_1x100.png b/public/css/ui-lightness/images/ui-bg_highlight-soft_100_eeeeee_1x100.png deleted file mode 100755 index f1273672d..000000000 Binary files a/public/css/ui-lightness/images/ui-bg_highlight-soft_100_eeeeee_1x100.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-bg_highlight-soft_75_ffe45c_1x100.png b/public/css/ui-lightness/images/ui-bg_highlight-soft_75_ffe45c_1x100.png deleted file mode 100755 index 359397acf..000000000 Binary files a/public/css/ui-lightness/images/ui-bg_highlight-soft_75_ffe45c_1x100.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-icons_222222_256x240.png b/public/css/ui-lightness/images/ui-icons_222222_256x240.png deleted file mode 100755 index b273ff111..000000000 Binary files a/public/css/ui-lightness/images/ui-icons_222222_256x240.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-icons_228ef1_256x240.png b/public/css/ui-lightness/images/ui-icons_228ef1_256x240.png deleted file mode 100755 index a641a371a..000000000 Binary files a/public/css/ui-lightness/images/ui-icons_228ef1_256x240.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-icons_ef8c08_256x240.png b/public/css/ui-lightness/images/ui-icons_ef8c08_256x240.png deleted file mode 100755 index 85e63e9f6..000000000 Binary files a/public/css/ui-lightness/images/ui-icons_ef8c08_256x240.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-icons_ffd27a_256x240.png b/public/css/ui-lightness/images/ui-icons_ffd27a_256x240.png deleted file mode 100755 index e117effa3..000000000 Binary files a/public/css/ui-lightness/images/ui-icons_ffd27a_256x240.png and /dev/null differ diff --git a/public/css/ui-lightness/images/ui-icons_ffffff_256x240.png b/public/css/ui-lightness/images/ui-icons_ffffff_256x240.png deleted file mode 100755 index 42f8f992c..000000000 Binary files a/public/css/ui-lightness/images/ui-icons_ffffff_256x240.png and /dev/null differ diff --git a/public/css/ui-lightness/jquery-ui-1.8.21.custom.css b/public/css/ui-lightness/jquery-ui-1.8.21.custom.css deleted file mode 100755 index 056689d59..000000000 --- a/public/css/ui-lightness/jquery-ui-1.8.21.custom.css +++ /dev/null @@ -1,377 +0,0 @@ -/*! - * jQuery UI CSS Framework 1.8.21 - * - * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * http://docs.jquery.com/UI/Theming/API - */ - -/* Layout helpers -----------------------------------*/ -.ui-helper-hidden { display: none; } -.ui-helper-hidden-accessible { position: absolute !important; clip: rect(1px 1px 1px 1px); clip: rect(1px,1px,1px,1px); } -.ui-helper-reset { margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none; } -.ui-helper-clearfix:before, .ui-helper-clearfix:after { content: ""; display: table; } -.ui-helper-clearfix:after { clear: both; } -.ui-helper-clearfix { zoom: 1; } -.ui-helper-zfix { width: 100%; height: 100%; top: 0; left: 0; position: absolute; opacity: 0; filter:Alpha(Opacity=0); } - - -/* Interaction Cues -----------------------------------*/ -.ui-state-disabled { cursor: default !important; } - - -/* Icons -----------------------------------*/ - -/* states and images */ -.ui-icon { display: block; text-indent: -99999px; overflow: hidden; background-repeat: no-repeat; } - - -/* Misc visuals -----------------------------------*/ - -/* Overlays */ -.ui-widget-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } - - -/*! - * jQuery UI CSS Framework 1.8.21 - * - * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * http://docs.jquery.com/UI/Theming/API - * - * To view and modify this theme, visit http://jqueryui.com/themeroller/?ffDefault=Trebuchet%20MS,%20Tahoma,%20Verdana,%20Arial,%20sans-serif&fwDefault=bold&fsDefault=1.1em&cornerRadius=4px&bgColorHeader=f6a828&bgTextureHeader=12_gloss_wave.png&bgImgOpacityHeader=35&borderColorHeader=e78f08&fcHeader=ffffff&iconColorHeader=ffffff&bgColorContent=eeeeee&bgTextureContent=03_highlight_soft.png&bgImgOpacityContent=100&borderColorContent=dddddd&fcContent=333333&iconColorContent=222222&bgColorDefault=f6f6f6&bgTextureDefault=02_glass.png&bgImgOpacityDefault=100&borderColorDefault=cccccc&fcDefault=1c94c4&iconColorDefault=ef8c08&bgColorHover=fdf5ce&bgTextureHover=02_glass.png&bgImgOpacityHover=100&borderColorHover=fbcb09&fcHover=c77405&iconColorHover=ef8c08&bgColorActive=ffffff&bgTextureActive=02_glass.png&bgImgOpacityActive=65&borderColorActive=fbd850&fcActive=eb8f00&iconColorActive=ef8c08&bgColorHighlight=ffe45c&bgTextureHighlight=03_highlight_soft.png&bgImgOpacityHighlight=75&borderColorHighlight=fed22f&fcHighlight=363636&iconColorHighlight=228ef1&bgColorError=b81900&bgTextureError=08_diagonals_thick.png&bgImgOpacityError=18&borderColorError=cd0a0a&fcError=ffffff&iconColorError=ffd27a&bgColorOverlay=666666&bgTextureOverlay=08_diagonals_thick.png&bgImgOpacityOverlay=20&opacityOverlay=50&bgColorShadow=000000&bgTextureShadow=01_flat.png&bgImgOpacityShadow=10&opacityShadow=20&thicknessShadow=5px&offsetTopShadow=-5px&offsetLeftShadow=-5px&cornerRadiusShadow=5px - */ - - -/* Component containers -----------------------------------*/ -.ui-widget { font-family: Trebuchet MS, Tahoma, Verdana, Arial, sans-serif; font-size: 1.1em; } -.ui-widget .ui-widget { font-size: 1em; } -.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Trebuchet MS, Tahoma, Verdana, Arial, sans-serif; font-size: 1em; } -.ui-widget-content { border: 1px solid #dddddd; background: #eeeeee url(images/ui-bg_highlight-soft_100_eeeeee_1x100.png) 50% top repeat-x; color: #333333; } -.ui-widget-content a { color: #333333; } -.ui-widget-header { border: 1px solid #e78f08; background: #f6a828 url(images/ui-bg_gloss-wave_35_f6a828_500x100.png) 50% 50% repeat-x; color: #ffffff; font-weight: bold; } -.ui-widget-header a { color: #ffffff; } - -/* Interaction states -----------------------------------*/ -.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #cccccc; background: #f6f6f6 url(images/ui-bg_glass_100_f6f6f6_1x400.png) 50% 50% repeat-x; font-weight: bold; color: #1c94c4; } -.ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #1c94c4; text-decoration: none; } -.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #fbcb09; background: #fdf5ce url(images/ui-bg_glass_100_fdf5ce_1x400.png) 50% 50% repeat-x; font-weight: bold; color: #c77405; } -.ui-state-hover a, .ui-state-hover a:hover { color: #c77405; text-decoration: none; } -.ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { border: 1px solid #fbd850; background: #ffffff url(images/ui-bg_glass_65_ffffff_1x400.png) 50% 50% repeat-x; font-weight: bold; color: #eb8f00; } -.ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #eb8f00; text-decoration: none; } -.ui-widget :active { outline: none; } - -/* Interaction Cues -----------------------------------*/ -.ui-state-highlight, .ui-widget-content .ui-state-highlight, .ui-widget-header .ui-state-highlight {border: 1px solid #fed22f; background: #ffe45c url(images/ui-bg_highlight-soft_75_ffe45c_1x100.png) 50% top repeat-x; color: #363636; } -.ui-state-highlight a, .ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a { color: #363636; } -.ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error {border: 1px solid #cd0a0a; background: #b81900 url(images/ui-bg_diagonals-thick_18_b81900_40x40.png) 50% 50% repeat; color: #ffffff; } -.ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #ffffff; } -.ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { color: #ffffff; } -.ui-priority-primary, .ui-widget-content .ui-priority-primary, .ui-widget-header .ui-priority-primary { font-weight: bold; } -.ui-priority-secondary, .ui-widget-content .ui-priority-secondary, .ui-widget-header .ui-priority-secondary { opacity: .7; filter:Alpha(Opacity=70); font-weight: normal; } -.ui-state-disabled, .ui-widget-content .ui-state-disabled, .ui-widget-header .ui-state-disabled { opacity: .35; filter:Alpha(Opacity=35); background-image: none; } - -/* Icons -----------------------------------*/ - -/* states and images */ -.ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_222222_256x240.png); } -.ui-widget-content .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); } -.ui-widget-header .ui-icon {background-image: url(images/ui-icons_ffffff_256x240.png); } -.ui-state-default .ui-icon { background-image: url(images/ui-icons_ef8c08_256x240.png); } -.ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_ef8c08_256x240.png); } -.ui-state-active .ui-icon {background-image: url(images/ui-icons_ef8c08_256x240.png); } -.ui-state-highlight .ui-icon {background-image: url(images/ui-icons_228ef1_256x240.png); } -.ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_ffd27a_256x240.png); } - -/* positioning */ -.ui-icon-carat-1-n { background-position: 0 0; } -.ui-icon-carat-1-ne { background-position: -16px 0; } -.ui-icon-carat-1-e { background-position: -32px 0; } -.ui-icon-carat-1-se { background-position: -48px 0; } -.ui-icon-carat-1-s { background-position: -64px 0; } -.ui-icon-carat-1-sw { background-position: -80px 0; } -.ui-icon-carat-1-w { background-position: -96px 0; } -.ui-icon-carat-1-nw { background-position: -112px 0; } -.ui-icon-carat-2-n-s { background-position: -128px 0; } -.ui-icon-carat-2-e-w { background-position: -144px 0; } -.ui-icon-triangle-1-n { background-position: 0 -16px; } -.ui-icon-triangle-1-ne { background-position: -16px -16px; } -.ui-icon-triangle-1-e { background-position: -32px -16px; } -.ui-icon-triangle-1-se { background-position: -48px -16px; } -.ui-icon-triangle-1-s { background-position: -64px -16px; } -.ui-icon-triangle-1-sw { background-position: -80px -16px; } -.ui-icon-triangle-1-w { background-position: -96px -16px; } -.ui-icon-triangle-1-nw { background-position: -112px -16px; } -.ui-icon-triangle-2-n-s { background-position: -128px -16px; } -.ui-icon-triangle-2-e-w { background-position: -144px -16px; } -.ui-icon-arrow-1-n { background-position: 0 -32px; } -.ui-icon-arrow-1-ne { background-position: -16px -32px; } -.ui-icon-arrow-1-e { background-position: -32px -32px; } -.ui-icon-arrow-1-se { background-position: -48px -32px; } -.ui-icon-arrow-1-s { background-position: -64px -32px; } -.ui-icon-arrow-1-sw { background-position: -80px -32px; } -.ui-icon-arrow-1-w { background-position: -96px -32px; } -.ui-icon-arrow-1-nw { background-position: -112px -32px; } -.ui-icon-arrow-2-n-s { background-position: -128px -32px; } -.ui-icon-arrow-2-ne-sw { background-position: -144px -32px; } -.ui-icon-arrow-2-e-w { background-position: -160px -32px; } -.ui-icon-arrow-2-se-nw { background-position: -176px -32px; } -.ui-icon-arrowstop-1-n { background-position: -192px -32px; } -.ui-icon-arrowstop-1-e { background-position: -208px -32px; } -.ui-icon-arrowstop-1-s { background-position: -224px -32px; } -.ui-icon-arrowstop-1-w { background-position: -240px -32px; } -.ui-icon-arrowthick-1-n { background-position: 0 -48px; } -.ui-icon-arrowthick-1-ne { background-position: -16px -48px; } -.ui-icon-arrowthick-1-e { background-position: -32px -48px; } -.ui-icon-arrowthick-1-se { background-position: -48px -48px; } -.ui-icon-arrowthick-1-s { background-position: -64px -48px; } -.ui-icon-arrowthick-1-sw { background-position: -80px -48px; } -.ui-icon-arrowthick-1-w { background-position: -96px -48px; } -.ui-icon-arrowthick-1-nw { background-position: -112px -48px; } -.ui-icon-arrowthick-2-n-s { background-position: -128px -48px; } -.ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; } -.ui-icon-arrowthick-2-e-w { background-position: -160px -48px; } -.ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; } -.ui-icon-arrowthickstop-1-n { background-position: -192px -48px; } -.ui-icon-arrowthickstop-1-e { background-position: -208px -48px; } -.ui-icon-arrowthickstop-1-s { background-position: -224px -48px; } -.ui-icon-arrowthickstop-1-w { background-position: -240px -48px; } -.ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; } -.ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; } -.ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; } -.ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; } -.ui-icon-arrowreturn-1-w { background-position: -64px -64px; } -.ui-icon-arrowreturn-1-n { background-position: -80px -64px; } -.ui-icon-arrowreturn-1-e { background-position: -96px -64px; } -.ui-icon-arrowreturn-1-s { background-position: -112px -64px; } -.ui-icon-arrowrefresh-1-w { background-position: -128px -64px; } -.ui-icon-arrowrefresh-1-n { background-position: -144px -64px; } -.ui-icon-arrowrefresh-1-e { background-position: -160px -64px; } -.ui-icon-arrowrefresh-1-s { background-position: -176px -64px; } -.ui-icon-arrow-4 { background-position: 0 -80px; } -.ui-icon-arrow-4-diag { background-position: -16px -80px; } -.ui-icon-extlink { background-position: -32px -80px; } -.ui-icon-newwin { background-position: -48px -80px; } -.ui-icon-refresh { background-position: -64px -80px; } -.ui-icon-shuffle { background-position: -80px -80px; } -.ui-icon-transfer-e-w { background-position: -96px -80px; } -.ui-icon-transferthick-e-w { background-position: -112px -80px; } -.ui-icon-folder-collapsed { background-position: 0 -96px; } -.ui-icon-folder-open { background-position: -16px -96px; } -.ui-icon-document { background-position: -32px -96px; } -.ui-icon-document-b { background-position: -48px -96px; } -.ui-icon-note { background-position: -64px -96px; } -.ui-icon-mail-closed { background-position: -80px -96px; } -.ui-icon-mail-open { background-position: -96px -96px; } -.ui-icon-suitcase { background-position: -112px -96px; } -.ui-icon-comment { background-position: -128px -96px; } -.ui-icon-person { background-position: -144px -96px; } -.ui-icon-print { background-position: -160px -96px; } -.ui-icon-trash { background-position: -176px -96px; } -.ui-icon-locked { background-position: -192px -96px; } -.ui-icon-unlocked { background-position: -208px -96px; } -.ui-icon-bookmark { background-position: -224px -96px; } -.ui-icon-tag { background-position: -240px -96px; } -.ui-icon-home { background-position: 0 -112px; } -.ui-icon-flag { background-position: -16px -112px; } -.ui-icon-calendar { background-position: -32px -112px; } -.ui-icon-cart { background-position: -48px -112px; } -.ui-icon-pencil { background-position: -64px -112px; } -.ui-icon-clock { background-position: -80px -112px; } -.ui-icon-disk { background-position: -96px -112px; } -.ui-icon-calculator { background-position: -112px -112px; } -.ui-icon-zoomin { background-position: -128px -112px; } -.ui-icon-zoomout { background-position: -144px -112px; } -.ui-icon-search { background-position: -160px -112px; } -.ui-icon-wrench { background-position: -176px -112px; } -.ui-icon-gear { background-position: -192px -112px; } -.ui-icon-heart { background-position: -208px -112px; } -.ui-icon-star { background-position: -224px -112px; } -.ui-icon-link { background-position: -240px -112px; } -.ui-icon-cancel { background-position: 0 -128px; } -.ui-icon-plus { background-position: -16px -128px; } -.ui-icon-plusthick { background-position: -32px -128px; } -.ui-icon-minus { background-position: -48px -128px; } -.ui-icon-minusthick { background-position: -64px -128px; } -.ui-icon-close { background-position: -80px -128px; } -.ui-icon-closethick { background-position: -96px -128px; } -.ui-icon-key { background-position: -112px -128px; } -.ui-icon-lightbulb { background-position: -128px -128px; } -.ui-icon-scissors { background-position: -144px -128px; } -.ui-icon-clipboard { background-position: -160px -128px; } -.ui-icon-copy { background-position: -176px -128px; } -.ui-icon-contact { background-position: -192px -128px; } -.ui-icon-image { background-position: -208px -128px; } -.ui-icon-video { background-position: -224px -128px; } -.ui-icon-script { background-position: -240px -128px; } -.ui-icon-alert { background-position: 0 -144px; } -.ui-icon-info { background-position: -16px -144px; } -.ui-icon-notice { background-position: -32px -144px; } -.ui-icon-help { background-position: -48px -144px; } -.ui-icon-check { background-position: -64px -144px; } -.ui-icon-bullet { background-position: -80px -144px; } -.ui-icon-radio-off { background-position: -96px -144px; } -.ui-icon-radio-on { background-position: -112px -144px; } -.ui-icon-pin-w { background-position: -128px -144px; } -.ui-icon-pin-s { background-position: -144px -144px; } -.ui-icon-play { background-position: 0 -160px; } -.ui-icon-pause { background-position: -16px -160px; } -.ui-icon-seek-next { background-position: -32px -160px; } -.ui-icon-seek-prev { background-position: -48px -160px; } -.ui-icon-seek-end { background-position: -64px -160px; } -.ui-icon-seek-start { background-position: -80px -160px; } -/* ui-icon-seek-first is deprecated, use ui-icon-seek-start instead */ -.ui-icon-seek-first { background-position: -80px -160px; } -.ui-icon-stop { background-position: -96px -160px; } -.ui-icon-eject { background-position: -112px -160px; } -.ui-icon-volume-off { background-position: -128px -160px; } -.ui-icon-volume-on { background-position: -144px -160px; } -.ui-icon-power { background-position: 0 -176px; } -.ui-icon-signal-diag { background-position: -16px -176px; } -.ui-icon-signal { background-position: -32px -176px; } -.ui-icon-battery-0 { background-position: -48px -176px; } -.ui-icon-battery-1 { background-position: -64px -176px; } -.ui-icon-battery-2 { background-position: -80px -176px; } -.ui-icon-battery-3 { background-position: -96px -176px; } -.ui-icon-circle-plus { background-position: 0 -192px; } -.ui-icon-circle-minus { background-position: -16px -192px; } -.ui-icon-circle-close { background-position: -32px -192px; } -.ui-icon-circle-triangle-e { background-position: -48px -192px; } -.ui-icon-circle-triangle-s { background-position: -64px -192px; } -.ui-icon-circle-triangle-w { background-position: -80px -192px; } -.ui-icon-circle-triangle-n { background-position: -96px -192px; } -.ui-icon-circle-arrow-e { background-position: -112px -192px; } -.ui-icon-circle-arrow-s { background-position: -128px -192px; } -.ui-icon-circle-arrow-w { background-position: -144px -192px; } -.ui-icon-circle-arrow-n { background-position: -160px -192px; } -.ui-icon-circle-zoomin { background-position: -176px -192px; } -.ui-icon-circle-zoomout { background-position: -192px -192px; } -.ui-icon-circle-check { background-position: -208px -192px; } -.ui-icon-circlesmall-plus { background-position: 0 -208px; } -.ui-icon-circlesmall-minus { background-position: -16px -208px; } -.ui-icon-circlesmall-close { background-position: -32px -208px; } -.ui-icon-squaresmall-plus { background-position: -48px -208px; } -.ui-icon-squaresmall-minus { background-position: -64px -208px; } -.ui-icon-squaresmall-close { background-position: -80px -208px; } -.ui-icon-grip-dotted-vertical { background-position: 0 -224px; } -.ui-icon-grip-dotted-horizontal { background-position: -16px -224px; } -.ui-icon-grip-solid-vertical { background-position: -32px -224px; } -.ui-icon-grip-solid-horizontal { background-position: -48px -224px; } -.ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; } -.ui-icon-grip-diagonal-se { background-position: -80px -224px; } - - -/* Misc visuals -----------------------------------*/ - -/* Corner radius */ -.ui-corner-all, .ui-corner-top, .ui-corner-left, .ui-corner-tl { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; -khtml-border-top-left-radius: 4px; border-top-left-radius: 4px; } -.ui-corner-all, .ui-corner-top, .ui-corner-right, .ui-corner-tr { -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; -khtml-border-top-right-radius: 4px; border-top-right-radius: 4px; } -.ui-corner-all, .ui-corner-bottom, .ui-corner-left, .ui-corner-bl { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; -khtml-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; } -.ui-corner-all, .ui-corner-bottom, .ui-corner-right, .ui-corner-br { -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; -khtml-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; } - -/* Overlays */ -.ui-widget-overlay { background: #666666 url(images/ui-bg_diagonals-thick_20_666666_40x40.png) 50% 50% repeat; opacity: .50;filter:Alpha(Opacity=50); } -.ui-widget-shadow { margin: -5px 0 0 -5px; padding: 5px; background: #000000 url(images/ui-bg_flat_10_000000_40x100.png) 50% 50% repeat-x; opacity: .20;filter:Alpha(Opacity=20); -moz-border-radius: 5px; -khtml-border-radius: 5px; -webkit-border-radius: 5px; border-radius: 5px; }/*! - * jQuery UI Slider 1.8.21 - * - * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * http://docs.jquery.com/UI/Slider#theming - */ -.ui-slider { position: relative; text-align: left; } -.ui-slider .ui-slider-handle { position: absolute; z-index: 2; width: 1.2em; height: 1.2em; cursor: default; } -.ui-slider .ui-slider-range { position: absolute; z-index: 1; font-size: .7em; display: block; border: 0; background-position: 0 0; } - -.ui-slider-horizontal { height: .8em; } -.ui-slider-horizontal .ui-slider-handle { top: -.3em; margin-left: -.6em; } -.ui-slider-horizontal .ui-slider-range { top: 0; height: 100%; } -.ui-slider-horizontal .ui-slider-range-min { left: 0; } -.ui-slider-horizontal .ui-slider-range-max { right: 0; } - -.ui-slider-vertical { width: .8em; height: 100px; } -.ui-slider-vertical .ui-slider-handle { left: -.3em; margin-left: 0; margin-bottom: -.6em; } -.ui-slider-vertical .ui-slider-range { left: 0; width: 100%; } -.ui-slider-vertical .ui-slider-range-min { bottom: 0; } -.ui-slider-vertical .ui-slider-range-max { top: 0; }/*! - * jQuery UI Datepicker 1.8.21 - * - * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * http://docs.jquery.com/UI/Datepicker#theming - */ -.ui-datepicker { width: 17em; padding: .2em .2em 0; display: none; } -.ui-datepicker .ui-datepicker-header { position:relative; padding:.2em 0; } -.ui-datepicker .ui-datepicker-prev, .ui-datepicker .ui-datepicker-next { position:absolute; top: 2px; width: 1.8em; height: 1.8em; } -.ui-datepicker .ui-datepicker-prev-hover, .ui-datepicker .ui-datepicker-next-hover { top: 1px; } -.ui-datepicker .ui-datepicker-prev { left:2px; } -.ui-datepicker .ui-datepicker-next { right:2px; } -.ui-datepicker .ui-datepicker-prev-hover { left:1px; } -.ui-datepicker .ui-datepicker-next-hover { right:1px; } -.ui-datepicker .ui-datepicker-prev span, .ui-datepicker .ui-datepicker-next span { display: block; position: absolute; left: 50%; margin-left: -8px; top: 50%; margin-top: -8px; } -.ui-datepicker .ui-datepicker-title { margin: 0 2.3em; line-height: 1.8em; text-align: center; } -.ui-datepicker .ui-datepicker-title select { font-size:1em; margin:1px 0; } -.ui-datepicker select.ui-datepicker-month-year {width: 100%;} -.ui-datepicker select.ui-datepicker-month, -.ui-datepicker select.ui-datepicker-year { width: 49%;} -.ui-datepicker table {width: 100%; font-size: .9em; border-collapse: collapse; margin:0 0 .4em; } -.ui-datepicker th { padding: .7em .3em; text-align: center; font-weight: bold; border: 0; } -.ui-datepicker td { border: 0; padding: 1px; } -.ui-datepicker td span, .ui-datepicker td a { display: block; padding: .2em; text-align: right; text-decoration: none; } -.ui-datepicker .ui-datepicker-buttonpane { background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0; } -.ui-datepicker .ui-datepicker-buttonpane button { float: right; margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; } -.ui-datepicker .ui-datepicker-buttonpane button.ui-datepicker-current { float:left; } - -/* with multiple calendars */ -.ui-datepicker.ui-datepicker-multi { width:auto; } -.ui-datepicker-multi .ui-datepicker-group { float:left; } -.ui-datepicker-multi .ui-datepicker-group table { width:95%; margin:0 auto .4em; } -.ui-datepicker-multi-2 .ui-datepicker-group { width:50%; } -.ui-datepicker-multi-3 .ui-datepicker-group { width:33.3%; } -.ui-datepicker-multi-4 .ui-datepicker-group { width:25%; } -.ui-datepicker-multi .ui-datepicker-group-last .ui-datepicker-header { border-left-width:0; } -.ui-datepicker-multi .ui-datepicker-group-middle .ui-datepicker-header { border-left-width:0; } -.ui-datepicker-multi .ui-datepicker-buttonpane { clear:left; } -.ui-datepicker-row-break { clear:both; width:100%; font-size:0em; } - -/* RTL support */ -.ui-datepicker-rtl { direction: rtl; } -.ui-datepicker-rtl .ui-datepicker-prev { right: 2px; left: auto; } -.ui-datepicker-rtl .ui-datepicker-next { left: 2px; right: auto; } -.ui-datepicker-rtl .ui-datepicker-prev:hover { right: 1px; left: auto; } -.ui-datepicker-rtl .ui-datepicker-next:hover { left: 1px; right: auto; } -.ui-datepicker-rtl .ui-datepicker-buttonpane { clear:right; } -.ui-datepicker-rtl .ui-datepicker-buttonpane button { float: left; } -.ui-datepicker-rtl .ui-datepicker-buttonpane button.ui-datepicker-current { float:right; } -.ui-datepicker-rtl .ui-datepicker-group { float:right; } -.ui-datepicker-rtl .ui-datepicker-group-last .ui-datepicker-header { border-right-width:0; border-left-width:1px; } -.ui-datepicker-rtl .ui-datepicker-group-middle .ui-datepicker-header { border-right-width:0; border-left-width:1px; } - -/* IE6 IFRAME FIX (taken from datepicker 1.5.3 */ -.ui-datepicker-cover { - display: none; /*sorry for IE5*/ - display/**/: block; /*sorry for IE5*/ - position: absolute; /*must have*/ - z-index: -1; /*must have*/ - filter: mask(); /*must have*/ - top: -4px; /*must have*/ - left: -4px; /*must have*/ - width: 200px; /*must have*/ - height: 200px; /*must have*/ -} \ No newline at end of file diff --git a/public/css/ui-lightness/jquery-ui-timepicker-addon.css b/public/css/ui-lightness/jquery-ui-timepicker-addon.css deleted file mode 100755 index e0d03c290..000000000 --- a/public/css/ui-lightness/jquery-ui-timepicker-addon.css +++ /dev/null @@ -1,6 +0,0 @@ -.ui-timepicker-div .ui-widget-header { margin-bottom: 8px; } -.ui-timepicker-div dl { text-align: left; } -.ui-timepicker-div dl dt { height: 25px; margin-bottom: -25px; } -.ui-timepicker-div dl dd { margin: 0 10px 10px 65px; } -.ui-timepicker-div td { font-size: 90%; } -.ui-tpicker-grid-label { background: none; border: none; margin: 0; padding: 0; } diff --git a/public/dist/README.md b/public/dist/README.md new file mode 100644 index 000000000..7c064a256 --- /dev/null +++ b/public/dist/README.md @@ -0,0 +1,19 @@ +# Materia Widget Dependencies + +This package is intended for use by [Materia](https://github.com/ucfopen/Materia), an open-source platform for interactive educational games and tools developed by the University of Central Florida. + +With Materia 10.0 and the conversion from AngularJS to React, the **Materia-Server-Client-Assets** repo is deprecated, but the Materia Widget Development Kit still requires access to certain CSS and JS assets from the main repo. This package contains those assets. + +### Publishing New Versions + +This widget uses the `workflow_dispatch` event to publish new versions through GitHub Actions. No inputs are required. The action is configured to be publish the package to NPM, and as such, the `NPM_TOKEN` value must be available in the repository's secrets. If the `workflow_dispatch` option is unavailable, you can use GitHub CLI to run the workflow manually via: + +``` +gh workflow run publish_widget_dependencies.yml +``` + +If on a branch other than master, you can additionally specify the branch in the command: + +``` +gh workflow run publish_widget_dependencies.yml --ref +``` diff --git a/public/dist/package.json b/public/dist/package.json new file mode 100644 index 000000000..d9ebc93c8 --- /dev/null +++ b/public/dist/package.json @@ -0,0 +1,29 @@ +{ + "name": "materia-widget-dependencies", + "description": "The Widget Dependencies package provides js and css assets from Materia that are required for proper functioning of the Widget Development Kit.", + "author": "University of Central Florida, Center for Distributed Learning", + "license": "AGPL-3.0", + "files": [ + "js/materia.js", + "js/materia.enginecore.js", + "js/materia.creatorcore.js", + "js/materia.scorecore.js", + "js/player-page.js", + "css/player-page.css", + "js/creator-page.js", + "css/creator-page.css", + "js/qset-history.js", + "css/qset-history.css", + "js/question-importer.js", + "css/question-importer.css", + "js/guides.js", + "css/widget-guide.css", + "js/scores.js", + "css/scores.css" + ], + "version": "0.1.0", + "repository": { + "type": "git", + "url": "https://github.com/ucfopen/Materia" + } +} \ No newline at end of file diff --git a/public/dist/path.js b/public/dist/path.js new file mode 100644 index 000000000..68db50ee5 --- /dev/null +++ b/public/dist/path.js @@ -0,0 +1 @@ +module.exports = __dirname \ No newline at end of file diff --git a/public/favicon-128.png b/public/favicon-128.png new file mode 100644 index 000000000..905bfbde2 Binary files /dev/null and b/public/favicon-128.png differ diff --git a/public/favicon-180.png b/public/favicon-180.png new file mode 100644 index 000000000..618c983f7 Binary files /dev/null and b/public/favicon-180.png differ diff --git a/public/favicon-192.png b/public/favicon-192.png new file mode 100644 index 000000000..de0482f02 Binary files /dev/null and b/public/favicon-192.png differ diff --git a/public/favicon-32.png b/public/favicon-32.png new file mode 100644 index 000000000..913f96227 Binary files /dev/null and b/public/favicon-32.png differ diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index 8fcd1d763..000000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/img/banner_final.png b/public/img/banner_final.png new file mode 100644 index 000000000..37f5ae887 Binary files /dev/null and b/public/img/banner_final.png differ diff --git a/public/img/envelope.svg b/public/img/envelope.svg new file mode 100644 index 000000000..9ad1e5ab4 --- /dev/null +++ b/public/img/envelope.svg @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/public/img/front1.png b/public/img/front1.png index 59278052d..45de3f208 100644 Binary files a/public/img/front1.png and b/public/img/front1.png differ diff --git a/public/img/front2.png b/public/img/front2.png index db371fac8..1f5c18081 100644 Binary files a/public/img/front2.png and b/public/img/front2.png differ diff --git a/public/img/front3.png b/public/img/front3.png index d6814659d..cd4a7af7d 100644 Binary files a/public/img/front3.png and b/public/img/front3.png differ diff --git a/public/img/icon-cancel.svg b/public/img/icon-cancel.svg new file mode 100644 index 000000000..c0a779b70 --- /dev/null +++ b/public/img/icon-cancel.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/img/keyboard_icon.png b/public/img/keyboard_icon.png new file mode 100644 index 000000000..d3dbcc275 Binary files /dev/null and b/public/img/keyboard_icon.png differ diff --git a/public/img/kogneato_metal_detecting.png b/public/img/kogneato_metal_detecting.png index 2808a2e1a..e423fcd76 100644 Binary files a/public/img/kogneato_metal_detecting.png and b/public/img/kogneato_metal_detecting.png differ diff --git a/public/img/kogneato_mywidgets.svg b/public/img/kogneato_mywidgets.svg new file mode 100644 index 000000000..be3909502 --- /dev/null +++ b/public/img/kogneato_mywidgets.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/kogneato_mywidgets_bearded.svg b/public/img/kogneato_mywidgets_bearded.svg new file mode 100644 index 000000000..3f407c6a0 --- /dev/null +++ b/public/img/kogneato_mywidgets_bearded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/kogneato_no_scores.svg b/public/img/kogneato_no_scores.svg new file mode 100644 index 000000000..b41e7c578 --- /dev/null +++ b/public/img/kogneato_no_scores.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/kogneato_no_scores_bearded.svg b/public/img/kogneato_no_scores_bearded.svg new file mode 100644 index 000000000..fe02a6ae8 --- /dev/null +++ b/public/img/kogneato_no_scores_bearded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/kogneato_with_a_broom.svg b/public/img/kogneato_with_a_broom.svg new file mode 100644 index 000000000..1b48bfbb0 --- /dev/null +++ b/public/img/kogneato_with_a_broom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/kogneato_with_a_broom_bearded.svg b/public/img/kogneato_with_a_broom_bearded.svg new file mode 100644 index 000000000..ce7f1c2e6 --- /dev/null +++ b/public/img/kogneato_with_a_broom_bearded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/materia-logo-default.svg b/public/img/materia-logo-default.svg new file mode 100644 index 000000000..7025e9e37 --- /dev/null +++ b/public/img/materia-logo-default.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/materia-logo-thin.svg b/public/img/materia-logo-thin.svg new file mode 100644 index 000000000..e8ef22156 --- /dev/null +++ b/public/img/materia-logo-thin.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/materia-post-login-banner-1.svg b/public/img/materia-post-login-banner-1.svg new file mode 100644 index 000000000..1828fddae --- /dev/null +++ b/public/img/materia-post-login-banner-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/materia-post-login-banner-2.svg b/public/img/materia-post-login-banner-2.svg new file mode 100644 index 000000000..5ada391cb --- /dev/null +++ b/public/img/materia-post-login-banner-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/materia-post-login-banner-3.svg b/public/img/materia-post-login-banner-3.svg new file mode 100644 index 000000000..af13dec66 --- /dev/null +++ b/public/img/materia-post-login-banner-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/pencil.png b/public/img/pencil.png deleted file mode 100644 index 998a18251..000000000 Binary files a/public/img/pencil.png and /dev/null differ diff --git a/public/img/pencil.svg b/public/img/pencil.svg new file mode 100644 index 000000000..7526e062a --- /dev/null +++ b/public/img/pencil.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/retina/front1@2x.png b/public/img/retina/front1@2x.png new file mode 100644 index 000000000..462586f82 Binary files /dev/null and b/public/img/retina/front1@2x.png differ diff --git a/public/img/retina/front2@2x.png b/public/img/retina/front2@2x.png new file mode 100644 index 000000000..1f5c18081 Binary files /dev/null and b/public/img/retina/front2@2x.png differ diff --git a/public/img/retina/front3@2x.png b/public/img/retina/front3@2x.png new file mode 100644 index 000000000..cd4a7af7d Binary files /dev/null and b/public/img/retina/front3@2x.png differ diff --git a/public/img/screen_reader_icon.png b/public/img/screen_reader_icon.png new file mode 100644 index 000000000..93a4b499a Binary files /dev/null and b/public/img/screen_reader_icon.png differ diff --git a/public/img/social-ucf-open.png b/public/img/social-ucf-open.png new file mode 100644 index 000000000..7c1d4af38 Binary files /dev/null and b/public/img/social-ucf-open.png differ diff --git a/public/img/tri_color_spinner.png b/public/img/tri_color_spinner.png new file mode 100644 index 000000000..2c025afb3 Binary files /dev/null and b/public/img/tri_color_spinner.png differ diff --git a/public/js/jquery-ui-1.10.3.custom.min.js b/public/js/jquery-ui-1.10.3.custom.min.js deleted file mode 100755 index 194e194e6..000000000 --- a/public/js/jquery-ui-1.10.3.custom.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! jQuery UI - v1.10.3 - 2013-07-17 -* http://jqueryui.com -* Includes: jquery.ui.core.js, jquery.ui.widget.js, jquery.ui.mouse.js, jquery.ui.position.js, jquery.ui.datepicker.js, jquery.ui.progressbar.js, jquery.ui.slider.js, jquery.ui.effect.js, jquery.ui.effect-slide.js -* Copyright 2013 jQuery Foundation and other contributors Licensed MIT */ - -(function(e,t){function i(t,i){var a,n,r,o=t.nodeName.toLowerCase();return"area"===o?(a=t.parentNode,n=a.name,t.href&&n&&"map"===a.nodeName.toLowerCase()?(r=e("img[usemap=#"+n+"]")[0],!!r&&s(r)):!1):(/input|select|textarea|button|object/.test(o)?!t.disabled:"a"===o?t.href||i:i)&&s(t)}function s(t){return e.expr.filters.visible(t)&&!e(t).parents().addBack().filter(function(){return"hidden"===e.css(this,"visibility")}).length}var a=0,n=/^ui-id-\d+$/;e.ui=e.ui||{},e.extend(e.ui,{version:"1.10.3",keyCode:{BACKSPACE:8,COMMA:188,DELETE:46,DOWN:40,END:35,ENTER:13,ESCAPE:27,HOME:36,LEFT:37,NUMPAD_ADD:107,NUMPAD_DECIMAL:110,NUMPAD_DIVIDE:111,NUMPAD_ENTER:108,NUMPAD_MULTIPLY:106,NUMPAD_SUBTRACT:109,PAGE_DOWN:34,PAGE_UP:33,PERIOD:190,RIGHT:39,SPACE:32,TAB:9,UP:38}}),e.fn.extend({focus:function(t){return function(i,s){return"number"==typeof i?this.each(function(){var t=this;setTimeout(function(){e(t).focus(),s&&s.call(t)},i)}):t.apply(this,arguments)}}(e.fn.focus),scrollParent:function(){var t;return t=e.ui.ie&&/(static|relative)/.test(this.css("position"))||/absolute/.test(this.css("position"))?this.parents().filter(function(){return/(relative|absolute|fixed)/.test(e.css(this,"position"))&&/(auto|scroll)/.test(e.css(this,"overflow")+e.css(this,"overflow-y")+e.css(this,"overflow-x"))}).eq(0):this.parents().filter(function(){return/(auto|scroll)/.test(e.css(this,"overflow")+e.css(this,"overflow-y")+e.css(this,"overflow-x"))}).eq(0),/fixed/.test(this.css("position"))||!t.length?e(document):t},zIndex:function(i){if(i!==t)return this.css("zIndex",i);if(this.length)for(var s,a,n=e(this[0]);n.length&&n[0]!==document;){if(s=n.css("position"),("absolute"===s||"relative"===s||"fixed"===s)&&(a=parseInt(n.css("zIndex"),10),!isNaN(a)&&0!==a))return a;n=n.parent()}return 0},uniqueId:function(){return this.each(function(){this.id||(this.id="ui-id-"+ ++a)})},removeUniqueId:function(){return this.each(function(){n.test(this.id)&&e(this).removeAttr("id")})}}),e.extend(e.expr[":"],{data:e.expr.createPseudo?e.expr.createPseudo(function(t){return function(i){return!!e.data(i,t)}}):function(t,i,s){return!!e.data(t,s[3])},focusable:function(t){return i(t,!isNaN(e.attr(t,"tabindex")))},tabbable:function(t){var s=e.attr(t,"tabindex"),a=isNaN(s);return(a||s>=0)&&i(t,!a)}}),e("").outerWidth(1).jquery||e.each(["Width","Height"],function(i,s){function a(t,i,s,a){return e.each(n,function(){i-=parseFloat(e.css(t,"padding"+this))||0,s&&(i-=parseFloat(e.css(t,"border"+this+"Width"))||0),a&&(i-=parseFloat(e.css(t,"margin"+this))||0)}),i}var n="Width"===s?["Left","Right"]:["Top","Bottom"],r=s.toLowerCase(),o={innerWidth:e.fn.innerWidth,innerHeight:e.fn.innerHeight,outerWidth:e.fn.outerWidth,outerHeight:e.fn.outerHeight};e.fn["inner"+s]=function(i){return i===t?o["inner"+s].call(this):this.each(function(){e(this).css(r,a(this,i)+"px")})},e.fn["outer"+s]=function(t,i){return"number"!=typeof t?o["outer"+s].call(this,t):this.each(function(){e(this).css(r,a(this,t,!0,i)+"px")})}}),e.fn.addBack||(e.fn.addBack=function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}),e("").data("a-b","a").removeData("a-b").data("a-b")&&(e.fn.removeData=function(t){return function(i){return arguments.length?t.call(this,e.camelCase(i)):t.call(this)}}(e.fn.removeData)),e.ui.ie=!!/msie [\w.]+/.exec(navigator.userAgent.toLowerCase()),e.support.selectstart="onselectstart"in document.createElement("div"),e.fn.extend({disableSelection:function(){return this.bind((e.support.selectstart?"selectstart":"mousedown")+".ui-disableSelection",function(e){e.preventDefault()})},enableSelection:function(){return this.unbind(".ui-disableSelection")}}),e.extend(e.ui,{plugin:{add:function(t,i,s){var a,n=e.ui[t].prototype;for(a in s)n.plugins[a]=n.plugins[a]||[],n.plugins[a].push([i,s[a]])},call:function(e,t,i){var s,a=e.plugins[t];if(a&&e.element[0].parentNode&&11!==e.element[0].parentNode.nodeType)for(s=0;a.length>s;s++)e.options[a[s][0]]&&a[s][1].apply(e.element,i)}},hasScroll:function(t,i){if("hidden"===e(t).css("overflow"))return!1;var s=i&&"left"===i?"scrollLeft":"scrollTop",a=!1;return t[s]>0?!0:(t[s]=1,a=t[s]>0,t[s]=0,a)}})})(jQuery);(function(e,t){var i=0,s=Array.prototype.slice,n=e.cleanData;e.cleanData=function(t){for(var i,s=0;null!=(i=t[s]);s++)try{e(i).triggerHandler("remove")}catch(a){}n(t)},e.widget=function(i,s,n){var a,r,o,h,l={},u=i.split(".")[0];i=i.split(".")[1],a=u+"-"+i,n||(n=s,s=e.Widget),e.expr[":"][a.toLowerCase()]=function(t){return!!e.data(t,a)},e[u]=e[u]||{},r=e[u][i],o=e[u][i]=function(e,i){return this._createWidget?(arguments.length&&this._createWidget(e,i),t):new o(e,i)},e.extend(o,r,{version:n.version,_proto:e.extend({},n),_childConstructors:[]}),h=new s,h.options=e.widget.extend({},h.options),e.each(n,function(i,n){return e.isFunction(n)?(l[i]=function(){var e=function(){return s.prototype[i].apply(this,arguments)},t=function(e){return s.prototype[i].apply(this,e)};return function(){var i,s=this._super,a=this._superApply;return this._super=e,this._superApply=t,i=n.apply(this,arguments),this._super=s,this._superApply=a,i}}(),t):(l[i]=n,t)}),o.prototype=e.widget.extend(h,{widgetEventPrefix:r?h.widgetEventPrefix:i},l,{constructor:o,namespace:u,widgetName:i,widgetFullName:a}),r?(e.each(r._childConstructors,function(t,i){var s=i.prototype;e.widget(s.namespace+"."+s.widgetName,o,i._proto)}),delete r._childConstructors):s._childConstructors.push(o),e.widget.bridge(i,o)},e.widget.extend=function(i){for(var n,a,r=s.call(arguments,1),o=0,h=r.length;h>o;o++)for(n in r[o])a=r[o][n],r[o].hasOwnProperty(n)&&a!==t&&(i[n]=e.isPlainObject(a)?e.isPlainObject(i[n])?e.widget.extend({},i[n],a):e.widget.extend({},a):a);return i},e.widget.bridge=function(i,n){var a=n.prototype.widgetFullName||i;e.fn[i]=function(r){var o="string"==typeof r,h=s.call(arguments,1),l=this;return r=!o&&h.length?e.widget.extend.apply(null,[r].concat(h)):r,o?this.each(function(){var s,n=e.data(this,a);return n?e.isFunction(n[r])&&"_"!==r.charAt(0)?(s=n[r].apply(n,h),s!==n&&s!==t?(l=s&&s.jquery?l.pushStack(s.get()):s,!1):t):e.error("no such method '"+r+"' for "+i+" widget instance"):e.error("cannot call methods on "+i+" prior to initialization; "+"attempted to call method '"+r+"'")}):this.each(function(){var t=e.data(this,a);t?t.option(r||{})._init():e.data(this,a,new n(r,this))}),l}},e.Widget=function(){},e.Widget._childConstructors=[],e.Widget.prototype={widgetName:"widget",widgetEventPrefix:"",defaultElement:"
",options:{disabled:!1,create:null},_createWidget:function(t,s){s=e(s||this.defaultElement||this)[0],this.element=e(s),this.uuid=i++,this.eventNamespace="."+this.widgetName+this.uuid,this.options=e.widget.extend({},this.options,this._getCreateOptions(),t),this.bindings=e(),this.hoverable=e(),this.focusable=e(),s!==this&&(e.data(s,this.widgetFullName,this),this._on(!0,this.element,{remove:function(e){e.target===s&&this.destroy()}}),this.document=e(s.style?s.ownerDocument:s.document||s),this.window=e(this.document[0].defaultView||this.document[0].parentWindow)),this._create(),this._trigger("create",null,this._getCreateEventData()),this._init()},_getCreateOptions:e.noop,_getCreateEventData:e.noop,_create:e.noop,_init:e.noop,destroy:function(){this._destroy(),this.element.unbind(this.eventNamespace).removeData(this.widgetName).removeData(this.widgetFullName).removeData(e.camelCase(this.widgetFullName)),this.widget().unbind(this.eventNamespace).removeAttr("aria-disabled").removeClass(this.widgetFullName+"-disabled "+"ui-state-disabled"),this.bindings.unbind(this.eventNamespace),this.hoverable.removeClass("ui-state-hover"),this.focusable.removeClass("ui-state-focus")},_destroy:e.noop,widget:function(){return this.element},option:function(i,s){var n,a,r,o=i;if(0===arguments.length)return e.widget.extend({},this.options);if("string"==typeof i)if(o={},n=i.split("."),i=n.shift(),n.length){for(a=o[i]=e.widget.extend({},this.options[i]),r=0;n.length-1>r;r++)a[n[r]]=a[n[r]]||{},a=a[n[r]];if(i=n.pop(),s===t)return a[i]===t?null:a[i];a[i]=s}else{if(s===t)return this.options[i]===t?null:this.options[i];o[i]=s}return this._setOptions(o),this},_setOptions:function(e){var t;for(t in e)this._setOption(t,e[t]);return this},_setOption:function(e,t){return this.options[e]=t,"disabled"===e&&(this.widget().toggleClass(this.widgetFullName+"-disabled ui-state-disabled",!!t).attr("aria-disabled",t),this.hoverable.removeClass("ui-state-hover"),this.focusable.removeClass("ui-state-focus")),this},enable:function(){return this._setOption("disabled",!1)},disable:function(){return this._setOption("disabled",!0)},_on:function(i,s,n){var a,r=this;"boolean"!=typeof i&&(n=s,s=i,i=!1),n?(s=a=e(s),this.bindings=this.bindings.add(s)):(n=s,s=this.element,a=this.widget()),e.each(n,function(n,o){function h(){return i||r.options.disabled!==!0&&!e(this).hasClass("ui-state-disabled")?("string"==typeof o?r[o]:o).apply(r,arguments):t}"string"!=typeof o&&(h.guid=o.guid=o.guid||h.guid||e.guid++);var l=n.match(/^(\w+)\s*(.*)$/),u=l[1]+r.eventNamespace,c=l[2];c?a.delegate(c,u,h):s.bind(u,h)})},_off:function(e,t){t=(t||"").split(" ").join(this.eventNamespace+" ")+this.eventNamespace,e.unbind(t).undelegate(t)},_delay:function(e,t){function i(){return("string"==typeof e?s[e]:e).apply(s,arguments)}var s=this;return setTimeout(i,t||0)},_hoverable:function(t){this.hoverable=this.hoverable.add(t),this._on(t,{mouseenter:function(t){e(t.currentTarget).addClass("ui-state-hover")},mouseleave:function(t){e(t.currentTarget).removeClass("ui-state-hover")}})},_focusable:function(t){this.focusable=this.focusable.add(t),this._on(t,{focusin:function(t){e(t.currentTarget).addClass("ui-state-focus")},focusout:function(t){e(t.currentTarget).removeClass("ui-state-focus")}})},_trigger:function(t,i,s){var n,a,r=this.options[t];if(s=s||{},i=e.Event(i),i.type=(t===this.widgetEventPrefix?t:this.widgetEventPrefix+t).toLowerCase(),i.target=this.element[0],a=i.originalEvent)for(n in a)n in i||(i[n]=a[n]);return this.element.trigger(i,s),!(e.isFunction(r)&&r.apply(this.element[0],[i].concat(s))===!1||i.isDefaultPrevented())}},e.each({show:"fadeIn",hide:"fadeOut"},function(t,i){e.Widget.prototype["_"+t]=function(s,n,a){"string"==typeof n&&(n={effect:n});var r,o=n?n===!0||"number"==typeof n?i:n.effect||i:t;n=n||{},"number"==typeof n&&(n={duration:n}),r=!e.isEmptyObject(n),n.complete=a,n.delay&&s.delay(n.delay),r&&e.effects&&e.effects.effect[o]?s[t](n):o!==t&&s[o]?s[o](n.duration,n.easing,a):s.queue(function(i){e(this)[t](),a&&a.call(s[0]),i()})}})})(jQuery);(function(e){var t=!1;e(document).mouseup(function(){t=!1}),e.widget("ui.mouse",{version:"1.10.3",options:{cancel:"input,textarea,button,select,option",distance:1,delay:0},_mouseInit:function(){var t=this;this.element.bind("mousedown."+this.widgetName,function(e){return t._mouseDown(e)}).bind("click."+this.widgetName,function(i){return!0===e.data(i.target,t.widgetName+".preventClickEvent")?(e.removeData(i.target,t.widgetName+".preventClickEvent"),i.stopImmediatePropagation(),!1):undefined}),this.started=!1},_mouseDestroy:function(){this.element.unbind("."+this.widgetName),this._mouseMoveDelegate&&e(document).unbind("mousemove."+this.widgetName,this._mouseMoveDelegate).unbind("mouseup."+this.widgetName,this._mouseUpDelegate)},_mouseDown:function(i){if(!t){this._mouseStarted&&this._mouseUp(i),this._mouseDownEvent=i;var s=this,n=1===i.which,a="string"==typeof this.options.cancel&&i.target.nodeName?e(i.target).closest(this.options.cancel).length:!1;return n&&!a&&this._mouseCapture(i)?(this.mouseDelayMet=!this.options.delay,this.mouseDelayMet||(this._mouseDelayTimer=setTimeout(function(){s.mouseDelayMet=!0},this.options.delay)),this._mouseDistanceMet(i)&&this._mouseDelayMet(i)&&(this._mouseStarted=this._mouseStart(i)!==!1,!this._mouseStarted)?(i.preventDefault(),!0):(!0===e.data(i.target,this.widgetName+".preventClickEvent")&&e.removeData(i.target,this.widgetName+".preventClickEvent"),this._mouseMoveDelegate=function(e){return s._mouseMove(e)},this._mouseUpDelegate=function(e){return s._mouseUp(e)},e(document).bind("mousemove."+this.widgetName,this._mouseMoveDelegate).bind("mouseup."+this.widgetName,this._mouseUpDelegate),i.preventDefault(),t=!0,!0)):!0}},_mouseMove:function(t){return e.ui.ie&&(!document.documentMode||9>document.documentMode)&&!t.button?this._mouseUp(t):this._mouseStarted?(this._mouseDrag(t),t.preventDefault()):(this._mouseDistanceMet(t)&&this._mouseDelayMet(t)&&(this._mouseStarted=this._mouseStart(this._mouseDownEvent,t)!==!1,this._mouseStarted?this._mouseDrag(t):this._mouseUp(t)),!this._mouseStarted)},_mouseUp:function(t){return e(document).unbind("mousemove."+this.widgetName,this._mouseMoveDelegate).unbind("mouseup."+this.widgetName,this._mouseUpDelegate),this._mouseStarted&&(this._mouseStarted=!1,t.target===this._mouseDownEvent.target&&e.data(t.target,this.widgetName+".preventClickEvent",!0),this._mouseStop(t)),!1},_mouseDistanceMet:function(e){return Math.max(Math.abs(this._mouseDownEvent.pageX-e.pageX),Math.abs(this._mouseDownEvent.pageY-e.pageY))>=this.options.distance},_mouseDelayMet:function(){return this.mouseDelayMet},_mouseStart:function(){},_mouseDrag:function(){},_mouseStop:function(){},_mouseCapture:function(){return!0}})})(jQuery);(function(t,e){function i(t,e,i){return[parseFloat(t[0])*(p.test(t[0])?e/100:1),parseFloat(t[1])*(p.test(t[1])?i/100:1)]}function s(e,i){return parseInt(t.css(e,i),10)||0}function n(e){var i=e[0];return 9===i.nodeType?{width:e.width(),height:e.height(),offset:{top:0,left:0}}:t.isWindow(i)?{width:e.width(),height:e.height(),offset:{top:e.scrollTop(),left:e.scrollLeft()}}:i.preventDefault?{width:0,height:0,offset:{top:i.pageY,left:i.pageX}}:{width:e.outerWidth(),height:e.outerHeight(),offset:e.offset()}}t.ui=t.ui||{};var a,o=Math.max,r=Math.abs,h=Math.round,l=/left|center|right/,c=/top|center|bottom/,u=/[\+\-]\d+(\.[\d]+)?%?/,d=/^\w+/,p=/%$/,f=t.fn.position;t.position={scrollbarWidth:function(){if(a!==e)return a;var i,s,n=t("
"),o=n.children()[0];return t("body").append(n),i=o.offsetWidth,n.css("overflow","scroll"),s=o.offsetWidth,i===s&&(s=n[0].clientWidth),n.remove(),a=i-s},getScrollInfo:function(e){var i=e.isWindow?"":e.element.css("overflow-x"),s=e.isWindow?"":e.element.css("overflow-y"),n="scroll"===i||"auto"===i&&e.widths?"left":i>0?"right":"center",vertical:0>a?"top":n>0?"bottom":"middle"};u>p&&p>r(i+s)&&(h.horizontal="center"),d>m&&m>r(n+a)&&(h.vertical="middle"),h.important=o(r(i),r(s))>o(r(n),r(a))?"horizontal":"vertical",e.using.call(this,t,h)}),c.offset(t.extend(C,{using:l}))})},t.ui.position={fit:{left:function(t,e){var i,s=e.within,n=s.isWindow?s.scrollLeft:s.offset.left,a=s.width,r=t.left-e.collisionPosition.marginLeft,h=n-r,l=r+e.collisionWidth-a-n;e.collisionWidth>a?h>0&&0>=l?(i=t.left+h+e.collisionWidth-a-n,t.left+=h-i):t.left=l>0&&0>=h?n:h>l?n+a-e.collisionWidth:n:h>0?t.left+=h:l>0?t.left-=l:t.left=o(t.left-r,t.left)},top:function(t,e){var i,s=e.within,n=s.isWindow?s.scrollTop:s.offset.top,a=e.within.height,r=t.top-e.collisionPosition.marginTop,h=n-r,l=r+e.collisionHeight-a-n;e.collisionHeight>a?h>0&&0>=l?(i=t.top+h+e.collisionHeight-a-n,t.top+=h-i):t.top=l>0&&0>=h?n:h>l?n+a-e.collisionHeight:n:h>0?t.top+=h:l>0?t.top-=l:t.top=o(t.top-r,t.top)}},flip:{left:function(t,e){var i,s,n=e.within,a=n.offset.left+n.scrollLeft,o=n.width,h=n.isWindow?n.scrollLeft:n.offset.left,l=t.left-e.collisionPosition.marginLeft,c=l-h,u=l+e.collisionWidth-o-h,d="left"===e.my[0]?-e.elemWidth:"right"===e.my[0]?e.elemWidth:0,p="left"===e.at[0]?e.targetWidth:"right"===e.at[0]?-e.targetWidth:0,f=-2*e.offset[0];0>c?(i=t.left+d+p+f+e.collisionWidth-o-a,(0>i||r(c)>i)&&(t.left+=d+p+f)):u>0&&(s=t.left-e.collisionPosition.marginLeft+d+p+f-h,(s>0||u>r(s))&&(t.left+=d+p+f))},top:function(t,e){var i,s,n=e.within,a=n.offset.top+n.scrollTop,o=n.height,h=n.isWindow?n.scrollTop:n.offset.top,l=t.top-e.collisionPosition.marginTop,c=l-h,u=l+e.collisionHeight-o-h,d="top"===e.my[1],p=d?-e.elemHeight:"bottom"===e.my[1]?e.elemHeight:0,f="top"===e.at[1]?e.targetHeight:"bottom"===e.at[1]?-e.targetHeight:0,m=-2*e.offset[1];0>c?(s=t.top+p+f+m+e.collisionHeight-o-a,t.top+p+f+m>c&&(0>s||r(c)>s)&&(t.top+=p+f+m)):u>0&&(i=t.top-e.collisionPosition.marginTop+p+f+m-h,t.top+p+f+m>u&&(i>0||u>r(i))&&(t.top+=p+f+m))}},flipfit:{left:function(){t.ui.position.flip.left.apply(this,arguments),t.ui.position.fit.left.apply(this,arguments)},top:function(){t.ui.position.flip.top.apply(this,arguments),t.ui.position.fit.top.apply(this,arguments)}}},function(){var e,i,s,n,a,o=document.getElementsByTagName("body")[0],r=document.createElement("div");e=document.createElement(o?"div":"body"),s={visibility:"hidden",width:0,height:0,border:0,margin:0,background:"none"},o&&t.extend(s,{position:"absolute",left:"-1000px",top:"-1000px"});for(a in s)e.style[a]=s[a];e.appendChild(r),i=o||document.documentElement,i.insertBefore(e,i.firstChild),r.style.cssText="position: absolute; left: 10.7432222px;",n=t(r).offset().left,t.support.offsetFractions=n>10&&11>n,e.innerHTML="",i.removeChild(e)}()})(jQuery);(function(t,e){function i(){this._curInst=null,this._keyEvent=!1,this._disabledInputs=[],this._datepickerShowing=!1,this._inDialog=!1,this._mainDivId="ui-datepicker-div",this._inlineClass="ui-datepicker-inline",this._appendClass="ui-datepicker-append",this._triggerClass="ui-datepicker-trigger",this._dialogClass="ui-datepicker-dialog",this._disableClass="ui-datepicker-disabled",this._unselectableClass="ui-datepicker-unselectable",this._currentClass="ui-datepicker-current-day",this._dayOverClass="ui-datepicker-days-cell-over",this.regional=[],this.regional[""]={closeText:"Done",prevText:"Prev",nextText:"Next",currentText:"Today",monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],weekHeader:"Wk",dateFormat:"mm/dd/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""},this._defaults={showOn:"focus",showAnim:"fadeIn",showOptions:{},defaultDate:null,appendText:"",buttonText:"...",buttonImage:"",buttonImageOnly:!1,hideIfNoPrevNext:!1,navigationAsDateFormat:!1,gotoCurrent:!1,changeMonth:!1,changeYear:!1,yearRange:"c-10:c+10",showOtherMonths:!1,selectOtherMonths:!1,showWeek:!1,calculateWeek:this.iso8601Week,shortYearCutoff:"+10",minDate:null,maxDate:null,duration:"fast",beforeShowDay:null,beforeShow:null,onSelect:null,onChangeMonthYear:null,onClose:null,numberOfMonths:1,showCurrentAtPos:0,stepMonths:1,stepBigMonths:12,altField:"",altFormat:"",constrainInput:!0,showButtonPanel:!1,autoSize:!1,disabled:!1},t.extend(this._defaults,this.regional[""]),this.dpDiv=s(t("
"))}function s(e){var i="button, .ui-datepicker-prev, .ui-datepicker-next, .ui-datepicker-calendar td a";return e.delegate(i,"mouseout",function(){t(this).removeClass("ui-state-hover"),-1!==this.className.indexOf("ui-datepicker-prev")&&t(this).removeClass("ui-datepicker-prev-hover"),-1!==this.className.indexOf("ui-datepicker-next")&&t(this).removeClass("ui-datepicker-next-hover")}).delegate(i,"mouseover",function(){t.datepicker._isDisabledDatepicker(a.inline?e.parent()[0]:a.input[0])||(t(this).parents(".ui-datepicker-calendar").find("a").removeClass("ui-state-hover"),t(this).addClass("ui-state-hover"),-1!==this.className.indexOf("ui-datepicker-prev")&&t(this).addClass("ui-datepicker-prev-hover"),-1!==this.className.indexOf("ui-datepicker-next")&&t(this).addClass("ui-datepicker-next-hover"))})}function n(e,i){t.extend(e,i);for(var s in i)null==i[s]&&(e[s]=i[s]);return e}t.extend(t.ui,{datepicker:{version:"1.10.3"}});var a,r="datepicker";t.extend(i.prototype,{markerClassName:"hasDatepicker",maxRows:4,_widgetDatepicker:function(){return this.dpDiv},setDefaults:function(t){return n(this._defaults,t||{}),this},_attachDatepicker:function(e,i){var s,n,a;s=e.nodeName.toLowerCase(),n="div"===s||"span"===s,e.id||(this.uuid+=1,e.id="dp"+this.uuid),a=this._newInst(t(e),n),a.settings=t.extend({},i||{}),"input"===s?this._connectDatepicker(e,a):n&&this._inlineDatepicker(e,a)},_newInst:function(e,i){var n=e[0].id.replace(/([^A-Za-z0-9_\-])/g,"\\\\$1");return{id:n,input:e,selectedDay:0,selectedMonth:0,selectedYear:0,drawMonth:0,drawYear:0,inline:i,dpDiv:i?s(t("
")):this.dpDiv}},_connectDatepicker:function(e,i){var s=t(e);i.append=t([]),i.trigger=t([]),s.hasClass(this.markerClassName)||(this._attachments(s,i),s.addClass(this.markerClassName).keydown(this._doKeyDown).keypress(this._doKeyPress).keyup(this._doKeyUp),this._autoSize(i),t.data(e,r,i),i.settings.disabled&&this._disableDatepicker(e))},_attachments:function(e,i){var s,n,a,r=this._get(i,"appendText"),o=this._get(i,"isRTL");i.append&&i.append.remove(),r&&(i.append=t(""+r+""),e[o?"before":"after"](i.append)),e.unbind("focus",this._showDatepicker),i.trigger&&i.trigger.remove(),s=this._get(i,"showOn"),("focus"===s||"both"===s)&&e.focus(this._showDatepicker),("button"===s||"both"===s)&&(n=this._get(i,"buttonText"),a=this._get(i,"buttonImage"),i.trigger=t(this._get(i,"buttonImageOnly")?t("").addClass(this._triggerClass).attr({src:a,alt:n,title:n}):t("").addClass(this._triggerClass).html(a?t("").attr({src:a,alt:n,title:n}):n)),e[o?"before":"after"](i.trigger),i.trigger.click(function(){return t.datepicker._datepickerShowing&&t.datepicker._lastInput===e[0]?t.datepicker._hideDatepicker():t.datepicker._datepickerShowing&&t.datepicker._lastInput!==e[0]?(t.datepicker._hideDatepicker(),t.datepicker._showDatepicker(e[0])):t.datepicker._showDatepicker(e[0]),!1}))},_autoSize:function(t){if(this._get(t,"autoSize")&&!t.inline){var e,i,s,n,a=new Date(2009,11,20),r=this._get(t,"dateFormat");r.match(/[DM]/)&&(e=function(t){for(i=0,s=0,n=0;t.length>n;n++)t[n].length>i&&(i=t[n].length,s=n);return s},a.setMonth(e(this._get(t,r.match(/MM/)?"monthNames":"monthNamesShort"))),a.setDate(e(this._get(t,r.match(/DD/)?"dayNames":"dayNamesShort"))+20-a.getDay())),t.input.attr("size",this._formatDate(t,a).length)}},_inlineDatepicker:function(e,i){var s=t(e);s.hasClass(this.markerClassName)||(s.addClass(this.markerClassName).append(i.dpDiv),t.data(e,r,i),this._setDate(i,this._getDefaultDate(i),!0),this._updateDatepicker(i),this._updateAlternate(i),i.settings.disabled&&this._disableDatepicker(e),i.dpDiv.css("display","block"))},_dialogDatepicker:function(e,i,s,a,o){var h,l,c,u,d,p=this._dialogInst;return p||(this.uuid+=1,h="dp"+this.uuid,this._dialogInput=t(""),this._dialogInput.keydown(this._doKeyDown),t("body").append(this._dialogInput),p=this._dialogInst=this._newInst(this._dialogInput,!1),p.settings={},t.data(this._dialogInput[0],r,p)),n(p.settings,a||{}),i=i&&i.constructor===Date?this._formatDate(p,i):i,this._dialogInput.val(i),this._pos=o?o.length?o:[o.pageX,o.pageY]:null,this._pos||(l=document.documentElement.clientWidth,c=document.documentElement.clientHeight,u=document.documentElement.scrollLeft||document.body.scrollLeft,d=document.documentElement.scrollTop||document.body.scrollTop,this._pos=[l/2-100+u,c/2-150+d]),this._dialogInput.css("left",this._pos[0]+20+"px").css("top",this._pos[1]+"px"),p.settings.onSelect=s,this._inDialog=!0,this.dpDiv.addClass(this._dialogClass),this._showDatepicker(this._dialogInput[0]),t.blockUI&&t.blockUI(this.dpDiv),t.data(this._dialogInput[0],r,p),this},_destroyDatepicker:function(e){var i,s=t(e),n=t.data(e,r);s.hasClass(this.markerClassName)&&(i=e.nodeName.toLowerCase(),t.removeData(e,r),"input"===i?(n.append.remove(),n.trigger.remove(),s.removeClass(this.markerClassName).unbind("focus",this._showDatepicker).unbind("keydown",this._doKeyDown).unbind("keypress",this._doKeyPress).unbind("keyup",this._doKeyUp)):("div"===i||"span"===i)&&s.removeClass(this.markerClassName).empty())},_enableDatepicker:function(e){var i,s,n=t(e),a=t.data(e,r);n.hasClass(this.markerClassName)&&(i=e.nodeName.toLowerCase(),"input"===i?(e.disabled=!1,a.trigger.filter("button").each(function(){this.disabled=!1}).end().filter("img").css({opacity:"1.0",cursor:""})):("div"===i||"span"===i)&&(s=n.children("."+this._inlineClass),s.children().removeClass("ui-state-disabled"),s.find("select.ui-datepicker-month, select.ui-datepicker-year").prop("disabled",!1)),this._disabledInputs=t.map(this._disabledInputs,function(t){return t===e?null:t}))},_disableDatepicker:function(e){var i,s,n=t(e),a=t.data(e,r);n.hasClass(this.markerClassName)&&(i=e.nodeName.toLowerCase(),"input"===i?(e.disabled=!0,a.trigger.filter("button").each(function(){this.disabled=!0}).end().filter("img").css({opacity:"0.5",cursor:"default"})):("div"===i||"span"===i)&&(s=n.children("."+this._inlineClass),s.children().addClass("ui-state-disabled"),s.find("select.ui-datepicker-month, select.ui-datepicker-year").prop("disabled",!0)),this._disabledInputs=t.map(this._disabledInputs,function(t){return t===e?null:t}),this._disabledInputs[this._disabledInputs.length]=e)},_isDisabledDatepicker:function(t){if(!t)return!1;for(var e=0;this._disabledInputs.length>e;e++)if(this._disabledInputs[e]===t)return!0;return!1},_getInst:function(e){try{return t.data(e,r)}catch(i){throw"Missing instance data for this datepicker"}},_optionDatepicker:function(i,s,a){var r,o,h,l,c=this._getInst(i);return 2===arguments.length&&"string"==typeof s?"defaults"===s?t.extend({},t.datepicker._defaults):c?"all"===s?t.extend({},c.settings):this._get(c,s):null:(r=s||{},"string"==typeof s&&(r={},r[s]=a),c&&(this._curInst===c&&this._hideDatepicker(),o=this._getDateDatepicker(i,!0),h=this._getMinMaxDate(c,"min"),l=this._getMinMaxDate(c,"max"),n(c.settings,r),null!==h&&r.dateFormat!==e&&r.minDate===e&&(c.settings.minDate=this._formatDate(c,h)),null!==l&&r.dateFormat!==e&&r.maxDate===e&&(c.settings.maxDate=this._formatDate(c,l)),"disabled"in r&&(r.disabled?this._disableDatepicker(i):this._enableDatepicker(i)),this._attachments(t(i),c),this._autoSize(c),this._setDate(c,o),this._updateAlternate(c),this._updateDatepicker(c)),e)},_changeDatepicker:function(t,e,i){this._optionDatepicker(t,e,i)},_refreshDatepicker:function(t){var e=this._getInst(t);e&&this._updateDatepicker(e)},_setDateDatepicker:function(t,e){var i=this._getInst(t);i&&(this._setDate(i,e),this._updateDatepicker(i),this._updateAlternate(i))},_getDateDatepicker:function(t,e){var i=this._getInst(t);return i&&!i.inline&&this._setDateFromField(i,e),i?this._getDate(i):null},_doKeyDown:function(e){var i,s,n,a=t.datepicker._getInst(e.target),r=!0,o=a.dpDiv.is(".ui-datepicker-rtl");if(a._keyEvent=!0,t.datepicker._datepickerShowing)switch(e.keyCode){case 9:t.datepicker._hideDatepicker(),r=!1;break;case 13:return n=t("td."+t.datepicker._dayOverClass+":not(."+t.datepicker._currentClass+")",a.dpDiv),n[0]&&t.datepicker._selectDay(e.target,a.selectedMonth,a.selectedYear,n[0]),i=t.datepicker._get(a,"onSelect"),i?(s=t.datepicker._formatDate(a),i.apply(a.input?a.input[0]:null,[s,a])):t.datepicker._hideDatepicker(),!1;case 27:t.datepicker._hideDatepicker();break;case 33:t.datepicker._adjustDate(e.target,e.ctrlKey?-t.datepicker._get(a,"stepBigMonths"):-t.datepicker._get(a,"stepMonths"),"M");break;case 34:t.datepicker._adjustDate(e.target,e.ctrlKey?+t.datepicker._get(a,"stepBigMonths"):+t.datepicker._get(a,"stepMonths"),"M");break;case 35:(e.ctrlKey||e.metaKey)&&t.datepicker._clearDate(e.target),r=e.ctrlKey||e.metaKey;break;case 36:(e.ctrlKey||e.metaKey)&&t.datepicker._gotoToday(e.target),r=e.ctrlKey||e.metaKey;break;case 37:(e.ctrlKey||e.metaKey)&&t.datepicker._adjustDate(e.target,o?1:-1,"D"),r=e.ctrlKey||e.metaKey,e.originalEvent.altKey&&t.datepicker._adjustDate(e.target,e.ctrlKey?-t.datepicker._get(a,"stepBigMonths"):-t.datepicker._get(a,"stepMonths"),"M");break;case 38:(e.ctrlKey||e.metaKey)&&t.datepicker._adjustDate(e.target,-7,"D"),r=e.ctrlKey||e.metaKey;break;case 39:(e.ctrlKey||e.metaKey)&&t.datepicker._adjustDate(e.target,o?-1:1,"D"),r=e.ctrlKey||e.metaKey,e.originalEvent.altKey&&t.datepicker._adjustDate(e.target,e.ctrlKey?+t.datepicker._get(a,"stepBigMonths"):+t.datepicker._get(a,"stepMonths"),"M");break;case 40:(e.ctrlKey||e.metaKey)&&t.datepicker._adjustDate(e.target,7,"D"),r=e.ctrlKey||e.metaKey;break;default:r=!1}else 36===e.keyCode&&e.ctrlKey?t.datepicker._showDatepicker(this):r=!1;r&&(e.preventDefault(),e.stopPropagation())},_doKeyPress:function(i){var s,n,a=t.datepicker._getInst(i.target);return t.datepicker._get(a,"constrainInput")?(s=t.datepicker._possibleChars(t.datepicker._get(a,"dateFormat")),n=String.fromCharCode(null==i.charCode?i.keyCode:i.charCode),i.ctrlKey||i.metaKey||" ">n||!s||s.indexOf(n)>-1):e},_doKeyUp:function(e){var i,s=t.datepicker._getInst(e.target);if(s.input.val()!==s.lastVal)try{i=t.datepicker.parseDate(t.datepicker._get(s,"dateFormat"),s.input?s.input.val():null,t.datepicker._getFormatConfig(s)),i&&(t.datepicker._setDateFromField(s),t.datepicker._updateAlternate(s),t.datepicker._updateDatepicker(s))}catch(n){}return!0},_showDatepicker:function(e){if(e=e.target||e,"input"!==e.nodeName.toLowerCase()&&(e=t("input",e.parentNode)[0]),!t.datepicker._isDisabledDatepicker(e)&&t.datepicker._lastInput!==e){var i,s,a,r,o,h,l;i=t.datepicker._getInst(e),t.datepicker._curInst&&t.datepicker._curInst!==i&&(t.datepicker._curInst.dpDiv.stop(!0,!0),i&&t.datepicker._datepickerShowing&&t.datepicker._hideDatepicker(t.datepicker._curInst.input[0])),s=t.datepicker._get(i,"beforeShow"),a=s?s.apply(e,[e,i]):{},a!==!1&&(n(i.settings,a),i.lastVal=null,t.datepicker._lastInput=e,t.datepicker._setDateFromField(i),t.datepicker._inDialog&&(e.value=""),t.datepicker._pos||(t.datepicker._pos=t.datepicker._findPos(e),t.datepicker._pos[1]+=e.offsetHeight),r=!1,t(e).parents().each(function(){return r|="fixed"===t(this).css("position"),!r}),o={left:t.datepicker._pos[0],top:t.datepicker._pos[1]},t.datepicker._pos=null,i.dpDiv.empty(),i.dpDiv.css({position:"absolute",display:"block",top:"-1000px"}),t.datepicker._updateDatepicker(i),o=t.datepicker._checkOffset(i,o,r),i.dpDiv.css({position:t.datepicker._inDialog&&t.blockUI?"static":r?"fixed":"absolute",display:"none",left:o.left+"px",top:o.top+"px"}),i.inline||(h=t.datepicker._get(i,"showAnim"),l=t.datepicker._get(i,"duration"),i.dpDiv.zIndex(t(e).zIndex()+1),t.datepicker._datepickerShowing=!0,t.effects&&t.effects.effect[h]?i.dpDiv.show(h,t.datepicker._get(i,"showOptions"),l):i.dpDiv[h||"show"](h?l:null),t.datepicker._shouldFocusInput(i)&&i.input.focus(),t.datepicker._curInst=i))}},_updateDatepicker:function(e){this.maxRows=4,a=e,e.dpDiv.empty().append(this._generateHTML(e)),this._attachHandlers(e),e.dpDiv.find("."+this._dayOverClass+" a").mouseover();var i,s=this._getNumberOfMonths(e),n=s[1],r=17;e.dpDiv.removeClass("ui-datepicker-multi-2 ui-datepicker-multi-3 ui-datepicker-multi-4").width(""),n>1&&e.dpDiv.addClass("ui-datepicker-multi-"+n).css("width",r*n+"em"),e.dpDiv[(1!==s[0]||1!==s[1]?"add":"remove")+"Class"]("ui-datepicker-multi"),e.dpDiv[(this._get(e,"isRTL")?"add":"remove")+"Class"]("ui-datepicker-rtl"),e===t.datepicker._curInst&&t.datepicker._datepickerShowing&&t.datepicker._shouldFocusInput(e)&&e.input.focus(),e.yearshtml&&(i=e.yearshtml,setTimeout(function(){i===e.yearshtml&&e.yearshtml&&e.dpDiv.find("select.ui-datepicker-year:first").replaceWith(e.yearshtml),i=e.yearshtml=null},0))},_shouldFocusInput:function(t){return t.input&&t.input.is(":visible")&&!t.input.is(":disabled")&&!t.input.is(":focus")},_checkOffset:function(e,i,s){var n=e.dpDiv.outerWidth(),a=e.dpDiv.outerHeight(),r=e.input?e.input.outerWidth():0,o=e.input?e.input.outerHeight():0,h=document.documentElement.clientWidth+(s?0:t(document).scrollLeft()),l=document.documentElement.clientHeight+(s?0:t(document).scrollTop());return i.left-=this._get(e,"isRTL")?n-r:0,i.left-=s&&i.left===e.input.offset().left?t(document).scrollLeft():0,i.top-=s&&i.top===e.input.offset().top+o?t(document).scrollTop():0,i.left-=Math.min(i.left,i.left+n>h&&h>n?Math.abs(i.left+n-h):0),i.top-=Math.min(i.top,i.top+a>l&&l>a?Math.abs(a+o):0),i},_findPos:function(e){for(var i,s=this._getInst(e),n=this._get(s,"isRTL");e&&("hidden"===e.type||1!==e.nodeType||t.expr.filters.hidden(e));)e=e[n?"previousSibling":"nextSibling"];return i=t(e).offset(),[i.left,i.top]},_hideDatepicker:function(e){var i,s,n,a,o=this._curInst;!o||e&&o!==t.data(e,r)||this._datepickerShowing&&(i=this._get(o,"showAnim"),s=this._get(o,"duration"),n=function(){t.datepicker._tidyDialog(o)},t.effects&&(t.effects.effect[i]||t.effects[i])?o.dpDiv.hide(i,t.datepicker._get(o,"showOptions"),s,n):o.dpDiv["slideDown"===i?"slideUp":"fadeIn"===i?"fadeOut":"hide"](i?s:null,n),i||n(),this._datepickerShowing=!1,a=this._get(o,"onClose"),a&&a.apply(o.input?o.input[0]:null,[o.input?o.input.val():"",o]),this._lastInput=null,this._inDialog&&(this._dialogInput.css({position:"absolute",left:"0",top:"-100px"}),t.blockUI&&(t.unblockUI(),t("body").append(this.dpDiv))),this._inDialog=!1)},_tidyDialog:function(t){t.dpDiv.removeClass(this._dialogClass).unbind(".ui-datepicker-calendar")},_checkExternalClick:function(e){if(t.datepicker._curInst){var i=t(e.target),s=t.datepicker._getInst(i[0]);(i[0].id!==t.datepicker._mainDivId&&0===i.parents("#"+t.datepicker._mainDivId).length&&!i.hasClass(t.datepicker.markerClassName)&&!i.closest("."+t.datepicker._triggerClass).length&&t.datepicker._datepickerShowing&&(!t.datepicker._inDialog||!t.blockUI)||i.hasClass(t.datepicker.markerClassName)&&t.datepicker._curInst!==s)&&t.datepicker._hideDatepicker()}},_adjustDate:function(e,i,s){var n=t(e),a=this._getInst(n[0]);this._isDisabledDatepicker(n[0])||(this._adjustInstDate(a,i+("M"===s?this._get(a,"showCurrentAtPos"):0),s),this._updateDatepicker(a))},_gotoToday:function(e){var i,s=t(e),n=this._getInst(s[0]);this._get(n,"gotoCurrent")&&n.currentDay?(n.selectedDay=n.currentDay,n.drawMonth=n.selectedMonth=n.currentMonth,n.drawYear=n.selectedYear=n.currentYear):(i=new Date,n.selectedDay=i.getDate(),n.drawMonth=n.selectedMonth=i.getMonth(),n.drawYear=n.selectedYear=i.getFullYear()),this._notifyChange(n),this._adjustDate(s)},_selectMonthYear:function(e,i,s){var n=t(e),a=this._getInst(n[0]);a["selected"+("M"===s?"Month":"Year")]=a["draw"+("M"===s?"Month":"Year")]=parseInt(i.options[i.selectedIndex].value,10),this._notifyChange(a),this._adjustDate(n)},_selectDay:function(e,i,s,n){var a,r=t(e);t(n).hasClass(this._unselectableClass)||this._isDisabledDatepicker(r[0])||(a=this._getInst(r[0]),a.selectedDay=a.currentDay=t("a",n).html(),a.selectedMonth=a.currentMonth=i,a.selectedYear=a.currentYear=s,this._selectDate(e,this._formatDate(a,a.currentDay,a.currentMonth,a.currentYear)))},_clearDate:function(e){var i=t(e);this._selectDate(i,"")},_selectDate:function(e,i){var s,n=t(e),a=this._getInst(n[0]);i=null!=i?i:this._formatDate(a),a.input&&a.input.val(i),this._updateAlternate(a),s=this._get(a,"onSelect"),s?s.apply(a.input?a.input[0]:null,[i,a]):a.input&&a.input.trigger("change"),a.inline?this._updateDatepicker(a):(this._hideDatepicker(),this._lastInput=a.input[0],"object"!=typeof a.input[0]&&a.input.focus(),this._lastInput=null)},_updateAlternate:function(e){var i,s,n,a=this._get(e,"altField");a&&(i=this._get(e,"altFormat")||this._get(e,"dateFormat"),s=this._getDate(e),n=this.formatDate(i,s,this._getFormatConfig(e)),t(a).each(function(){t(this).val(n)}))},noWeekends:function(t){var e=t.getDay();return[e>0&&6>e,""]},iso8601Week:function(t){var e,i=new Date(t.getTime());return i.setDate(i.getDate()+4-(i.getDay()||7)),e=i.getTime(),i.setMonth(0),i.setDate(1),Math.floor(Math.round((e-i)/864e5)/7)+1},parseDate:function(i,s,n){if(null==i||null==s)throw"Invalid arguments";if(s="object"==typeof s?""+s:s+"",""===s)return null;var a,r,o,h,l=0,c=(n?n.shortYearCutoff:null)||this._defaults.shortYearCutoff,u="string"!=typeof c?c:(new Date).getFullYear()%100+parseInt(c,10),d=(n?n.dayNamesShort:null)||this._defaults.dayNamesShort,p=(n?n.dayNames:null)||this._defaults.dayNames,f=(n?n.monthNamesShort:null)||this._defaults.monthNamesShort,m=(n?n.monthNames:null)||this._defaults.monthNames,g=-1,v=-1,_=-1,b=-1,y=!1,x=function(t){var e=i.length>a+1&&i.charAt(a+1)===t;return e&&a++,e},k=function(t){var e=x(t),i="@"===t?14:"!"===t?20:"y"===t&&e?4:"o"===t?3:2,n=RegExp("^\\d{1,"+i+"}"),a=s.substring(l).match(n);if(!a)throw"Missing number at position "+l;return l+=a[0].length,parseInt(a[0],10)},w=function(i,n,a){var r=-1,o=t.map(x(i)?a:n,function(t,e){return[[e,t]]}).sort(function(t,e){return-(t[1].length-e[1].length)});if(t.each(o,function(t,i){var n=i[1];return s.substr(l,n.length).toLowerCase()===n.toLowerCase()?(r=i[0],l+=n.length,!1):e}),-1!==r)return r+1;throw"Unknown name at position "+l},D=function(){if(s.charAt(l)!==i.charAt(a))throw"Unexpected literal at position "+l;l++};for(a=0;i.length>a;a++)if(y)"'"!==i.charAt(a)||x("'")?D():y=!1;else switch(i.charAt(a)){case"d":_=k("d");break;case"D":w("D",d,p);break;case"o":b=k("o");break;case"m":v=k("m");break;case"M":v=w("M",f,m);break;case"y":g=k("y");break;case"@":h=new Date(k("@")),g=h.getFullYear(),v=h.getMonth()+1,_=h.getDate();break;case"!":h=new Date((k("!")-this._ticksTo1970)/1e4),g=h.getFullYear(),v=h.getMonth()+1,_=h.getDate();break;case"'":x("'")?D():y=!0;break;default:D()}if(s.length>l&&(o=s.substr(l),!/^\s+/.test(o)))throw"Extra/unparsed characters found in date: "+o;if(-1===g?g=(new Date).getFullYear():100>g&&(g+=(new Date).getFullYear()-(new Date).getFullYear()%100+(u>=g?0:-100)),b>-1)for(v=1,_=b;;){if(r=this._getDaysInMonth(g,v-1),r>=_)break;v++,_-=r}if(h=this._daylightSavingAdjust(new Date(g,v-1,_)),h.getFullYear()!==g||h.getMonth()+1!==v||h.getDate()!==_)throw"Invalid date";return h},ATOM:"yy-mm-dd",COOKIE:"D, dd M yy",ISO_8601:"yy-mm-dd",RFC_822:"D, d M y",RFC_850:"DD, dd-M-y",RFC_1036:"D, d M y",RFC_1123:"D, d M yy",RFC_2822:"D, d M yy",RSS:"D, d M y",TICKS:"!",TIMESTAMP:"@",W3C:"yy-mm-dd",_ticksTo1970:1e7*60*60*24*(718685+Math.floor(492.5)-Math.floor(19.7)+Math.floor(4.925)),formatDate:function(t,e,i){if(!e)return"";var s,n=(i?i.dayNamesShort:null)||this._defaults.dayNamesShort,a=(i?i.dayNames:null)||this._defaults.dayNames,r=(i?i.monthNamesShort:null)||this._defaults.monthNamesShort,o=(i?i.monthNames:null)||this._defaults.monthNames,h=function(e){var i=t.length>s+1&&t.charAt(s+1)===e;return i&&s++,i},l=function(t,e,i){var s=""+e;if(h(t))for(;i>s.length;)s="0"+s;return s},c=function(t,e,i,s){return h(t)?s[e]:i[e]},u="",d=!1;if(e)for(s=0;t.length>s;s++)if(d)"'"!==t.charAt(s)||h("'")?u+=t.charAt(s):d=!1;else switch(t.charAt(s)){case"d":u+=l("d",e.getDate(),2);break;case"D":u+=c("D",e.getDay(),n,a);break;case"o":u+=l("o",Math.round((new Date(e.getFullYear(),e.getMonth(),e.getDate()).getTime()-new Date(e.getFullYear(),0,0).getTime())/864e5),3);break;case"m":u+=l("m",e.getMonth()+1,2);break;case"M":u+=c("M",e.getMonth(),r,o);break;case"y":u+=h("y")?e.getFullYear():(10>e.getYear()%100?"0":"")+e.getYear()%100;break;case"@":u+=e.getTime();break;case"!":u+=1e4*e.getTime()+this._ticksTo1970;break;case"'":h("'")?u+="'":d=!0;break;default:u+=t.charAt(s)}return u},_possibleChars:function(t){var e,i="",s=!1,n=function(i){var s=t.length>e+1&&t.charAt(e+1)===i;return s&&e++,s};for(e=0;t.length>e;e++)if(s)"'"!==t.charAt(e)||n("'")?i+=t.charAt(e):s=!1;else switch(t.charAt(e)){case"d":case"m":case"y":case"@":i+="0123456789";break;case"D":case"M":return null;case"'":n("'")?i+="'":s=!0;break;default:i+=t.charAt(e)}return i},_get:function(t,i){return t.settings[i]!==e?t.settings[i]:this._defaults[i]},_setDateFromField:function(t,e){if(t.input.val()!==t.lastVal){var i=this._get(t,"dateFormat"),s=t.lastVal=t.input?t.input.val():null,n=this._getDefaultDate(t),a=n,r=this._getFormatConfig(t);try{a=this.parseDate(i,s,r)||n}catch(o){s=e?"":s}t.selectedDay=a.getDate(),t.drawMonth=t.selectedMonth=a.getMonth(),t.drawYear=t.selectedYear=a.getFullYear(),t.currentDay=s?a.getDate():0,t.currentMonth=s?a.getMonth():0,t.currentYear=s?a.getFullYear():0,this._adjustInstDate(t)}},_getDefaultDate:function(t){return this._restrictMinMax(t,this._determineDate(t,this._get(t,"defaultDate"),new Date))},_determineDate:function(e,i,s){var n=function(t){var e=new Date;return e.setDate(e.getDate()+t),e},a=function(i){try{return t.datepicker.parseDate(t.datepicker._get(e,"dateFormat"),i,t.datepicker._getFormatConfig(e))}catch(s){}for(var n=(i.toLowerCase().match(/^c/)?t.datepicker._getDate(e):null)||new Date,a=n.getFullYear(),r=n.getMonth(),o=n.getDate(),h=/([+\-]?[0-9]+)\s*(d|D|w|W|m|M|y|Y)?/g,l=h.exec(i);l;){switch(l[2]||"d"){case"d":case"D":o+=parseInt(l[1],10);break;case"w":case"W":o+=7*parseInt(l[1],10);break;case"m":case"M":r+=parseInt(l[1],10),o=Math.min(o,t.datepicker._getDaysInMonth(a,r));break;case"y":case"Y":a+=parseInt(l[1],10),o=Math.min(o,t.datepicker._getDaysInMonth(a,r))}l=h.exec(i)}return new Date(a,r,o)},r=null==i||""===i?s:"string"==typeof i?a(i):"number"==typeof i?isNaN(i)?s:n(i):new Date(i.getTime());return r=r&&"Invalid Date"==""+r?s:r,r&&(r.setHours(0),r.setMinutes(0),r.setSeconds(0),r.setMilliseconds(0)),this._daylightSavingAdjust(r)},_daylightSavingAdjust:function(t){return t?(t.setHours(t.getHours()>12?t.getHours()+2:0),t):null},_setDate:function(t,e,i){var s=!e,n=t.selectedMonth,a=t.selectedYear,r=this._restrictMinMax(t,this._determineDate(t,e,new Date));t.selectedDay=t.currentDay=r.getDate(),t.drawMonth=t.selectedMonth=t.currentMonth=r.getMonth(),t.drawYear=t.selectedYear=t.currentYear=r.getFullYear(),n===t.selectedMonth&&a===t.selectedYear||i||this._notifyChange(t),this._adjustInstDate(t),t.input&&t.input.val(s?"":this._formatDate(t))},_getDate:function(t){var e=!t.currentYear||t.input&&""===t.input.val()?null:this._daylightSavingAdjust(new Date(t.currentYear,t.currentMonth,t.currentDay));return e},_attachHandlers:function(e){var i=this._get(e,"stepMonths"),s="#"+e.id.replace(/\\\\/g,"\\");e.dpDiv.find("[data-handler]").map(function(){var e={prev:function(){t.datepicker._adjustDate(s,-i,"M")},next:function(){t.datepicker._adjustDate(s,+i,"M")},hide:function(){t.datepicker._hideDatepicker()},today:function(){t.datepicker._gotoToday(s)},selectDay:function(){return t.datepicker._selectDay(s,+this.getAttribute("data-month"),+this.getAttribute("data-year"),this),!1},selectMonth:function(){return t.datepicker._selectMonthYear(s,this,"M"),!1},selectYear:function(){return t.datepicker._selectMonthYear(s,this,"Y"),!1}};t(this).bind(this.getAttribute("data-event"),e[this.getAttribute("data-handler")])})},_generateHTML:function(t){var e,i,s,n,a,r,o,h,l,c,u,d,p,f,m,g,v,_,b,y,x,k,w,D,T,C,M,S,N,I,P,A,z,H,E,F,O,W,j,R=new Date,L=this._daylightSavingAdjust(new Date(R.getFullYear(),R.getMonth(),R.getDate())),Y=this._get(t,"isRTL"),B=this._get(t,"showButtonPanel"),J=this._get(t,"hideIfNoPrevNext"),K=this._get(t,"navigationAsDateFormat"),Q=this._getNumberOfMonths(t),V=this._get(t,"showCurrentAtPos"),U=this._get(t,"stepMonths"),q=1!==Q[0]||1!==Q[1],X=this._daylightSavingAdjust(t.currentDay?new Date(t.currentYear,t.currentMonth,t.currentDay):new Date(9999,9,9)),G=this._getMinMaxDate(t,"min"),$=this._getMinMaxDate(t,"max"),Z=t.drawMonth-V,te=t.drawYear;if(0>Z&&(Z+=12,te--),$)for(e=this._daylightSavingAdjust(new Date($.getFullYear(),$.getMonth()-Q[0]*Q[1]+1,$.getDate())),e=G&&G>e?G:e;this._daylightSavingAdjust(new Date(te,Z,1))>e;)Z--,0>Z&&(Z=11,te--);for(t.drawMonth=Z,t.drawYear=te,i=this._get(t,"prevText"),i=K?this.formatDate(i,this._daylightSavingAdjust(new Date(te,Z-U,1)),this._getFormatConfig(t)):i,s=this._canAdjustMonth(t,-1,te,Z)?"
"+i+"":J?"":""+i+"",n=this._get(t,"nextText"),n=K?this.formatDate(n,this._daylightSavingAdjust(new Date(te,Z+U,1)),this._getFormatConfig(t)):n,a=this._canAdjustMonth(t,1,te,Z)?""+n+"":J?"":""+n+"",r=this._get(t,"currentText"),o=this._get(t,"gotoCurrent")&&t.currentDay?X:L,r=K?this.formatDate(r,o,this._getFormatConfig(t)):r,h=t.inline?"":"",l=B?"
"+(Y?h:"")+(this._isInRange(t,o)?"":"")+(Y?"":h)+"
":"",c=parseInt(this._get(t,"firstDay"),10),c=isNaN(c)?0:c,u=this._get(t,"showWeek"),d=this._get(t,"dayNames"),p=this._get(t,"dayNamesMin"),f=this._get(t,"monthNames"),m=this._get(t,"monthNamesShort"),g=this._get(t,"beforeShowDay"),v=this._get(t,"showOtherMonths"),_=this._get(t,"selectOtherMonths"),b=this._getDefaultDate(t),y="",k=0;Q[0]>k;k++){for(w="",this.maxRows=4,D=0;Q[1]>D;D++){if(T=this._daylightSavingAdjust(new Date(te,Z,t.selectedDay)),C=" ui-corner-all",M="",q){if(M+="
"}for(M+="
"+(/all|left/.test(C)&&0===k?Y?a:s:"")+(/all|right/.test(C)&&0===k?Y?s:a:"")+this._generateMonthYearHeader(t,Z,te,G,$,k>0||D>0,f,m)+"
"+"",S=u?"":"",x=0;7>x;x++)N=(x+c)%7,S+="=5?" class='ui-datepicker-week-end'":"")+">"+""+p[N]+"";for(M+=S+"",I=this._getDaysInMonth(te,Z),te===t.selectedYear&&Z===t.selectedMonth&&(t.selectedDay=Math.min(t.selectedDay,I)),P=(this._getFirstDayOfMonth(te,Z)-c+7)%7,A=Math.ceil((P+I)/7),z=q?this.maxRows>A?this.maxRows:A:A,this.maxRows=z,H=this._daylightSavingAdjust(new Date(te,Z,1-P)),E=0;z>E;E++){for(M+="",F=u?"":"",x=0;7>x;x++)O=g?g.apply(t.input?t.input[0]:null,[H]):[!0,""],W=H.getMonth()!==Z,j=W&&!_||!O[0]||G&&G>H||$&&H>$,F+="",H.setDate(H.getDate()+1),H=this._daylightSavingAdjust(H);M+=F+""}Z++,Z>11&&(Z=0,te++),M+="
"+this._get(t,"weekHeader")+"
"+this._get(t,"calculateWeek")(H)+""+(W&&!v?" ":j?""+H.getDate()+"":""+H.getDate()+"")+"
"+(q?"
"+(Q[0]>0&&D===Q[1]-1?"
":""):""),w+=M}y+=w}return y+=l,t._keyEvent=!1,y},_generateMonthYearHeader:function(t,e,i,s,n,a,r,o){var h,l,c,u,d,p,f,m,g=this._get(t,"changeMonth"),v=this._get(t,"changeYear"),_=this._get(t,"showMonthAfterYear"),b="
",y="";if(a||!g)y+=""+r[e]+"";else{for(h=s&&s.getFullYear()===i,l=n&&n.getFullYear()===i,y+=""}if(_||(b+=y+(!a&&g&&v?"":" ")),!t.yearshtml)if(t.yearshtml="",a||!v)b+=""+i+"";else{for(u=this._get(t,"yearRange").split(":"),d=(new Date).getFullYear(),p=function(t){var e=t.match(/c[+\-].*/)?i+parseInt(t.substring(1),10):t.match(/[+\-].*/)?d+parseInt(t,10):parseInt(t,10); -return isNaN(e)?d:e},f=p(u[0]),m=Math.max(f,p(u[1]||"")),f=s?Math.max(f,s.getFullYear()):f,m=n?Math.min(m,n.getFullYear()):m,t.yearshtml+="",b+=t.yearshtml,t.yearshtml=null}return b+=this._get(t,"yearSuffix"),_&&(b+=(!a&&g&&v?"":" ")+y),b+="
"},_adjustInstDate:function(t,e,i){var s=t.drawYear+("Y"===i?e:0),n=t.drawMonth+("M"===i?e:0),a=Math.min(t.selectedDay,this._getDaysInMonth(s,n))+("D"===i?e:0),r=this._restrictMinMax(t,this._daylightSavingAdjust(new Date(s,n,a)));t.selectedDay=r.getDate(),t.drawMonth=t.selectedMonth=r.getMonth(),t.drawYear=t.selectedYear=r.getFullYear(),("M"===i||"Y"===i)&&this._notifyChange(t)},_restrictMinMax:function(t,e){var i=this._getMinMaxDate(t,"min"),s=this._getMinMaxDate(t,"max"),n=i&&i>e?i:e;return s&&n>s?s:n},_notifyChange:function(t){var e=this._get(t,"onChangeMonthYear");e&&e.apply(t.input?t.input[0]:null,[t.selectedYear,t.selectedMonth+1,t])},_getNumberOfMonths:function(t){var e=this._get(t,"numberOfMonths");return null==e?[1,1]:"number"==typeof e?[1,e]:e},_getMinMaxDate:function(t,e){return this._determineDate(t,this._get(t,e+"Date"),null)},_getDaysInMonth:function(t,e){return 32-this._daylightSavingAdjust(new Date(t,e,32)).getDate()},_getFirstDayOfMonth:function(t,e){return new Date(t,e,1).getDay()},_canAdjustMonth:function(t,e,i,s){var n=this._getNumberOfMonths(t),a=this._daylightSavingAdjust(new Date(i,s+(0>e?e:n[0]*n[1]),1));return 0>e&&a.setDate(this._getDaysInMonth(a.getFullYear(),a.getMonth())),this._isInRange(t,a)},_isInRange:function(t,e){var i,s,n=this._getMinMaxDate(t,"min"),a=this._getMinMaxDate(t,"max"),r=null,o=null,h=this._get(t,"yearRange");return h&&(i=h.split(":"),s=(new Date).getFullYear(),r=parseInt(i[0],10),o=parseInt(i[1],10),i[0].match(/[+\-].*/)&&(r+=s),i[1].match(/[+\-].*/)&&(o+=s)),(!n||e.getTime()>=n.getTime())&&(!a||e.getTime()<=a.getTime())&&(!r||e.getFullYear()>=r)&&(!o||o>=e.getFullYear())},_getFormatConfig:function(t){var e=this._get(t,"shortYearCutoff");return e="string"!=typeof e?e:(new Date).getFullYear()%100+parseInt(e,10),{shortYearCutoff:e,dayNamesShort:this._get(t,"dayNamesShort"),dayNames:this._get(t,"dayNames"),monthNamesShort:this._get(t,"monthNamesShort"),monthNames:this._get(t,"monthNames")}},_formatDate:function(t,e,i,s){e||(t.currentDay=t.selectedDay,t.currentMonth=t.selectedMonth,t.currentYear=t.selectedYear);var n=e?"object"==typeof e?e:this._daylightSavingAdjust(new Date(s,i,e)):this._daylightSavingAdjust(new Date(t.currentYear,t.currentMonth,t.currentDay));return this.formatDate(this._get(t,"dateFormat"),n,this._getFormatConfig(t))}}),t.fn.datepicker=function(e){if(!this.length)return this;t.datepicker.initialized||(t(document).mousedown(t.datepicker._checkExternalClick),t.datepicker.initialized=!0),0===t("#"+t.datepicker._mainDivId).length&&t("body").append(t.datepicker.dpDiv);var i=Array.prototype.slice.call(arguments,1);return"string"!=typeof e||"isDisabled"!==e&&"getDate"!==e&&"widget"!==e?"option"===e&&2===arguments.length&&"string"==typeof arguments[1]?t.datepicker["_"+e+"Datepicker"].apply(t.datepicker,[this[0]].concat(i)):this.each(function(){"string"==typeof e?t.datepicker["_"+e+"Datepicker"].apply(t.datepicker,[this].concat(i)):t.datepicker._attachDatepicker(this,e)}):t.datepicker["_"+e+"Datepicker"].apply(t.datepicker,[this[0]].concat(i))},t.datepicker=new i,t.datepicker.initialized=!1,t.datepicker.uuid=(new Date).getTime(),t.datepicker.version="1.10.3"})(jQuery);(function(t,e){t.widget("ui.progressbar",{version:"1.10.3",options:{max:100,value:0,change:null,complete:null},min:0,_create:function(){this.oldValue=this.options.value=this._constrainedValue(),this.element.addClass("ui-progressbar ui-widget ui-widget-content ui-corner-all").attr({role:"progressbar","aria-valuemin":this.min}),this.valueDiv=t("
").appendTo(this.element),this._refreshValue()},_destroy:function(){this.element.removeClass("ui-progressbar ui-widget ui-widget-content ui-corner-all").removeAttr("role").removeAttr("aria-valuemin").removeAttr("aria-valuemax").removeAttr("aria-valuenow"),this.valueDiv.remove()},value:function(t){return t===e?this.options.value:(this.options.value=this._constrainedValue(t),this._refreshValue(),e)},_constrainedValue:function(t){return t===e&&(t=this.options.value),this.indeterminate=t===!1,"number"!=typeof t&&(t=0),this.indeterminate?!1:Math.min(this.options.max,Math.max(this.min,t))},_setOptions:function(t){var e=t.value;delete t.value,this._super(t),this.options.value=this._constrainedValue(e),this._refreshValue()},_setOption:function(t,e){"max"===t&&(e=Math.max(this.min,e)),this._super(t,e)},_percentage:function(){return this.indeterminate?100:100*(this.options.value-this.min)/(this.options.max-this.min)},_refreshValue:function(){var e=this.options.value,i=this._percentage();this.valueDiv.toggle(this.indeterminate||e>this.min).toggleClass("ui-corner-right",e===this.options.max).width(i.toFixed(0)+"%"),this.element.toggleClass("ui-progressbar-indeterminate",this.indeterminate),this.indeterminate?(this.element.removeAttr("aria-valuenow"),this.overlayDiv||(this.overlayDiv=t("
").appendTo(this.valueDiv))):(this.element.attr({"aria-valuemax":this.options.max,"aria-valuenow":e}),this.overlayDiv&&(this.overlayDiv.remove(),this.overlayDiv=null)),this.oldValue!==e&&(this.oldValue=e,this._trigger("change")),e===this.options.max&&this._trigger("complete")}})})(jQuery);(function(t){var e=5;t.widget("ui.slider",t.ui.mouse,{version:"1.10.3",widgetEventPrefix:"slide",options:{animate:!1,distance:0,max:100,min:0,orientation:"horizontal",range:!1,step:1,value:0,values:null,change:null,slide:null,start:null,stop:null},_create:function(){this._keySliding=!1,this._mouseSliding=!1,this._animateOff=!0,this._handleIndex=null,this._detectOrientation(),this._mouseInit(),this.element.addClass("ui-slider ui-slider-"+this.orientation+" ui-widget"+" ui-widget-content"+" ui-corner-all"),this._refresh(),this._setOption("disabled",this.options.disabled),this._animateOff=!1},_refresh:function(){this._createRange(),this._createHandles(),this._setupEvents(),this._refreshValue()},_createHandles:function(){var e,i,s=this.options,n=this.element.find(".ui-slider-handle").addClass("ui-state-default ui-corner-all"),a="",o=[];for(i=s.values&&s.values.length||1,n.length>i&&(n.slice(i).remove(),n=n.slice(0,i)),e=n.length;i>e;e++)o.push(a);this.handles=n.add(t(o.join("")).appendTo(this.element)),this.handle=this.handles.eq(0),this.handles.each(function(e){t(this).data("ui-slider-handle-index",e)})},_createRange:function(){var e=this.options,i="";e.range?(e.range===!0&&(e.values?e.values.length&&2!==e.values.length?e.values=[e.values[0],e.values[0]]:t.isArray(e.values)&&(e.values=e.values.slice(0)):e.values=[this._valueMin(),this._valueMin()]),this.range&&this.range.length?this.range.removeClass("ui-slider-range-min ui-slider-range-max").css({left:"",bottom:""}):(this.range=t("
").appendTo(this.element),i="ui-slider-range ui-widget-header ui-corner-all"),this.range.addClass(i+("min"===e.range||"max"===e.range?" ui-slider-range-"+e.range:""))):this.range=t([])},_setupEvents:function(){var t=this.handles.add(this.range).filter("a");this._off(t),this._on(t,this._handleEvents),this._hoverable(t),this._focusable(t)},_destroy:function(){this.handles.remove(),this.range.remove(),this.element.removeClass("ui-slider ui-slider-horizontal ui-slider-vertical ui-widget ui-widget-content ui-corner-all"),this._mouseDestroy()},_mouseCapture:function(e){var i,s,n,a,o,r,h,l,u=this,c=this.options;return c.disabled?!1:(this.elementSize={width:this.element.outerWidth(),height:this.element.outerHeight()},this.elementOffset=this.element.offset(),i={x:e.pageX,y:e.pageY},s=this._normValueFromMouse(i),n=this._valueMax()-this._valueMin()+1,this.handles.each(function(e){var i=Math.abs(s-u.values(e));(n>i||n===i&&(e===u._lastChangedValue||u.values(e)===c.min))&&(n=i,a=t(this),o=e)}),r=this._start(e,o),r===!1?!1:(this._mouseSliding=!0,this._handleIndex=o,a.addClass("ui-state-active").focus(),h=a.offset(),l=!t(e.target).parents().addBack().is(".ui-slider-handle"),this._clickOffset=l?{left:0,top:0}:{left:e.pageX-h.left-a.width()/2,top:e.pageY-h.top-a.height()/2-(parseInt(a.css("borderTopWidth"),10)||0)-(parseInt(a.css("borderBottomWidth"),10)||0)+(parseInt(a.css("marginTop"),10)||0)},this.handles.hasClass("ui-state-hover")||this._slide(e,o,s),this._animateOff=!0,!0))},_mouseStart:function(){return!0},_mouseDrag:function(t){var e={x:t.pageX,y:t.pageY},i=this._normValueFromMouse(e);return this._slide(t,this._handleIndex,i),!1},_mouseStop:function(t){return this.handles.removeClass("ui-state-active"),this._mouseSliding=!1,this._stop(t,this._handleIndex),this._change(t,this._handleIndex),this._handleIndex=null,this._clickOffset=null,this._animateOff=!1,!1},_detectOrientation:function(){this.orientation="vertical"===this.options.orientation?"vertical":"horizontal"},_normValueFromMouse:function(t){var e,i,s,n,a;return"horizontal"===this.orientation?(e=this.elementSize.width,i=t.x-this.elementOffset.left-(this._clickOffset?this._clickOffset.left:0)):(e=this.elementSize.height,i=t.y-this.elementOffset.top-(this._clickOffset?this._clickOffset.top:0)),s=i/e,s>1&&(s=1),0>s&&(s=0),"vertical"===this.orientation&&(s=1-s),n=this._valueMax()-this._valueMin(),a=this._valueMin()+s*n,this._trimAlignValue(a)},_start:function(t,e){var i={handle:this.handles[e],value:this.value()};return this.options.values&&this.options.values.length&&(i.value=this.values(e),i.values=this.values()),this._trigger("start",t,i)},_slide:function(t,e,i){var s,n,a;this.options.values&&this.options.values.length?(s=this.values(e?0:1),2===this.options.values.length&&this.options.range===!0&&(0===e&&i>s||1===e&&s>i)&&(i=s),i!==this.values(e)&&(n=this.values(),n[e]=i,a=this._trigger("slide",t,{handle:this.handles[e],value:i,values:n}),s=this.values(e?0:1),a!==!1&&this.values(e,i,!0))):i!==this.value()&&(a=this._trigger("slide",t,{handle:this.handles[e],value:i}),a!==!1&&this.value(i))},_stop:function(t,e){var i={handle:this.handles[e],value:this.value()};this.options.values&&this.options.values.length&&(i.value=this.values(e),i.values=this.values()),this._trigger("stop",t,i)},_change:function(t,e){if(!this._keySliding&&!this._mouseSliding){var i={handle:this.handles[e],value:this.value()};this.options.values&&this.options.values.length&&(i.value=this.values(e),i.values=this.values()),this._lastChangedValue=e,this._trigger("change",t,i)}},value:function(t){return arguments.length?(this.options.value=this._trimAlignValue(t),this._refreshValue(),this._change(null,0),undefined):this._value()},values:function(e,i){var s,n,a;if(arguments.length>1)return this.options.values[e]=this._trimAlignValue(i),this._refreshValue(),this._change(null,e),undefined;if(!arguments.length)return this._values();if(!t.isArray(arguments[0]))return this.options.values&&this.options.values.length?this._values(e):this.value();for(s=this.options.values,n=arguments[0],a=0;s.length>a;a+=1)s[a]=this._trimAlignValue(n[a]),this._change(null,a);this._refreshValue()},_setOption:function(e,i){var s,n=0;switch("range"===e&&this.options.range===!0&&("min"===i?(this.options.value=this._values(0),this.options.values=null):"max"===i&&(this.options.value=this._values(this.options.values.length-1),this.options.values=null)),t.isArray(this.options.values)&&(n=this.options.values.length),t.Widget.prototype._setOption.apply(this,arguments),e){case"orientation":this._detectOrientation(),this.element.removeClass("ui-slider-horizontal ui-slider-vertical").addClass("ui-slider-"+this.orientation),this._refreshValue();break;case"value":this._animateOff=!0,this._refreshValue(),this._change(null,0),this._animateOff=!1;break;case"values":for(this._animateOff=!0,this._refreshValue(),s=0;n>s;s+=1)this._change(null,s);this._animateOff=!1;break;case"min":case"max":this._animateOff=!0,this._refreshValue(),this._animateOff=!1;break;case"range":this._animateOff=!0,this._refresh(),this._animateOff=!1}},_value:function(){var t=this.options.value;return t=this._trimAlignValue(t)},_values:function(t){var e,i,s;if(arguments.length)return e=this.options.values[t],e=this._trimAlignValue(e);if(this.options.values&&this.options.values.length){for(i=this.options.values.slice(),s=0;i.length>s;s+=1)i[s]=this._trimAlignValue(i[s]);return i}return[]},_trimAlignValue:function(t){if(this._valueMin()>=t)return this._valueMin();if(t>=this._valueMax())return this._valueMax();var e=this.options.step>0?this.options.step:1,i=(t-this._valueMin())%e,s=t-i;return 2*Math.abs(i)>=e&&(s+=i>0?e:-e),parseFloat(s.toFixed(5))},_valueMin:function(){return this.options.min},_valueMax:function(){return this.options.max},_refreshValue:function(){var e,i,s,n,a,o=this.options.range,r=this.options,h=this,l=this._animateOff?!1:r.animate,u={};this.options.values&&this.options.values.length?this.handles.each(function(s){i=100*((h.values(s)-h._valueMin())/(h._valueMax()-h._valueMin())),u["horizontal"===h.orientation?"left":"bottom"]=i+"%",t(this).stop(1,1)[l?"animate":"css"](u,r.animate),h.options.range===!0&&("horizontal"===h.orientation?(0===s&&h.range.stop(1,1)[l?"animate":"css"]({left:i+"%"},r.animate),1===s&&h.range[l?"animate":"css"]({width:i-e+"%"},{queue:!1,duration:r.animate})):(0===s&&h.range.stop(1,1)[l?"animate":"css"]({bottom:i+"%"},r.animate),1===s&&h.range[l?"animate":"css"]({height:i-e+"%"},{queue:!1,duration:r.animate}))),e=i}):(s=this.value(),n=this._valueMin(),a=this._valueMax(),i=a!==n?100*((s-n)/(a-n)):0,u["horizontal"===this.orientation?"left":"bottom"]=i+"%",this.handle.stop(1,1)[l?"animate":"css"](u,r.animate),"min"===o&&"horizontal"===this.orientation&&this.range.stop(1,1)[l?"animate":"css"]({width:i+"%"},r.animate),"max"===o&&"horizontal"===this.orientation&&this.range[l?"animate":"css"]({width:100-i+"%"},{queue:!1,duration:r.animate}),"min"===o&&"vertical"===this.orientation&&this.range.stop(1,1)[l?"animate":"css"]({height:i+"%"},r.animate),"max"===o&&"vertical"===this.orientation&&this.range[l?"animate":"css"]({height:100-i+"%"},{queue:!1,duration:r.animate}))},_handleEvents:{keydown:function(i){var s,n,a,o,r=t(i.target).data("ui-slider-handle-index");switch(i.keyCode){case t.ui.keyCode.HOME:case t.ui.keyCode.END:case t.ui.keyCode.PAGE_UP:case t.ui.keyCode.PAGE_DOWN:case t.ui.keyCode.UP:case t.ui.keyCode.RIGHT:case t.ui.keyCode.DOWN:case t.ui.keyCode.LEFT:if(i.preventDefault(),!this._keySliding&&(this._keySliding=!0,t(i.target).addClass("ui-state-active"),s=this._start(i,r),s===!1))return}switch(o=this.options.step,n=a=this.options.values&&this.options.values.length?this.values(r):this.value(),i.keyCode){case t.ui.keyCode.HOME:a=this._valueMin();break;case t.ui.keyCode.END:a=this._valueMax();break;case t.ui.keyCode.PAGE_UP:a=this._trimAlignValue(n+(this._valueMax()-this._valueMin())/e);break;case t.ui.keyCode.PAGE_DOWN:a=this._trimAlignValue(n-(this._valueMax()-this._valueMin())/e);break;case t.ui.keyCode.UP:case t.ui.keyCode.RIGHT:if(n===this._valueMax())return;a=this._trimAlignValue(n+o);break;case t.ui.keyCode.DOWN:case t.ui.keyCode.LEFT:if(n===this._valueMin())return;a=this._trimAlignValue(n-o)}this._slide(i,r,a)},click:function(t){t.preventDefault()},keyup:function(e){var i=t(e.target).data("ui-slider-handle-index");this._keySliding&&(this._keySliding=!1,this._stop(e,i),this._change(e,i),t(e.target).removeClass("ui-state-active"))}}})})(jQuery);(function(t,e){var i="ui-effects-";t.effects={effect:{}},function(t,e){function i(t,e,i){var s=u[e.type]||{};return null==t?i||!e.def?null:e.def:(t=s.floor?~~t:parseFloat(t),isNaN(t)?e.def:s.mod?(t+s.mod)%s.mod:0>t?0:t>s.max?s.max:t)}function s(i){var s=l(),n=s._rgba=[];return i=i.toLowerCase(),f(h,function(t,a){var o,r=a.re.exec(i),h=r&&a.parse(r),l=a.space||"rgba";return h?(o=s[l](h),s[c[l].cache]=o[c[l].cache],n=s._rgba=o._rgba,!1):e}),n.length?("0,0,0,0"===n.join()&&t.extend(n,a.transparent),s):a[i]}function n(t,e,i){return i=(i+1)%1,1>6*i?t+6*(e-t)*i:1>2*i?e:2>3*i?t+6*(e-t)*(2/3-i):t}var a,o="backgroundColor borderBottomColor borderLeftColor borderRightColor borderTopColor color columnRuleColor outlineColor textDecorationColor textEmphasisColor",r=/^([\-+])=\s*(\d+\.?\d*)/,h=[{re:/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(\d?(?:\.\d+)?)\s*)?\)/,parse:function(t){return[t[1],t[2],t[3],t[4]]}},{re:/rgba?\(\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*(?:,\s*(\d?(?:\.\d+)?)\s*)?\)/,parse:function(t){return[2.55*t[1],2.55*t[2],2.55*t[3],t[4]]}},{re:/#([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})/,parse:function(t){return[parseInt(t[1],16),parseInt(t[2],16),parseInt(t[3],16)]}},{re:/#([a-f0-9])([a-f0-9])([a-f0-9])/,parse:function(t){return[parseInt(t[1]+t[1],16),parseInt(t[2]+t[2],16),parseInt(t[3]+t[3],16)]}},{re:/hsla?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*(?:,\s*(\d?(?:\.\d+)?)\s*)?\)/,space:"hsla",parse:function(t){return[t[1],t[2]/100,t[3]/100,t[4]]}}],l=t.Color=function(e,i,s,n){return new t.Color.fn.parse(e,i,s,n)},c={rgba:{props:{red:{idx:0,type:"byte"},green:{idx:1,type:"byte"},blue:{idx:2,type:"byte"}}},hsla:{props:{hue:{idx:0,type:"degrees"},saturation:{idx:1,type:"percent"},lightness:{idx:2,type:"percent"}}}},u={"byte":{floor:!0,max:255},percent:{max:1},degrees:{mod:360,floor:!0}},d=l.support={},p=t("

")[0],f=t.each;p.style.cssText="background-color:rgba(1,1,1,.5)",d.rgba=p.style.backgroundColor.indexOf("rgba")>-1,f(c,function(t,e){e.cache="_"+t,e.props.alpha={idx:3,type:"percent",def:1}}),l.fn=t.extend(l.prototype,{parse:function(n,o,r,h){if(n===e)return this._rgba=[null,null,null,null],this;(n.jquery||n.nodeType)&&(n=t(n).css(o),o=e);var u=this,d=t.type(n),p=this._rgba=[];return o!==e&&(n=[n,o,r,h],d="array"),"string"===d?this.parse(s(n)||a._default):"array"===d?(f(c.rgba.props,function(t,e){p[e.idx]=i(n[e.idx],e)}),this):"object"===d?(n instanceof l?f(c,function(t,e){n[e.cache]&&(u[e.cache]=n[e.cache].slice())}):f(c,function(e,s){var a=s.cache;f(s.props,function(t,e){if(!u[a]&&s.to){if("alpha"===t||null==n[t])return;u[a]=s.to(u._rgba)}u[a][e.idx]=i(n[t],e,!0)}),u[a]&&0>t.inArray(null,u[a].slice(0,3))&&(u[a][3]=1,s.from&&(u._rgba=s.from(u[a])))}),this):e},is:function(t){var i=l(t),s=!0,n=this;return f(c,function(t,a){var o,r=i[a.cache];return r&&(o=n[a.cache]||a.to&&a.to(n._rgba)||[],f(a.props,function(t,i){return null!=r[i.idx]?s=r[i.idx]===o[i.idx]:e})),s}),s},_space:function(){var t=[],e=this;return f(c,function(i,s){e[s.cache]&&t.push(i)}),t.pop()},transition:function(t,e){var s=l(t),n=s._space(),a=c[n],o=0===this.alpha()?l("transparent"):this,r=o[a.cache]||a.to(o._rgba),h=r.slice();return s=s[a.cache],f(a.props,function(t,n){var a=n.idx,o=r[a],l=s[a],c=u[n.type]||{};null!==l&&(null===o?h[a]=l:(c.mod&&(l-o>c.mod/2?o+=c.mod:o-l>c.mod/2&&(o-=c.mod)),h[a]=i((l-o)*e+o,n)))}),this[n](h)},blend:function(e){if(1===this._rgba[3])return this;var i=this._rgba.slice(),s=i.pop(),n=l(e)._rgba;return l(t.map(i,function(t,e){return(1-s)*n[e]+s*t}))},toRgbaString:function(){var e="rgba(",i=t.map(this._rgba,function(t,e){return null==t?e>2?1:0:t});return 1===i[3]&&(i.pop(),e="rgb("),e+i.join()+")"},toHslaString:function(){var e="hsla(",i=t.map(this.hsla(),function(t,e){return null==t&&(t=e>2?1:0),e&&3>e&&(t=Math.round(100*t)+"%"),t});return 1===i[3]&&(i.pop(),e="hsl("),e+i.join()+")"},toHexString:function(e){var i=this._rgba.slice(),s=i.pop();return e&&i.push(~~(255*s)),"#"+t.map(i,function(t){return t=(t||0).toString(16),1===t.length?"0"+t:t}).join("")},toString:function(){return 0===this._rgba[3]?"transparent":this.toRgbaString()}}),l.fn.parse.prototype=l.fn,c.hsla.to=function(t){if(null==t[0]||null==t[1]||null==t[2])return[null,null,null,t[3]];var e,i,s=t[0]/255,n=t[1]/255,a=t[2]/255,o=t[3],r=Math.max(s,n,a),h=Math.min(s,n,a),l=r-h,c=r+h,u=.5*c;return e=h===r?0:s===r?60*(n-a)/l+360:n===r?60*(a-s)/l+120:60*(s-n)/l+240,i=0===l?0:.5>=u?l/c:l/(2-c),[Math.round(e)%360,i,u,null==o?1:o]},c.hsla.from=function(t){if(null==t[0]||null==t[1]||null==t[2])return[null,null,null,t[3]];var e=t[0]/360,i=t[1],s=t[2],a=t[3],o=.5>=s?s*(1+i):s+i-s*i,r=2*s-o;return[Math.round(255*n(r,o,e+1/3)),Math.round(255*n(r,o,e)),Math.round(255*n(r,o,e-1/3)),a]},f(c,function(s,n){var a=n.props,o=n.cache,h=n.to,c=n.from;l.fn[s]=function(s){if(h&&!this[o]&&(this[o]=h(this._rgba)),s===e)return this[o].slice();var n,r=t.type(s),u="array"===r||"object"===r?s:arguments,d=this[o].slice();return f(a,function(t,e){var s=u["object"===r?t:e.idx];null==s&&(s=d[e.idx]),d[e.idx]=i(s,e)}),c?(n=l(c(d)),n[o]=d,n):l(d)},f(a,function(e,i){l.fn[e]||(l.fn[e]=function(n){var a,o=t.type(n),h="alpha"===e?this._hsla?"hsla":"rgba":s,l=this[h](),c=l[i.idx];return"undefined"===o?c:("function"===o&&(n=n.call(this,c),o=t.type(n)),null==n&&i.empty?this:("string"===o&&(a=r.exec(n),a&&(n=c+parseFloat(a[2])*("+"===a[1]?1:-1))),l[i.idx]=n,this[h](l)))})})}),l.hook=function(e){var i=e.split(" ");f(i,function(e,i){t.cssHooks[i]={set:function(e,n){var a,o,r="";if("transparent"!==n&&("string"!==t.type(n)||(a=s(n)))){if(n=l(a||n),!d.rgba&&1!==n._rgba[3]){for(o="backgroundColor"===i?e.parentNode:e;(""===r||"transparent"===r)&&o&&o.style;)try{r=t.css(o,"backgroundColor"),o=o.parentNode}catch(h){}n=n.blend(r&&"transparent"!==r?r:"_default")}n=n.toRgbaString()}try{e.style[i]=n}catch(h){}}},t.fx.step[i]=function(e){e.colorInit||(e.start=l(e.elem,i),e.end=l(e.end),e.colorInit=!0),t.cssHooks[i].set(e.elem,e.start.transition(e.end,e.pos))}})},l.hook(o),t.cssHooks.borderColor={expand:function(t){var e={};return f(["Top","Right","Bottom","Left"],function(i,s){e["border"+s+"Color"]=t}),e}},a=t.Color.names={aqua:"#00ffff",black:"#000000",blue:"#0000ff",fuchsia:"#ff00ff",gray:"#808080",green:"#008000",lime:"#00ff00",maroon:"#800000",navy:"#000080",olive:"#808000",purple:"#800080",red:"#ff0000",silver:"#c0c0c0",teal:"#008080",white:"#ffffff",yellow:"#ffff00",transparent:[null,null,null,0],_default:"#ffffff"}}(jQuery),function(){function i(e){var i,s,n=e.ownerDocument.defaultView?e.ownerDocument.defaultView.getComputedStyle(e,null):e.currentStyle,a={};if(n&&n.length&&n[0]&&n[n[0]])for(s=n.length;s--;)i=n[s],"string"==typeof n[i]&&(a[t.camelCase(i)]=n[i]);else for(i in n)"string"==typeof n[i]&&(a[i]=n[i]);return a}function s(e,i){var s,n,o={};for(s in i)n=i[s],e[s]!==n&&(a[s]||(t.fx.step[s]||!isNaN(parseFloat(n)))&&(o[s]=n));return o}var n=["add","remove","toggle"],a={border:1,borderBottom:1,borderColor:1,borderLeft:1,borderRight:1,borderTop:1,borderWidth:1,margin:1,padding:1};t.each(["borderLeftStyle","borderRightStyle","borderBottomStyle","borderTopStyle"],function(e,i){t.fx.step[i]=function(t){("none"!==t.end&&!t.setAttr||1===t.pos&&!t.setAttr)&&(jQuery.style(t.elem,i,t.end),t.setAttr=!0)}}),t.fn.addBack||(t.fn.addBack=function(t){return this.add(null==t?this.prevObject:this.prevObject.filter(t))}),t.effects.animateClass=function(e,a,o,r){var h=t.speed(a,o,r);return this.queue(function(){var a,o=t(this),r=o.attr("class")||"",l=h.children?o.find("*").addBack():o;l=l.map(function(){var e=t(this);return{el:e,start:i(this)}}),a=function(){t.each(n,function(t,i){e[i]&&o[i+"Class"](e[i])})},a(),l=l.map(function(){return this.end=i(this.el[0]),this.diff=s(this.start,this.end),this}),o.attr("class",r),l=l.map(function(){var e=this,i=t.Deferred(),s=t.extend({},h,{queue:!1,complete:function(){i.resolve(e)}});return this.el.animate(this.diff,s),i.promise()}),t.when.apply(t,l.get()).done(function(){a(),t.each(arguments,function(){var e=this.el;t.each(this.diff,function(t){e.css(t,"")})}),h.complete.call(o[0])})})},t.fn.extend({addClass:function(e){return function(i,s,n,a){return s?t.effects.animateClass.call(this,{add:i},s,n,a):e.apply(this,arguments)}}(t.fn.addClass),removeClass:function(e){return function(i,s,n,a){return arguments.length>1?t.effects.animateClass.call(this,{remove:i},s,n,a):e.apply(this,arguments)}}(t.fn.removeClass),toggleClass:function(i){return function(s,n,a,o,r){return"boolean"==typeof n||n===e?a?t.effects.animateClass.call(this,n?{add:s}:{remove:s},a,o,r):i.apply(this,arguments):t.effects.animateClass.call(this,{toggle:s},n,a,o)}}(t.fn.toggleClass),switchClass:function(e,i,s,n,a){return t.effects.animateClass.call(this,{add:i,remove:e},s,n,a)}})}(),function(){function s(e,i,s,n){return t.isPlainObject(e)&&(i=e,e=e.effect),e={effect:e},null==i&&(i={}),t.isFunction(i)&&(n=i,s=null,i={}),("number"==typeof i||t.fx.speeds[i])&&(n=s,s=i,i={}),t.isFunction(s)&&(n=s,s=null),i&&t.extend(e,i),s=s||i.duration,e.duration=t.fx.off?0:"number"==typeof s?s:s in t.fx.speeds?t.fx.speeds[s]:t.fx.speeds._default,e.complete=n||i.complete,e}function n(e){return!e||"number"==typeof e||t.fx.speeds[e]?!0:"string"!=typeof e||t.effects.effect[e]?t.isFunction(e)?!0:"object"!=typeof e||e.effect?!1:!0:!0}t.extend(t.effects,{version:"1.10.3",save:function(t,e){for(var s=0;e.length>s;s++)null!==e[s]&&t.data(i+e[s],t[0].style[e[s]])},restore:function(t,s){var n,a;for(a=0;s.length>a;a++)null!==s[a]&&(n=t.data(i+s[a]),n===e&&(n=""),t.css(s[a],n))},setMode:function(t,e){return"toggle"===e&&(e=t.is(":hidden")?"show":"hide"),e},getBaseline:function(t,e){var i,s;switch(t[0]){case"top":i=0;break;case"middle":i=.5;break;case"bottom":i=1;break;default:i=t[0]/e.height}switch(t[1]){case"left":s=0;break;case"center":s=.5;break;case"right":s=1;break;default:s=t[1]/e.width}return{x:s,y:i}},createWrapper:function(e){if(e.parent().is(".ui-effects-wrapper"))return e.parent();var i={width:e.outerWidth(!0),height:e.outerHeight(!0),"float":e.css("float")},s=t("

").addClass("ui-effects-wrapper").css({fontSize:"100%",background:"transparent",border:"none",margin:0,padding:0}),n={width:e.width(),height:e.height()},a=document.activeElement;try{a.id}catch(o){a=document.body}return e.wrap(s),(e[0]===a||t.contains(e[0],a))&&t(a).focus(),s=e.parent(),"static"===e.css("position")?(s.css({position:"relative"}),e.css({position:"relative"})):(t.extend(i,{position:e.css("position"),zIndex:e.css("z-index")}),t.each(["top","left","bottom","right"],function(t,s){i[s]=e.css(s),isNaN(parseInt(i[s],10))&&(i[s]="auto")}),e.css({position:"relative",top:0,left:0,right:"auto",bottom:"auto"})),e.css(n),s.css(i).show()},removeWrapper:function(e){var i=document.activeElement;return e.parent().is(".ui-effects-wrapper")&&(e.parent().replaceWith(e),(e[0]===i||t.contains(e[0],i))&&t(i).focus()),e},setTransition:function(e,i,s,n){return n=n||{},t.each(i,function(t,i){var a=e.cssUnit(i);a[0]>0&&(n[i]=a[0]*s+a[1])}),n}}),t.fn.extend({effect:function(){function e(e){function s(){t.isFunction(a)&&a.call(n[0]),t.isFunction(e)&&e()}var n=t(this),a=i.complete,r=i.mode;(n.is(":hidden")?"hide"===r:"show"===r)?(n[r](),s()):o.call(n[0],i,s)}var i=s.apply(this,arguments),n=i.mode,a=i.queue,o=t.effects.effect[i.effect];return t.fx.off||!o?n?this[n](i.duration,i.complete):this.each(function(){i.complete&&i.complete.call(this)}):a===!1?this.each(e):this.queue(a||"fx",e)},show:function(t){return function(e){if(n(e))return t.apply(this,arguments);var i=s.apply(this,arguments);return i.mode="show",this.effect.call(this,i)}}(t.fn.show),hide:function(t){return function(e){if(n(e))return t.apply(this,arguments);var i=s.apply(this,arguments);return i.mode="hide",this.effect.call(this,i)}}(t.fn.hide),toggle:function(t){return function(e){if(n(e)||"boolean"==typeof e)return t.apply(this,arguments);var i=s.apply(this,arguments);return i.mode="toggle",this.effect.call(this,i)}}(t.fn.toggle),cssUnit:function(e){var i=this.css(e),s=[];return t.each(["em","px","%","pt"],function(t,e){i.indexOf(e)>0&&(s=[parseFloat(i),e])}),s}})}(),function(){var e={};t.each(["Quad","Cubic","Quart","Quint","Expo"],function(t,i){e[i]=function(e){return Math.pow(e,t+2)}}),t.extend(e,{Sine:function(t){return 1-Math.cos(t*Math.PI/2)},Circ:function(t){return 1-Math.sqrt(1-t*t)},Elastic:function(t){return 0===t||1===t?t:-Math.pow(2,8*(t-1))*Math.sin((80*(t-1)-7.5)*Math.PI/15)},Back:function(t){return t*t*(3*t-2)},Bounce:function(t){for(var e,i=4;((e=Math.pow(2,--i))-1)/11>t;);return 1/Math.pow(4,3-i)-7.5625*Math.pow((3*e-2)/22-t,2)}}),t.each(e,function(e,i){t.easing["easeIn"+e]=i,t.easing["easeOut"+e]=function(t){return 1-i(1-t)},t.easing["easeInOut"+e]=function(t){return.5>t?i(2*t)/2:1-i(-2*t+2)/2}})}()})(jQuery);(function(t){t.effects.effect.slide=function(e,i){var s,n=t(this),a=["position","top","bottom","left","right","width","height"],o=t.effects.setMode(n,e.mode||"show"),r="show"===o,h=e.direction||"left",l="up"===h||"down"===h?"top":"left",c="up"===h||"left"===h,u={};t.effects.save(n,a),n.show(),s=e.distance||n["top"===l?"outerHeight":"outerWidth"](!0),t.effects.createWrapper(n).css({overflow:"hidden"}),r&&n.css(l,c?isNaN(s)?"-"+s:-s:s),u[l]=(r?c?"+=":"-=":c?"-=":"+=")+s,n.animate(u,{queue:!1,duration:e.duration,easing:e.easing,complete:function(){"hide"===o&&n.hide(),t.effects.restore(n,a),t.effects.removeWrapper(n),i()}})}})(jQuery); \ No newline at end of file diff --git a/public/js/jquery-ui-1.8.21.custom.min.js b/public/js/jquery-ui-1.8.21.custom.min.js deleted file mode 100755 index 0ba69b401..000000000 --- a/public/js/jquery-ui-1.8.21.custom.min.js +++ /dev/null @@ -1,33 +0,0 @@ -/*! jQuery UI - v1.8.21 - 2012-06-05 -* https://github.com/jquery/jquery-ui -* Includes: jquery.ui.core.js -* Copyright (c) 2012 AUTHORS.txt; Licensed MIT, GPL */ -(function(a,b){function c(b,c){var e=b.nodeName.toLowerCase();if("area"===e){var f=b.parentNode,g=f.name,h;return!b.href||!g||f.nodeName.toLowerCase()!=="map"?!1:(h=a("img[usemap=#"+g+"]")[0],!!h&&d(h))}return(/input|select|textarea|button|object/.test(e)?!b.disabled:"a"==e?b.href||c:c)&&d(b)}function d(b){return!a(b).parents().andSelf().filter(function(){return a.curCSS(this,"visibility")==="hidden"||a.expr.filters.hidden(this)}).length}a.ui=a.ui||{};if(a.ui.version)return;a.extend(a.ui,{version:"1.8.21",keyCode:{ALT:18,BACKSPACE:8,CAPS_LOCK:20,COMMA:188,COMMAND:91,COMMAND_LEFT:91,COMMAND_RIGHT:93,CONTROL:17,DELETE:46,DOWN:40,END:35,ENTER:13,ESCAPE:27,HOME:36,INSERT:45,LEFT:37,MENU:93,NUMPAD_ADD:107,NUMPAD_DECIMAL:110,NUMPAD_DIVIDE:111,NUMPAD_ENTER:108,NUMPAD_MULTIPLY:106,NUMPAD_SUBTRACT:109,PAGE_DOWN:34,PAGE_UP:33,PERIOD:190,RIGHT:39,SHIFT:16,SPACE:32,TAB:9,UP:38,WINDOWS:91}}),a.fn.extend({propAttr:a.fn.prop||a.fn.attr,_focus:a.fn.focus,focus:function(b,c){return typeof b=="number"?this.each(function(){var d=this;setTimeout(function(){a(d).focus(),c&&c.call(d)},b)}):this._focus.apply(this,arguments)},scrollParent:function(){var b;return a.browser.msie&&/(static|relative)/.test(this.css("position"))||/absolute/.test(this.css("position"))?b=this.parents().filter(function(){return/(relative|absolute|fixed)/.test(a.curCSS(this,"position",1))&&/(auto|scroll)/.test(a.curCSS(this,"overflow",1)+a.curCSS(this,"overflow-y",1)+a.curCSS(this,"overflow-x",1))}).eq(0):b=this.parents().filter(function(){return/(auto|scroll)/.test(a.curCSS(this,"overflow",1)+a.curCSS(this,"overflow-y",1)+a.curCSS(this,"overflow-x",1))}).eq(0),/fixed/.test(this.css("position"))||!b.length?a(document):b},zIndex:function(c){if(c!==b)return this.css("zIndex",c);if(this.length){var d=a(this[0]),e,f;while(d.length&&d[0]!==document){e=d.css("position");if(e==="absolute"||e==="relative"||e==="fixed"){f=parseInt(d.css("zIndex"),10);if(!isNaN(f)&&f!==0)return f}d=d.parent()}}return 0},disableSelection:function(){return this.bind((a.support.selectstart?"selectstart":"mousedown")+".ui-disableSelection",function(a){a.preventDefault()})},enableSelection:function(){return this.unbind(".ui-disableSelection")}}),a.each(["Width","Height"],function(c,d){function h(b,c,d,f){return a.each(e,function(){c-=parseFloat(a.curCSS(b,"padding"+this,!0))||0,d&&(c-=parseFloat(a.curCSS(b,"border"+this+"Width",!0))||0),f&&(c-=parseFloat(a.curCSS(b,"margin"+this,!0))||0)}),c}var e=d==="Width"?["Left","Right"]:["Top","Bottom"],f=d.toLowerCase(),g={innerWidth:a.fn.innerWidth,innerHeight:a.fn.innerHeight,outerWidth:a.fn.outerWidth,outerHeight:a.fn.outerHeight};a.fn["inner"+d]=function(c){return c===b?g["inner"+d].call(this):this.each(function(){a(this).css(f,h(this,c)+"px")})},a.fn["outer"+d]=function(b,c){return typeof b!="number"?g["outer"+d].call(this,b):this.each(function(){a(this).css(f,h(this,b,!0,c)+"px")})}}),a.extend(a.expr[":"],{data:function(b,c,d){return!!a.data(b,d[3])},focusable:function(b){return c(b,!isNaN(a.attr(b,"tabindex")))},tabbable:function(b){var d=a.attr(b,"tabindex"),e=isNaN(d);return(e||d>=0)&&c(b,!e)}}),a(function(){var b=document.body,c=b.appendChild(c=document.createElement("div"));c.offsetHeight,a.extend(c.style,{minHeight:"100px",height:"auto",padding:0,borderWidth:0}),a.support.minHeight=c.offsetHeight===100,a.support.selectstart="onselectstart"in c,b.removeChild(c).style.display="none"}),a.extend(a.ui,{plugin:{add:function(b,c,d){var e=a.ui[b].prototype;for(var f in d)e.plugins[f]=e.plugins[f]||[],e.plugins[f].push([c,d[f]])},call:function(a,b,c){var d=a.plugins[b];if(!d||!a.element[0].parentNode)return;for(var e=0;e0?!0:(b[d]=1,e=b[d]>0,b[d]=0,e)},isOverAxis:function(a,b,c){return a>b&&a=9||!!b.button?this._mouseStarted?(this._mouseDrag(b),b.preventDefault()):(this._mouseDistanceMet(b)&&this._mouseDelayMet(b)&&(this._mouseStarted=this._mouseStart(this._mouseDownEvent,b)!==!1,this._mouseStarted?this._mouseDrag(b):this._mouseUp(b)),!this._mouseStarted):this._mouseUp(b)},_mouseUp:function(b){return a(document).unbind("mousemove."+this.widgetName,this._mouseMoveDelegate).unbind("mouseup."+this.widgetName,this._mouseUpDelegate),this._mouseStarted&&(this._mouseStarted=!1,b.target==this._mouseDownEvent.target&&a.data(b.target,this.widgetName+".preventClickEvent",!0),this._mouseStop(b)),!1},_mouseDistanceMet:function(a){return Math.max(Math.abs(this._mouseDownEvent.pageX-a.pageX),Math.abs(this._mouseDownEvent.pageY-a.pageY))>=this.options.distance},_mouseDelayMet:function(a){return this.mouseDelayMet},_mouseStart:function(a){},_mouseDrag:function(a){},_mouseStop:function(a){},_mouseCapture:function(a){return!0}})})(jQuery);;/*! jQuery UI - v1.8.21 - 2012-06-05 -* https://github.com/jquery/jquery-ui -* Includes: jquery.ui.position.js -* Copyright (c) 2012 AUTHORS.txt; Licensed MIT, GPL */ -(function(a,b){a.ui=a.ui||{};var c=/left|center|right/,d=/top|center|bottom/,e="center",f={},g=a.fn.position,h=a.fn.offset;a.fn.position=function(b){if(!b||!b.of)return g.apply(this,arguments);b=a.extend({},b);var h=a(b.of),i=h[0],j=(b.collision||"flip").split(" "),k=b.offset?b.offset.split(" "):[0,0],l,m,n;return i.nodeType===9?(l=h.width(),m=h.height(),n={top:0,left:0}):i.setTimeout?(l=h.width(),m=h.height(),n={top:h.scrollTop(),left:h.scrollLeft()}):i.preventDefault?(b.at="left top",l=m=0,n={top:b.of.pageY,left:b.of.pageX}):(l=h.outerWidth(),m=h.outerHeight(),n=h.offset()),a.each(["my","at"],function(){var a=(b[this]||"").split(" ");a.length===1&&(a=c.test(a[0])?a.concat([e]):d.test(a[0])?[e].concat(a):[e,e]),a[0]=c.test(a[0])?a[0]:e,a[1]=d.test(a[1])?a[1]:e,b[this]=a}),j.length===1&&(j[1]=j[0]),k[0]=parseInt(k[0],10)||0,k.length===1&&(k[1]=k[0]),k[1]=parseInt(k[1],10)||0,b.at[0]==="right"?n.left+=l:b.at[0]===e&&(n.left+=l/2),b.at[1]==="bottom"?n.top+=m:b.at[1]===e&&(n.top+=m/2),n.left+=k[0],n.top+=k[1],this.each(function(){var c=a(this),d=c.outerWidth(),g=c.outerHeight(),h=parseInt(a.curCSS(this,"marginLeft",!0))||0,i=parseInt(a.curCSS(this,"marginTop",!0))||0,o=d+h+(parseInt(a.curCSS(this,"marginRight",!0))||0),p=g+i+(parseInt(a.curCSS(this,"marginBottom",!0))||0),q=a.extend({},n),r;b.my[0]==="right"?q.left-=d:b.my[0]===e&&(q.left-=d/2),b.my[1]==="bottom"?q.top-=g:b.my[1]===e&&(q.top-=g/2),f.fractions||(q.left=Math.round(q.left),q.top=Math.round(q.top)),r={left:q.left-h,top:q.top-i},a.each(["left","top"],function(c,e){a.ui.position[j[c]]&&a.ui.position[j[c]][e](q,{targetWidth:l,targetHeight:m,elemWidth:d,elemHeight:g,collisionPosition:r,collisionWidth:o,collisionHeight:p,offset:k,my:b.my,at:b.at})}),a.fn.bgiframe&&c.bgiframe(),c.offset(a.extend(q,{using:b.using}))})},a.ui.position={fit:{left:function(b,c){var d=a(window),e=c.collisionPosition.left+c.collisionWidth-d.width()-d.scrollLeft();b.left=e>0?b.left-e:Math.max(b.left-c.collisionPosition.left,b.left)},top:function(b,c){var d=a(window),e=c.collisionPosition.top+c.collisionHeight-d.height()-d.scrollTop();b.top=e>0?b.top-e:Math.max(b.top-c.collisionPosition.top,b.top)}},flip:{left:function(b,c){if(c.at[0]===e)return;var d=a(window),f=c.collisionPosition.left+c.collisionWidth-d.width()-d.scrollLeft(),g=c.my[0]==="left"?-c.elemWidth:c.my[0]==="right"?c.elemWidth:0,h=c.at[0]==="left"?c.targetWidth:-c.targetWidth,i=-2*c.offset[0];b.left+=c.collisionPosition.left<0?g+h+i:f>0?g+h+i:0},top:function(b,c){if(c.at[1]===e)return;var d=a(window),f=c.collisionPosition.top+c.collisionHeight-d.height()-d.scrollTop(),g=c.my[1]==="top"?-c.elemHeight:c.my[1]==="bottom"?c.elemHeight:0,h=c.at[1]==="top"?c.targetHeight:-c.targetHeight,i=-2*c.offset[1];b.top+=c.collisionPosition.top<0?g+h+i:f>0?g+h+i:0}}},a.offset.setOffset||(a.offset.setOffset=function(b,c){/static/.test(a.curCSS(b,"position"))&&(b.style.position="relative");var d=a(b),e=d.offset(),f=parseInt(a.curCSS(b,"top",!0),10)||0,g=parseInt(a.curCSS(b,"left",!0),10)||0,h={top:c.top-e.top+f,left:c.left-e.left+g};"using"in c?c.using.call(b,h):d.css(h)},a.fn.offset=function(b){var c=this[0];return!c||!c.ownerDocument?null:b?a.isFunction(b)?this.each(function(c){a(this).offset(b.call(this,c,a(this).offset()))}):this.each(function(){a.offset.setOffset(this,b)}):h.call(this)}),function(){var b=document.getElementsByTagName("body")[0],c=document.createElement("div"),d,e,g,h,i;d=document.createElement(b?"div":"body"),g={visibility:"hidden",width:0,height:0,border:0,margin:0,background:"none"},b&&a.extend(g,{position:"absolute",left:"-1000px",top:"-1000px"});for(var j in g)d.style[j]=g[j];d.appendChild(c),e=b||document.documentElement,e.insertBefore(d,e.firstChild),c.style.cssText="position: absolute; left: 10.7432222px; top: 10.432325px; height: 30px; width: 201px;",h=a(c).offset(function(a,b){return b}).offset(),d.innerHTML="",e.removeChild(d),i=h.top+h.left+(b?2e3:0),f.fractions=i>21&&i<22}()})(jQuery);;/*! jQuery UI - v1.8.21 - 2012-06-05 -* https://github.com/jquery/jquery-ui -* Includes: jquery.ui.slider.js -* Copyright (c) 2012 AUTHORS.txt; Licensed MIT, GPL */ -(function(a,b){var c=5;a.widget("ui.slider",a.ui.mouse,{widgetEventPrefix:"slide",options:{animate:!1,distance:0,max:100,min:0,orientation:"horizontal",range:!1,step:1,value:0,values:null},_create:function(){var b=this,d=this.options,e=this.element.find(".ui-slider-handle").addClass("ui-state-default ui-corner-all"),f="",g=d.values&&d.values.length||1,h=[];this._keySliding=!1,this._mouseSliding=!1,this._animateOff=!0,this._handleIndex=null,this._detectOrientation(),this._mouseInit(),this.element.addClass("ui-slider ui-slider-"+this.orientation+" ui-widget"+" ui-widget-content"+" ui-corner-all"+(d.disabled?" ui-slider-disabled ui-disabled":"")),this.range=a([]),d.range&&(d.range===!0&&(d.values||(d.values=[this._valueMin(),this._valueMin()]),d.values.length&&d.values.length!==2&&(d.values=[d.values[0],d.values[0]])),this.range=a("
").appendTo(this.element).addClass("ui-slider-range ui-widget-header"+(d.range==="min"||d.range==="max"?" ui-slider-range-"+d.range:"")));for(var i=e.length;ic&&(f=c,g=a(this),i=b)}),c.range===!0&&this.values(1)===c.min&&(i+=1,g=a(this.handles[i])),j=this._start(b,i),j===!1?!1:(this._mouseSliding=!0,h._handleIndex=i,g.addClass("ui-state-active").focus(),k=g.offset(),l=!a(b.target).parents().andSelf().is(".ui-slider-handle"),this._clickOffset=l?{left:0,top:0}:{left:b.pageX-k.left-g.width()/2,top:b.pageY-k.top-g.height()/2-(parseInt(g.css("borderTopWidth"),10)||0)-(parseInt(g.css("borderBottomWidth"),10)||0)+(parseInt(g.css("marginTop"),10)||0)},this.handles.hasClass("ui-state-hover")||this._slide(b,i,e),this._animateOff=!0,!0))},_mouseStart:function(a){return!0},_mouseDrag:function(a){var b={x:a.pageX,y:a.pageY},c=this._normValueFromMouse(b);return this._slide(a,this._handleIndex,c),!1},_mouseStop:function(a){return this.handles.removeClass("ui-state-active"),this._mouseSliding=!1,this._stop(a,this._handleIndex),this._change(a,this._handleIndex),this._handleIndex=null,this._clickOffset=null,this._animateOff=!1,!1},_detectOrientation:function(){this.orientation=this.options.orientation==="vertical"?"vertical":"horizontal"},_normValueFromMouse:function(a){var b,c,d,e,f;return this.orientation==="horizontal"?(b=this.elementSize.width,c=a.x-this.elementOffset.left-(this._clickOffset?this._clickOffset.left:0)):(b=this.elementSize.height,c=a.y-this.elementOffset.top-(this._clickOffset?this._clickOffset.top:0)),d=c/b,d>1&&(d=1),d<0&&(d=0),this.orientation==="vertical"&&(d=1-d),e=this._valueMax()-this._valueMin(),f=this._valueMin()+d*e,this._trimAlignValue(f)},_start:function(a,b){var c={handle:this.handles[b],value:this.value()};return this.options.values&&this.options.values.length&&(c.value=this.values(b),c.values=this.values()),this._trigger("start",a,c)},_slide:function(a,b,c){var d,e,f;this.options.values&&this.options.values.length?(d=this.values(b?0:1),this.options.values.length===2&&this.options.range===!0&&(b===0&&c>d||b===1&&c1){this.options.values[b]=this._trimAlignValue(c),this._refreshValue(),this._change(null,b);return}if(!arguments.length)return this._values();if(!a.isArray(arguments[0]))return this.options.values&&this.options.values.length?this._values(b):this.value();d=this.options.values,e=arguments[0];for(f=0;f=this._valueMax())return this._valueMax();var b=this.options.step>0?this.options.step:1,c=(a-this._valueMin())%b,d=a-c;return Math.abs(c)*2>=b&&(d+=c>0?b:-b),parseFloat(d.toFixed(5))},_valueMin:function(){return this.options.min},_valueMax:function(){return this.options.max},_refreshValue:function(){var b=this.options.range,c=this.options,d=this,e=this._animateOff?!1:c.animate,f,g={},h,i,j,k;this.options.values&&this.options.values.length?this.handles.each(function(b,i){f=(d.values(b)-d._valueMin())/(d._valueMax()-d._valueMin())*100,g[d.orientation==="horizontal"?"left":"bottom"]=f+"%",a(this).stop(1,1)[e?"animate":"css"](g,c.animate),d.options.range===!0&&(d.orientation==="horizontal"?(b===0&&d.range.stop(1,1)[e?"animate":"css"]({left:f+"%"},c.animate),b===1&&d.range[e?"animate":"css"]({width:f-h+"%"},{queue:!1,duration:c.animate})):(b===0&&d.range.stop(1,1)[e?"animate":"css"]({bottom:f+"%"},c.animate),b===1&&d.range[e?"animate":"css"]({height:f-h+"%"},{queue:!1,duration:c.animate}))),h=f}):(i=this.value(),j=this._valueMin(),k=this._valueMax(),f=k!==j?(i-j)/(k-j)*100:0,g[d.orientation==="horizontal"?"left":"bottom"]=f+"%",this.handle.stop(1,1)[e?"animate":"css"](g,c.animate),b==="min"&&this.orientation==="horizontal"&&this.range.stop(1,1)[e?"animate":"css"]({width:f+"%"},c.animate),b==="max"&&this.orientation==="horizontal"&&this.range[e?"animate":"css"]({width:100-f+"%"},{queue:!1,duration:c.animate}),b==="min"&&this.orientation==="vertical"&&this.range.stop(1,1)[e?"animate":"css"]({height:f+"%"},c.animate),b==="max"&&this.orientation==="vertical"&&this.range[e?"animate":"css"]({height:100-f+"%"},{queue:!1,duration:c.animate}))}}),a.extend(a.ui.slider,{version:"1.8.21"})})(jQuery);;/*! jQuery UI - v1.8.21 - 2012-06-05 -* https://github.com/jquery/jquery-ui -* Includes: jquery.ui.datepicker.js -* Copyright (c) 2012 AUTHORS.txt; Licensed MIT, GPL */ -(function($,undefined){function Datepicker(){this.debug=!1,this._curInst=null,this._keyEvent=!1,this._disabledInputs=[],this._datepickerShowing=!1,this._inDialog=!1,this._mainDivId="ui-datepicker-div",this._inlineClass="ui-datepicker-inline",this._appendClass="ui-datepicker-append",this._triggerClass="ui-datepicker-trigger",this._dialogClass="ui-datepicker-dialog",this._disableClass="ui-datepicker-disabled",this._unselectableClass="ui-datepicker-unselectable",this._currentClass="ui-datepicker-current-day",this._dayOverClass="ui-datepicker-days-cell-over",this.regional=[],this.regional[""]={closeText:"Done",prevText:"Prev",nextText:"Next",currentText:"Today",monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],weekHeader:"Wk",dateFormat:"mm/dd/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""},this._defaults={showOn:"focus",showAnim:"fadeIn",showOptions:{},defaultDate:null,appendText:"",buttonText:"...",buttonImage:"",buttonImageOnly:!1,hideIfNoPrevNext:!1,navigationAsDateFormat:!1,gotoCurrent:!1,changeMonth:!1,changeYear:!1,yearRange:"c-10:c+10",showOtherMonths:!1,selectOtherMonths:!1,showWeek:!1,calculateWeek:this.iso8601Week,shortYearCutoff:"+10",minDate:null,maxDate:null,duration:"fast",beforeShowDay:null,beforeShow:null,onSelect:null,onChangeMonthYear:null,onClose:null,numberOfMonths:1,showCurrentAtPos:0,stepMonths:1,stepBigMonths:12,altField:"",altFormat:"",constrainInput:!0,showButtonPanel:!1,autoSize:!1,disabled:!1},$.extend(this._defaults,this.regional[""]),this.dpDiv=bindHover($('
'))}function bindHover(a){var b="button, .ui-datepicker-prev, .ui-datepicker-next, .ui-datepicker-calendar td a";return a.bind("mouseout",function(a){var c=$(a.target).closest(b);if(!c.length)return;c.removeClass("ui-state-hover ui-datepicker-prev-hover ui-datepicker-next-hover")}).bind("mouseover",function(c){var d=$(c.target).closest(b);if($.datepicker._isDisabledDatepicker(instActive.inline?a.parent()[0]:instActive.input[0])||!d.length)return;d.parents(".ui-datepicker-calendar").find("a").removeClass("ui-state-hover"),d.addClass("ui-state-hover"),d.hasClass("ui-datepicker-prev")&&d.addClass("ui-datepicker-prev-hover"),d.hasClass("ui-datepicker-next")&&d.addClass("ui-datepicker-next-hover")})}function extendRemove(a,b){$.extend(a,b);for(var c in b)if(b[c]==null||b[c]==undefined)a[c]=b[c];return a}function isArray(a){return a&&($.browser.safari&&typeof a=="object"&&a.length||a.constructor&&a.constructor.toString().match(/\Array\(\)/))}$.extend($.ui,{datepicker:{version:"1.8.21"}});var PROP_NAME="datepicker",dpuuid=(new Date).getTime(),instActive;$.extend(Datepicker.prototype,{markerClassName:"hasDatepicker",maxRows:4,log:function(){this.debug&&console.log.apply("",arguments)},_widgetDatepicker:function(){return this.dpDiv},setDefaults:function(a){return extendRemove(this._defaults,a||{}),this},_attachDatepicker:function(target,settings){var inlineSettings=null;for(var attrName in this._defaults){var attrValue=target.getAttribute("date:"+attrName);if(attrValue){inlineSettings=inlineSettings||{};try{inlineSettings[attrName]=eval(attrValue)}catch(err){inlineSettings[attrName]=attrValue}}}var nodeName=target.nodeName.toLowerCase(),inline=nodeName=="div"||nodeName=="span";target.id||(this.uuid+=1,target.id="dp"+this.uuid);var inst=this._newInst($(target),inline);inst.settings=$.extend({},settings||{},inlineSettings||{}),nodeName=="input"?this._connectDatepicker(target,inst):inline&&this._inlineDatepicker(target,inst)},_newInst:function(a,b){var c=a[0].id.replace(/([^A-Za-z0-9_-])/g,"\\\\$1");return{id:c,input:a,selectedDay:0,selectedMonth:0,selectedYear:0,drawMonth:0,drawYear:0,inline:b,dpDiv:b?bindHover($('
')):this.dpDiv}},_connectDatepicker:function(a,b){var c=$(a);b.append=$([]),b.trigger=$([]);if(c.hasClass(this.markerClassName))return;this._attachments(c,b),c.addClass(this.markerClassName).keydown(this._doKeyDown).keypress(this._doKeyPress).keyup(this._doKeyUp).bind("setData.datepicker",function(a,c,d){b.settings[c]=d}).bind("getData.datepicker",function(a,c){return this._get(b,c)}),this._autoSize(b),$.data(a,PROP_NAME,b),b.settings.disabled&&this._disableDatepicker(a)},_attachments:function(a,b){var c=this._get(b,"appendText"),d=this._get(b,"isRTL");b.append&&b.append.remove(),c&&(b.append=$(''+c+""),a[d?"before":"after"](b.append)),a.unbind("focus",this._showDatepicker),b.trigger&&b.trigger.remove();var e=this._get(b,"showOn");(e=="focus"||e=="both")&&a.focus(this._showDatepicker);if(e=="button"||e=="both"){var f=this._get(b,"buttonText"),g=this._get(b,"buttonImage");b.trigger=$(this._get(b,"buttonImageOnly")?$("").addClass(this._triggerClass).attr({src:g,alt:f,title:f}):$('').addClass(this._triggerClass).html(g==""?f:$("").attr({src:g,alt:f,title:f}))),a[d?"before":"after"](b.trigger),b.trigger.click(function(){return $.datepicker._datepickerShowing&&$.datepicker._lastInput==a[0]?$.datepicker._hideDatepicker():$.datepicker._datepickerShowing&&$.datepicker._lastInput!=a[0]?($.datepicker._hideDatepicker(),$.datepicker._showDatepicker(a[0])):$.datepicker._showDatepicker(a[0]),!1})}},_autoSize:function(a){if(this._get(a,"autoSize")&&!a.inline){var b=new Date(2009,11,20),c=this._get(a,"dateFormat");if(c.match(/[DM]/)){var d=function(a){var b=0,c=0;for(var d=0;db&&(b=a[d].length,c=d);return c};b.setMonth(d(this._get(a,c.match(/MM/)?"monthNames":"monthNamesShort"))),b.setDate(d(this._get(a,c.match(/DD/)?"dayNames":"dayNamesShort"))+20-b.getDay())}a.input.attr("size",this._formatDate(a,b).length)}},_inlineDatepicker:function(a,b){var c=$(a);if(c.hasClass(this.markerClassName))return;c.addClass(this.markerClassName).append(b.dpDiv).bind("setData.datepicker",function(a,c,d){b.settings[c]=d}).bind("getData.datepicker",function(a,c){return this._get(b,c)}),$.data(a,PROP_NAME,b),this._setDate(b,this._getDefaultDate(b),!0),this._updateDatepicker(b),this._updateAlternate(b),b.settings.disabled&&this._disableDatepicker(a),b.dpDiv.css("display","block")},_dialogDatepicker:function(a,b,c,d,e){var f=this._dialogInst;if(!f){this.uuid+=1;var g="dp"+this.uuid;this._dialogInput=$(''),this._dialogInput.keydown(this._doKeyDown),$("body").append(this._dialogInput),f=this._dialogInst=this._newInst(this._dialogInput,!1),f.settings={},$.data(this._dialogInput[0],PROP_NAME,f)}extendRemove(f.settings,d||{}),b=b&&b.constructor==Date?this._formatDate(f,b):b,this._dialogInput.val(b),this._pos=e?e.length?e:[e.pageX,e.pageY]:null;if(!this._pos){var h=document.documentElement.clientWidth,i=document.documentElement.clientHeight,j=document.documentElement.scrollLeft||document.body.scrollLeft,k=document.documentElement.scrollTop||document.body.scrollTop;this._pos=[h/2-100+j,i/2-150+k]}return this._dialogInput.css("left",this._pos[0]+20+"px").css("top",this._pos[1]+"px"),f.settings.onSelect=c,this._inDialog=!0,this.dpDiv.addClass(this._dialogClass),this._showDatepicker(this._dialogInput[0]),$.blockUI&&$.blockUI(this.dpDiv),$.data(this._dialogInput[0],PROP_NAME,f),this},_destroyDatepicker:function(a){var b=$(a),c=$.data(a,PROP_NAME);if(!b.hasClass(this.markerClassName))return;var d=a.nodeName.toLowerCase();$.removeData(a,PROP_NAME),d=="input"?(c.append.remove(),c.trigger.remove(),b.removeClass(this.markerClassName).unbind("focus",this._showDatepicker).unbind("keydown",this._doKeyDown).unbind("keypress",this._doKeyPress).unbind("keyup",this._doKeyUp)):(d=="div"||d=="span")&&b.removeClass(this.markerClassName).empty()},_enableDatepicker:function(a){var b=$(a),c=$.data(a,PROP_NAME);if(!b.hasClass(this.markerClassName))return;var d=a.nodeName.toLowerCase();if(d=="input")a.disabled=!1,c.trigger.filter("button").each(function(){this.disabled=!1}).end().filter("img").css({opacity:"1.0",cursor:""});else if(d=="div"||d=="span"){var e=b.children("."+this._inlineClass);e.children().removeClass("ui-state-disabled"),e.find("select.ui-datepicker-month, select.ui-datepicker-year").removeAttr("disabled")}this._disabledInputs=$.map(this._disabledInputs,function(b){return b==a?null:b})},_disableDatepicker:function(a){var b=$(a),c=$.data(a,PROP_NAME);if(!b.hasClass(this.markerClassName))return;var d=a.nodeName.toLowerCase();if(d=="input")a.disabled=!0,c.trigger.filter("button").each(function(){this.disabled=!0}).end().filter("img").css({opacity:"0.5",cursor:"default"});else if(d=="div"||d=="span"){var e=b.children("."+this._inlineClass);e.children().addClass("ui-state-disabled"),e.find("select.ui-datepicker-month, select.ui-datepicker-year").attr("disabled","disabled")}this._disabledInputs=$.map(this._disabledInputs,function(b){return b==a?null:b}),this._disabledInputs[this._disabledInputs.length]=a},_isDisabledDatepicker:function(a){if(!a)return!1;for(var b=0;b-1}},_doKeyUp:function(a){var b=$.datepicker._getInst(a.target);if(b.input.val()!=b.lastVal)try{var c=$.datepicker.parseDate($.datepicker._get(b,"dateFormat"),b.input?b.input.val():null,$.datepicker._getFormatConfig(b));c&&($.datepicker._setDateFromField(b),$.datepicker._updateAlternate(b),$.datepicker._updateDatepicker(b))}catch(d){$.datepicker.log(d)}return!0},_showDatepicker:function(a){a=a.target||a,a.nodeName.toLowerCase()!="input"&&(a=$("input",a.parentNode)[0]);if($.datepicker._isDisabledDatepicker(a)||$.datepicker._lastInput==a)return;var b=$.datepicker._getInst(a);$.datepicker._curInst&&$.datepicker._curInst!=b&&($.datepicker._curInst.dpDiv.stop(!0,!0),b&&$.datepicker._datepickerShowing&&$.datepicker._hideDatepicker($.datepicker._curInst.input[0]));var c=$.datepicker._get(b,"beforeShow"),d=c?c.apply(a,[a,b]):{};if(d===!1)return;extendRemove(b.settings,d),b.lastVal=null,$.datepicker._lastInput=a,$.datepicker._setDateFromField(b),$.datepicker._inDialog&&(a.value=""),$.datepicker._pos||($.datepicker._pos=$.datepicker._findPos(a),$.datepicker._pos[1]+=a.offsetHeight);var e=!1;$(a).parents().each(function(){return e|=$(this).css("position")=="fixed",!e}),e&&$.browser.opera&&($.datepicker._pos[0]-=document.documentElement.scrollLeft,$.datepicker._pos[1]-=document.documentElement.scrollTop);var f={left:$.datepicker._pos[0],top:$.datepicker._pos[1]};$.datepicker._pos=null,b.dpDiv.empty(),b.dpDiv.css({position:"absolute",display:"block",top:"-1000px"}),$.datepicker._updateDatepicker(b),f=$.datepicker._checkOffset(b,f,e),b.dpDiv.css({position:$.datepicker._inDialog&&$.blockUI?"static":e?"fixed":"absolute",display:"none",left:f.left+"px",top:f.top+"px"});if(!b.inline){var g=$.datepicker._get(b,"showAnim"),h=$.datepicker._get(b,"duration"),i=function(){var a=b.dpDiv.find("iframe.ui-datepicker-cover");if(!!a.length){var c=$.datepicker._getBorders(b.dpDiv);a.css({left:-c[0],top:-c[1],width:b.dpDiv.outerWidth(),height:b.dpDiv.outerHeight()})}};b.dpDiv.zIndex($(a).zIndex()+1),$.datepicker._datepickerShowing=!0,$.effects&&$.effects[g]?b.dpDiv.show(g,$.datepicker._get(b,"showOptions"),h,i):b.dpDiv[g||"show"](g?h:null,i),(!g||!h)&&i(),b.input.is(":visible")&&!b.input.is(":disabled")&&b.input.focus(),$.datepicker._curInst=b}},_updateDatepicker:function(a){var b=this;b.maxRows=4;var c=$.datepicker._getBorders(a.dpDiv);instActive=a,a.dpDiv.empty().append(this._generateHTML(a));var d=a.dpDiv.find("iframe.ui-datepicker-cover");!d.length||d.css({left:-c[0],top:-c[1],width:a.dpDiv.outerWidth(),height:a.dpDiv.outerHeight()}),a.dpDiv.find("."+this._dayOverClass+" a").mouseover();var e=this._getNumberOfMonths(a),f=e[1],g=17;a.dpDiv.removeClass("ui-datepicker-multi-2 ui-datepicker-multi-3 ui-datepicker-multi-4").width(""),f>1&&a.dpDiv.addClass("ui-datepicker-multi-"+f).css("width",g*f+"em"),a.dpDiv[(e[0]!=1||e[1]!=1?"add":"remove")+"Class"]("ui-datepicker-multi"),a.dpDiv[(this._get(a,"isRTL")?"add":"remove")+"Class"]("ui-datepicker-rtl"),a==$.datepicker._curInst&&$.datepicker._datepickerShowing&&a.input&&a.input.is(":visible")&&!a.input.is(":disabled")&&a.input[0]!=document.activeElement&&a.input.focus();if(a.yearshtml){var h=a.yearshtml;setTimeout(function(){h===a.yearshtml&&a.yearshtml&&a.dpDiv.find("select.ui-datepicker-year:first").replaceWith(a.yearshtml),h=a.yearshtml=null},0)}},_getBorders:function(a){var b=function(a){return{thin:1,medium:2,thick:3}[a]||a};return[parseFloat(b(a.css("border-left-width"))),parseFloat(b(a.css("border-top-width")))]},_checkOffset:function(a,b,c){var d=a.dpDiv.outerWidth(),e=a.dpDiv.outerHeight(),f=a.input?a.input.outerWidth():0,g=a.input?a.input.outerHeight():0,h=document.documentElement.clientWidth+$(document).scrollLeft(),i=document.documentElement.clientHeight+$(document).scrollTop();return b.left-=this._get(a,"isRTL")?d-f:0,b.left-=c&&b.left==a.input.offset().left?$(document).scrollLeft():0,b.top-=c&&b.top==a.input.offset().top+g?$(document).scrollTop():0,b.left-=Math.min(b.left,b.left+d>h&&h>d?Math.abs(b.left+d-h):0),b.top-=Math.min(b.top,b.top+e>i&&i>e?Math.abs(e+g):0),b},_findPos:function(a){var b=this._getInst(a),c=this._get(b,"isRTL");while(a&&(a.type=="hidden"||a.nodeType!=1||$.expr.filters.hidden(a)))a=a[c?"previousSibling":"nextSibling"];var d=$(a).offset();return[d.left,d.top]},_hideDatepicker:function(a){var b=this._curInst;if(!b||a&&b!=$.data(a,PROP_NAME))return;if(this._datepickerShowing){var c=this._get(b,"showAnim"),d=this._get(b,"duration"),e=function(){$.datepicker._tidyDialog(b)};$.effects&&$.effects[c]?b.dpDiv.hide(c,$.datepicker._get(b,"showOptions"),d,e):b.dpDiv[c=="slideDown"?"slideUp":c=="fadeIn"?"fadeOut":"hide"](c?d:null,e),c||e(),this._datepickerShowing=!1;var f=this._get(b,"onClose");f&&f.apply(b.input?b.input[0]:null,[b.input?b.input.val():"",b]),this._lastInput=null,this._inDialog&&(this._dialogInput.css({position:"absolute",left:"0",top:"-100px"}),$.blockUI&&($.unblockUI(),$("body").append(this.dpDiv))),this._inDialog=!1}},_tidyDialog:function(a){a.dpDiv.removeClass(this._dialogClass).unbind(".ui-datepicker-calendar")},_checkExternalClick:function(a){if(!$.datepicker._curInst)return;var b=$(a.target),c=$.datepicker._getInst(b[0]);(b[0].id!=$.datepicker._mainDivId&&b.parents("#"+$.datepicker._mainDivId).length==0&&!b.hasClass($.datepicker.markerClassName)&&!b.closest("."+$.datepicker._triggerClass).length&&$.datepicker._datepickerShowing&&(!$.datepicker._inDialog||!$.blockUI)||b.hasClass($.datepicker.markerClassName)&&$.datepicker._curInst!=c)&&$.datepicker._hideDatepicker()},_adjustDate:function(a,b,c){var d=$(a),e=this._getInst(d[0]);if(this._isDisabledDatepicker(d[0]))return;this._adjustInstDate(e,b+(c=="M"?this._get(e,"showCurrentAtPos"):0),c),this._updateDatepicker(e)},_gotoToday:function(a){var b=$(a),c=this._getInst(b[0]);if(this._get(c,"gotoCurrent")&&c.currentDay)c.selectedDay=c.currentDay,c.drawMonth=c.selectedMonth=c.currentMonth,c.drawYear=c.selectedYear=c.currentYear;else{var d=new Date;c.selectedDay=d.getDate(),c.drawMonth=c.selectedMonth=d.getMonth(),c.drawYear=c.selectedYear=d.getFullYear()}this._notifyChange(c),this._adjustDate(b)},_selectMonthYear:function(a,b,c){var d=$(a),e=this._getInst(d[0]);e["selected"+(c=="M"?"Month":"Year")]=e["draw"+(c=="M"?"Month":"Year")]=parseInt(b.options[b.selectedIndex].value,10),this._notifyChange(e),this._adjustDate(d)},_selectDay:function(a,b,c,d){var e=$(a);if($(d).hasClass(this._unselectableClass)||this._isDisabledDatepicker(e[0]))return;var f=this._getInst(e[0]);f.selectedDay=f.currentDay=$("a",d).html(),f.selectedMonth=f.currentMonth=b,f.selectedYear=f.currentYear=c,this._selectDate(a,this._formatDate(f,f.currentDay,f.currentMonth,f.currentYear))},_clearDate:function(a){var b=$(a),c=this._getInst(b[0]);this._selectDate(b,"")},_selectDate:function(a,b){var c=$(a),d=this._getInst(c[0]);b=b!=null?b:this._formatDate(d),d.input&&d.input.val(b),this._updateAlternate(d);var e=this._get(d,"onSelect");e?e.apply(d.input?d.input[0]:null,[b,d]):d.input&&d.input.trigger("change"),d.inline?this._updateDatepicker(d):(this._hideDatepicker(),this._lastInput=d.input[0],typeof d.input[0]!="object"&&d.input.focus(),this._lastInput=null)},_updateAlternate:function(a){var b=this._get(a,"altField");if(b){var c=this._get(a,"altFormat")||this._get(a,"dateFormat"),d=this._getDate(a),e=this.formatDate(c,d,this._getFormatConfig(a));$(b).each(function(){$(this).val(e)})}},noWeekends:function(a){var b=a.getDay();return[b>0&&b<6,""]},iso8601Week:function(a){var b=new Date(a.getTime());b.setDate(b.getDate()+4-(b.getDay()||7));var c=b.getTime();return b.setMonth(0),b.setDate(1),Math.floor(Math.round((c-b)/864e5)/7)+1},parseDate:function(a,b,c){if(a==null||b==null)throw"Invalid arguments";b=typeof b=="object"?b.toString():b+"";if(b=="")return null;var d=(c?c.shortYearCutoff:null)||this._defaults.shortYearCutoff;d=typeof d!="string"?d:(new Date).getFullYear()%100+parseInt(d,10);var e=(c?c.dayNamesShort:null)||this._defaults.dayNamesShort,f=(c?c.dayNames:null)||this._defaults.dayNames,g=(c?c.monthNamesShort:null)||this._defaults.monthNamesShort,h=(c?c.monthNames:null)||this._defaults.monthNames,i=-1,j=-1,k=-1,l=-1,m=!1,n=function(b){var c=s+1-1){j=1,k=l;do{var u=this._getDaysInMonth(i,j-1);if(k<=u)break;j++,k-=u}while(!0)}var t=this._daylightSavingAdjust(new Date(i,j-1,k));if(t.getFullYear()!=i||t.getMonth()+1!=j||t.getDate()!=k)throw"Invalid date";return t},ATOM:"yy-mm-dd",COOKIE:"D, dd M yy",ISO_8601:"yy-mm-dd",RFC_822:"D, d M y",RFC_850:"DD, dd-M-y",RFC_1036:"D, d M y",RFC_1123:"D, d M yy",RFC_2822:"D, d M yy",RSS:"D, d M y",TICKS:"!",TIMESTAMP:"@",W3C:"yy-mm-dd",_ticksTo1970:(718685+Math.floor(492.5)-Math.floor(19.7)+Math.floor(4.925))*24*60*60*1e7,formatDate:function(a,b,c){if(!b)return"";var d=(c?c.dayNamesShort:null)||this._defaults.dayNamesShort,e=(c?c.dayNames:null)||this._defaults.dayNames,f=(c?c.monthNamesShort:null)||this._defaults.monthNamesShort,g=(c?c.monthNames:null)||this._defaults.monthNames,h=function(b){var c=m+112?a.getHours()+2:0),a):null},_setDate:function(a,b,c){var d=!b,e=a.selectedMonth,f=a.selectedYear,g=this._restrictMinMax(a,this._determineDate(a,b,new Date));a.selectedDay=a.currentDay=g.getDate(),a.drawMonth=a.selectedMonth=a.currentMonth=g.getMonth(),a.drawYear=a.selectedYear=a.currentYear=g.getFullYear(),(e!=a.selectedMonth||f!=a.selectedYear)&&!c&&this._notifyChange(a),this._adjustInstDate(a),a.input&&a.input.val(d?"":this._formatDate(a))},_getDate:function(a){var b=!a.currentYear||a.input&&a.input.val()==""?null:this._daylightSavingAdjust(new Date(a.currentYear,a.currentMonth,a.currentDay));return b},_generateHTML:function(a){var b=new Date;b=this._daylightSavingAdjust(new Date(b.getFullYear(),b.getMonth(),b.getDate()));var c=this._get(a,"isRTL"),d=this._get(a,"showButtonPanel"),e=this._get(a,"hideIfNoPrevNext"),f=this._get(a,"navigationAsDateFormat"),g=this._getNumberOfMonths(a),h=this._get(a,"showCurrentAtPos"),i=this._get(a,"stepMonths"),j=g[0]!=1||g[1]!=1,k=this._daylightSavingAdjust(a.currentDay?new Date(a.currentYear,a.currentMonth,a.currentDay):new Date(9999,9,9)),l=this._getMinMaxDate(a,"min"),m=this._getMinMaxDate(a,"max"),n=a.drawMonth-h,o=a.drawYear;n<0&&(n+=12,o--);if(m){var p=this._daylightSavingAdjust(new Date(m.getFullYear(),m.getMonth()-g[0]*g[1]+1,m.getDate()));p=l&&pp)n--,n<0&&(n=11,o--)}a.drawMonth=n,a.drawYear=o;var q=this._get(a,"prevText");q=f?this.formatDate(q,this._daylightSavingAdjust(new Date(o,n-i,1)),this._getFormatConfig(a)):q;var r=this._canAdjustMonth(a,-1,o,n)?''+q+"":e?"":''+q+"",s=this._get(a,"nextText");s=f?this.formatDate(s,this._daylightSavingAdjust(new Date(o,n+i,1)),this._getFormatConfig(a)):s;var t=this._canAdjustMonth(a,1,o,n)?''+s+"":e?"":''+s+"",u=this._get(a,"currentText"),v=this._get(a,"gotoCurrent")&&a.currentDay?k:b;u=f?this.formatDate(u,v,this._getFormatConfig(a)):u;var w=a.inline?"":'",x=d?'
'+(c?w:"")+(this._isInRange(a,v)?'":"")+(c?"":w)+"
":"",y=parseInt(this._get(a,"firstDay"),10);y=isNaN(y)?0:y;var z=this._get(a,"showWeek"),A=this._get(a,"dayNames"),B=this._get(a,"dayNamesShort"),C=this._get(a,"dayNamesMin"),D=this._get(a,"monthNames"),E=this._get(a,"monthNamesShort"),F=this._get(a,"beforeShowDay"),G=this._get(a,"showOtherMonths"),H=this._get(a,"selectOtherMonths"),I=this._get(a,"calculateWeek")||this.iso8601Week,J=this._getDefaultDate(a),K="";for(var L=0;L1)switch(N){case 0:Q+=" ui-datepicker-group-first",P=" ui-corner-"+(c?"right":"left");break;case g[1]-1:Q+=" ui-datepicker-group-last",P=" ui-corner-"+(c?"left":"right");break;default:Q+=" ui-datepicker-group-middle",P=""}Q+='">'}Q+='
'+(/all|left/.test(P)&&L==0?c?t:r:"")+(/all|right/.test(P)&&L==0?c?r:t:"")+this._generateMonthYearHeader(a,n,o,l,m,L>0||N>0,D,E)+'
'+"";var R=z?'":"";for(var S=0;S<7;S++){var T=(S+y)%7;R+="=5?' class="ui-datepicker-week-end"':"")+">"+''+C[T]+""}Q+=R+"";var U=this._getDaysInMonth(o,n);o==a.selectedYear&&n==a.selectedMonth&&(a.selectedDay=Math.min(a.selectedDay,U));var V=(this._getFirstDayOfMonth(o,n)-y+7)%7,W=Math.ceil((V+U)/7),X=j?this.maxRows>W?this.maxRows:W:W;this.maxRows=X;var Y=this._daylightSavingAdjust(new Date(o,n,1-V));for(var Z=0;Z";var _=z?'":"";for(var S=0;S<7;S++){var ba=F?F.apply(a.input?a.input[0]:null,[Y]):[!0,""],bb=Y.getMonth()!=n,bc=bb&&!H||!ba[0]||l&&Ym;_+='",Y.setDate(Y.getDate()+1),Y=this._daylightSavingAdjust(Y)}Q+=_+""}n++,n>11&&(n=0,o++),Q+="
'+this._get(a,"weekHeader")+"
'+this._get(a,"calculateWeek")(Y)+""+(bb&&!G?" ":bc?''+Y.getDate()+"":''+Y.getDate()+"")+"
"+(j?"
"+(g[0]>0&&N==g[1]-1?'
':""):""),M+=Q}K+=M}return K+=x+($.browser.msie&&parseInt($.browser.version,10)<7&&!a.inline?'':""),a._keyEvent=!1,K},_generateMonthYearHeader:function(a,b,c,d,e,f,g,h){var i=this._get(a,"changeMonth"),j=this._get(a,"changeYear"),k=this._get(a,"showMonthAfterYear"),l='
',m="";if(f||!i)m+=''+g[b]+"";else{var n=d&&d.getFullYear()==c,o=e&&e.getFullYear()==c;m+='"}k||(l+=m+(f||!i||!j?" ":""));if(!a.yearshtml){a.yearshtml="";if(f||!j)l+=''+c+"";else{var q=this._get(a,"yearRange").split(":"),r=(new Date).getFullYear(),s=function(a){var b=a.match(/c[+-].*/)?c+parseInt(a.substring(1),10):a.match(/[+-].*/)?r+parseInt(a,10):parseInt(a,10);return isNaN(b)?r:b},t=s(q[0]),u=Math.max(t,s(q[1]||""));t=d?Math.max(t,d.getFullYear()):t,u=e?Math.min(u,e.getFullYear()):u,a.yearshtml+='",l+=a.yearshtml,a.yearshtml=null}}return l+=this._get(a,"yearSuffix"),k&&(l+=(f||!i||!j?" ":"")+m),l+="
",l},_adjustInstDate:function(a,b,c){var d=a.drawYear+(c=="Y"?b:0),e=a.drawMonth+(c=="M"?b:0),f=Math.min(a.selectedDay,this._getDaysInMonth(d,e))+(c=="D"?b:0),g=this._restrictMinMax(a,this._daylightSavingAdjust(new Date(d,e,f)));a.selectedDay=g.getDate(),a.drawMonth=a.selectedMonth=g.getMonth(),a.drawYear=a.selectedYear=g.getFullYear(),(c=="M"||c=="Y")&&this._notifyChange(a)},_restrictMinMax:function(a,b){var c=this._getMinMaxDate(a,"min"),d=this._getMinMaxDate(a,"max"),e=c&&bd?d:e,e},_notifyChange:function(a){var b=this._get(a,"onChangeMonthYear");b&&b.apply(a.input?a.input[0]:null,[a.selectedYear,a.selectedMonth+1,a])},_getNumberOfMonths:function(a){var b=this._get(a,"numberOfMonths");return b==null?[1,1]:typeof b=="number"?[1,b]:b},_getMinMaxDate:function(a,b){return this._determineDate(a,this._get(a,b+"Date"),null)},_getDaysInMonth:function(a,b){return 32-this._daylightSavingAdjust(new Date(a,b,32)).getDate()},_getFirstDayOfMonth:function(a,b){return(new Date(a,b,1)).getDay()},_canAdjustMonth:function(a,b,c,d){var e=this._getNumberOfMonths(a),f=this._daylightSavingAdjust(new Date(c,d+(b<0?b:e[0]*e[1]),1));return b<0&&f.setDate(this._getDaysInMonth(f.getFullYear(),f.getMonth())),this._isInRange(a,f)},_isInRange:function(a,b){var c=this._getMinMaxDate(a,"min"),d=this._getMinMaxDate(a,"max");return(!c||b.getTime()>=c.getTime())&&(!d||b.getTime()<=d.getTime())},_getFormatConfig:function(a){var b=this._get(a,"shortYearCutoff");return b=typeof b!="string"?b:(new Date).getFullYear()%100+parseInt(b,10),{shortYearCutoff:b,dayNamesShort:this._get(a,"dayNamesShort"),dayNames:this._get(a,"dayNames"),monthNamesShort:this._get(a,"monthNamesShort"),monthNames:this._get(a,"monthNames")}},_formatDate:function(a,b,c,d){b||(a.currentDay=a.selectedDay,a.currentMonth=a.selectedMonth,a.currentYear=a.selectedYear);var e=b?typeof b=="object"?b:this._daylightSavingAdjust(new Date(d,c,b)):this._daylightSavingAdjust(new Date(a.currentYear,a.currentMonth,a.currentDay));return this.formatDate(this._get(a,"dateFormat"),e,this._getFormatConfig(a))}}),$.fn.datepicker=function(a){if(!this.length)return this;$.datepicker.initialized||($(document).mousedown($.datepicker._checkExternalClick).find("body").append($.datepicker.dpDiv),$.datepicker.initialized=!0);var b=Array.prototype.slice.call(arguments,1);return typeof a!="string"||a!="isDisabled"&&a!="getDate"&&a!="widget"?a=="option"&&arguments.length==2&&typeof arguments[1]=="string"?$.datepicker["_"+a+"Datepicker"].apply($.datepicker,[this[0]].concat(b)):this.each(function(){typeof a=="string"?$.datepicker["_"+a+"Datepicker"].apply($.datepicker,[this].concat(b)):$.datepicker._attachDatepicker(this,a)}):$.datepicker["_"+a+"Datepicker"].apply($.datepicker,[this[0]].concat(b))},$.datepicker=new Datepicker,$.datepicker.initialized=!1,$.datepicker.uuid=(new Date).getTime(),$.datepicker.version="1.8.21",window["DP_jQuery_"+dpuuid]=$})(jQuery);;/*! jQuery UI - v1.8.21 - 2012-06-05 -* https://github.com/jquery/jquery-ui -* Includes: jquery.effects.core.js -* Copyright (c) 2012 AUTHORS.txt; Licensed MIT, GPL */ -jQuery.effects||function(a,b){function c(b){var c;return b&&b.constructor==Array&&b.length==3?b:(c=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(b))?[parseInt(c[1],10),parseInt(c[2],10),parseInt(c[3],10)]:(c=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(b))?[parseFloat(c[1])*2.55,parseFloat(c[2])*2.55,parseFloat(c[3])*2.55]:(c=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(b))?[parseInt(c[1],16),parseInt(c[2],16),parseInt(c[3],16)]:(c=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(b))?[parseInt(c[1]+c[1],16),parseInt(c[2]+c[2],16),parseInt(c[3]+c[3],16)]:(c=/rgba\(0, 0, 0, 0\)/.exec(b))?e.transparent:e[a.trim(b).toLowerCase()]}function d(b,d){var e;do{e=a.curCSS(b,d);if(e!=""&&e!="transparent"||a.nodeName(b,"body"))break;d="backgroundColor"}while(b=b.parentNode);return c(e)}function h(){var a=document.defaultView?document.defaultView.getComputedStyle(this,null):this.currentStyle,b={},c,d;if(a&&a.length&&a[0]&&a[a[0]]){var e=a.length;while(e--)c=a[e],typeof a[c]=="string"&&(d=c.replace(/\-(\w)/g,function(a,b){return b.toUpperCase()}),b[d]=a[c])}else for(c in a)typeof a[c]=="string"&&(b[c]=a[c]);return b}function i(b){var c,d;for(c in b)d=b[c],(d==null||a.isFunction(d)||c in g||/scrollbar/.test(c)||!/color/i.test(c)&&isNaN(parseFloat(d)))&&delete b[c];return b}function j(a,b){var c={_:0},d;for(d in b)a[d]!=b[d]&&(c[d]=b[d]);return c}function k(b,c,d,e){typeof b=="object"&&(e=c,d=null,c=b,b=c.effect),a.isFunction(c)&&(e=c,d=null,c={});if(typeof c=="number"||a.fx.speeds[c])e=d,d=c,c={};return a.isFunction(d)&&(e=d,d=null),c=c||{},d=d||c.duration,d=a.fx.off?0:typeof d=="number"?d:d in a.fx.speeds?a.fx.speeds[d]:a.fx.speeds._default,e=e||c.complete,[b,c,d,e]}function l(b){return!b||typeof b=="number"||a.fx.speeds[b]?!0:typeof b=="string"&&!a.effects[b]?!0:!1}a.effects={},a.each(["backgroundColor","borderBottomColor","borderLeftColor","borderRightColor","borderTopColor","borderColor","color","outlineColor"],function(b,e){a.fx.step[e]=function(a){a.colorInit||(a.start=d(a.elem,e),a.end=c(a.end),a.colorInit=!0),a.elem.style[e]="rgb("+Math.max(Math.min(parseInt(a.pos*(a.end[0]-a.start[0])+a.start[0],10),255),0)+","+Math.max(Math.min(parseInt(a.pos*(a.end[1]-a.start[1])+a.start[1],10),255),0)+","+Math.max(Math.min(parseInt(a.pos*(a.end[2]-a.start[2])+a.start[2],10),255),0)+")"}});var e={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0],transparent:[255,255,255]},f=["add","remove","toggle"],g={border:1,borderBottom:1,borderColor:1,borderLeft:1,borderRight:1,borderTop:1,borderWidth:1,margin:1,padding:1};a.effects.animateClass=function(b,c,d,e){return a.isFunction(d)&&(e=d,d=null),this.queue(function(){var g=a(this),k=g.attr("style")||" ",l=i(h.call(this)),m,n=g.attr("class")||"";a.each(f,function(a,c){b[c]&&g[c+"Class"](b[c])}),m=i(h.call(this)),g.attr("class",n),g.animate(j(l,m),{queue:!1,duration:c,easing:d,complete:function(){a.each(f,function(a,c){b[c]&&g[c+"Class"](b[c])}),typeof g.attr("style")=="object"?(g.attr("style").cssText="",g.attr("style").cssText=k):g.attr("style",k),e&&e.apply(this,arguments),a.dequeue(this)}})})},a.fn.extend({_addClass:a.fn.addClass,addClass:function(b,c,d,e){return c?a.effects.animateClass.apply(this,[{add:b},c,d,e]):this._addClass(b)},_removeClass:a.fn.removeClass,removeClass:function(b,c,d,e){return c?a.effects.animateClass.apply(this,[{remove:b},c,d,e]):this._removeClass(b)},_toggleClass:a.fn.toggleClass,toggleClass:function(c,d,e,f,g){return typeof d=="boolean"||d===b?e?a.effects.animateClass.apply(this,[d?{add:c}:{remove:c},e,f,g]):this._toggleClass(c,d):a.effects.animateClass.apply(this,[{toggle:c},d,e,f])},switchClass:function(b,c,d,e,f){return a.effects.animateClass.apply(this,[{add:c,remove:b},d,e,f])}}),a.extend(a.effects,{version:"1.8.21",save:function(a,b){for(var c=0;c").addClass("ui-effects-wrapper").css({fontSize:"100%",background:"transparent",border:"none",margin:0,padding:0}),e=document.activeElement;try{e.id}catch(f){e=document.body}return b.wrap(d),(b[0]===e||a.contains(b[0],e))&&a(e).focus(),d=b.parent(),b.css("position")=="static"?(d.css({position:"relative"}),b.css({position:"relative"})):(a.extend(c,{position:b.css("position"),zIndex:b.css("z-index")}),a.each(["top","left","bottom","right"],function(a,d){c[d]=b.css(d),isNaN(parseInt(c[d],10))&&(c[d]="auto")}),b.css({position:"relative",top:0,left:0,right:"auto",bottom:"auto"})),d.css(c).show()},removeWrapper:function(b){var c,d=document.activeElement;return b.parent().is(".ui-effects-wrapper")?(c=b.parent().replaceWith(b),(b[0]===d||a.contains(b[0],d))&&a(d).focus(),c):b},setTransition:function(b,c,d,e){return e=e||{},a.each(c,function(a,c){var f=b.cssUnit(c);f[0]>0&&(e[c]=f[0]*d+f[1])}),e}}),a.fn.extend({effect:function(b,c,d,e){var f=k.apply(this,arguments),g={options:f[1],duration:f[2],callback:f[3]},h=g.options.mode,i=a.effects[b];return a.fx.off||!i?h?this[h](g.duration,g.callback):this.each(function(){g.callback&&g.callback.call(this)}):i.call(this,g)},_show:a.fn.show,show:function(a){if(l(a))return this._show.apply(this,arguments);var b=k.apply(this,arguments);return b[1].mode="show",this.effect.apply(this,b)},_hide:a.fn.hide,hide:function(a){if(l(a))return this._hide.apply(this,arguments);var b=k.apply(this,arguments);return b[1].mode="hide",this.effect.apply(this,b)},__toggle:a.fn.toggle,toggle:function(b){if(l(b)||typeof b=="boolean"||a.isFunction(b))return this.__toggle.apply(this,arguments);var c=k.apply(this,arguments);return c[1].mode="toggle",this.effect.apply(this,c)},cssUnit:function(b){var c=this.css(b),d=[];return a.each(["em","px","%","pt"],function(a,b){c.indexOf(b)>0&&(d=[parseFloat(c),b])}),d}}),a.easing.jswing=a.easing.swing,a.extend(a.easing,{def:"easeOutQuad",swing:function(b,c,d,e,f){return a.easing[a.easing.def](b,c,d,e,f)},easeInQuad:function(a,b,c,d,e){return d*(b/=e)*b+c},easeOutQuad:function(a,b,c,d,e){return-d*(b/=e)*(b-2)+c},easeInOutQuad:function(a,b,c,d,e){return(b/=e/2)<1?d/2*b*b+c:-d/2*(--b*(b-2)-1)+c},easeInCubic:function(a,b,c,d,e){return d*(b/=e)*b*b+c},easeOutCubic:function(a,b,c,d,e){return d*((b=b/e-1)*b*b+1)+c},easeInOutCubic:function(a,b,c,d,e){return(b/=e/2)<1?d/2*b*b*b+c:d/2*((b-=2)*b*b+2)+c},easeInQuart:function(a,b,c,d,e){return d*(b/=e)*b*b*b+c},easeOutQuart:function(a,b,c,d,e){return-d*((b=b/e-1)*b*b*b-1)+c},easeInOutQuart:function(a,b,c,d,e){return(b/=e/2)<1?d/2*b*b*b*b+c:-d/2*((b-=2)*b*b*b-2)+c},easeInQuint:function(a,b,c,d,e){return d*(b/=e)*b*b*b*b+c},easeOutQuint:function(a,b,c,d,e){return d*((b=b/e-1)*b*b*b*b+1)+c},easeInOutQuint:function(a,b,c,d,e){return(b/=e/2)<1?d/2*b*b*b*b*b+c:d/2*((b-=2)*b*b*b*b+2)+c},easeInSine:function(a,b,c,d,e){return-d*Math.cos(b/e*(Math.PI/2))+d+c},easeOutSine:function(a,b,c,d,e){return d*Math.sin(b/e*(Math.PI/2))+c},easeInOutSine:function(a,b,c,d,e){return-d/2*(Math.cos(Math.PI*b/e)-1)+c},easeInExpo:function(a,b,c,d,e){return b==0?c:d*Math.pow(2,10*(b/e-1))+c},easeOutExpo:function(a,b,c,d,e){return b==e?c+d:d*(-Math.pow(2,-10*b/e)+1)+c},easeInOutExpo:function(a,b,c,d,e){return b==0?c:b==e?c+d:(b/=e/2)<1?d/2*Math.pow(2,10*(b-1))+c:d/2*(-Math.pow(2,-10*--b)+2)+c},easeInCirc:function(a,b,c,d,e){return-d*(Math.sqrt(1-(b/=e)*b)-1)+c},easeOutCirc:function(a,b,c,d,e){return d*Math.sqrt(1-(b=b/e-1)*b)+c},easeInOutCirc:function(a,b,c,d,e){return(b/=e/2)<1?-d/2*(Math.sqrt(1-b*b)-1)+c:d/2*(Math.sqrt(1-(b-=2)*b)+1)+c},easeInElastic:function(a,b,c,d,e){var f=1.70158,g=0,h=d;if(b==0)return c;if((b/=e)==1)return c+d;g||(g=e*.3);if(h + + + , document.getElementById('app')) diff --git a/src/500.js b/src/500.js new file mode 100644 index 000000000..f1b3452bf --- /dev/null +++ b/src/500.js @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { QueryClient, QueryClientProvider, QueryCache } from 'react-query' +import { ReactQueryDevtools } from "react-query/devtools"; +import Action500 from './components/500' + +const queryCache = new QueryCache() +export const queryClient = new QueryClient({ queryCache }) + +ReactDOM.render( + + + + , document.getElementById('app')) diff --git a/src/__test__/mockapi/admin_widgets_get.json b/src/__test__/mockapi/admin_widgets_get.json new file mode 100644 index 000000000..721ffa546 --- /dev/null +++ b/src/__test__/mockapi/admin_widgets_get.json @@ -0,0 +1,503 @@ +[ + { + "clean_name": "adventure", + "creator": "creator.html", + "created_at": "1664990830", + "dir": "9-adventure/", + "flash_version": "0", + "api_version": "2", + "height": "593", + "id": "9", + "is_answer_encrypted": true, + "in_catalog": false, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": true, + "is_storage_enabled": false, + "package_hash": "5ff86d7cf34b03dc9e967505b9cb0f2c", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Media" + ], + "supported_data": [ + "Question/Answer", + "Multiple Choice" + ], + "excerpt": "Build branching scenarios where your student's choices lead them down different paths.", + "about": "An advanced flexible scenario-building tool.", + "playdata_exporters": [ + "Survey Formatting" + ], + "demo": "prA3M" + }, + "name": "Adventure", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "Adventure", + "score_screen": "", + "width": "800", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + { + "clean_name": "crossword", + "creator": "creator.html", + "created_at": "1656007466", + "dir": "1-crossword/", + "flash_version": "10", + "api_version": "2", + "height": "592", + "id": "1", + "is_answer_encrypted": true, + "in_catalog": true, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": true, + "is_storage_enabled": false, + "package_hash": "0c0b0b35ff3edaad4f7c08f3e3e9366c", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "I", + "excerpt": "A quiz tool that uses words and clues to randomly generate a crossword puzzle.", + "demo": "XxSgi" + }, + "name": "Crossword", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "Crossword", + "score_screen": "scoreScreen.html", + "width": "715", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + { + "clean_name": "enigma", + "creator": "creator.html", + "created_at": "1656007477", + "dir": "4-enigma/", + "flash_version": "0", + "api_version": "2", + "height": "548", + "id": "4", + "is_answer_encrypted": true, + "in_catalog": true, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": true, + "is_storage_enabled": false, + "package_hash": "58d525910af085f9cc7d1ed7f3ac74c4", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Multiple Choice" + ], + "about": "Enigma is a Jeopardy-like online study and quiz tool for reviewing subject matter, concepts, and principles. Questions are separated into categorical rows.", + "excerpt": "A Jeopardy-like study and quiz tool. Questions are separated into categorical rows.", + "demo": "rfiHg" + }, + "name": "Enigma", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "EnigmaGS", + "score_screen": "", + "width": "750", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + { + "clean_name": "equation-sandbox", + "creator": "creator.html", + "created_at": "1656007495", + "dir": "10-equation-sandbox/", + "flash_version": "10", + "api_version": "2", + "height": "600", + "id": "10", + "is_answer_encrypted": true, + "in_catalog": true, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": false, + "is_storage_enabled": false, + "package_hash": "e6e968ca5bdcee5691a76df40bacb98c", + "meta_data": { + "features": [ + "Customizable" + ], + "supported_data": [ + "Custom" + ], + "about": "With Equation Sandbox, students will be able to experiment with the graph of an equation you create. Manipulating the variables of the equation alter the graph, allowing students to learn first-hand how a function graph is created.", + "excerpt": "Interactive graphs from parameterized equations", + "demo": "aqJbU" + }, + "name": "Equation Sandbox", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "EquationSandbox", + "score_screen": "", + "width": "800", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + { + "clean_name": "flash-cards", + "creator": "creator.html", + "created_at": "1656007483", + "dir": "6-flash-cards/", + "flash_version": "10", + "api_version": "2", + "height": "516", + "id": "6", + "is_answer_encrypted": true, + "in_catalog": true, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": false, + "is_storage_enabled": false, + "package_hash": "5f0c22fd67d9b8538ad1695163018fc9", + "meta_data": { + "features": [ + "Customizable", + "Media", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Flashcards is a study tool that allows students to use a virtual deck of cards to study concepts paired with definitions or images or audio or embedded video links.", + "excerpt": "A study tool featuring a deck of flashcards.", + "demo": "qSarw" + }, + "name": "Flash Cards", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "FlashCards", + "score_screen": "", + "width": "800", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + { + "clean_name": "guess-the-phrase", + "creator": "creator.html", + "created_at": "1656007469", + "dir": "2-guess-the-phrase/", + "flash_version": "0", + "api_version": "2", + "height": "620", + "id": "2", + "is_answer_encrypted": true, + "in_catalog": true, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": true, + "is_storage_enabled": false, + "package_hash": "84b63c8793845e8ea530ba502d8ac0dd", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Guess the Phrase is a guessing game played by guessing letters in a word using the provided clues. Each wrong guess lowers the anvil a little more. Five wrong guesses and the player loses.", + "excerpt": "Students are provided with a clue and must guess the word or phrase within a certain amount of letters.", + "demo": "6ujh2" + }, + "name": "Guess the Phrase", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "Hangman", + "score_screen": "scoreScreen.html", + "width": "800", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + { + "clean_name": "labeling", + "creator": "creator.html", + "created_at": "1656007480", + "dir": "5-labeling/", + "flash_version": "10", + "api_version": "2", + "height": "601", + "id": "5", + "is_answer_encrypted": true, + "in_catalog": true, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": true, + "is_storage_enabled": false, + "package_hash": "9079221629921a4661d07253ee7c6334", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Media", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "In the Labeling widget, students will need to correctly place the labels that you create and add to an image of your choice. They will receive a score depending on how many correct placements they make.", + "excerpt": "A quiz tool which requires students to correctly identify certain parts of an image by placing labels.", + "demo": "k9YxM" + }, + "name": "Labeling", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "Labeling", + "score_screen": "", + "width": "800", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + { + "clean_name": "matching", + "creator": "creator.html", + "created_at": "1656007474", + "dir": "3-matching/", + "flash_version": "0", + "api_version": "2", + "height": "548", + "id": "3", + "is_answer_encrypted": true, + "in_catalog": true, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": true, + "is_storage_enabled": false, + "package_hash": "224d201dc426843e39d8a39abe8663c3", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly", + "Media" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Matching provides a left and a right list. Students are asked to match the items on the left with the corresponding item on the right.", + "excerpt": "Students must match one set of words or phrases to a corresponding word, phrase, or definition.", + "demo": "4X1uq" + }, + "name": "Matching", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "Matching", + "score_screen": "", + "width": "750", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + { + "clean_name": "simple-survey", + "creator": "creator.html", + "created_at": "1656007501", + "dir": "12-simple-survey/", + "flash_version": "0", + "api_version": "2", + "height": "0", + "id": "12", + "is_answer_encrypted": true, + "in_catalog": true, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": true, + "is_storage_enabled": false, + "package_hash": "9a317a35b7be082d5973211af26b8c83", + "meta_data": { + "features": [ + "Customizable", + "Media" + ], + "supported_data": [ + "Multiple Choice", + "Survey" + ], + "about": "Build simple surveys with multiple choice, check-all-that-apply, and free response questions.", + "excerpt": "Build surveys or questionnaires with a range of question options including multiple choice, check-all-that-apply, and free response. Questions are not scored and students are given full credit upon completion.", + "playdata_exporters": [ + "Survey Formatting" + ], + "demo": "5ZDfc" + }, + "name": "Simple Survey", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "SurveyWidget", + "score_screen": "scorescreen.html", + "width": "0", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + { + "clean_name": "sort-it-out", + "creator": "creator.html", + "created_at": "1656007497", + "dir": "11-sort-it-out/", + "flash_version": "0", + "api_version": "2", + "height": "650", + "id": "11", + "is_answer_encrypted": true, + "in_catalog": true, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": true, + "is_storage_enabled": false, + "package_hash": "3dee8dcd2eb72211af1e0b7b2a782718", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly", + "Media" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Sort It Out! provides items randomly spread across the screen with folders at the bottom. Students are asked to drag and drop the items into their appropriate folders.", + "excerpt": "Students must sort items on a messy computer desktop into their appropriate folders.", + "demo": "SdU00" + }, + "name": "Sort It Out!", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "SortItOut", + "score_screen": "scoreScreen.html", + "width": "960", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + { + "clean_name": "this-or-that", + "creator": "creator.html", + "created_at": "1656007510", + "dir": "7-this-or-that/", + "flash_version": "10", + "api_version": "2", + "height": "515", + "id": "7", + "is_answer_encrypted": true, + "in_catalog": true, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": true, + "is_storage_enabled": false, + "package_hash": "9d75a1b66b913db29dc2a2cd414789a5", + "meta_data": { + "features": [ + "Customizable", + "Media", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Multiple Choice" + ], + "about": "This or That is a game in which students must answer questions by choosing one of two images.", + "excerpt": "Students must answer a question by choosing one of two images.", + "demo": "MvN9U" + }, + "name": "This Or That", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "ThisOrThat", + "score_screen": "scoreScreen.html", + "width": "800", + "creator_guide": "", + "player_guide": "guides/player.html" + }, + { + "clean_name": "word-search", + "creator": "creator.html", + "created_at": "1656007489", + "dir": "8-word-search/", + "flash_version": "10", + "api_version": "2", + "height": "600", + "id": "8", + "is_answer_encrypted": true, + "in_catalog": true, + "is_editable": true, + "is_playable": true, + "is_qset_encrypted": true, + "is_scalable": false, + "is_scorable": true, + "is_storage_enabled": false, + "package_hash": "00bc9092b2a0affabe2d996da64c15ae", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Word search is played by finding words in a grid of letters. Students can circle words by dragging from the start of the word to the end.", + "excerpt": "A study tool where students must search a word puzzle for a predetermined set of words.", + "demo": "hE1HU" + }, + "name": "Word Search", + "player": "player.html", + "question_types": "", + "restrict_publish": false, + "score_module": "WordSearch", + "score_screen": "", + "width": "800", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + } + ] \ No newline at end of file diff --git a/src/__test__/mockapi/crossword_demo_instance.json b/src/__test__/mockapi/crossword_demo_instance.json new file mode 100644 index 000000000..a937e9f4d --- /dev/null +++ b/src/__test__/mockapi/crossword_demo_instance.json @@ -0,0 +1,237 @@ +{ + "attempts": "-1", + "clean_name": "famous-landmarks", + "close_at": "-1", + "created_at": "1656007466", + "embed_url": "https://localhost/embed/XxSgi/famous-landmarks", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "XxSgi", + "is_draft": false, + "name": "Famous Landmarks", + "open_at": "-1", + "play_url": "https://localhost/play/XxSgi/famous-landmarks", + "preview_url": "https://localhost/preview/XxSgi/famous-landmarks", + "published_by": null, + "user_id": "1", + "widget": { + "clean_name": "crossword", + "creator": "creator.html", + "created_at": "1656007466", + "dir": "1-crossword/", + "flash_version": "10", + "api_version": "2", + "height": "592", + "id": "1", + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "0c0b0b35ff3edaad4f7c08f3e3e9366c", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "I", + "excerpt": "A quiz tool that uses words and clues to randomly generate a crossword puzzle.", + "demo": "XxSgi" + }, + "name": "Crossword", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Crossword", + "score_screen": "scoreScreen.html", + "width": "715", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": "1", + "data": { + "items": [ + { + "items": [ + { + "materiaType": "question", + "id": "ac4b8819-8416-4a65-81d2-b327ea604663", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "The tallest mountain in the world, and the ultimate challenge for mountain climbers everywhere." + } + ], + "answers": [ + { + "value": 100, + "text": "Everest", + "id": "d6643f8c-31fd-46e8-b86c-76e5be6ac215" + } + ], + "options": { + "y": 3, + "x": 4, + "posSet": 1, + "hint": "Starts with an 'E'.", + "dir": 1 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "da1a1385-2b17-4be6-b5d6-803dc783c9dc", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "A white marble mausoleum commissioned in 1632 by an emperor to house the tomb of his favorite wife of three." + } + ], + "answers": [ + { + "value": 100, + "text": "The Taj-Mahal", + "id": "5c0f1f45-6b0d-4e1e-a3e6-a18a91335151" + } + ], + "options": { + "y": 17, + "x": 5, + "posSet": 1, + "hint": "Located in India", + "dir": 0 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "212d5992-beb2-40de-a655-75a1507323db", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "Home for the president of the United States of America." + } + ], + "answers": [ + { + "value": 100, + "text": "The White House", + "id": "c58bf162-c1c2-4cee-9c39-863155371487" + } + ], + "options": { + "y": "3", + "x": "7", + "posSet": 1, + "hint": "They painted it white.", + "dir": "1" + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "54a6721e-f130-4adc-91b6-01fd18e40db2", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "Mysterious landmark of several large standing stones arranged in a circle." + } + ], + "answers": [ + { + "value": 100, + "text": "Stonehenge", + "id": "dd6cf7e3-af2b-4646-af4e-9cc1442d1b4e" + } + ], + "options": { + "y": 10, + "x": 6, + "posSet": 1, + "hint": "This monument is in England.", + "dir": 0 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "e34b2621-b23a-43b7-9fc6-7c07a0394f9f", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "This is one of the world's oldest statues - A lion with a human head that stands in the Giza Plateau." + } + ], + "answers": [ + { + "value": 100, + "text": "Sphinx", + "id": "52aca647-51b6-4081-9ea7-d3eb67a9c995" + } + ], + "options": { + "y": 0, + "x": 1, + "posSet": 1, + "hint": "It needs a new nose!", + "dir": 1 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "f6e25927-8239-4a5a-bcc3-be42d43360bb", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "A monument built for the 1889 World's Fair, this metal structure can be found on the Champ de Mars in Paris." + } + ], + "answers": [ + { + "value": 100, + "text": "Eiffel Tower", + "id": "7b692988-9c8e-48a9-b908-695a30d7b6d3" + } + ], + "options": { + "y": 3, + "x": 0, + "posSet": 1, + "hint": "___ Tower.", + "dir": 0 + }, + "assets": [] + } + ] + } + ], + "options": { + "freeWords": 1, + "hintPenalty": 50 + }, + "id": "2" + }, + "id": "2" + } + } \ No newline at end of file diff --git a/src/__test__/mockapi/crossword_demo_instance_draft.json b/src/__test__/mockapi/crossword_demo_instance_draft.json new file mode 100644 index 000000000..456120394 --- /dev/null +++ b/src/__test__/mockapi/crossword_demo_instance_draft.json @@ -0,0 +1,237 @@ +{ + "attempts": "-1", + "clean_name": "famous-landmarks", + "close_at": "-1", + "created_at": "1656007466", + "embed_url": "https://localhost/embed/XxSgi/famous-landmarks", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "XxSgi", + "is_draft": true, + "name": "Famous Landmarks", + "open_at": "-1", + "play_url": "https://localhost/play/XxSgi/famous-landmarks", + "preview_url": "https://localhost/preview/XxSgi/famous-landmarks", + "published_by": null, + "user_id": "1", + "widget": { + "clean_name": "crossword", + "creator": "creator.html", + "created_at": "1656007466", + "dir": "1-crossword/", + "flash_version": "10", + "api_version": "2", + "height": "592", + "id": "1", + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "0c0b0b35ff3edaad4f7c08f3e3e9366c", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "I", + "excerpt": "A quiz tool that uses words and clues to randomly generate a crossword puzzle.", + "demo": "XxSgi" + }, + "name": "Crossword", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Crossword", + "score_screen": "scoreScreen.html", + "width": "715", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": "1", + "data": { + "items": [ + { + "items": [ + { + "materiaType": "question", + "id": "ac4b8819-8416-4a65-81d2-b327ea604663", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "The tallest mountain in the world, and the ultimate challenge for mountain climbers everywhere." + } + ], + "answers": [ + { + "value": 100, + "text": "Everest", + "id": "d6643f8c-31fd-46e8-b86c-76e5be6ac215" + } + ], + "options": { + "y": 3, + "x": 4, + "posSet": 1, + "hint": "Starts with an 'E'.", + "dir": 1 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "da1a1385-2b17-4be6-b5d6-803dc783c9dc", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "A white marble mausoleum commissioned in 1632 by an emperor to house the tomb of his favorite wife of three." + } + ], + "answers": [ + { + "value": 100, + "text": "The Taj-Mahal", + "id": "5c0f1f45-6b0d-4e1e-a3e6-a18a91335151" + } + ], + "options": { + "y": 17, + "x": 5, + "posSet": 1, + "hint": "Located in India", + "dir": 0 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "212d5992-beb2-40de-a655-75a1507323db", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "Home for the president of the United States of America." + } + ], + "answers": [ + { + "value": 100, + "text": "The White House", + "id": "c58bf162-c1c2-4cee-9c39-863155371487" + } + ], + "options": { + "y": "3", + "x": "7", + "posSet": 1, + "hint": "They painted it white.", + "dir": "1" + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "54a6721e-f130-4adc-91b6-01fd18e40db2", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "Mysterious landmark of several large standing stones arranged in a circle." + } + ], + "answers": [ + { + "value": 100, + "text": "Stonehenge", + "id": "dd6cf7e3-af2b-4646-af4e-9cc1442d1b4e" + } + ], + "options": { + "y": 10, + "x": 6, + "posSet": 1, + "hint": "This monument is in England.", + "dir": 0 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "e34b2621-b23a-43b7-9fc6-7c07a0394f9f", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "This is one of the world's oldest statues - A lion with a human head that stands in the Giza Plateau." + } + ], + "answers": [ + { + "value": 100, + "text": "Sphinx", + "id": "52aca647-51b6-4081-9ea7-d3eb67a9c995" + } + ], + "options": { + "y": 0, + "x": 1, + "posSet": 1, + "hint": "It needs a new nose!", + "dir": 1 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "f6e25927-8239-4a5a-bcc3-be42d43360bb", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "A monument built for the 1889 World's Fair, this metal structure can be found on the Champ de Mars in Paris." + } + ], + "answers": [ + { + "value": 100, + "text": "Eiffel Tower", + "id": "7b692988-9c8e-48a9-b908-695a30d7b6d3" + } + ], + "options": { + "y": 3, + "x": 0, + "posSet": 1, + "hint": "___ Tower.", + "dir": 0 + }, + "assets": [] + } + ] + } + ], + "options": { + "freeWords": 1, + "hintPenalty": 50 + }, + "id": "2" + }, + "id": "2" + } + } \ No newline at end of file diff --git a/src/__test__/mockapi/crossword_demo_qset.json b/src/__test__/mockapi/crossword_demo_qset.json new file mode 100644 index 000000000..c625d1e11 --- /dev/null +++ b/src/__test__/mockapi/crossword_demo_qset.json @@ -0,0 +1,173 @@ +{ + "version": "1", + "data": { + "items": [ + { + "items": [ + { + "materiaType": "question", + "id": "ac4b8819-8416-4a65-81d2-b327ea604663", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "The tallest mountain in the world, and the ultimate challenge for mountain climbers everywhere." + } + ], + "answers": [ + { + "value": 100, + "text": "Everest", + "id": "d6643f8c-31fd-46e8-b86c-76e5be6ac215" + } + ], + "options": { + "y": 3, + "x": 4, + "posSet": 1, + "hint": "Starts with an 'E'.", + "dir": 1 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "da1a1385-2b17-4be6-b5d6-803dc783c9dc", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "A white marble mausoleum commissioned in 1632 by an emperor to house the tomb of his favorite wife of three." + } + ], + "answers": [ + { + "value": 100, + "text": "The Taj-Mahal", + "id": "5c0f1f45-6b0d-4e1e-a3e6-a18a91335151" + } + ], + "options": { + "y": 17, + "x": 5, + "posSet": 1, + "hint": "Located in India", + "dir": 0 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "212d5992-beb2-40de-a655-75a1507323db", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "Home for the president of the United States of America." + } + ], + "answers": [ + { + "value": 100, + "text": "The White House", + "id": "c58bf162-c1c2-4cee-9c39-863155371487" + } + ], + "options": { + "y": "3", + "x": "7", + "posSet": 1, + "hint": "They painted it white.", + "dir": "1" + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "54a6721e-f130-4adc-91b6-01fd18e40db2", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "Mysterious landmark of several large standing stones arranged in a circle." + } + ], + "answers": [ + { + "value": 100, + "text": "Stonehenge", + "id": "dd6cf7e3-af2b-4646-af4e-9cc1442d1b4e" + } + ], + "options": { + "y": 10, + "x": 6, + "posSet": 1, + "hint": "This monument is in England.", + "dir": 0 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "e34b2621-b23a-43b7-9fc6-7c07a0394f9f", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "This is one of the world's oldest statues - A lion with a human head that stands in the Giza Plateau." + } + ], + "answers": [ + { + "value": 100, + "text": "Sphinx", + "id": "52aca647-51b6-4081-9ea7-d3eb67a9c995" + } + ], + "options": { + "y": 0, + "x": 1, + "posSet": 1, + "hint": "It needs a new nose!", + "dir": 1 + }, + "assets": [] + }, + { + "materiaType": "question", + "id": "f6e25927-8239-4a5a-bcc3-be42d43360bb", + "type": "QA", + "created_at": 1656007466, + "questions": [ + { + "text": "A monument built for the 1889 World's Fair, this metal structure can be found on the Champ de Mars in Paris." + } + ], + "answers": [ + { + "value": 100, + "text": "Eiffel Tower", + "id": "7b692988-9c8e-48a9-b908-695a30d7b6d3" + } + ], + "options": { + "y": 3, + "x": 0, + "posSet": 1, + "hint": "___ Tower.", + "dir": 0 + }, + "assets": [] + } + ] + } + ], + "options": { + "freeWords": 1, + "hintPenalty": 50 + }, + "id": "2" + }, + "id": "2" + } \ No newline at end of file diff --git a/src/__test__/mockapi/crossword_demo_widget_info.json b/src/__test__/mockapi/crossword_demo_widget_info.json new file mode 100644 index 000000000..0a053c94f --- /dev/null +++ b/src/__test__/mockapi/crossword_demo_widget_info.json @@ -0,0 +1,41 @@ +{ + "clean_name": "crossword", + "creator": "creator.html", + "created_at": "1656007466", + "dir": "1-crossword/", + "flash_version": "10", + "api_version": "2", + "height": "592", + "id": "1", + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "0c0b0b35ff3edaad4f7c08f3e3e9366c", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "I", + "excerpt": "A quiz tool that uses words and clues to randomly generate a crossword puzzle.", + "demo": "XxSgi" + }, + "name": "Crossword", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Crossword", + "score_screen": "scoreScreen.html", + "width": "715", + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + } \ No newline at end of file diff --git a/src/__test__/mockapi/play_logs_get.json b/src/__test__/mockapi/play_logs_get.json new file mode 100644 index 000000000..84d296075 --- /dev/null +++ b/src/__test__/mockapi/play_logs_get.json @@ -0,0 +1,74 @@ +[ + { + "id": "7eyBQMqpasdhDqd8WRxjzJVT1VFdkIySlNLbVVONzhzTzFpZ1ZlQjRWWHBhMi1laHZPU1hyamdFQlE", + "time": "1481231416", + "done": "1", + "perc": "100", + "elapsed": "16", + "qset_id": "15722", + "user_id": "93489", + "first": "Brandon", + "last": "Stull", + "username": "1655169" + }, + { + "id": "XEkAmVmF5Wdsl8Hq0XTPWRYSDNhenNsamp3SlN6YnhPZ3ljWHhQZ1FXbHdfUVp5UTZOaDM5NHBFU2c", + "time": "1479232577", + "done": "0", + "perc": "0", + "elapsed": "1", + "qset_id": "15722", + "user_id": "23450", + "first": "Corey", + "last": "Peterson", + "username": "1799433" + }, + { + "id": "gbibLLtTRGKdrsmu1JAC6G9Gczl0ZkxmM2hsaTRPV0xka3NnVllKelhUSjZTVzlRUy1OVWtLbnVkeW8", + "time": "1479232547", + "done": "0", + "perc": "0", + "elapsed": "1", + "qset_id": "15722", + "user_id": "3078", + "first": "Ian", + "last": "Turgeon", + "username": "0396856" + }, + { + "id": "krUDYQoanelCnJ9LeGPgwzBCUm1zU0FOeXhpSkxXSmJEWXRYOExOVXdSUC1NeUlyQWVKUTR3azBTSjg", + "time": "1479232621", + "done": "1", + "perc": "100", + "elapsed": "10", + "qset_id": "15722", + "user_id": "23450", + "first": "Corey", + "last": "Peterson", + "username": "1799433" + }, + { + "id": "o3LKhvkmfRTnahiAHdez3ltRFdzckpmS0NxLTU3VnFZNGU5aDVnU0VmdUxjZ2lBS0ZaYkpfdm9LcWc", + "time": "1479232589", + "done": "0", + "perc": "0", + "elapsed": "1", + "qset_id": "15722", + "user_id": "23450", + "first": "Corey", + "last": "Peterson", + "username": "1799433" + }, + { + "id": "o3LKhvkmfRTnahiAHdez3ltRFdzckpmS0NxLTU3VnFZNGU5aDVnU0VmdUxjZ2lBS0ZaYkpfdm9LcWc", + "time": "1479232589", + "done": "0", + "perc": "0", + "elapsed": "1", + "qset_id": "15722", + "user_id": "23450", + "first": "Corey", + "last": "Peterson", + "username": "1799433" + } +] diff --git a/src/__test__/mockapi/play_storage_get.json b/src/__test__/mockapi/play_storage_get.json new file mode 100644 index 000000000..cf69d473a --- /dev/null +++ b/src/__test__/mockapi/play_storage_get.json @@ -0,0 +1,159 @@ +{ + "SomeExampleTable": [ + { + "play": { + "user": "0396856", + "firstName": "Ian", + "lastName": "Turgeon", + "time": "1470528099", + "cleanTime": "02/21/2016 12:07:12 EST", + "play_id": "3qVT1axpqa6nFUNLDpld5RTRsNVlQOTJ0VklIc05PY01Tb3R4Zmk4dHI3akFxeGZkZGpoRW5xRm8" + }, + "data": { + "Mirrored": "D", + "Picture": "2", + "ReactionTime": "594", + "RotationAngle": "135", + "SubjectCorrect": "false" + } + }, + { + "play": { + "user": "0396856", + "firstName": "Ian", + "lastName": "Turgeon", + "time": "1470528099", + "cleanTime": "02/21/2016 12:07:12 EST", + "play_id": "3qVT1axpqa6nFUNLDpld5RTRsNVlQOTJ0VklIc05PY01Tb3R4Zmk4dHI3akFxeGZkZGpoRW5xRm8" + }, + "data": { + "Mirrored": "D", + "Picture": "4", + "ReactionTime": "190", + "RotationAngle": "45", + "SubjectCorrect": "false" + } + }, + { + "play": { + "user": "0396856", + "firstName": "Ian", + "lastName": "Turgeon", + "time": "1470528099", + "cleanTime": "02/21/2016 12:07:12 EST", + "play_id": "3qVT1axpqa6nFUNLDpld5RTRsNVlQOTJ0VklIc05PY01Tb3R4Zmk4dHI3akFxeGZkZGpoRW5xRm8" + }, + "data": { + "Mirrored": "S", + "Picture": "5", + "ReactionTime": "135", + "RotationAngle": "270", + "SubjectCorrect": "true" + } + }, + { + "play": { + "user": "0396856", + "firstName": "Ian", + "lastName": "Turgeon", + "time": "1470528099", + "cleanTime": "02/21/2016 12:07:12 EST", + "play_id": "3qVT1axpqa6nFUNLDpld5RTRsNVlQOTJ0VklIc05PY01Tb3R4Zmk4dHI3akFxeGZkZGpoRW5xRm8" + }, + "data": { + "Mirrored": "S", + "Picture": "6", + "ReactionTime": "32", + "RotationAngle": "135", + "SubjectCorrect": "true" + } + }, + { + "play": { + "user": "0396856", + "firstName": "Ian", + "lastName": "Turgeon", + "time": "1020398401", + "cleanTime": "02/21/2002 12:07:12 EST", + "play_id": "3qVT1axpqa6nFUNLDpld5RTRsNVlQOTJ0VklIc05PY01Tb3R4Zmk4dHI3akFxeGZkZGpoRW5xRm8" + }, + "data": { + "Mirrored": "S", + "Picture": "6", + "ReactionTime": "32", + "RotationAngle": "135", + "SubjectCorrect": "true" + } + } + ], + "SomeOtherTable": [ + { + "play": { + "user": "0396856", + "firstName": "Ian", + "lastName": "Turgeon", + "time": "1470528099", + "cleanTime": "02/21/2016 12:07:12 EST", + "play_id": "3qVT1axpqa6nFUNLDpld5RTRsNVlQOTJ0VklIc05PY01Tb3R4Zmk4dHI3akFxeGZkZGpoRW5xRm8" + }, + "data": { + "Mirrored": "D", + "Picture": "2", + "ReactionTime": "594", + "RotationAngle": "135", + "SubjectCorrect": "false" + } + }, + { + "play": { + "user": "0396856", + "firstName": "Ian", + "lastName": "Turgeon", + "time": "1470528099", + "cleanTime": "02/21/2016 12:07:12 EST", + "play_id": "3qVT1axpqa6nFUNLDpld5RTRsNVlQOTJ0VklIc05PY01Tb3R4Zmk4dHI3akFxeGZkZGpoRW5xRm8" + }, + "data": { + "Mirrored": "D", + "Picture": "4", + "ReactionTime": "190", + "RotationAngle": "45", + "SubjectCorrect": "false" + } + }, + { + "play": { + "user": "0396856", + "firstName": "Ian", + "lastName": "Turgeon", + "time": "1470528099", + "cleanTime": "02/21/2016 12:07:12 EST", + "play_id": "3qVT1axpqa6nFUNLDpld5RTRsNVlQOTJ0VklIc05PY01Tb3R4Zmk4dHI3akFxeGZkZGpoRW5xRm8" + }, + "data": { + "Mirrored": "S", + "Picture": "5", + "ReactionTime": "135", + "RotationAngle": "270", + "SubjectCorrect": "true" + } + }, + { + "play": { + "user": "0396856", + "firstName": "Ian", + "lastName": "Turgeon", + "time": "1470528099", + "cleanTime": "02/21/2016 12:07:12 EST", + "play_id": "3qVT1axpqa6nFUNLDpld5RTRsNVlQOTJ0VklIc05PY01Tb3R4Zmk4dHI3akFxeGZkZGpoRW5xRm8" + }, + "data": { + "Mirrored": "S", + "Picture": "6", + "ReactionTime": "32", + "RotationAngle": "135", + "SubjectCorrect": "true" + } + } + ] +} diff --git a/src/__test__/mockapi/semester_date_ranges_get.json b/src/__test__/mockapi/semester_date_ranges_get.json new file mode 100644 index 000000000..35ad15619 --- /dev/null +++ b/src/__test__/mockapi/semester_date_ranges_get.json @@ -0,0 +1,662 @@ +[ + { + "year": "2004", + "semester": "Summer", + "start": "1084147200", + "end": "1091663999" + }, + { + "year": "2004", + "semester": "Fall", + "start": "1091664000", + "end": "1103155199" + }, + { + "year": "2005", + "semester": "Spring", + "start": "1103155200", + "end": "1115337599" + }, + { + "year": "2005", + "semester": "Summer", + "start": "1115337600", + "end": "1123718399" + }, + { + "year": "2005", + "semester": "Fall", + "start": "1123718400", + "end": "1134604799" + }, + { + "year": "2006", + "semester": "Spring", + "start": "1134604800", + "end": "1146787199" + }, + { + "year": "2006", + "semester": "Summer", + "start": "1146787200", + "end": "1154822399" + }, + { + "year": "2006", + "semester": "Fall", + "start": "1154822400", + "end": "1165795199" + }, + { + "year": "2007", + "semester": "Spring", + "start": "1165795200", + "end": "1178063999" + }, + { + "year": "2007", + "semester": "Summer", + "start": "1178064000", + "end": "1186271999" + }, + { + "year": "2007", + "semester": "Fall", + "start": "1186272000", + "end": "1197590399" + }, + { + "year": "2008", + "semester": "Spring", + "start": "1197590400", + "end": "1209686399" + }, + { + "year": "2008", + "semester": "Summer", + "start": "1209686400", + "end": "1217635199" + }, + { + "year": "2008", + "semester": "Fall", + "start": "1217635200", + "end": "1218067199" + }, + { + "year": "2009", + "semester": "Spring", + "start": "1218067200", + "end": "1241740799" + }, + { + "year": "2009", + "semester": "Summer", + "start": "1241740800", + "end": "1250121599" + }, + { + "year": "2009", + "semester": "Fall", + "start": "1250121600", + "end": "1261094399" + }, + { + "year": "2010", + "semester": "Spring", + "start": "1261094400", + "end": "1273190399" + }, + { + "year": "2010", + "semester": "Summer", + "start": "1273190400", + "end": "1281571199" + }, + { + "year": "2010", + "semester": "Fall", + "start": "1281571200", + "end": "1292543999" + }, + { + "year": "2011", + "semester": "Spring", + "start": "1292544000", + "end": "1304639999" + }, + { + "year": "2011", + "semester": "Summer", + "start": "1304640000", + "end": "1313038799" + }, + { + "year": "2011", + "semester": "Fall", + "start": "1313038800", + "end": "1323910699" + }, + { + "year": "2012", + "semester": "Spring", + "start": "1323910700", + "end": "1336089539" + }, + { + "year": "2012", + "semester": "Summer", + "start": "1336089540", + "end": "1344297600" + }, + { + "year": "2012", + "semester": "Fall", + "start": "1344297601", + "end": "1356912000" + }, + { + "year": "2013", + "semester": "Spring", + "start": "1356912001", + "end": "1367539200" + }, + { + "year": "2013", + "semester": "Summer", + "start": "1367539201", + "end": "1375833600" + }, + { + "year": "2013", + "semester": "Fall", + "start": "1375833601", + "end": "1388448000" + }, + { + "year": "2014", + "semester": "Spring", + "start": "1388448001", + "end": "1399075200" + }, + { + "year": "2014", + "semester": "Summer", + "start": "1399075201", + "end": "1407369600" + }, + { + "year": "2014", + "semester": "Fall", + "start": "1407369601", + "end": "1419984000" + }, + { + "year": "2015", + "semester": "Spring", + "start": "1419984001", + "end": "1430611200" + }, + { + "year": "2015", + "semester": "Summer", + "start": "1430611201", + "end": "1438905600" + }, + { + "year": "2015", + "semester": "Fall", + "start": "1438905601", + "end": "1451520000" + }, + { + "year": "2016", + "semester": "Spring", + "start": "1451520001", + "end": "1462233600" + }, + { + "year": "2016", + "semester": "Summer", + "start": "1462233601", + "end": "1470528000" + }, + { + "year": "2016", + "semester": "Fall", + "start": "1470528001", + "end": "1483142400" + }, + { + "year": "2017", + "semester": "Spring", + "start": "1483142401", + "end": "1493769600" + }, + { + "year": "2017", + "semester": "Summer", + "start": "1493769601", + "end": "1502064000" + }, + { + "year": "2017", + "semester": "Fall", + "start": "1502064001", + "end": "1514678400" + }, + { + "year": "2018", + "semester": "Spring", + "start": "1514678401", + "end": "1525305600" + }, + { + "year": "2018", + "semester": "Summer", + "start": "1525305601", + "end": "1533600000" + }, + { + "year": "2018", + "semester": "Fall", + "start": "1533600001", + "end": "1546214400" + }, + { + "year": "2019", + "semester": "Spring", + "start": "1546214401", + "end": "1556841600" + }, + { + "year": "2019", + "semester": "Summer", + "start": "1556841601", + "end": "1565136000" + }, + { + "year": "2019", + "semester": "Fall", + "start": "1565136001", + "end": "1577750400" + }, + { + "year": "2020", + "semester": "Spring", + "start": "1577750401", + "end": "1588464000" + }, + { + "year": "2020", + "semester": "Summer", + "start": "1588464001", + "end": "1596758400" + }, + { + "year": "2020", + "semester": "Fall", + "start": "1596758401", + "end": "1609372800" + }, + { + "year": "2021", + "semester": "Spring", + "start": "1609372801", + "end": "1620000000" + }, + { + "year": "2021", + "semester": "Summer", + "start": "1620000001", + "end": "1628294400" + }, + { + "year": "2021", + "semester": "Fall", + "start": "1628294401", + "end": "1640908800" + }, + { + "year": "2022", + "semester": "Spring", + "start": "1640908801", + "end": "1651536000" + }, + { + "year": "2022", + "semester": "Summer", + "start": "1651536001", + "end": "1659830400" + }, + { + "year": "2022", + "semester": "Fall", + "start": "1659830401", + "end": "1672444800" + }, + { + "year": "2023", + "semester": "Spring", + "start": "1672444801", + "end": "1683072000" + }, + { + "year": "2023", + "semester": "Summer", + "start": "1683072001", + "end": "1691366400" + }, + { + "year": "2023", + "semester": "Fall", + "start": "1691366401", + "end": "1703980800" + }, + { + "year": "2024", + "semester": "Spring", + "start": "1703980801", + "end": "1714694400" + }, + { + "year": "2024", + "semester": "Summer", + "start": "1714694401", + "end": "1722988800" + }, + { + "year": "2024", + "semester": "Fall", + "start": "1722988801", + "end": "1735603200" + }, + { + "year": "2001", + "semester": "Spring", + "start": "978325201", + "end": "988862400" + }, + { + "year": "2001", + "semester": "Summer", + "start": "988862401", + "end": "997156800" + }, + { + "year": "2001", + "semester": "Fall", + "start": "997156801", + "end": "1009774800" + }, + { + "year": "2002", + "semester": "Spring", + "start": "1009774801", + "end": "1020398400" + }, + { + "year": "2002", + "semester": "Summer", + "start": "1020398401", + "end": "1028692800" + }, + { + "year": "2002", + "semester": "Fall", + "start": "1028692801", + "end": "1041310800" + }, + { + "year": "2003", + "semester": "Spring", + "start": "1041310801", + "end": "1051934400" + }, + { + "year": "2003", + "semester": "Summer", + "start": "1051934401", + "end": "1060228800" + }, + { + "year": "2003", + "semester": "Fall", + "start": "1060228801", + "end": "1072846800" + }, + { + "year": "2025", + "semester": "Spring", + "start": "1735621201", + "end": "1746244800" + }, + { + "year": "2025", + "semester": "Summer", + "start": "1746244801", + "end": "1754539200" + }, + { + "year": "2025", + "semester": "Fall", + "start": "1754539201", + "end": "1767157200" + }, + { + "year": "2026", + "semester": "Spring", + "start": "1767157201", + "end": "1777780800" + }, + { + "year": "2026", + "semester": "Summer", + "start": "1777780801", + "end": "1786075200" + }, + { + "year": "2026", + "semester": "Fall", + "start": "1786075201", + "end": "1798693200" + }, + { + "year": "2027", + "semester": "Spring", + "start": "1798693201", + "end": "1809316800" + }, + { + "year": "2027", + "semester": "Summer", + "start": "1809316801", + "end": "1817611200" + }, + { + "year": "2027", + "semester": "Fall", + "start": "1817611201", + "end": "1830229200" + }, + { + "year": "2028", + "semester": "Spring", + "start": "1830229201", + "end": "1840939200" + }, + { + "year": "2028", + "semester": "Summer", + "start": "1840939201", + "end": "1849233600" + }, + { + "year": "2028", + "semester": "Fall", + "start": "1849233601", + "end": "1861851600" + }, + { + "year": "2029", + "semester": "Spring", + "start": "1861851601", + "end": "1872475200" + }, + { + "year": "2029", + "semester": "Summer", + "start": "1872475201", + "end": "1880769600" + }, + { + "year": "2029", + "semester": "Fall", + "start": "1880769601", + "end": "1893387600" + }, + { + "year": "2030", + "semester": "Spring", + "start": "1893387601", + "end": "1904011200" + }, + { + "year": "2030", + "semester": "Summer", + "start": "1904011201", + "end": "1912305600" + }, + { + "year": "2030", + "semester": "Fall", + "start": "1912305601", + "end": "1924923600" + }, + { + "year": "2031", + "semester": "Spring", + "start": "1924923601", + "end": "1935547200" + }, + { + "year": "2031", + "semester": "Summer", + "start": "1935547201", + "end": "1943841600" + }, + { + "year": "2031", + "semester": "Fall", + "start": "1943841601", + "end": "1956459600" + }, + { + "year": "2032", + "semester": "Spring", + "start": "1956459601", + "end": "1967169600" + }, + { + "year": "2032", + "semester": "Summer", + "start": "1967169601", + "end": "1975464000" + }, + { + "year": "2032", + "semester": "Fall", + "start": "1975464001", + "end": "1988082000" + }, + { + "year": "2033", + "semester": "Spring", + "start": "1988082001", + "end": "1998705600" + }, + { + "year": "2033", + "semester": "Summer", + "start": "1998705601", + "end": "2007000000" + }, + { + "year": "2033", + "semester": "Fall", + "start": "2007000001", + "end": "2019618000" + }, + { + "year": "2034", + "semester": "Spring", + "start": "2019618001", + "end": "2030241600" + }, + { + "year": "2034", + "semester": "Summer", + "start": "2030241601", + "end": "2038536000" + }, + { + "year": "2034", + "semester": "Fall", + "start": "2038536001", + "end": "2051154000" + }, + { + "year": "2035", + "semester": "Spring", + "start": "2051154001", + "end": "2061777600" + }, + { + "year": "2035", + "semester": "Summer", + "start": "2061777601", + "end": "2070072000" + }, + { + "year": "2035", + "semester": "Fall", + "start": "2070072001", + "end": "2082690000" + }, + { + "year": "2036", + "semester": "Spring", + "start": "2082690001", + "end": "2093400000" + }, + { + "year": "2036", + "semester": "Summer", + "start": "2093400001", + "end": "2101694400" + }, + { + "year": "2036", + "semester": "Fall", + "start": "2101694401", + "end": "2114312400" + }, + { + "year": "2037", + "semester": "Spring", + "start": "2114312401", + "end": "2124936000" + }, + { + "year": "2037", + "semester": "Summer", + "start": "2124936001", + "end": "2133230400" + }, + { + "year": "2037", + "semester": "Fall", + "start": "2133230401", + "end": "2145848400" + } +] diff --git a/src/__test__/mockapi/users_get.json b/src/__test__/mockapi/users_get.json new file mode 100644 index 000000000..8904ffa38 --- /dev/null +++ b/src/__test__/mockapi/users_get.json @@ -0,0 +1,78 @@ +[ + { + "id": 9, + "username": "test_lti_user05f2db072c", + "first": "Unofficial Test", + "last": "User 05f2db072c", + "group": 1, + "email": "test.lti.user05f2db072c@materia.com", + "last_login": 1679405996, + "profile_fields": { + "useGravatar": true, + "notify": true, + "created_by": "ucfopen.github.io" + }, + "created_at": 1679405995, + "updated_at": 1679405995, + "avatar": "https://secure.gravatar.com/avatar/543ba2bc16bde6fcd386f38d73887dd8?s=50&d=retro", + "is_student": false, + "is_support_user": false + }, + { + "id": 10, + "username": "test_lti_user34f5b1afec", + "first": "Unofficial Test", + "last": "User 34f5b1afec", + "group": 1, + "email": "test.lti.user34f5b1afec@materia.com", + "last_login": 1679408602, + "profile_fields": { + "useGravatar": true, + "notify": true, + "created_by": "ucfopen.github.io" + }, + "created_at": 1679408602, + "updated_at": 1679408602, + "avatar": "https://secure.gravatar.com/avatar/86fa3b34700d29d39bd908e8fffd1f78?s=50&d=retro", + "is_student": false, + "is_support_user": false + }, + { + "id": 11, + "username": "test_lti_user6f17ffa34b", + "first": "Unofficial Test", + "last": "User 6f17ffa34b", + "group": 1, + "email": "test.lti.user6f17ffa34b@materia.com", + "last_login": 1679939387, + "profile_fields": { + "useGravatar": true, + "notify": true, + "created_by": "ucfopen.github.io" + }, + "created_at": 1679939387, + "updated_at": 1679939387, + "avatar": "https://secure.gravatar.com/avatar/42e64399aaa62b6fcbd2460ba411007a?s=50&d=retro", + "is_student": false, + "is_support_user": false + }, + { + "id": 12, + "username": "test_lti_userf664f64d7d", + "first": "Unofficial Test", + "last": "User f664f64d7d", + "group": 1, + "email": "test.lti.userf664f64d7d@materia.com", + "last_login": 1679939906, + "profile_fields": { + "useGravatar": true, + "notify": true, + "created_by": "ucfopen.github.io" + }, + "created_at": 1679939906, + "updated_at": 1679939906, + "avatar": "https://secure.gravatar.com/avatar/5256dbeaaad7f29317a1977eff48424e?s=50&d=retro", + "is_student": false, + "is_support_user": false + } + ] \ No newline at end of file diff --git a/src/__test__/mockapi/widget_instances_after_update.json b/src/__test__/mockapi/widget_instances_after_update.json new file mode 100644 index 000000000..41fa11646 --- /dev/null +++ b/src/__test__/mockapi/widget_instances_after_update.json @@ -0,0 +1,1110 @@ +[ + { + "attempts": 10, + "clean_name": "market-day", + "close_at": 1681403400000, + "created_at": 1679408963, + "embed_url": "https://localhost/embed/qdJD6/market-day", + "is_student_made": false, + "is_embedded": true, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": false, + "height": 0, + "id": "qdJD6", + "is_draft": false, + "name": "Market Day", + "open_at": 1681308000000, + "play_url": "https://localhost/play/qdJD6/market-day", + "preview_url": "https://localhost/preview/qdJD6/market-day", + "published_by": null, + "user_id": 8, + "widget": { + "clean_name": "adventure", + "creator": "creator.html", + "created_at": 1680098456, + "dir": "9-adventure/", + "flash_version": 0, + "api_version": 2, + "height": 593, + "id": 9, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "eaae19b012b19d140c572a00e6b80169", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Media" + ], + "supported_data": [ + "Question/Answer", + "Multiple Choice" + ], + "excerpt": "Build branching scenarios where your student's choices lead them down different paths.", + "about": "An advanced flexible scenario-building tool.", + "playdata_exporters": [ + "Survey Formatting" + ], + "demo": "H6D5s" + }, + "name": "Adventure", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Adventure", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "my-adventure-widget", + "close_at": -1, + "created_at": 1679408963, + "embed_url": "https://localhost/embed/robot/adventure-copy", + "is_student_made": false, + "is_embedded": true, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": false, + "height": 0, + "id": "robot", + "is_draft": false, + "name": "Adventure Copy", + "open_at": -1, + "play_url": "https://localhost/play/robot/adventure-copy", + "preview_url": "https://localhost/preview/robot/adventure-copy", + "published_by": null, + "user_id": 8, + "widget": { + "clean_name": "adventure", + "creator": "creator.html", + "created_at": 1680098456, + "dir": "9-adventure/", + "flash_version": 0, + "api_version": 2, + "height": 593, + "id": 9, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "eaae19b012b19d140c572a00e6b80169", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Media" + ], + "supported_data": [ + "Question/Answer", + "Multiple Choice" + ], + "excerpt": "Build branching scenarios where your student's choices lead them down different paths.", + "about": "An advanced flexible scenario-building tool.", + "playdata_exporters": [ + "Survey Formatting" + ], + "demo": "H6D5s" + }, + "name": "Adventure", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Adventure", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "my-adventure-widget", + "close_at": -1, + "created_at": 1679408963, + "embed_url": "https://localhost/embed/robot/adventure-copy-no-access", + "is_student_made": false, + "is_embedded": true, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": false, + "height": 0, + "id": "robot2", + "is_draft": false, + "name": "Adventure Copy No Access", + "open_at": -1, + "play_url": "https://localhost/play/robot/adventure-copy-no-access", + "preview_url": "https://localhost/preview/robot/adventure-copy-no-access", + "published_by": null, + "user_id": 8, + "widget": { + "clean_name": "adventure", + "creator": "creator.html", + "created_at": 1680098456, + "dir": "9-adventure/", + "flash_version": 0, + "api_version": 2, + "height": 593, + "id": 9, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "eaae19b012b19d140c572a00e6b80169", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Media" + ], + "supported_data": [ + "Question/Answer", + "Multiple Choice" + ], + "excerpt": "Build branching scenarios where your student's choices lead them down different paths.", + "about": "An advanced flexible scenario-building tool.", + "playdata_exporters": [ + "Survey Formatting" + ], + "demo": "H6D5s" + }, + "name": "Adventure", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Adventure", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "spanish-verbs", + "close_at": -1, + "created_at": 1678198799, + "embed_url": "https://localhost/embed/GaZbU/spanish-verbs", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "GaZbU", + "is_draft": false, + "name": "Spanish Verbs", + "open_at": -1, + "play_url": "https://localhost/play/GaZbU/spanish-verbs", + "preview_url": "https://localhost/preview/GaZbU/spanish-verbs", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "last-chance-cadet", + "creator": "creator.html", + "created_at": 1678198812, + "dir": "15-last-chance-cadet/", + "flash_version": 0, + "api_version": 2, + "height": 600, + "id": 15, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "f4d955c8a5d2e0fb081ae6c7cb230ded", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly", + "Media" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Matching provides a left and a right list. Students are asked to match the items on the left with the corresponding item on the right.", + "excerpt": "Students must match one set of words or phrases to a corresponding word, phrase, or definition.", + "demo": "GaZbU" + }, + "name": "Last Chance Cadet", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Matching", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "syntax-sorter-widget-demo", + "close_at": -1, + "created_at": 1678198794, + "embed_url": "https://localhost/embed/97cKO/syntax-sorter-widget-demo", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "97cKO", + "is_draft": false, + "name": "Syntax Sorter Widget Demo", + "open_at": -1, + "play_url": "https://localhost/play/97cKO/syntax-sorter-widget-demo", + "preview_url": "https://localhost/preview/97cKO/syntax-sorter-widget-demo", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "syntax-sorter", + "creator": "creator.html", + "created_at": 1680098467, + "dir": "14-syntax-sorter/", + "flash_version": 0, + "api_version": 2, + "height": 680, + "id": 14, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "07459df66f4b1f0aec7a6dba8c18dac6", + "meta_data": { + "features": [ + "Customizable", + "Scorable" + ], + "supported_data": [ + "Custom Questions" + ], + "about": "Create sentences or phrases out of individual tokens and categorize them based on their syntax. Students must arrange the tokens correctly.", + "excerpt": "Tokenize sentences or phrases and identify each element based on its syntax. Students are tasked with correctly arranging the tokens.", + "demo": "97cKO" + }, + "name": "Syntax Sorter", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "LanguageWidget", + "score_screen": "scoreScreen.html", + "width": 900, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "american-conflicts-from-1776-1975", + "close_at": -1, + "created_at": 1678198792, + "embed_url": "https://localhost/embed/SfKDI/american-conflicts-from-1776-1975", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "SfKDI", + "is_draft": false, + "name": "American Conflicts from 1776 - 1975", + "open_at": -1, + "play_url": "https://localhost/play/SfKDI/american-conflicts-from-1776-1975", + "preview_url": "https://localhost/preview/SfKDI/american-conflicts-from-1776-1975", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "sequencer", + "creator": "creator.html", + "created_at": 1680098465, + "dir": "13-sequencer/", + "flash_version": 10, + "api_version": 2, + "height": 548, + "id": 13, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "4ad3cc64804f1a20ae1fa748add522f5", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Sequencer is about teaching ordered events and sequences. Items are presented to students in a random order for them to re-arrange.", + "excerpt": "Students must order a random set of words or phrases in the correct order.", + "demo": "SfKDI" + }, + "name": "Sequencer", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Sequencer", + "score_screen": "", + "width": 750, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "severe-weather-survey", + "close_at": -1, + "created_at": 1678198790, + "embed_url": "https://localhost/embed/RgxqS/severe-weather-survey", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "RgxqS", + "is_draft": false, + "name": "Severe Weather Survey", + "open_at": -1, + "play_url": "https://localhost/play/RgxqS/severe-weather-survey", + "preview_url": "https://localhost/preview/RgxqS/severe-weather-survey", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "simple-survey", + "creator": "creator.html", + "created_at": 1680098463, + "dir": "12-simple-survey/", + "flash_version": 0, + "api_version": 2, + "height": 0, + "id": 12, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "9a317a35b7be082d5973211af26b8c83", + "meta_data": { + "features": [ + "Customizable", + "Media" + ], + "supported_data": [ + "Multiple Choice", + "Survey" + ], + "about": "Build simple surveys with multiple choice, check-all-that-apply, and free response questions.", + "excerpt": "Build surveys or questionnaires with a range of question options including multiple choice, check-all-that-apply, and free response. Questions are not scored and students are given full credit upon completion.", + "playdata_exporters": [ + "Survey Formatting" + ], + "demo": "RgxqS" + }, + "name": "Simple Survey", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "SurveyWidget", + "score_screen": "scorescreen.html", + "width": 0, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "famous-artists", + "close_at": -1, + "created_at": 1678198788, + "embed_url": "https://localhost/embed/ax9KS/famous-artists", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "ax9KS", + "is_draft": false, + "name": "Famous Artists", + "open_at": -1, + "play_url": "https://localhost/play/ax9KS/famous-artists", + "preview_url": "https://localhost/preview/ax9KS/famous-artists", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "sort-it-out", + "creator": "creator.html", + "created_at": 1680098460, + "dir": "11-sort-it-out/", + "flash_version": 0, + "api_version": 2, + "height": 650, + "id": 11, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "3dee8dcd2eb72211af1e0b7b2a782718", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly", + "Media" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Sort It Out! provides items randomly spread across the screen with folders at the bottom. Students are asked to drag and drop the items into their appropriate folders.", + "excerpt": "Students must sort items on a messy computer desktop into their appropriate folders.", + "demo": "ax9KS" + }, + "name": "Sort It Out!", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "SortItOut", + "score_screen": "scoreScreen.html", + "width": 960, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "equation-sandbox", + "close_at": -1, + "created_at": 1678198785, + "embed_url": "https://localhost/embed/fSIiW/equation-sandbox", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "fSIiW", + "is_draft": false, + "name": "Equation Sandbox", + "open_at": -1, + "play_url": "https://localhost/play/fSIiW/equation-sandbox", + "preview_url": "https://localhost/preview/fSIiW/equation-sandbox", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "equation-sandbox", + "creator": "creator.html", + "created_at": 1680098459, + "dir": "10-equation-sandbox/", + "flash_version": 10, + "api_version": 2, + "height": 600, + "id": 10, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "0", + "is_storage_enabled": "0", + "package_hash": "e6e968ca5bdcee5691a76df40bacb98c", + "meta_data": { + "features": [ + "Customizable" + ], + "supported_data": [ + "Custom" + ], + "about": "With Equation Sandbox, students will be able to experiment with the graph of an equation you create. Manipulating the variables of the equation alter the graph, allowing students to learn first-hand how a function graph is created.", + "excerpt": "Interactive graphs from parameterized equations", + "demo": "fSIiW" + }, + "name": "Equation Sandbox", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "EquationSandbox", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "emergency-room-care", + "close_at": -1, + "created_at": 1678198783, + "embed_url": "https://localhost/embed/H6D5s/emergency-room-care", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "H6D5s", + "is_draft": false, + "name": "Emergency Room Care", + "open_at": -1, + "play_url": "https://localhost/play/H6D5s/emergency-room-care", + "preview_url": "https://localhost/preview/H6D5s/emergency-room-care", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "adventure", + "creator": "creator.html", + "created_at": 1680098456, + "dir": "9-adventure/", + "flash_version": 0, + "api_version": 2, + "height": 593, + "id": 9, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "eaae19b012b19d140c572a00e6b80169", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Media" + ], + "supported_data": [ + "Question/Answer", + "Multiple Choice" + ], + "excerpt": "Build branching scenarios where your student's choices lead them down different paths.", + "about": "An advanced flexible scenario-building tool.", + "playdata_exporters": [ + "Survey Formatting" + ], + "demo": "H6D5s" + }, + "name": "Adventure", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Adventure", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "creative-writing-vocabulary", + "close_at": -1, + "created_at": 1678198780, + "embed_url": "https://localhost/embed/73JM8/creative-writing-vocabulary", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "73JM8", + "is_draft": false, + "name": "Creative Writing Vocabulary", + "open_at": -1, + "play_url": "https://localhost/play/73JM8/creative-writing-vocabulary", + "preview_url": "https://localhost/preview/73JM8/creative-writing-vocabulary", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "word-search", + "creator": "creator.html", + "created_at": 1680098453, + "dir": "8-word-search/", + "flash_version": 10, + "api_version": 2, + "height": 600, + "id": 8, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "00bc9092b2a0affabe2d996da64c15ae", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Word search is played by finding words in a grid of letters. Students can circle words by dragging from the start of the word to the end.", + "excerpt": "A study tool where students must search a word puzzle for a predetermined set of words.", + "demo": "73JM8" + }, + "name": "Word Search", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "WordSearch", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "this-or-that", + "close_at": -1, + "created_at": 1678198779, + "embed_url": "https://localhost/embed/TPTxw/this-or-that", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "TPTxw", + "is_draft": false, + "name": "This or That", + "open_at": -1, + "play_url": "https://localhost/play/TPTxw/this-or-that", + "preview_url": "https://localhost/preview/TPTxw/this-or-that", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "this-or-that", + "creator": "creator.html", + "created_at": 1680098451, + "dir": "7-this-or-that/", + "flash_version": 10, + "api_version": 2, + "height": 515, + "id": 7, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "6d79f21eb27d76f5438d3349fe8e6f91", + "meta_data": { + "features": [ + "Customizable", + "Media", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Multiple Choice" + ], + "about": "This or That is a game in which students must answer questions by choosing one of two images.", + "excerpt": "Students must answer a question by choosing one of two images.", + "demo": "TPTxw" + }, + "name": "This Or That", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "ThisOrThat", + "score_screen": "scoreScreen.html", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "tv-show-trivia", + "close_at": -1, + "created_at": 1678198771, + "embed_url": "https://localhost/embed/fNKXI/tv-show-trivia", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "fNKXI", + "is_draft": false, + "name": "TV Show Trivia", + "open_at": -1, + "play_url": "https://localhost/play/fNKXI/tv-show-trivia", + "preview_url": "https://localhost/preview/fNKXI/tv-show-trivia", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "enigma", + "creator": "creator.html", + "created_at": 1680098445, + "dir": "4-enigma/", + "flash_version": 0, + "api_version": 2, + "height": 548, + "id": 4, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "58d525910af085f9cc7d1ed7f3ac74c4", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Multiple Choice" + ], + "about": "Enigma is a Jeopardy-like online study and quiz tool for reviewing subject matter, concepts, and principles. Questions are separated into categorical rows.", + "excerpt": "A Jeopardy-like study and quiz tool. Questions are separated into categorical rows.", + "demo": "fNKXI" + }, + "name": "Enigma", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "EnigmaGS", + "score_screen": "", + "width": 750, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "spanish-verbs", + "close_at": -1, + "created_at": 1678198769, + "embed_url": "https://localhost/embed/KOWZ6/spanish-verbs", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "KOWZ6", + "is_draft": false, + "name": "Spanish Verbs", + "open_at": -1, + "play_url": "https://localhost/play/KOWZ6/spanish-verbs", + "preview_url": "https://localhost/preview/KOWZ6/spanish-verbs", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "matching", + "creator": "creator.html", + "created_at": 1680098443, + "dir": "3-matching/", + "flash_version": 0, + "api_version": 2, + "height": 548, + "id": 3, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "224d201dc426843e39d8a39abe8663c3", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly", + "Media" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Matching provides a left and a right list. Students are asked to match the items on the left with the corresponding item on the right.", + "excerpt": "Students must match one set of words or phrases to a corresponding word, phrase, or definition.", + "demo": "KOWZ6" + }, + "name": "Matching", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Matching", + "score_screen": "", + "width": 750, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "parts-of-a-cell", + "close_at": -1, + "created_at": 1678198767, + "embed_url": "https://localhost/embed/vmCjQ/parts-of-a-cell", + "is_student_made": false, + "is_embedded": false, + "is_deleted": true, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "vmCjQ", + "is_draft": false, + "name": "Parts of a Cell", + "open_at": -1, + "play_url": "https://localhost/play/vmCjQ/parts-of-a-cell", + "preview_url": "https://localhost/preview/vmCjQ/parts-of-a-cell", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "guess-the-phrase", + "creator": "creator.html", + "created_at": 1680098441, + "dir": "2-guess-the-phrase/", + "flash_version": 0, + "api_version": 2, + "height": 620, + "id": 2, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "84b63c8793845e8ea530ba502d8ac0dd", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Guess the Phrase is a guessing game played by guessing letters in a word using the provided clues. Each wrong guess lowers the anvil a little more. Five wrong guesses and the player loses.", + "excerpt": "Students are provided with a clue and must guess the word or phrase within a certain amount of letters.", + "demo": "vmCjQ" + }, + "name": "Guess the Phrase", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Hangman", + "score_screen": "scoreScreen.html", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "famous-landmarks", + "close_at": -1, + "created_at": 1678198764, + "embed_url": "https://localhost/embed/ux0O8/famous-landmarks", + "is_student_made": false, + "is_embedded": false, + "is_deleted": true, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "ux0O8", + "is_draft": false, + "name": "Famous Landmarks", + "open_at": -1, + "play_url": "https://localhost/play/ux0O8/famous-landmarks", + "preview_url": "https://localhost/preview/ux0O8/famous-landmarks", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "crossword", + "creator": "creator.html", + "created_at": 1680098439, + "dir": "1-crossword/", + "flash_version": 10, + "api_version": 2, + "height": 592, + "id": 1, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "0c0b0b35ff3edaad4f7c08f3e3e9366c", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "In Crossword, fill in the blank squares with: (a) words based on the clues provided in the text and/or (b) by the letters overlapping from other words.", + "excerpt": "A quiz tool that uses words and clues to randomly generate a crossword puzzle.", + "demo": "ux0O8" + }, + "name": "Crossword", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Crossword", + "score_screen": "scoreScreen.html", + "width": 715, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + } + ] \ No newline at end of file diff --git a/src/__test__/mockapi/widget_instances_get.json b/src/__test__/mockapi/widget_instances_get.json new file mode 100644 index 000000000..552347843 --- /dev/null +++ b/src/__test__/mockapi/widget_instances_get.json @@ -0,0 +1,1038 @@ +[ + { + "attempts": -1, + "clean_name": "my-adventure-widget", + "close_at": -1, + "created_at": 1679408963, + "embed_url": "https://localhost/embed/qdJD6/my-adventure-widget", + "is_student_made": false, + "is_embedded": true, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": false, + "height": 0, + "id": "qdJD6", + "is_draft": false, + "name": "My Adventure Widget", + "open_at": -1, + "play_url": "https://localhost/play/qdJD6/my-adventure-widget", + "preview_url": "https://localhost/preview/qdJD6/my-adventure-widget", + "published_by": null, + "user_id": 8, + "widget": { + "clean_name": "adventure", + "creator": "creator.html", + "created_at": 1680098456, + "dir": "9-adventure/", + "flash_version": 0, + "api_version": 2, + "height": 593, + "id": 9, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "eaae19b012b19d140c572a00e6b80169", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Media" + ], + "supported_data": [ + "Question/Answer", + "Multiple Choice" + ], + "excerpt": "Build branching scenarios where your student's choices lead them down different paths.", + "about": "An advanced flexible scenario-building tool.", + "playdata_exporters": [ + "Survey Formatting" + ], + "demo": "H6D5s" + }, + "name": "Adventure", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Adventure", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "my-adventure-widget", + "close_at": -1, + "created_at": 1678214055, + "embed_url": "https://localhost/embed/Im4Bc/my-adventure-widget", + "is_student_made": true, + "is_embedded": false, + "is_deleted": true, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "Im4Bc", + "is_draft": false, + "name": "My Adventure Widget", + "open_at": -1, + "play_url": "https://localhost/play/Im4Bc/my-adventure-widget", + "preview_url": "https://localhost/preview/Im4Bc/my-adventure-widget", + "published_by": null, + "user_id": 7, + "widget": { + "clean_name": "adventure", + "creator": "creator.html", + "created_at": 1680098456, + "dir": "9-adventure/", + "flash_version": 0, + "api_version": 2, + "height": 593, + "id": 9, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "eaae19b012b19d140c572a00e6b80169", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Media" + ], + "supported_data": [ + "Question/Answer", + "Multiple Choice" + ], + "excerpt": "Build branching scenarios where your student's choices lead them down different paths.", + "about": "An advanced flexible scenario-building tool.", + "playdata_exporters": [ + "Survey Formatting" + ], + "demo": "H6D5s" + }, + "name": "Adventure", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Adventure", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "spanish-verbs", + "close_at": -1, + "created_at": 1678198799, + "embed_url": "https://localhost/embed/GaZbU/spanish-verbs", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "GaZbU", + "is_draft": false, + "name": "Spanish Verbs", + "open_at": -1, + "play_url": "https://localhost/play/GaZbU/spanish-verbs", + "preview_url": "https://localhost/preview/GaZbU/spanish-verbs", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "last-chance-cadet", + "creator": "creator.html", + "created_at": 1678198812, + "dir": "15-last-chance-cadet/", + "flash_version": 0, + "api_version": 2, + "height": 600, + "id": 15, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "f4d955c8a5d2e0fb081ae6c7cb230ded", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly", + "Media" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Matching provides a left and a right list. Students are asked to match the items on the left with the corresponding item on the right.", + "excerpt": "Students must match one set of words or phrases to a corresponding word, phrase, or definition.", + "demo": "GaZbU" + }, + "name": "Last Chance Cadet", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Matching", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "syntax-sorter-widget-demo", + "close_at": -1, + "created_at": 1678198794, + "embed_url": "https://localhost/embed/97cKO/syntax-sorter-widget-demo", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "97cKO", + "is_draft": false, + "name": "Syntax Sorter Widget Demo", + "open_at": -1, + "play_url": "https://localhost/play/97cKO/syntax-sorter-widget-demo", + "preview_url": "https://localhost/preview/97cKO/syntax-sorter-widget-demo", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "syntax-sorter", + "creator": "creator.html", + "created_at": 1680098467, + "dir": "14-syntax-sorter/", + "flash_version": 0, + "api_version": 2, + "height": 680, + "id": 14, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "07459df66f4b1f0aec7a6dba8c18dac6", + "meta_data": { + "features": [ + "Customizable", + "Scorable" + ], + "supported_data": [ + "Custom Questions" + ], + "about": "Create sentences or phrases out of individual tokens and categorize them based on their syntax. Students must arrange the tokens correctly.", + "excerpt": "Tokenize sentences or phrases and identify each element based on its syntax. Students are tasked with correctly arranging the tokens.", + "demo": "97cKO" + }, + "name": "Syntax Sorter", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "LanguageWidget", + "score_screen": "scoreScreen.html", + "width": 900, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "american-conflicts-from-1776-1975", + "close_at": -1, + "created_at": 1678198792, + "embed_url": "https://localhost/embed/SfKDI/american-conflicts-from-1776-1975", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "SfKDI", + "is_draft": false, + "name": "American Conflicts from 1776 - 1975", + "open_at": -1, + "play_url": "https://localhost/play/SfKDI/american-conflicts-from-1776-1975", + "preview_url": "https://localhost/preview/SfKDI/american-conflicts-from-1776-1975", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "sequencer", + "creator": "creator.html", + "created_at": 1680098465, + "dir": "13-sequencer/", + "flash_version": 10, + "api_version": 2, + "height": 548, + "id": 13, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "4ad3cc64804f1a20ae1fa748add522f5", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Sequencer is about teaching ordered events and sequences. Items are presented to students in a random order for them to re-arrange.", + "excerpt": "Students must order a random set of words or phrases in the correct order.", + "demo": "SfKDI" + }, + "name": "Sequencer", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Sequencer", + "score_screen": "", + "width": 750, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "severe-weather-survey", + "close_at": -1, + "created_at": 1678198790, + "embed_url": "https://localhost/embed/RgxqS/severe-weather-survey", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "RgxqS", + "is_draft": false, + "name": "Severe Weather Survey", + "open_at": -1, + "play_url": "https://localhost/play/RgxqS/severe-weather-survey", + "preview_url": "https://localhost/preview/RgxqS/severe-weather-survey", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "simple-survey", + "creator": "creator.html", + "created_at": 1680098463, + "dir": "12-simple-survey/", + "flash_version": 0, + "api_version": 2, + "height": 0, + "id": 12, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "9a317a35b7be082d5973211af26b8c83", + "meta_data": { + "features": [ + "Customizable", + "Media" + ], + "supported_data": [ + "Multiple Choice", + "Survey" + ], + "about": "Build simple surveys with multiple choice, check-all-that-apply, and free response questions.", + "excerpt": "Build surveys or questionnaires with a range of question options including multiple choice, check-all-that-apply, and free response. Questions are not scored and students are given full credit upon completion.", + "playdata_exporters": [ + "Survey Formatting" + ], + "demo": "RgxqS" + }, + "name": "Simple Survey", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "SurveyWidget", + "score_screen": "scorescreen.html", + "width": 0, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "famous-artists", + "close_at": -1, + "created_at": 1678198788, + "embed_url": "https://localhost/embed/ax9KS/famous-artists", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "ax9KS", + "is_draft": false, + "name": "Famous Artists", + "open_at": -1, + "play_url": "https://localhost/play/ax9KS/famous-artists", + "preview_url": "https://localhost/preview/ax9KS/famous-artists", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "sort-it-out", + "creator": "creator.html", + "created_at": 1680098460, + "dir": "11-sort-it-out/", + "flash_version": 0, + "api_version": 2, + "height": 650, + "id": 11, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "3dee8dcd2eb72211af1e0b7b2a782718", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly", + "Media" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Sort It Out! provides items randomly spread across the screen with folders at the bottom. Students are asked to drag and drop the items into their appropriate folders.", + "excerpt": "Students must sort items on a messy computer desktop into their appropriate folders.", + "demo": "ax9KS" + }, + "name": "Sort It Out!", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "SortItOut", + "score_screen": "scoreScreen.html", + "width": 960, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "equation-sandbox", + "close_at": -1, + "created_at": 1678198785, + "embed_url": "https://localhost/embed/fSIiW/equation-sandbox", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "fSIiW", + "is_draft": false, + "name": "Equation Sandbox", + "open_at": -1, + "play_url": "https://localhost/play/fSIiW/equation-sandbox", + "preview_url": "https://localhost/preview/fSIiW/equation-sandbox", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "equation-sandbox", + "creator": "creator.html", + "created_at": 1680098459, + "dir": "10-equation-sandbox/", + "flash_version": 10, + "api_version": 2, + "height": 600, + "id": 10, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "0", + "is_storage_enabled": "0", + "package_hash": "e6e968ca5bdcee5691a76df40bacb98c", + "meta_data": { + "features": [ + "Customizable" + ], + "supported_data": [ + "Custom" + ], + "about": "With Equation Sandbox, students will be able to experiment with the graph of an equation you create. Manipulating the variables of the equation alter the graph, allowing students to learn first-hand how a function graph is created.", + "excerpt": "Interactive graphs from parameterized equations", + "demo": "fSIiW" + }, + "name": "Equation Sandbox", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "EquationSandbox", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "emergency-room-care", + "close_at": -1, + "created_at": 1678198783, + "embed_url": "https://localhost/embed/H6D5s/emergency-room-care", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "H6D5s", + "is_draft": false, + "name": "Emergency Room Care", + "open_at": -1, + "play_url": "https://localhost/play/H6D5s/emergency-room-care", + "preview_url": "https://localhost/preview/H6D5s/emergency-room-care", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "adventure", + "creator": "creator.html", + "created_at": 1680098456, + "dir": "9-adventure/", + "flash_version": 0, + "api_version": 2, + "height": 593, + "id": 9, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "eaae19b012b19d140c572a00e6b80169", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Media" + ], + "supported_data": [ + "Question/Answer", + "Multiple Choice" + ], + "excerpt": "Build branching scenarios where your student's choices lead them down different paths.", + "about": "An advanced flexible scenario-building tool.", + "playdata_exporters": [ + "Survey Formatting" + ], + "demo": "H6D5s" + }, + "name": "Adventure", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Adventure", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "creative-writing-vocabulary", + "close_at": -1, + "created_at": 1678198780, + "embed_url": "https://localhost/embed/73JM8/creative-writing-vocabulary", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "73JM8", + "is_draft": false, + "name": "Creative Writing Vocabulary", + "open_at": -1, + "play_url": "https://localhost/play/73JM8/creative-writing-vocabulary", + "preview_url": "https://localhost/preview/73JM8/creative-writing-vocabulary", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "word-search", + "creator": "creator.html", + "created_at": 1680098453, + "dir": "8-word-search/", + "flash_version": 10, + "api_version": 2, + "height": 600, + "id": 8, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "00bc9092b2a0affabe2d996da64c15ae", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Word search is played by finding words in a grid of letters. Students can circle words by dragging from the start of the word to the end.", + "excerpt": "A study tool where students must search a word puzzle for a predetermined set of words.", + "demo": "73JM8" + }, + "name": "Word Search", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "WordSearch", + "score_screen": "", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "this-or-that", + "close_at": -1, + "created_at": 1678198779, + "embed_url": "https://localhost/embed/TPTxw/this-or-that", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "TPTxw", + "is_draft": false, + "name": "This or That", + "open_at": -1, + "play_url": "https://localhost/play/TPTxw/this-or-that", + "preview_url": "https://localhost/preview/TPTxw/this-or-that", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "this-or-that", + "creator": "creator.html", + "created_at": 1680098451, + "dir": "7-this-or-that/", + "flash_version": 10, + "api_version": 2, + "height": 515, + "id": 7, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "6d79f21eb27d76f5438d3349fe8e6f91", + "meta_data": { + "features": [ + "Customizable", + "Media", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Multiple Choice" + ], + "about": "This or That is a game in which students must answer questions by choosing one of two images.", + "excerpt": "Students must answer a question by choosing one of two images.", + "demo": "TPTxw" + }, + "name": "This Or That", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "ThisOrThat", + "score_screen": "scoreScreen.html", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "tv-show-trivia", + "close_at": -1, + "created_at": 1678198771, + "embed_url": "https://localhost/embed/fNKXI/tv-show-trivia", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "fNKXI", + "is_draft": false, + "name": "TV Show Trivia", + "open_at": -1, + "play_url": "https://localhost/play/fNKXI/tv-show-trivia", + "preview_url": "https://localhost/preview/fNKXI/tv-show-trivia", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "enigma", + "creator": "creator.html", + "created_at": 1680098445, + "dir": "4-enigma/", + "flash_version": 0, + "api_version": 2, + "height": 548, + "id": 4, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "58d525910af085f9cc7d1ed7f3ac74c4", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Multiple Choice" + ], + "about": "Enigma is a Jeopardy-like online study and quiz tool for reviewing subject matter, concepts, and principles. Questions are separated into categorical rows.", + "excerpt": "A Jeopardy-like study and quiz tool. Questions are separated into categorical rows.", + "demo": "fNKXI" + }, + "name": "Enigma", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "EnigmaGS", + "score_screen": "", + "width": 750, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "spanish-verbs", + "close_at": -1, + "created_at": 1678198769, + "embed_url": "https://localhost/embed/KOWZ6/spanish-verbs", + "is_student_made": false, + "is_embedded": false, + "is_deleted": false, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "KOWZ6", + "is_draft": false, + "name": "Spanish Verbs", + "open_at": -1, + "play_url": "https://localhost/play/KOWZ6/spanish-verbs", + "preview_url": "https://localhost/preview/KOWZ6/spanish-verbs", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "matching", + "creator": "creator.html", + "created_at": 1680098443, + "dir": "3-matching/", + "flash_version": 0, + "api_version": 2, + "height": 548, + "id": 3, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "224d201dc426843e39d8a39abe8663c3", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly", + "Media" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Matching provides a left and a right list. Students are asked to match the items on the left with the corresponding item on the right.", + "excerpt": "Students must match one set of words or phrases to a corresponding word, phrase, or definition.", + "demo": "KOWZ6" + }, + "name": "Matching", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Matching", + "score_screen": "", + "width": 750, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "parts-of-a-cell", + "close_at": -1, + "created_at": 1678198767, + "embed_url": "https://localhost/embed/vmCjQ/parts-of-a-cell", + "is_student_made": false, + "is_embedded": false, + "is_deleted": true, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "vmCjQ", + "is_draft": false, + "name": "Parts of a Cell", + "open_at": -1, + "play_url": "https://localhost/play/vmCjQ/parts-of-a-cell", + "preview_url": "https://localhost/preview/vmCjQ/parts-of-a-cell", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "guess-the-phrase", + "creator": "creator.html", + "created_at": 1680098441, + "dir": "2-guess-the-phrase/", + "flash_version": 0, + "api_version": 2, + "height": 620, + "id": 2, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "84b63c8793845e8ea530ba502d8ac0dd", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "Guess the Phrase is a guessing game played by guessing letters in a word using the provided clues. Each wrong guess lowers the anvil a little more. Five wrong guesses and the player loses.", + "excerpt": "Students are provided with a clue and must guess the word or phrase within a certain amount of letters.", + "demo": "vmCjQ" + }, + "name": "Guess the Phrase", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Hangman", + "score_screen": "scoreScreen.html", + "width": 800, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + }, + { + "attempts": -1, + "clean_name": "famous-landmarks", + "close_at": -1, + "created_at": 1678198764, + "embed_url": "https://localhost/embed/ux0O8/famous-landmarks", + "is_student_made": false, + "is_embedded": false, + "is_deleted": true, + "embedded_only": false, + "student_access": false, + "guest_access": true, + "height": 0, + "id": "ux0O8", + "is_draft": false, + "name": "Famous Landmarks", + "open_at": -1, + "play_url": "https://localhost/play/ux0O8/famous-landmarks", + "preview_url": "https://localhost/preview/ux0O8/famous-landmarks", + "published_by": null, + "user_id": 1, + "widget": { + "clean_name": "crossword", + "creator": "creator.html", + "created_at": 1680098439, + "dir": "1-crossword/", + "flash_version": 10, + "api_version": 2, + "height": 592, + "id": 1, + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "0c0b0b35ff3edaad4f7c08f3e3e9366c", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "In Crossword, fill in the blank squares with: (a) words based on the clues provided in the text and/or (b) by the letters overlapping from other words.", + "excerpt": "A quiz tool that uses words and clues to randomly generate a crossword puzzle.", + "demo": "ux0O8" + }, + "name": "Crossword", + "player": "player.html", + "question_types": "", + "restrict_publish": "0", + "score_module": "Crossword", + "score_screen": "scoreScreen.html", + "width": 715, + "creator_guide": "guides/creator.html", + "player_guide": "guides/player.html" + }, + "width": 0, + "qset": { + "version": null, + "data": null + } + } + ] \ No newline at end of file diff --git a/src/__test__/mockapi/widgets_get.json b/src/__test__/mockapi/widgets_get.json new file mode 100644 index 000000000..cd1bd3f56 --- /dev/null +++ b/src/__test__/mockapi/widgets_get.json @@ -0,0 +1,39 @@ +[ + { + "clean_name": "crossword", + "creator": "creator.html", + "created_at": "1504110204", + "dir": "10-crossword/", + "flash_version": "10", + "api_version": "2", + "height": "592", + "id": "10", + "is_answer_encrypted": "1", + "in_catalog": "1", + "is_editable": "1", + "is_playable": "1", + "is_qset_encrypted": "1", + "is_scalable": "0", + "is_scorable": "1", + "is_storage_enabled": "0", + "package_hash": "d441fed43435d2c3416867cd9d05c48f", + "meta_data": { + "features": [ + "Customizable", + "Scorable", + "Mobile Friendly" + ], + "supported_data": [ + "Question/Answer" + ], + "about": "In Crossword, fill in the blank squares with: (a) words based on the clues provided in the text and/or (b) by the letters overlapping from other words.", + "excerpt": "A quiz tool that uses words and clues to randomly generate a crossword puzzle.", + "demo": "9zBg0" + }, + "name": "Crossword", + "player": "player.html", + "question_types": "", + "score_module": "Crossword", + "width": "715" + } +] diff --git a/src/catalog.js b/src/catalog.js new file mode 100644 index 000000000..f36b1ada3 --- /dev/null +++ b/src/catalog.js @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { QueryClient, QueryClientProvider, QueryCache } from 'react-query' +import { ReactQueryDevtools } from "react-query/devtools"; +import CatalogPage from './components/catalog-page' + +const queryCache = new QueryCache() +export const queryClient = new QueryClient({ queryCache }) + +ReactDOM.render( + + + + , document.getElementById('app')) diff --git a/src/closed.js b/src/closed.js new file mode 100644 index 000000000..512e3eb15 --- /dev/null +++ b/src/closed.js @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { QueryClient, QueryClientProvider, QueryCache } from 'react-query' +import { ReactQueryDevtools } from "react-query/devtools"; +import Closed from './components/closed' + +const queryCache = new QueryCache() +export const queryClient = new QueryClient({ queryCache }) + +ReactDOM.render( + + + + , document.getElementById('app')) diff --git a/src/components/404.jsx b/src/components/404.jsx new file mode 100644 index 000000000..41fb06555 --- /dev/null +++ b/src/components/404.jsx @@ -0,0 +1,17 @@ +import React from 'react' +import Header from './header' +import './404.scss' + +const Action404 = () => ( + <> +
+
+
+

404

+

We may have lost the page you're looking for.

+
+
+ +) + +export default Action404 diff --git a/src/components/404.scss b/src/components/404.scss new file mode 100644 index 000000000..62e826602 --- /dev/null +++ b/src/components/404.scss @@ -0,0 +1,63 @@ +@import './include.scss'; + +#notfound { + background: #fff; + width: 100vw; + max-width: 1200px; + min-width: 600px; + + margin: 0 auto; + + .page { + position: relative; + display: block; + + width: 80%; + height: 285px; + padding-top: 200px; + margin: 50px auto; + + background: url('/img/kogneato_metal_detecting.png') 66% no-repeat, + url('/img/404_logo_balls.png') 80% no-repeat, + url('/img/404_beachandocean.png') 0 255px no-repeat; + background-position: right center; + + h1 { + width: 50%; + height: 50%; + background: url('/img/404_insand.png') top center no-repeat; + background-size: contain; + text-indent: -9000px; + position: absolute; + top: 30px; + left: 10px; + } + + p { + position: relative; + top: 100px; + padding-left: 5%; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.75); + font-size: 18px; + font-weight: 700; + text-align: left; + } + + @media (max-width: 625px) { + h1 { + left: 5px; + + width: 33%; + height: 33%; + } + + p { + position: relative; + top: 40px; + background: rgba(255,255,255,0.85); + padding: 15px 5px; + text-align: center; + } + } + } +} \ No newline at end of file diff --git a/src/components/500.jsx b/src/components/500.jsx new file mode 100644 index 000000000..464050266 --- /dev/null +++ b/src/components/500.jsx @@ -0,0 +1,26 @@ +import React from 'react' +import Header from './header' +import SupportInfo from './support-info' + +import './500.scss' + +const Action500 = () => ( + <> +
+
+
+

500 :(

+

+ Uh oh! Something's broken. Looks like an internal server error. + To get help with resolving this issue, contact support below. +

+
+ +
+ +
+
+ +) + +export default Action500 diff --git a/src/components/500.scss b/src/components/500.scss new file mode 100644 index 000000000..78df58230 --- /dev/null +++ b/src/components/500.scss @@ -0,0 +1,43 @@ +@import './include.scss'; + +.container.general { + width: 100vw; + max-width: 1000px; + min-width: 600px; + height: 100%; + + margin: 40px auto 0 auto; + padding-bottom: 40px; + background-color: #fff; + + .page { + position: relative; + width: 80%; + margin: 0 auto; + + h1 { + position: relative; + left: 50%; + width: 400px; + margin: 50px 0 50px -200px; + padding-bottom: 30px; + + font-size: 100px; + text-align: center; + color: #5ca2cc; + + } + + p { + font-weight: 400; + } + } + + #support-info-500 { + width: 50%; + max-width: 600px; + margin: 0 auto; + + text-align: center; + } +} \ No newline at end of file diff --git a/src/components/__snapshots__/accessibility-indicator.test.js.snap b/src/components/__snapshots__/accessibility-indicator.test.js.snap new file mode 100644 index 000000000..4ca2870d4 --- /dev/null +++ b/src/components/__snapshots__/accessibility-indicator.test.js.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccessibilityIndicator should render 1`] = ` +
+
+ + Accessibility: + +
    +
  • +
    + +
    + + Keyboard is  + + fully supported + + +
  • +
  • +
    + +
    + + Screen reader is  + + fully supported + + +
  • +
+
+
+`; diff --git a/src/components/accessibility-indicator.jsx b/src/components/accessibility-indicator.jsx new file mode 100644 index 000000000..b0f9c2dc2 --- /dev/null +++ b/src/components/accessibility-indicator.jsx @@ -0,0 +1,60 @@ +import React from 'react' +import KeyboardIcon from './keyboard-icon' +import ScreenReaderIcon from './screen-reader-icon' + +const AccessibilityIndicator = ({widget = {}}) => { + + const accessLevelToText = (level) => { + switch(level?.toLowerCase()) { + case 'full': + return 'fully' + case 'limited': + return 'partially' + default: + return 'not' + } + } + + let descriptionRender = '' + if (widget.accessibility.keyboard.toLowerCase() != 'unavailable' || widget.accessibility.screen_reader.toLowerCase() != 'unavailable') { + descriptionRender = widget.accessibility.description + } + else { + descriptionRender = 'No accessibility information is provided for this widget.' + } + + return ( +
+
+ Accessibility: +
    +
  • +
    + +
    + Keyboard is  + + {`${accessLevelToText(widget.accessibility?.keyboard)} supported`} + + +
  • +
  • +
    + +
    + Screen readers are  + + {`${accessLevelToText(widget.accessibility?.screen_reader)} supported`} + + +
  • +
  • 0 ? 'show' : ''}`}> + {descriptionRender} +
  • +
+
+
+ ) +} + +export default AccessibilityIndicator diff --git a/src/components/accessibility-indicator.test.js b/src/components/accessibility-indicator.test.js new file mode 100644 index 000000000..80fdb9aa4 --- /dev/null +++ b/src/components/accessibility-indicator.test.js @@ -0,0 +1,50 @@ +/** + * @jest-environment jsdom + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import toJson from "enzyme-to-json"; +import AccessibilityIndicator from './accessibility-indicator.jsx'; + +const getProps = (val = 'Full') => ({ + accessibility: { + keyboard: val, + screen_reader: val + } +}) + +describe('AccessibilityIndicator', () => { + it('should render', () => { + const wrapper = shallow(); + expect(toJson(wrapper)).toMatchSnapshot(); + }) + + it('should change component as props change expected', () => { + // Full + let component = shallow(); + expect(component.find('#keyboard-access-level').text()).toBe('fully supported'); + expect(component.find('#screen-reader-access-level').text()).toBe('fully supported'); + + // Limited + component = shallow(); + expect(component.find('#keyboard-access-level').text()).toBe('partially supported'); + expect(component.find('#screen-reader-access-level').text()).toBe('partially supported'); + + // None + component = shallow(); + expect(component.find('#keyboard-access-level').text()).toBe('not supported'); + expect(component.find('#screen-reader-access-level').text()).toBe('not supported'); + + // Random + component = shallow(); + expect(component.find('#keyboard-access-level').text()).toBe('not supported'); + expect(component.find('#screen-reader-access-level').text()).toBe('not supported'); + }); + + it('should render correctly when props not set', () => { + const component = shallow(); + expect(component.find('#keyboard-access-level').text()).toBe('not supported'); + expect(component.find('#screen-reader-access-level').text()).toBe('not supported'); + }); +}); diff --git a/src/components/alert.jsx b/src/components/alert.jsx new file mode 100644 index 000000000..8bc652d05 --- /dev/null +++ b/src/components/alert.jsx @@ -0,0 +1,24 @@ + +import React from 'react' + +const Alert = ({ msg, title, fatal, showLoginButton, onCloseCallback }) => { + + const reloadPage = () => { + window.location.reload() + } + + return ( +
+
+

{ title }

+ { msg } +
+ { fatal ? '' : } + { showLoginButton ? : '' } +
+
+
+ ) +} + +export default Alert \ No newline at end of file diff --git a/src/components/attempts-slider.jsx b/src/components/attempts-slider.jsx new file mode 100644 index 000000000..1fb7a0e37 --- /dev/null +++ b/src/components/attempts-slider.jsx @@ -0,0 +1,134 @@ +import React, { useEffect, useState } from 'react' +import './my-widgets-settings-dialog.scss' + +const AttemptsSlider = ({inst, parentState, setParentState}) => { + + const [rawSliderVal, setRawSliderVal] = useState(parseInt(parentState.sliderVal)) + const [sliderStopped, setSliderStopped] = useState(false) + + // slider is moved + const sliderChange = e => { + if (parentState.formData.changes.access === 'guest') return + setRawSliderVal(parseFloat(e.target.value)) + } + + // slider is released (mouse up or blur event) + const sliderStop = e => { + setSliderStopped(true) + } + + // now that the slider value isn't actively changing, round the raw value to the nearest stop + // pass that rounded value up to the parent component + useEffect(() => { + if (sliderStopped && parentState.formData.changes.access != 'guest') { + const sliderInfo = getSliderInfo(rawSliderVal) + setParentState({...parentState, sliderVal: sliderInfo.val, lastActive: sliderInfo.last}) + setSliderStopped(false) + } + },[sliderStopped]) + + // when the rounded value is updated in parent's state, update the raw slider value to match + // this also synchronizes the slider with the rounded value when the number is clicked instead of interacting with the slider itself + useEffect(() => { + if (parseInt(parentState.sliderVal) !== rawSliderVal) { + setRawSliderVal(parseInt(parentState.sliderVal)) + } + }, [parentState.sliderVal]) + + // takes a raw value and returns the rounded value to match the closest stop + // note the values here represent the position on the slider that corresponds with a stop and not the actual attempt count + const getSliderInfo = val => { + switch(true){ + case val === -1: + return {val: '100', last: 8} + case val <= 3: + return {val: '1', last: 0} + case val <= 7: + return {val: '5', last: 1} + case val <= 11: + return {val: '9', last: 2} + case val <= 15: + return {val: '13', last: 3} + case val <= 27.5: + return {val: '17', last: 4} + case val <= 48: + return {val: '39', last: 5} + case val <= 68: + return {val: '59', last: 6} + case val <= 89: + return {val: '79', last: 7} + default: + return {val: '100', last: 8} + } + } + + // Used when the number is clicked on the slider + const updateSliderNum = (val, index) => { + // Attempts always unlimited when guest access is true + if (parentState.formData.changes.access === 'guest') return + + setParentState({...parentState, sliderVal: val.toString(), lastActive: index}) + } + + const generateStopSpan = (stopId, sliderPosition, display) => { + const spanClass = parentState.lastActive === stopId ? 'active' : '' + const stopClickHandler = () => updateSliderNum(sliderPosition, stopId) + return ( + + {display} + + ) + } + + let guestModeRender = null + if (parentState.formData.changes.access === 'guest') { + guestModeRender = ( +
+ Attempts are unlimited when Guest Mode is enabled. +
+ ) + } + + return ( +
+
+ +
+
+ { generateStopSpan(0, 1, '1') } + { generateStopSpan(1, 5, '2') } + { generateStopSpan(2, 9, '3') } + { generateStopSpan(3, 13, '4') } + { generateStopSpan(4, 17, '5') } + { generateStopSpan(5, 39, '10') } + { generateStopSpan(6, 59, '15') } + { generateStopSpan(7, 79, '20') } + { generateStopSpan(8, 100, 'Unlimited') } +
+
+
+ Attempts are the number of times a student can complete a widget. + Only their highest score counts. + { guestModeRender } +
+
+
+ ) +} + +export default AttemptsSlider diff --git a/src/components/attempts-slider.test.js b/src/components/attempts-slider.test.js new file mode 100644 index 000000000..3fc2938a4 --- /dev/null +++ b/src/components/attempts-slider.test.js @@ -0,0 +1,138 @@ +/** + * @jest-environment jsdom + */ + + import React from 'react'; +import { render, screen } from '@testing-library/react' +import AttemptsSlider from './attempts-slider.jsx'; +//import { shallow } from 'enzyme'; +//import toJson from "enzyme-to-json"; + +const getState = (sliderVal = 1, guestMode = "notguest", lastActive = 0) => ({ + formData: { + changes: { + access: guestMode + } + }, + sliderVal: sliderVal, + lastActive: lastActive, +}) + +const mockSetState = jest.fn() + +describe('AttemptsSlider', () => { + test.todo('test title') +}) + +/* +describe('AttemptsSlider', () => { + test('test title', () => { + //const component = shallow(); + expect(1).toBe(1) + }) +}) + + +const getState = (sliderVal = 1, guestMode = "notguest", lastActive = 0) => ({ + formData: { + changes: { + access: guestMode + } + }, + sliderVal: sliderVal, + lastActive: lastActive, +}) + +const mockSetState = jest.fn() +const mockSliderNum = jest.fn() +const makeEvent = (val = '1') => ({ + target: { value: val }, + stopPropagation: jest.fn(), + preventDefault: jest.fn() +}) + +describe('AttemptsSlider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render', () => { + const component = shallow(); + expect(toJson(component)).toMatchSnapshot(); + }); + + test('clicking number should update component', () => { + const component = shallow(); + expect(component.find('.active').length).toBe(1); + expect(component.find('.active').text()).toBe("1"); + + expect(mockSetState.mock.calls.length).toBe(0); + component.find('#attempt-holder').childAt(1).prop('onClick')(); + expect(mockSetState.mock.calls.length).toBe(1); + + // Click calls proper update + expect(mockSetState.mock.calls[0][0].sliderVal).toBe('5'); + }); + + test('label should not display without guest mode active', () => { + let component = shallow(); + expect(component.find('.desc-notice').length).toBe(0); + + component = shallow(); + expect(component.find('.desc-notice').length).toBe(1); + }); + + it('should be disabled when in guest mode', () => { + const component = shallow(); + expect(component.find('.disabled').length).toBe(0); + + component.setProps({ state: getState(1, "guest") }) + + // Components should be disabled via class + expect(component.find('.disabled').length).toBe(3); + + // Clicking shouldn't change state + expect(mockSetState.mock.calls.length).toBe(0); + component.find('#attempt-holder').childAt(1).prop('onClick')(); + expect(mockSetState.mock.calls.length).toBe(0); + + // OnChange shouldn't change state + component.find('#ui-slider').simulate('change', makeEvent("50")) + expect(mockSetState.mock.calls.length).toBe(0); + }); + + test('current value should change when props change', () => { + const component = shallow(); + expect(component.find('.active').length).toBe(1); + expect(component.find('.active').text()).toBe("1"); + + // Changing slider value + component.setProps({ state: getState(59, "notguest", 6) }) + expect(component.find('.active').length).toBe(1); + expect(component.find('.active').text()).toBe("15"); + }); + + test('on mouse up should change state values', () => { + const component = shallow(); + + // Clicking should change state + expect(mockSetState.mock.calls.length).toBe(0); + component.find('#ui-slider').simulate('mouseUp', makeEvent("50")) + expect(mockSetState.mock.calls.length).toBe(1); + expect(mockSetState.mock.calls[0][0].sliderVal).toBe("59"); + expect(mockSetState.mock.calls[0][0].lastActive).toBe(6); + }); + + test('on blur should change state values', () => { + const component = shallow(); + + // Clicking should change state + expect(mockSetState.mock.calls.length).toBe(0); + component.find('#ui-slider').simulate('blur', makeEvent("50")) + expect(mockSetState.mock.calls.length).toBe(1); + expect(mockSetState.mock.calls[0][0].sliderVal).toBe("59"); + expect(mockSetState.mock.calls[0][0].lastActive).toBe(6); + }); + +}); +*/ diff --git a/src/components/bar-graph.jsx b/src/components/bar-graph.jsx new file mode 100644 index 000000000..72e49fc66 --- /dev/null +++ b/src/components/bar-graph.jsx @@ -0,0 +1,117 @@ +import React, { useEffect, useRef } from 'react' +import { axisBottom, axisLeft, scaleBand, scaleLinear, select } from 'd3' + +const BarGraph = ({ data, width, height, rowLabel = `Y Axis`, colLabel = `X Axis`, graphTitle = 'Title' }) => { + + const linesColor = { color: `#a9a9a9` } + const margin = { top: 50, bottom: 25, left: 25, right: 25 } + const graphWidth = width - margin.left - margin.right + const graphHeight = height - margin.top - margin.bottom + + // grade points / bars + const xAxis = scaleBand().domain(data.map(({ label }) => label)).range([0, graphWidth]).padding(0.5) + + // num of students + const yAxis = scaleLinear().range([graphHeight, 0]).nice() + const largestNumStudents = Math.max(...data.map(({ value }) => value)) + largestNumStudents === 1 ? yAxis.domain([0, 1]) : yAxis.domain([0, largestNumStudents]) + + const ColAxis = ({ scale }) => { + const ref = useRef(null) + + useEffect(() => { + if (ref.current) { select(ref.current).call(axisLeft(scale)) } + }, [scale]) + + return + } + + const RowAxis = ({ scale, transform }) => { + const ref = useRef(null) + + useEffect(() => { + if (ref.current) { select(ref.current).call(axisBottom(scale)) } + }, [scale]) + + return + } + + const VerticalLines = ({ scale, transform }) => { + const ref = useRef(null) + + useEffect(() => { + if (ref.current) { + select(ref.current).call(axisBottom(scale) + .tickSize(-graphHeight, 0, 0) + .tickFormat("") + ) + } + }, [scale]) + + return + } + + const HorizontalLines = ({ scale }) => { + const ref = useRef(null) + + useEffect(() => { + if (ref.current) { + select(ref.current).call(axisLeft(scale) + .tickSize(-graphWidth, 0, 0) + .tickFormat("") + ) + } + }, [scale]) + + return + } + + const Bars = ({ data, height, xAxis, yAxis }) => { + return (<> + {data.map(({ value, label }) => ( + + ))} + ) + } + + return ( + + + + + + + + + + + + {graphTitle} + + + {colLabel} + + + {rowLabel} + + + + ) +} + +export default BarGraph \ No newline at end of file diff --git a/src/components/catalog-card.jsx b/src/components/catalog-card.jsx new file mode 100644 index 000000000..3c3ff208e --- /dev/null +++ b/src/components/catalog-card.jsx @@ -0,0 +1,82 @@ +import React from 'react' +import { iconUrl } from '../util/icon-url' +import KeyboardIcon from './keyboard-icon' +import ScreenReaderIcon from './screen-reader-icon' + +const isValidAccessVal = val => ['Full', 'Limited'].includes(val) + +const CatalogCard = ({ + id, + clean_name = '', + in_catalog = '0', + name = '', + dir = '', + meta_data, + isFiltered, + activeFilters = [] +}) => { + // 'Featured' label + let featuredLabelRender = null + if (in_catalog === '1') { + featuredLabelRender =
+ + + + + Featured +
+ } + + const supportedDataRender = meta_data.supported_data.map(supported => +
  • {supported}
  • + ) + + const featuresRender = meta_data.features.map(filter => +
  • {filter}
  • + ) + + if(isValidAccessVal(meta_data.accessibility_keyboard)) { + featuresRender.push( +
  • {`${meta_data.accessibility_keyboard}`} Support
  • + ) + } + + if(isValidAccessVal(meta_data.accessibility_reader)) { + featuresRender.push( +
  • {`${meta_data.accessibility_reader}`} Support
  • + ) + } + + return ( + + ) +} + +export default CatalogCard diff --git a/src/components/catalog-card.test.js b/src/components/catalog-card.test.js new file mode 100644 index 000000000..3e0dcd994 --- /dev/null +++ b/src/components/catalog-card.test.js @@ -0,0 +1,175 @@ +/** + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen, fireEvent, getByPlaceholderText, queryByTestId, queryByText } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from 'react-query' +import CatalogCard from './catalog-card.jsx' +import '@testing-library/jest-dom' + +const getPropData = () => ({ + id: "9", + clean_name: "adventure", + in_catalog: "1", + name: "Adventure", + dir: "9-adventure/", + meta_data: { + features: ["Customizable", "Scorable", "Media"], + supported_data: ["Question/Answer", "Multiple Choice"], + excerpt: "Build branching scenarios where your student's choices lead them down different paths.", + about: "An advanced flexible scenario-building tool.", + playdata_exporters: ["Survey Formatting"], + demo: "hFLbU", + accessibility_keyboard: 'Full', + accessibility_reader: 'Full' + }, + isFiltered: false, + activeFilters: [] +}) + +// Enables testing with react query +const renderWithClient = (children) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Turns retries off + retry: false, + }, + }, + }) + + const { rerender, ...result } = render({children}) + + return { + ...result, + rerender: (rerenderUi) => + rerender({rerenderUi}) + } +} + +describe('CatalogCard', () => { + + beforeEach(() => { + const div = document.createElement('div') + div.setAttribute('id', 'modal') + document.body.appendChild(div) + }) + + afterEach(() => { + const div = document.getElementById('modal') + if (div) { + document.body.removeChild(div) + } + }) + + it('renders correctly', async () => { + const propData = getPropData() + const rendered = renderWithClient() + + expect(screen.queryByText(/Adventure/i)).not.toBeNull() + expect(screen.queryByText(/Featured/i)).not.toBeNull() + expect(screen.queryByText(/Scorable/i)).not.toBeNull() + expect(screen.queryByText(/Build branching scenarios where your student's choices lead them down different paths/i)).not.toBeNull() + }) + + // Test fails + test('onhover shows keyboard access popup', () => { + const propData = getPropData() + const rendered = renderWithClient() + + const screenReaderPopup = screen.getByLabelText('screen-reader-access-popup') + const keyboardPopup = screen.getByLabelText('keyboard-access-popup') + + // Confirms keyboard and screen reader popups are not shown + // expect(keyboardPopup).toHaveStyle('visibility: hidden') + // expect(screenReaderPopup).toHaveStyle('visibility: hidden') + + // Fires onhover event to keyboard access icon + fireEvent.mouseOver(keyboardPopup) + + // Confirms only keyboard popup is shown + // expect(keyboardPopup).toHaveStyle('visibility: visible') + // expect(screenReaderPopup).toHaveStyle('visibility: hidden') + }) + + // Test fails + test('onhover shows screen reader access popup', () => { + const propData = getPropData() + const rendered = renderWithClient() + + const screenReaderPopup = screen.getByLabelText('screen-reader-access-popup') + const keyboardPopup = screen.getByLabelText('keyboard-access-popup') + + // Confirms keyboard and screen reader popups are not shown + //expect(keyboardPopup).toHaveStyle('visibility: hidden') + //expect(screenReaderPopup).toHaveStyle('visibility: hidden') + + // Fires onhover event to screen reader access icon + fireEvent.mouseOver(screenReaderPopup) + + // Confirms only screen reader popup is shown + //expect(keyboardPopup).toHaveStyle('visibility: hidden') + //expect(screenReaderPopup).toHaveStyle('visibility: visible') + }) + + it('should highlight active filters', () => { + const propData = getPropData() + propData.isFiltered = true + propData.activeFilters = ['Customizable'] + + const rendered = renderWithClient() + + expect(screen.queryByText('Customizable')).toHaveStyle('background: rgb(52 152 219);') + expect(screen.queryByText('Customizable')).toHaveStyle('color: rgb(255 255 255);') + + expect(screen.queryByText('Scorable')).toHaveStyle('background: rgb(238 238 238);') + expect(screen.queryByText('Scorable')).toHaveStyle('color: rgb(68 68 68);') + }) + + it('should highlight accessibility icons', () => { + const propData = getPropData() + propData.isFiltered = true + propData.activeFilters = ['Keyboard Accessible', 'Screen Reader Accessible'] + + const rendered = renderWithClient() + + expect(1).toBe(1) + }) + +}) \ No newline at end of file diff --git a/src/components/catalog-page.jsx b/src/components/catalog-page.jsx new file mode 100644 index 000000000..68a1a4b32 --- /dev/null +++ b/src/components/catalog-page.jsx @@ -0,0 +1,22 @@ +import React from 'react' +import { useQuery } from 'react-query' +import { apiGetWidgetsByType } from '../util/api' +import Header from './header' +import Catalog from './catalog' + +const CatalogPage = () => { + const { data: widgets, isLoading} = useQuery({ + queryKey: 'catalog-widgets', + queryFn: apiGetWidgetsByType, + staleTime: Infinity + }) + + return ( + <> +
    + + + ) +} + +export default CatalogPage diff --git a/src/components/catalog.jsx b/src/components/catalog.jsx new file mode 100644 index 000000000..2fde7a3e7 --- /dev/null +++ b/src/components/catalog.jsx @@ -0,0 +1,329 @@ +import React, { useState, useMemo } from 'react' +import CatalogCard from './catalog-card' +import KeyboardIcon from './keyboard-icon' +import ScreenReaderIcon from './screen-reader-icon' +import './catalog.scss' + +const isMobileDevice = () => window.matchMedia('(max-width: 720px)').matches + +const Catalog = ({widgets = [], isLoading = true}) => { + const [state, setState] = useState({ + searchText: '', + showingFilters: false, + showingAccessibility: false, + activeFilters: [], + showMobileFilters: false, + showMobileAccessibilityFilters: false + }) + const totalWidgets = widgets.length + + // collect all unique features and supported data + const filters = useMemo(() => { + const features = new Set() + const accessibility = new Set() + widgets.forEach(w => { + w.meta_data.features.forEach(f => {features.add(f)}) + w.meta_data.supported_data.forEach(f => {features.add(f)}) + if(w.meta_data.hasOwnProperty('accessibility_keyboard')) accessibility.add('Keyboard Accessible') + if(w.meta_data.hasOwnProperty('accessibility_reader')) accessibility.add('Screen Reader Accessible') + }) + return { + features: Array.from(features), + accessibility: Array.from(accessibility) + } + }, + [widgets] + ) + + // filter widgets based on search & features + const [filteredWidgets, isFiltered] = useMemo(() => { + let isFiltered = false + + // in_catalog widgets are already being rendered via featured widgets + // append remaining widgets that are playable but not in_catalog + let results = widgets.filter(w => { + return parseInt(w.is_playable) == 1 && parseInt(w.in_catalog) == 0 + }) + // filters are active, only match active filters + if(state.activeFilters.length){ + isFiltered = true + + // find widgets that have all the active filters + results = widgets.filter(w => { + const {features, supported_data, accessibility_keyboard, accessibility_reader} = w.meta_data + return state.activeFilters.every(f =>{ + if (features.includes(f) || supported_data.includes(f)) return true + if (accessibility_keyboard && f === 'Keyboard Accessible') return true + if (accessibility_reader && f === 'Screen Reader Accessible') return true + + return false + }) + }) + } + + // search widget names + if(state.searchText !== '') { + isFiltered = true + const re = new RegExp(state.searchText, 'i') + results = results.filter(w => re.test(w.name)) + } + + return [results, isFiltered] + }, [widgets, state.searchText, state.activeFilters]) + + const toggleFilter = filter => { + const newFilters = state.activeFilters.includes(filter) + ? state.activeFilters.filter(f => f != filter) + : [...state.activeFilters, filter] + + setState({...state, activeFilters: newFilters, showMobileFilters: false}) + } + + const accessibilityLinkClickHandler = () => { + if (state.showingAccessibility){ + setState({...state, showingAccessibility: !state.showingAccessibility, activeFilters: []}) + } + else { + setState({...state, showingAccessibility: !state.showingAccessibility}) + } + } + + const filterLinkClickHandler = () => { + if(state.showingFilters){ + setState({...state, showingFilters: !state.showingFilters, activeFilters: []}) + } + else { + setState({...state, showingFilters: !state.showingFilters}) + } + } + + let searchCloseRender = null + if (state.searchText) { + searchCloseRender = ( + + } + ) + + const accessibilityOptionsRender = filters.accessibility.map((filter, index) => { + const isEnabled = state.activeFilters.includes(filter) + const filterOptionClickHandler = () => toggleFilter(filter) + return + } + ) + + let featuredWidgetsRender = null + if (!isFiltered && totalWidgets > 0 ) { + const featuredWidgetListRender = widgets.filter(w => w.in_catalog==='1') + .map(w => ) + featuredWidgetsRender = ( +
    +

    + Featured Widgets +

    +
    + { featuredWidgetListRender } +
    +
    + ) + } + + const filteredWidgetsRender = filteredWidgets.map(w => + + ) + + let loadingOrWarningsRender = null + if (filteredWidgets.length < 1) { + const loadingMessageRender = isLoading ? Loading Widgets... : null + + let noWidgetsRender = null + if (!isLoading) { + if (isFiltered) { + noWidgetsRender = ( + + No widgets match the filters you set. + + + ) + } else if (!widgets.length) { + noWidgetsRender = No Widgets Installed + } else { + noWidgetsRender = null + } + } + + loadingOrWarningsRender = ( +
    + { loadingMessageRender } + { noWidgetsRender } +
    + ) + } + + let filterHiddenRender = null + if (isFiltered) { + filterHiddenRender = ( +
    + {totalWidgets - filteredWidgets.length} hidden by filters. + +
    + ) + } + + return ( +
    +
    +
    + +
    +

    Widget Catalog

    + + +
    + +
    + + +
    + { state.activeFilters.join(', ') } +
    +
    + { mobileFilterRender } +
    +
    + { filterOptionsRender } +
    +
    +
    +
    + { accessibilityOptionsRender } +
    +
    + + { featuredWidgetsRender } + +
    + { filteredWidgetsRender } +
    + + { loadingOrWarningsRender } + + { filterHiddenRender } +
    +
    +
    + ) +} + +export default Catalog diff --git a/src/components/catalog.scss b/src/components/catalog.scss new file mode 100644 index 000000000..608142f15 --- /dev/null +++ b/src/components/catalog.scss @@ -0,0 +1,669 @@ +@import 'include.scss'; + +.catalog { + .container { + margin: 25px auto; + position: relative; + } + + section.page { + margin: 0 auto; + background: #fff; + border-radius: 4px; + border: #e4e4e4 1px solid; + box-shadow: 1px 3px 10px #dcdcdc; + width: 96%; + max-width: 1500px; + //overflow: hidden; + } + + .mobile-only { + display: none; // shown on mobile + } + + .top { + display: flex; + flex-direction: row; + justify-content: space-between; + + position: relative; + z-index: 2; + + padding: 10px 15px; + background: #eee; + color: #333; + + font-weight: bold; + + h1 { + margin: 0; + display: inline-block; + } + + aside { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + + button.filter-toggle { + display: inline-block; + margin: 0 4px; + padding: 1px 0; + + background: none; + border: 0; + border-bottom: solid 1px #3690e6; + color: #3690e6; + font-size: 0.8em; + + cursor: pointer; + // text-decoration: underline; + + &.close-mode:after { + position: relative; + bottom: -1px; + content: 'X'; + margin-left: 3px; + + color: #000; + font-weight: bold; + border: 0; + } + } + + .search { + position: relative; + width: 215px; + margin-left: 10px; + // max-width: 42%; + + .search-icon { + position: absolute; + top: 6px; + left: 9px; + height: 16px; + width: 20px; + // fill: #898686; + svg { + height: 100%; + width: 100%; + } + } + + .search-close { + cursor: pointer; + position: absolute; + border: none; + background: none; + width: 16px; + height: 16px; + top: 8px; + right: 15px; + opacity: 0.8; + + &:hover { + opacity: 1; + } + + &:before, + &:after { + position: absolute; + left: 8px; + top: 0; + content: ' '; + height: 14px; + width: 2px; + background-color: white; + transform: rotate(45deg); + } + + &:after { + transform: rotate(-45deg); + } + } + + input { + box-sizing: border-box; + border: none; + width: 100%; + right: 0; + padding: 4px 35px 4px 35px; + font-size: 14px; + background: #fff; + border: solid 1px #b0b0b0; + border-radius: 12px; + margin: 0; + + &::-ms-clear { + display: none; + } + } + } + } + } + + .cancel_button { + display: inline-block; + background: none; + border: 0; + color: #3690e6; + margin: 0; + font-size: 0.9em; + cursor: pointer; + } + + #filters-container { + z-index: 1; + display: flex; + justify-content: space-between; + flex-direction: column; + position: relative; + overflow: hidden; + + &.ready { + max-height: 0; + transition: max-height 0.3s ease; + + &.open { + max-height: 300px; + } + + &.closed { + max-height: 0; + } + } + + .filter-labels-container { + display: flex; + justify-content: center; + flex-wrap: wrap; + margin: 20px 10px 0px 10px; + + &:before { + content: 'Features'; + position: absolute; + left: 50%; + top: 5px; + + font-size: 12px; + font-style: italic; + color: #888; + } + + &.accessibility:before { + content: 'Accessibility'; + position: absolute; + left: 50%; + top: 5px; + + font-size: 12px; + font-style: italic; + color: #888; + } + + button { + padding: 10px 12px; + font-size: 14px; + float: left; + background: white; + position: relative; + + &.feature-button { + cursor: pointer; + background: #f2f2f2; + border: 0; + margin: 3px 3px; + border-radius: 5px; + padding: 8px 10px; + + &:hover { + background: #bfe5ff; + } + + &.selected { + background: #3498db; + color: #fff; + + svg { + fill: #fff; + } + } + + svg { + width: 16px; + height: auto; + margin-right: 6px; + } + } + } + } + } + + #no-widgets-message { + text-align: center; + margin: 40px; + font-style: italic; + color: #888888; + } + + .widget-group { + border: 1px dashed #c6c6c9; + margin: 31px 10px 0; + border-radius: 17px; + + .container-label { + margin-top: -14px; + font-size: 1.3em; + color: #888888; + text-align: center; + font-style: italic; + font-weight: bold; + + span { + padding: 0 20px; + display: inline-block; + background: white; + } + } + } + + .widgets-container { + position: relative; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); + + &:not(.featured) { + margin: 22px 10px; + } + + .widget { + min-width: 300px; + min-height: 135px; + margin: 15px; + background-color: #ffffff; + border-radius: 5px; + border: #e4e4e4 1px solid; + box-shadow: 1px 3px 10px #dcdcdc; + opacity: 0; + position: relative; + transition: all 0.3s ease-in-out; + + &:hover { + border: transparent 1px solid; + background-color: #e2f3ff; + box-shadow: 0px 0px 4px #dcdcdc; // 1px 3px 10px 2px #888; + } + + &.filtered { + opacity: 1; + transform: scale(1); + + animation: grow 250ms ease; + @keyframes grow { + from { + opacity: 0; + transform: scale(0.1); + } + to { + opacity: 1; + transform: scale(1); + } + } + } + + &:not(.filtered) { + opacity: 1; + } + + .infocard { + color: black; + opacity: 1; + display: block; + height: 100%; + min-height: 135px; + text-decoration: none; + + &:hover { + text-decoration: none; + } + + &:focus { + background-color: #e2f3ff; + } + + .header { + float: left; + // margin-top: -4px; + margin-bottom: 4px; + z-index: -10; + width: 100%; + box-sizing: border-box; + padding-left: 130px; + background: #eee; + border-radius: 5px 5px 0 0; + + h1 { + display: block; + font-size: 18px; + font-weight: bold; + margin: 7px 5px 5px; + color: #333; + + &.featured { + margin-right: 110px; + } + } + + div.featured-label { + font-size: 14px; + font-weight: bold; + margin: 0; + padding: 6px 10px; + background: #bfe5ff; + border-radius: 0 5px 0 0; + position: absolute; + // top: -3px; + right: 0; + + svg { + position: relative; + top: 4px; + } + } + } + + .img-holder { + position: absolute; + top: 0px; + + img { + width: 115px; + height: 115px; + margin: 10px; + } + } + + .widget-info { + margin-left: 135px; + margin-right: 8px; + font-size: 0.88em; + + .accessibility-holder { + height: 25px; + + .accessibility-indicators { + display: flex; + align-items: center; + justify-content: flex-end; + margin: 0 10px 5px 0; + position: absolute; + right: 0; + bottom: 0; + + div { + position: relative; + + &:hover span.tool-tip { + visibility: visible; + } + svg { + height: 20px; + width: auto; + + &:last-of-type { + padding-left: 10px; + } + } + + .tool-tip { + visibility: hidden; + position: absolute; + right: -50px; + bottom: 25px; + z-index: 99; + padding: 10px; + box-shadow: 1px 2px 5px #888; + line-height: 18px; + font-size: 14px; + border-radius: 10px; + width: 100px; + background-color: #bfe5ff; + text-align: center; + } + } + } + } + } + + ul { + padding: 0; + width: 100%; + line-height: 20px; + overflow-y: auto; + overflow-x: visible; + display: block; + margin-top: 5px; + margin-bottom: 5px; + + li { + border-radius: 3px; + margin: 3px 10px 3px 0px; + padding: 1px 6px; + font-size: 10px; + background: #eeeeee; + color: #444; + display: inline-block; + font-size: 12px; + + &.selected { + background: #3498db; + color: white; + + &.accessibility-status svg { + fill: #fff; + } + } + + &.accessibility-status { + svg { + width: 12px; + height: auto; + margin-right: 6px; + } + } + } + } + } + } + } + + #hidden-count { + text-align: center; + margin: 10px 0 15px; + font-style: italic; + } +} + +@media (max-width: 960px) { + .catalog { + #filters-container { + text-align: center; + margin: auto 0; + + .filter-labels-container { + margin: 20px 10px 0px 10px; + } + } + + .widgets-container { + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + + .widget { + min-width: 200px; + text-align: center; + + .infocard { + .header { + background: none; + float: none; + padding: 0; + + h1 { + color: black; + margin-top: 0; + padding-top: 35px; + + &.featured { + margin-right: 5px; + } + } + + div.featured-label { + left: 0; + padding: 2px 0 5px; + border-radius: 5px 5px 0 0; + } + } + + .img-holder { + position: static; + display: inherit; + } + + .widget-info { + margin: 8px; + + dl.features_list { + display: none; + } + } + } + } + } + } +} + +@media (max-width: 720px) { + .catalog { + .container { + margin-top: 10px; + } + + .desktop-only { + display: none !important; + } + + .mobile-only { + display: block; + } + + section.page { + overflow: visible; + min-width: 320px; + } + + .top { + flex-direction: row; + position: relative; + padding: 10px 15px 5px; + + aside { + margin-top: 4px; + .label { + display: none; + } + } + + .search { + margin: auto; + } + + h1 { + font-size: 1.2em; + } + } + + .mobile-filter-select { + background: #eee; + font-size: 13px; + padding: 6px 10px; + position: relative; + min-height: 18px; + } + + .active-filters { + width: calc(100% - 20px); + padding: 6px 10px; + font-size: 13px; + background: #eee; + } + + .add-filter { + padding: 2px 7px; + border: none; + color: #3690e6; + text-decoration: underline; + cursor: pointer; + + &.open:before { + background: transparent; + bottom: 0; + content: ' '; + cursor: pointer; + display: block; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 2; + } + } + + #filter-dropdown { + position: absolute; + text-align: left; + background: white; + padding: 5px; + border: 1px solid #999; + border-radius: 3px; + width: 190px; + z-index: 3; + left: 10px; + top: 67px; + + &.accessibility { + left: 124px; + } + + label { + display: block; + padding: 3px 5px; + font-size: 12px; + } + } + + #filters-container { + display: none; + } + + .widgets-container { + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + + .widget { + min-width: 150px; + + .infocard { + img { + width: 90px; + height: 90px; + } + + dl { + line-height: 16px; + } + } + } + } + } +} diff --git a/src/components/catalog.test.js b/src/components/catalog.test.js new file mode 100644 index 000000000..f448a76b3 --- /dev/null +++ b/src/components/catalog.test.js @@ -0,0 +1,246 @@ +/** + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen, fireEvent, getByPlaceholderText, queryByTestId, queryByText } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from 'react-query' +import Catalog from './catalog.jsx' +import '@testing-library/jest-dom' + +const getWidgets = () => ([ + { + clean_name: "adventure", + creator: "creator.html", + created_at: "1621453611", + dir: "9-adventure/", + flash_version: "0", + api_version: "2", + height: "593", + id: "9", + is_answer_encrypted: "1", + in_catalog: "1", + is_editable: "1", + is_playable: "1", + is_qset_encrypted: "1", + is_scalable: "0", + is_scorable: "1", + is_storage_enabled: "0", + package_hash: "a7c66b6458007cc2f9467cef1f49d489", + meta_data: { + features: ["Customizable", "Scorable", "Media"], + supported_data: ["Question/Answer", "Multiple Choice"], + excerpt: "Build branching scenarios where your student's choices lead them down different paths.", + about: "An advanced flexible scenario-building tool.", + playdata_exporters: ["Survey Formatting"], + demo: "hFLbU", + accessibility_keyboard: "Full", + accessibility_reader: "Full" + }, + name: "Adventure", + player: "player.html", + question_types: "", + restrict_publish: "0", + score_module: "Adventure", + score_screen: "", + width: "800", + creator_guide: "guides/creator.html", + player_guide: "guides/player.html" + }, + { + clean_name: "crossword", + creator: "creator.html", + created_at: "1621453531", + dir: "1-crossword/", + flash_version: "10", + api_version: "2", + height: "592", + id: "1", + is_answer_encrypted: "1", + in_catalog: "1", + is_editable: "1", + is_playable: "1", + is_qset_encrypted: "1", + is_scalable: "0", + is_scorable: "1", + is_storage_enabled: "0", + package_hash: "c07c389f1316d9a97ce51c6598495e0a", + meta_data: { + features: ["Customizable", "Scorable", "Mobile Friendly"], + supported_data: ["Question/Answer"], + excerpt: "A quiz tool that uses words and clues to randomly generate a crossword puzzle.", + about: "In Crossword, fill in the blank squares with: (a) words based on the clues provided in the text and/or (b) by the letters overlapping from other words.", + playdata_exporters: ["Survey Formatting"], + demo: "y4Cye", + accessibility_keyboard: "Full", + accessibility_reader: "Limited" + }, + name: "Crossword", + player: "player.html", + question_types: "", + restrict_publish: "0", + score_module: "Crossword", + score_screen: "scoreScreen.html", + width: "715", + creator_guide: "guides/creator.html", + player_guide: "guides/player.html" + }, + // Doesn't have accessibility options + { + clean_name: "evaluate-a-rejection-letter", + creator: "creator.html", + created_at: "1614365891", + dir: "14-evaluate-a-rejection-letter/", + flash_version: "0", + api_version: "2", + height: "600", + id: "14", + is_answer_encrypted: "1", + in_catalog: "0", + is_editable: "0", + is_playable: "1", + is_qset_encrypted: "1", + is_scalable: "0", + is_scorable: "1", + is_storage_enabled: "1", + package_hash: "c633f75b879274559c7fbf444461ce20", + meta_data: { + features: ["Scorable", "Storage"], + supported_data: [], + excerpt: "Students read a rejection letter and answer questions about their response. They are directed to put themselves in the place of the individual being rejected, with some background context about their life situation.", + about: "A Widget in the UCF Psychology POPUP Series. Students read a rejection letter and answer questions about their response. They are directed to put themselves in the place of the individual being rejected, with some background context about their life situation.", + playdata_exporters: ["Survey Formatting"], + demo: "wllxI", + }, + name: "Evaluate a Rejection Letter", + player: "player.html", + question_types: "", + restrict_publish: "0", + score_module: "EvaluateaRejectionLetter", + score_screen: "", + width: "800", + creator_guide: "", + player_guide: "", + }, +]) + +// Enables testing with react query +const renderWithClient = (children) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Turns retries off + retry: false, + }, + }, + }) + + const { rerender, ...result } = render({children}) + + return { + ...result, + rerender: (rerenderUi) => + rerender({rerenderUi}) + } +} + +// Jest's official way to mock matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, // returned val + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}) + +describe('Catalog', () => { + + beforeEach(() => { + const div = document.createElement('div') + div.setAttribute('id', 'modal') + document.body.appendChild(div) + }) + + afterEach(() => { + const div = document.getElementById('modal') + if (div) { + document.body.removeChild(div) + } + }) + + it.only('renders correctly', async () => { + const rendered = renderWithClient() + + // Waits for data to load + //await screen.findAllByText('Test_Student_One Test_Lastname_One') + + expect(screen.queryByText('Adventure')).not.toBeNull() + expect(screen.queryByText('Crossword')).not.toBeNull() + expect(screen.queryByText('Evaluate a Rejection Letter')).not.toBeNull() + + // Gets widget card filters and not filter buttons in filter drawer + expect(screen.getAllByRole('listitem', { + name: /Customizable/i, + }).length).toBe(2) + + // Only Adventure and Crossword should be featured + expect(screen.getByTestId('featured-widgets').children.length).toBe(2) + + // Only one non featured widgets + expect(screen.getByTestId('non-featured-widgets').children.length).toBe(1) + + // features: ["Customizable", "Scorable", "Media"], + // supported_data: ["Question/Answer", "Multiple Choice"], + // Screen reader accessible & Keyboard accessible + // Mobile friendly + + // Only should be filter by feature button + expect(screen.getAllByRole('button').length).toBe(1) + + // Opens widget filter box + fireEvent.click(screen.getByRole('button', { name: /Filter by feature/i })) + + // Should have Clear filter button and 9 other filter buttons + expect(screen.getAllByRole('button').length).toBe(10) + }) + + it('renders with no widgets', async () => { + const rendered = renderWithClient() + + expect(screen.getByText(/No Widgets Installed/i)).not.toBeNull() + + // No widgets should be installed + expect(screen.queryByTestId('featured-widgets')).toBeNull() + expect(screen.queryByTestId('non-featured-widgets').children.length).toBe(0) + + // Only should be filter by feature button + expect(screen.getAllByRole('button').length).toBe(1) + + // Opens widget filter box + fireEvent.click(screen.getByRole('button', { name: /Filter by feature/i })) + + // Should only have the Clear filter button + expect(screen.getAllByRole('button').length).toBe(1) + }) + + it.todo('should properly filter widgets') + + it.todo('should highlight filter tag') + + it.todo('should highlight accessibility icon') + + it.todo('should display show all button') + + test.todo('clicking show all button should show all widgets') + + it.todo('should show all widgets and close filter box when filters are cleared') + + it.todo('search input should filter widgets') + +}) \ No newline at end of file diff --git a/src/components/closed.jsx b/src/components/closed.jsx new file mode 100644 index 000000000..86679842f --- /dev/null +++ b/src/components/closed.jsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from 'react'; +import Header from './header' +import Summary from './widget-summary' +import './login-page.scss' + +const Closed = () => { + + const [state, setState] = useState({ + isEmbedded: '', + instName: '', + widgetName: '', + summary: '', + description: '' + }) + + const waitForWindow = async () => { + while(!window.hasOwnProperty('IS_EMBEDDED') + && !window.hasOwnProperty('NAME') + && !window.hasOwnProperty('WIDGET_NAME') + && !window.hasOwnProperty('ICON_DIR') + && !window.hasOwnProperty('SUMMARY') + && !window.hasOwnProperty('DESC')) { + await new Promise(resolve => setTimeout(resolve, 500)) + } + } + + useEffect(() => { + waitForWindow() + .then(() => { + setState({ + isEmbedded: window.IS_EMBEDDED, + instName: window.NAME, + widgetName: window.WIDGET_NAME, + summary: window.SUMMARY, + description: window.DESC + }) + }) + },[]) + + return ( + <> + { state.isEmbedded ? '' :
    } +
    +
    + +

    { state.summary }

    +

    { state.description }

    +
    +
    + + ) +} + +export default Closed diff --git a/src/components/css/beard-mode.scss b/src/components/css/beard-mode.scss new file mode 100644 index 000000000..db57d8b0a --- /dev/null +++ b/src/components/css/beard-mode.scss @@ -0,0 +1,90 @@ +.my_widgets section.directions.unchosen { + &.bearded { + background: #fff url('/img/kogneato_mywidgets_bearded.svg') 50% 30%/250px auto no-repeat; + min-height: 850px; + } +} + +.my_widgets section.page div.scores #no-score-content { + &.bearded { + background: #fff url('/img/kogneato_no_scores_bearded.svg') 10% 30%/250px auto no-repeat !important; + } +} + +.my_widgets section.page div.scores #not-scorable { + &.bearded { + background: #fff url('/img/kogneato_with_a_broom_bearded.svg') 20% 20%/auto 240px no-repeat !important; + } +} + +.my_widgets aside .courses .widget_list { + overflow: auto; + padding-bottom: 30px; +} + +.bearded:before { + margin: 0; + padding: 0; + + width: 60px; + height: 80px; + position: absolute; +} +.big_bearded:before { + position: absolute; + padding-top: 20px; + + width: 276px; + height: 319px; +} + +/* Specific Beard Styles */ +.small_black_chops:before { + content: url(/img/beards/small/new/small_black_chops.png); + margin-left: -4px; +} + +.small_dusty_full:before { + content: url(/img/beards/small/new/small_dusty_full.png); + margin-left: -4px; +} + +.small_grey_gandalf:before { + content: url(/img/beards/small/new/small_grey_gandalf.png); + margin-left: -4px; +} + +.small_red_soul:before { + content: url(/img/beards/small/new/small_red_soul.png); + margin-left: -8px; +} + +/* Medium sized beards */ + +.med_dusty_full:before { + position: absolute; + content: url(/img/beards/medium/new/med_dusty_full.png); + top: -10px; + left: -10px; +} + +.med_black_chops:before { + position: absolute; + content: url(/img/beards/medium/new/med_black_chops.png); + top: -10px; + left: -10px; +} + +.med_grey_gandalf:before { + position: absolute; + content: url(/img/beards/medium/new/med_grey_gandalf.png); + top: -10px; + left: -10px; +} + +.med_red_soul:before { + position: absolute; + content: url(/img/beards/medium/new/med_red_soul.png); + left: -20px; + top: -10px; +} diff --git a/src/components/detail-carousel.jsx b/src/components/detail-carousel.jsx new file mode 100644 index 000000000..09f7a5f44 --- /dev/null +++ b/src/components/detail-carousel.jsx @@ -0,0 +1,422 @@ +import React, { useState, useEffect, useRef } from 'react' +import WidgetPlayer from './widget-player' +import windowSize from './hooks/useWindowSize' +import { WIDGET_URL } from './materia-constants' +import useCreatePlaySession from './hooks/useCreatePlaySession' + +const screenshotUrl = (widgetDir, size) => + WIDGET_URL + widgetDir + 'img/screen-shots/' + size + '.png' + +const screenshotThumbUrl = (widgetDir, size) => + WIDGET_URL + widgetDir + 'img/screen-shots/' + size + '-thumb.png' + +const initScreenshotData = () => { + return({ + screenshots: [], + numScreenshots: 0 + }) +} + +const initSelectionData = () => { + return ({ + selectedImage: { + num: 0, + reset: false + }, + mouseData: { + mouseDown: false, + xPos: 0, + yPos: 0, + offset: 0 + } + }) +} + +const initDemoData = () => { + return({ + demoLoading: false, + showDemoCover: true, + demoHeight: '', + demoWidth: '', + playId: null + }) +} + +const getVelocity = (newPos, oldPos, time) => { + const timeEnd = (new Date()).getTime() + const timeDiff = Math.max(0.01, (timeEnd - time)/1000) + return((newPos-oldPos)/timeDiff) +} + +const DetailCarousel = ({widget, widgetHeight=''}) => { + const [selectionData, setSelectionData] = useState(initSelectionData()) + const [screenshotData, setScreenshotData] = useState(initScreenshotData()) + const [demoData, setDemoData] = useState(initDemoData()) + const picScrollerRef = useRef(null) + const [windowWidth] = windowSize() + const createPlaySession = useCreatePlaySession() + + // Automatically adjusts screenshots based on window resize + useEffect(() => { + if (windowWidth !== 0) { + snapToImage(true) + } + }, [windowWidth]) + + // Gets the screenshots from the widget + useEffect(() => { + if (widget.meta_data) { + const _numScreenshots = ~~widget.meta_data?.num_screenshots || 3 + const _screenShots = [] + + for (let i = 1; i <= _numScreenshots; i++) { + _screenShots.push({ + full: screenshotUrl(widget.dir, i), + thumb: screenshotThumbUrl(widget.dir, i), + }) + } + + setScreenshotData({ + numScreenshots: _numScreenshots, + screenshots: _screenShots + }) + } + }, [widget]) + + // Snaps to the selected image on change + useEffect(() => { + if (selectionData.selectedImage.reset) { + snapToImage() + } + }, [selectionData.selectedImage]) + + const prevImage = () => { + if (screenshotData.numScreenshots !== 0) { + const index = (selectionData.selectedImage.num + screenshotData.numScreenshots) % (screenshotData.numScreenshots + 1) + setSelectionData({ + ...selectionData, + selectedImage: { + num: index, + reset: true + } + }) + } + } + + const nextImage = () => { + if (screenshotData.numScreenshots !== 0) { + const index = (selectionData.selectedImage.num + 1) % (screenshotData.numScreenshots + 1) + setSelectionData({ + ...selectionData, + selectedImage: { + num: index, + reset: true + } + }) + } + } + + // Gets the starting position and time + const handleMouseDown = (e) => { + setSelectionData({ + ...selectionData, + mouseData: { + ...selectionData.mouseData, + mouseDown: true, + xPos: e.pageX, + yPos: e.pageY, + time: (new Date()).getTime() + } + }) + } + + // Gets the difference in position, adds 0.3*velocity, and snaps to the closest image + const handleMouseUp = (e) => { + if (selectionData.mouseData.mouseDown) { + const newPos = (e.pageX-selectionData.mouseData.xPos) + selectionData.mouseData.offset + snapClosest(newPos + 0.3*getVelocity(e.pageX, selectionData.mouseData.xPos, selectionData.mouseData.time)) + } + } + + // Moves the image + const handleMouseMove = (e) => { + if (selectionData.mouseData.mouseDown) { + const _pics = picScrollerRef.current + + if (e.pageX == 0 && e.pageY == 0) { + return + } + + // note: deltaX is positive when dragging right (ie going back) + let x = (e.pageX-selectionData.mouseData.xPos) + selectionData.mouseData.offset + + // if the pan goes off the edge, divide the overflow amount by 10 + if (x > 0) x = x / 10 // overflow left + + const lastIndex = screenshotData.numScreenshots + const rightEdge = _pics.children[lastIndex].offsetLeft * -1 + x = Math.max(x, rightEdge + (x - rightEdge) / 10) // overflow right + + _pics.style.transition = '' + _pics.style.transform = `translate3D(${x}px, 0, 0)` + } + } + + // Same methods used for touch controls + const handleTouchDown = (e) => { + if (!selectionData.mouseData.mouseDown && e.changedTouches.length == 1) { + setSelectionData({ + ...selectionData, + mouseData: { + ...selectionData.mouseData, + mouseDown: true, + xPos: e.changedTouches[0].pageX, + yPos: e.changedTouches[0].pageY, + time: (new Date()).getTime() + } + }) + } + } + + const handleTouchUp = (e) => { + if (selectionData.mouseData.mouseDown && e.changedTouches.length == 1) { + const newPos = (e.changedTouches[0].pageX-selectionData.mouseData.xPos) + selectionData.mouseData.offset + snapClosest(newPos + 0.1*getVelocity(e.changedTouches[0].pageX, selectionData.mouseData.xPos, selectionData.mouseData.time)) + } + } + + const handleTouchMove = (e) => { + if (selectionData.mouseData.mouseDown && e.changedTouches.length > 0) { + const _pics = picScrollerRef.current + + if (e.changedTouches[0].pageX == 0 && e.changedTouches[0].pageY == 0) { + return + } + + // note: deltaX is positive when dragging right (ie going back) + let x = (e.changedTouches[0].pageX-selectionData.mouseData.xPos) + selectionData.mouseData.offset + + // if the pan goes off the edge, divide the overflow amount by 10 + if (x > 0) x = x / 10 // overflow left + + const lastIndex = screenshotData.numScreenshots + const rightEdge = _pics.children[lastIndex].offsetLeft * -1 + x = Math.max(x, rightEdge + (x - rightEdge) / 10) // overflow right + + _pics.style.transition = '' + _pics.style.transform = `translate3D(${x}px, 0, 0)` + } + } + + const snapClosest = (x, animate = true) => { + const _pics = picScrollerRef.current + if (_pics.children.length < 2) return // pics not loaded yet + + let minDiff = 9999 + let _offset = x + let _selectedImage = selectionData.selectedImage.num + + // Finds the closest image + for (let i = 0; i <= screenshotData.numScreenshots; i++) { + const childOffset = _pics.children[i].offsetLeft * -1 + const diff = Math.abs(childOffset - x) + if (diff < minDiff) { + minDiff = diff + _offset = childOffset + _selectedImage = i + } + } + + setSelectionData({ + ...selectionData, + selectedImage: { + num: _selectedImage, + reset: false + }, + mouseData: { + mouseDown: false, + xPos: 0, + yPos: 0, + offset: _offset + } + }) + + _pics.style.transform = `translate3D(${_offset}px, 0, 0)` + _pics.style.transition = animate ? 'ease transform 500ms' : '' + } + + const snapToImage = (fast=false) => { + const _pics = picScrollerRef.current + const i = selectionData.selectedImage.num + if (_pics.children.length && _pics.children[i]) { + const _offset = _pics.children[i].offsetLeft * -1 + fast ? _pics.style.transition = '' : _pics.style.transition = 'ease transform 500ms' + _pics.style.transform = `translate3D(${_offset}px, 0, 0)` + + setSelectionData({ + ...selectionData, + selectedImage: { + ...selectionData.selectedImage, + reset: false + }, + mouseData: { + mouseDown: false, + xPos: 0, + yPos: 0, + offset: _offset + } + }) + } + } + + // Starts player demo, but navigates to separate demo of screen isn't big enough + const showDemoClicked = () => { + if (isWideEnough()) { + const _height = (parseInt(widget.height) + 48) + 'px' + const _width = (parseInt(widget.width) + 10) + 'px' + + createPlaySession.mutate({ + widgetId: widget.meta_data.demo, + successFunc: (idVal) => setDemoData({ + demoLoading: true, + showDemoCover: false, + demoHeight: _height, + demoWidth: _width, + playId: idVal + }) + }) + } + else { + window.location = document.location.pathname + '/demo' + } + } + + const isWideEnough = () => { + if (widget.width == 0) { + return false // don't allow widgets with scalable width + } + // 150 in padding/margins needed + const sizeNeeded = parseInt(widget.width) + 150 + const userWidth = windowWidth + return userWidth > sizeNeeded + } + + const screenshotElements = [] + const screenshotDotElements = [] + screenshotData.screenshots.forEach((screenshot, index) => { + screenshotElements.push( +
    + +
    +

    Screenshot {index + 1} of {screenshotData.numScreenshots}

    +
    + ) + screenshotDotElements.push( + + ) + }) + + const handleDemoShortcutClick = () => setSelectionData({...selectionData, selectedImage: {num: 0, reset: true}}) + + let screenshotCarouselContentsRender = null + if (screenshotData.numScreenshots > 0) { + let demoRender = ( +
    + +
    + ) + + if (demoData.showDemoCover) { + demoRender = ( + <> + +
    + +
    +
    + + ) + } + + + screenshotCarouselContentsRender = ( +
    +
    + { demoRender } +

    {!demoData.showDemoCover ? 'Playing ' : '' }Demo

    +
    + + { screenshotElements } +
    + ) + } + + return ( +
    + + + +
    + { screenshotCarouselContentsRender } +
    + +
    + + { screenshotDotElements } +
    +
    + ) +} + +export default DetailCarousel diff --git a/src/components/detail-feature-list.jsx b/src/components/detail-feature-list.jsx new file mode 100644 index 000000000..7b5faeda4 --- /dev/null +++ b/src/components/detail-feature-list.jsx @@ -0,0 +1,35 @@ +import React, {useMemo} from 'react' +import DetailFeature from './detail-feature' + +const SUPPORTED_DATA = 'supported-data' +const FEATURES = 'features' +const DetailFeatureList = ({title, widgetData, type={SUPPORTED_DATA}}) => { + + const activeTab = useMemo(() => { + switch(type){ + case SUPPORTED_DATA: + return (widgetData.supported_data.map((data, index) => { + return () + })) + + case FEATURES: + return (widgetData.features.map((data, index) => { + return () + })) + + default: + return null + } + }, [type, widgetData]) + + return ( +
    + {title}: +
    + {activeTab} +
    +
    + ) +} + +export default DetailFeatureList diff --git a/src/components/detail-feature.jsx b/src/components/detail-feature.jsx new file mode 100644 index 000000000..6bdf71e3b --- /dev/null +++ b/src/components/detail-feature.jsx @@ -0,0 +1,28 @@ +import React, {useState} from 'react' +import './detail.scss' + +const DetailFeature = ({data, index}) => { + const [showData, setShowData] = useState(false) + + let descriptionRender = null + if (showData) { + descriptionRender = ( +
    + {data?.description} +
    + ) + } + + return ( +
    +
    setShowData(true)} + onMouseLeave={() => setShowData(false)}> + {data?.text} +
    + { descriptionRender } +
    + ) +} + +export default DetailFeature diff --git a/src/components/detail-page.jsx b/src/components/detail-page.jsx new file mode 100644 index 000000000..e8e6762b3 --- /dev/null +++ b/src/components/detail-page.jsx @@ -0,0 +1,26 @@ +import React from 'react' +import Header from './header' +import Detail from './detail' +import { useQuery } from 'react-query' +import { apiGetWidget } from '../util/api' + +const DetailPage = () => { + const nameArr = window.location.pathname.replace('/widgets/', '').split('/') + const widgetID = nameArr.pop().split('-').shift() + const { data: widget, isFetching: isFetching} = useQuery({ + queryKey: 'widget', + queryFn: () => apiGetWidget(widgetID), + enabled: !!widgetID, + placeholderData: {}, + staleTime: Infinity + }) + + return ( + <> +
    + + + ) +} + +export default DetailPage diff --git a/src/components/detail.jsx b/src/components/detail.jsx new file mode 100644 index 000000000..8dd42ef55 --- /dev/null +++ b/src/components/detail.jsx @@ -0,0 +1,236 @@ +import React, { useState, useEffect } from 'react' +import { iconUrl } from '../util/icon-url' +import DetailCarousel from './detail-carousel' +import DetailFeatureList from './detail-feature-list' +import LoadingIcon from './loading-icon' +import AccessibilityIndicator from './accessibility-indicator' +import { WIDGET_URL } from './materia-constants' + +const initWidgetData = () => ({ + hasPlayerGuide: false, + hasCreatorGuide: false, + demoLoading: false, + dataLoading: true, + maxPageWidth: '0px', + date: '', + creatorurl: document.location.pathname + '/create', + creators_guide: document.location.pathname + '/creators-guide', + players_guide: document.location.pathname + '/players-guide', + features: [], + supported_data: [], + accessibility: {}, +}) + +const getAccessibilityData = (metadata) => { + + return { + keyboard: metadata.accessibility_keyboard ? metadata.accessibility_keyboard : 'Unavailable', + screen_reader: metadata.accessibility_reader ? metadata.accessibility_reader : 'Unavailable', + description: metadata.accessibility_description ? metadata.accessibility_description : '' + } +} + +const _tooltipObject = (text) => ({ + text, + show: false, + description: + tooltipDescriptions[text] || 'This feature has no additional information associated with it.', +}) + +const tooltipDescriptions = { + Customizable: + 'As the widget creator, you supply the widget with data to make it relevant to your course.', + Scorable: 'This widget collects scores, and is well suited to gauge performance.', + Media: 'This widget uses image media as part of its supported data.', + 'Question/Answer': + 'Users provide a typed response or associate a predefined answer with each question.', + 'Multiple Choice': + 'Users select a response from a collection of possible answers to questions provided by the widget.', + 'Mobile Friendly': 'Designed with HTML5 to work on mobile devices like the iPad and iPhone.', + Fullscreen: 'This widget may be allowed to temporarily take up your entire screen.', +} + +const renderGuideElement = (guideLocation, text) => ( + +) + +const Detail = ({widget, isFetching}) => { + const [noAuthor, setNoAuthor] = useState(false) + const [height, setHeight] = useState('') + const [widgetData, setWidgetData] = useState({...initWidgetData(), + maxWidth: (widget.width || 700) + 150 + 'px' + }) + + useEffect(() => { + if (!isFetching) { + + setWidgetData({ + ...widgetData, + hasPlayerGuide: widget.player_guide != '', + hasCreatorGuide: widget.creator_guide != '', + maxWidth: ((parseInt(widget.width) || 700) + 150) + 'px', + supported_data: widget.meta_data['supported_data'].map(_tooltipObject), + features: widget.meta_data['features'].map(_tooltipObject), + accessibility: getAccessibilityData(widget.meta_data), + date: new Date(widget['created_at'] * 1000).toLocaleDateString(), + dataLoading: false, + }) + } + }, [isFetching]) + + // Waits for window value to load from server then sets it + useEffect(() => { + waitForWindow() + .then(() => { + setNoAuthor(window.NO_AUTHOR === '1' ? true : false) + setHeight(window.WIDGET_HEIGHT === '0' ? '' : window.WIDGET_HEIGHT) // Preloads height to avoid detail window resizing + }) + }, []) + + // Used to wait for window data to load + const waitForWindow = async () => { + while(!window.hasOwnProperty('NO_AUTHOR') || !window.hasOwnProperty('WIDGET_HEIGHT')) + await new Promise(resolve => setTimeout(resolve, 500)) + } + + let iconRender = null + let contentRender =
    + + if (!isFetching) { + iconRender = ( + <> + Current Widget +

    { widget?.name }

    +

    { widget.meta_data?.about }

    + + ) + + let featuresRender = null + if (widgetData.features.length > 0) { + featuresRender = ( + + ) + } + + let supportedDataRender = null + if (widgetData.supported_data.length > 0) { + supportedDataRender = ( + + ) + } + + let accessibilityRender = null + if(!widgetData.dataLoading) { + accessibilityRender = + } + + let createRender = null + if (!noAuthor) { + createRender = ( +
    +

    Want to use it in your course?

    +

    + + + + + + Create your widget + +

    +
    + ) + } + + let playerGuideRender = null + if (widgetData.hasPlayerGuide) { + playerGuideRender = renderGuideElement(widgetData.players_guide, "Player's Guide") + } + let creatorGuideRender = null + if (widgetData.hasCreatorGuide) { + creatorGuideRender = renderGuideElement(widgetData.creators_guide, "Creator's Guide'") + } + let guidesRender = null + if (widgetData.hasPlayerGuide || widgetData.hasCreatorGuide) { + guidesRender = ( +
    + Guides: + { playerGuideRender } + { creatorGuideRender } +
    + ) + } + + contentRender = ( + <> + + +
    +
    +
    + { featuresRender } + { supportedDataRender } + { accessibilityRender } +
    +
    + { createRender } + { guidesRender } +
    +
    + {widget?.name} was last updated on {widgetData.date} +
    + + ) + } + + return ( +
    + + + +
    +
    + { iconRender } +
    +

    { widget.meta_data?.about }

    + { contentRender } +
    +
    + ) +} + +export default Detail diff --git a/src/components/detail.scss b/src/components/detail.scss new file mode 100644 index 000000000..55bb2264b --- /dev/null +++ b/src/components/detail.scss @@ -0,0 +1,996 @@ +$color-features: #0093e7; +$color-features-active: #0357a5; +$color-red: #ca3b3b; +$color-yellow: #ffba5d; +$color-green: #c5dd60; + +.page { + margin: 0 auto 20px; + max-width: 950px; +} + +#breadcrumb-container { + display: inline-block; + position: relative; + top: 10px; + margin-left: 10px; + padding: 5px 10px; + background: #eee; + border-radius: 3px; + font-size: 12px; + border: 1px solid #ccc; + + .breadcrumb { + display: inline-block; + + a { + color: black; + } + } + + svg { + position: relative; + height: 15px; + top: 2px; + } +} + +#widget-about { + display: none; // shown on mobile +} + +.feature-list { + display: flex; + flex-direction: column; + margin-bottom: 12px; + + &:last-of-type { + margin-bottom: 0; + } + + &.accessibility-options { + .list-holder { + display: flex; + flex-direction: column; + + ul { + list-style-type: none; + margin: 0; + padding: 0; + + li { + &:first-of-type { + margin-bottom: 10px; + } + + &.accessibility-indicator { + display: none; + + &.show { + display: block; + } + } + + &.accessibility-description { + display: none; + margin: 10px 0 0 0; + + font-size: 14px; + font-weight: 300; + font-style: italic; + color: #5a5a5a; + + &.show { + display: block; + } + } + + .icon-spacer { + display: inline-block; + width: 35px; + margin-right: 10px; + + svg { + position: relative; + height: 20px; + top: 4px; + } + } + + span { + color: #000; + font-size: 16px; + } + } + } + } + } + + .feature-heading { + font-size: 14px; + // font-weight: 700; + margin-bottom: 6px; + + &.guide { + font-size: 0.7em; + } + } + + &.guides { + .feature:first-of-type { + margin-bottom: 10px; + } + } + + .feature-footer { + font-size: 0.7em; + } + + .item-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + } + + .item-list > * { + margin: 2px; + } + + .progress-bar { + display: flex; + margin: 40px 0 30px 0; + + .content-holder { + display: inherit; + position: absolute; + + .bar { + height: 17px; + //width: 100px; + width: 80px; + + &:not(:nth-child(3)) { + margin-right: 1px; + } + + &.red { + background-color: #e75b00; + } + + &.orange { + background-color: #e79300; + } + + &.green { + background-color: #4bcd70; + } + } + + .svg-arrow { + max-height: 35px; + } + + .access-icon { + display: flex; + flex-direction: column; + align-items: center; + position: absolute; + left: 0; + top: -30px; + + &.score-limited { + left: 81px; + } + + &.score-full { + left: 162px; + } + + .icon-backing { + display: grid; + border-radius: 5px 5px 0 0; + background-color: #4b4b4b; + width: 80px; + height: 30px; + } + + .icon-backing-single { + display: flex; + border-radius: 5px 5px 0 0; + background-color: #4b4b4b; + width: 80px; + height: 30px; + + svg { + width: 100%; + } + } + + .svg-icon { + max-width: 24px; + margin: auto; + } + } + } + } + + .feature { + margin-top: 0; + + .feature-description { + display: inline-block; + margin: 0 4px; + font-size: 12px; + } + + .guide-link { + font-size: 13px; + padding: 2px 25px 2px 6px; + border-radius: 2px; + background: #555; + color: #fff; + font-weight: bold; + text-decoration: none; + margin-right: 5px; + position: relative; + display: inline-block; + line-height: normal; + cursor: pointer; + + svg { + transform: scale(0.6); + position: absolute; + top: -2px; + right: 0px; + } + + &:hover { + background-color: #000; + svg { + right: -2px; + } + } + } + + .feature-name { + font-size: 14px; + cursor: default; + position: relative; + border-radius: 2px; + background: $color-features; + color: #fff; + padding: 4px 12px; + margin-right: 5px; + + font-weight: 700; + display: block; + + &:hover { + background: $color-features-active; + + &:after { + content: ''; + position: absolute; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid $color-features-active; + right: 0; + left: 0; + bottom: -6px; + margin: auto; + } + } + } + + .feature-description { + margin-top: 7px; + position: absolute; + z-index: 99; + padding: 15px; + color: #fff; + background: $color-features-active; + width: 250px; + border-radius: 15px; + box-shadow: 1px 2px 5px #888; + line-height: 18px; + font-size: 14px; + } + + .feature-progress { + display: flex; + flex-direction: column; + position: relative; + margin: 1px; + float: left; + text-align: center; + align-items: center; + justify-content: center; + min-width: 110px; + + &.unavailable { + color: #a2a2a2; + } + + .accessibility-text { + font-size: 0.6em; + margin-top: -1.5em; + margin-bottom: 0.5em; + min-width: 50px; + border-bottom: 1px solid #000; + + &.unavailable { + color: #a2a2a2; + border-bottom: 1px solid #a2a2a2; + content: 'Unavailable'; + } + } + + .accessibility-description { + &.unavailable { + color: #a2a2a2; + } + } + + .bar-overflow { + position: relative; + width: 90px; + height: 45px; + overflow: hidden; + + .bar { + position: absolute; + top: 0; + left: 0; + width: 90px; + height: 90px; + border-radius: 50%; + box-sizing: border-box; + border: 13px solid #fff; + transform: rotate(45deg); + + &.full { + transform: rotate(225deg); + animation-name: full-circle; + animation-duration: 1s; + animation-timing-function: ease-out; + border-bottom-color: #45f556; + border-right-color: #45f556; + } + + @keyframes full-circle { + 0% { + transform: rotate(45deg); + border-bottom-color: #45f556; + border-right-color: #45f556; + } + 100% { + transform: rotate(225deg); + } + } + + &.limited { + transform: rotate(135deg); + animation-name: half-circle; + animation-duration: 1s; + animation-timing-function: ease-out; + border-bottom-color: #fafc65; + border-right-color: #fafc65; + } + + @keyframes half-circle { + 0% { + transform: rotate(45deg); + border-bottom-color: #fafc65; + border-right-color: #fafc65; + } + 100% { + transform: rotate(135deg); + } + } + + &.none { + transform: rotate(65deg); + animation-name: small-circle; + animation-duration: 1s; + animation-timing-function: ease-out; + border-bottom-color: #fa2819; + border-right-color: #fa2819; + } + + @keyframes small-circle { + 0% { + transform: rotate(45deg); + border-bottom-color: #fa2819; + border-right-color: #fa2819; + } + 100% { + transform: rotate(65deg); + } + } + } + } + } + } +} + +a { + &.customizable { + background: #e53f1f; + color: #fff; + } + + &.scorable { + background: #abe51f; + color: #405118; + } + + &.scorable:hover { + color: #abe51f; + background: #405118; + } + + &.green { + background: #c4dd61; + color: #525252; + + &:hover { + background: #d5ea7f; + } + } +} + +.widget_detail { + background: #ffffff; + border-radius: 4px; + border: #e4e4e4 1px solid; + box-shadow: 1px 3px 10px #dcdcdc; + margin: 25px 10px; + padding-bottom: 70px; + min-height: 500px; + + .top { + background: #eee; + border-radius: 4px 4px 0 0; + color: #333; + padding: 15px 15px; + min-height: 92px; + + img { + height: 92px; + position: absolute; + } + + h1 { + margin: 0 0 0 110px; + font-size: 2em; + } + + p { + margin: 0 0 0 110px; + } + } + + .pics { + margin: 35px 60px 15px; + position: relative; + text-align: center; + + button.pic-arrow { + position: absolute; + top: calc(50% - 20px); + display: block; + width: 24px; + height: 24px; + padding: 0; + cursor: pointer; + border: none; + outline: none; + fill: white; + background: black; + border-radius: 100%; + opacity: 0.5; + + &:first-of-type { + left: -40px; + } + + &:last-of-type { + right: -40px; + } + + &:hover { + opacity: 0.7; + } + + &:focus { + opacity: 1; + } + } + + button.demo-dot { + margin: 0; + padding: 2px 8px 3px 18px; + position: relative; + background-color: #c0c0c0; + color: white; + border: none; + box-shadow: none; + border-radius: 6px; + cursor: pointer; + vertical-align: top; + top: 8px; + + &:before { + content: ' '; + position: absolute; + top: 4px; + left: 6px; + width: 0; + height: 0; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 8px solid white; + } + + &:hover, + &:focus { + background-color: #777; + outline: none; + color: white; + } + + &.selected { + background-color: #555; + color: white; + } + } + + button.pic-dot { + margin: 12px 10px; + padding: 0; + position: relative; + font-size: 0; + line-height: 0; + display: inline-block; + width: 10px; + height: 10px; + cursor: pointer; + color: transparent; + border: 0; + outline: none; + background: transparent; + + &:before { + position: absolute; + top: 0; + left: 0; + width: 10px; + height: 10px; + content: ' '; + border-radius: 100%; + background-color: black; + opacity: 0.25; + } + + &:hover:before { + opacity: 0.5; + } + + &:focus:before { + opacity: 0.5; + } + + &.selected:before { + opacity: 1; + } + } + } + + // pic scroller adapted from slick (github.com/kenwheeler/slick) + #pics-scroller-container { + overflow: hidden; + width: 100%; + } + + #pics-scroller { + display: flex; + transform: translate3d(-2px, 0, 0); + + > div { + width: 100%; + height: 100%; + min-height: 150px; + margin: auto 2px; + flex-grow: 0; + flex-shrink: 0; + padding: 5px; + background: #333; + box-sizing: border-box; + border-radius: 3px; + position: relative; + touch-action: none; + cursor: grab; + + &.playing, + &.loading { + background: #b944cc; + } + + &:active { + cursor: grabbing; + } + + img { + width: 100%; + height: 100%; + margin: auto auto 37px; + flex-grow: 0; + flex-shrink: 0; + border-radius: 3px; + } + + h3 { + position: absolute; + bottom: 0; + left: 0; + right: 0; + text-align: right; + color: white; + margin: 0; + padding: 10px 30px; + font-size: 15px; + height: 20px; + user-select: none; + } + } + } + + #demo-cover { + height: auto; + width: auto; + display: flex; + position: absolute; + margin: 5px 5px 45px; + border-radius: 3px; + top: 0; + right: 0; + bottom: 0; + left: 0; + align-items: center; + justify-content: center; + text-align: center; + background-repeat: no-repeat; + background-size: contain; + + &.hidden, + &.loading { + transform: translate3d(0, 0, 0); // safari fix for loading animation + + button { + padding-right: 45px; + + &:after { + content: ''; + width: 20px; + height: 20px; + position: absolute; + top: -7px; + right: 10px; + background-color: #535353; + margin: 20px auto; + -webkit-animation: sk-rotatePlane 1.2s infinite ease-in-out; + animation: sk-rotatePlane 1.2s infinite ease-in-out; + } + } + + #demo-cover-background { + opacity: 0.5; + } + } + + button { + transition: 300ms; + font-weight: 700; + box-shadow: 1px 2px 4px #888; + text-shadow: 1px 1px 1px rgba(255, 255, 255, 0.5); + font-size: 22px; + border: 3px solid #525252; + padding: 10px 20px 10px 50px; + position: relative; + cursor: pointer; + z-index: 2; + opacity: 1; + + &:hover { + text-decoration: underline; + + + #demo-cover-background { + opacity: 0.7; + } + } + + svg { + fill: #535353; + transform: scale(1.5); + position: absolute; + top: 11px; + left: 18px; + } + } + } + + #demo-cover-background { + background: radial-gradient( + ellipse farthest-corner at center center, + rgba(255, 255, 255, 0.85) 20%, + rgba(190, 190, 190, 0.97) 100% + ) + repeat scroll 0% 0%; + position: absolute; + height: 100%; + width: 100%; + top: 0; + left: 0; + transition: 1500ms; + } + + #player-container { + position: relative; + z-index: 2; + + section.widget { + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; + } + } + + // cover over images to make them draggable on iOS + .screenshot-drag-cover { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + + .bottom { + padding: 0 55px; + + .widget-action-buttons { + text-align: left; + margin-bottom: 20px; + + h4 { + color: #000; + font-size: 0.9em; + margin: 0px 0 6px 0; + padding: 10px 0 0 0; + + &:first-child { + padding: 0; + } + } + + p { + margin-top: 0; + padding: 0; + } + } + + .bottom-content { + margin: 0 auto 5px auto; + border-radius: 5px; + display: flex; + padding: 20px; + + .left-content { + display: flex; + flex-direction: column; + margin-right: auto; + max-width: 50%; + } + + .right-content { + display: flex; + flex-direction: column; + //align-items: flex-end; + } + } + + #createLink { + padding-left: 45px; + + svg { + position: absolute; + fill: #535353; + top: 5px; + left: 17px; + } + } + + #last-updated { + padding-left: 20px; + font-style: italic; + color: #b5b5b5; + font-size: 0.9em; + } + } + + .loading-icon-holder { + margin: 3.5em 0; + } +} + +@media (max-width: 720px) { + #breadcrumb-container { + padding: 3px 10px; + margin-bottom: 10px; + top: 0; + max-width: calc(100% - 40px); + } + + #widget-about { + display: block; + margin-top: 25px; + padding: 0 20px; + } + + .feature-list { + justify-content: center; + + .feature-heading { + text-align: center; + flex-basis: 100%; + margin-bottom: 10px; + } + } + + .widget_detail { + .bottom { + padding: 0 20px; + + .widget-action-buttons { + float: none; + margin: 0 auto; + text-align: center; + } + + #last-updated { + padding: 0; + text-align: center; + display: block; + } + + .bottom-content { + flex-direction: column-reverse; + align-items: center; + justify-content: center; + + .left-content { + max-width: 100%; + width: 100%; + margin: 12px 0 0 0; + } + + .right-content { + width: 100%; + max-width: 100%; + } + + .feature-list { + margin-top: 10px; + } + + .progress-bar { + justify-content: center; + align-items: center; + } + } + } + + .top { + padding: 10px; + min-height: auto; + + img { + height: 50px; + } + + h1 { + font-size: 1.5em; + margin-left: 60px; + } + + p { + display: none; + } + } + + #demo-cover { + margin-bottom: 28px; + + button { + font-size: 18px; + border-width: 1px; + padding: 7px 23px 8px 43px; + + svg { + transform: scale(1.2); + top: 6px; + left: 15px; + } + } + } + + .pics { + display: block; + position: relative; + margin: 25px 20px 25px; + + button.pic-arrow { + height: 24px; + width: 24px; + top: calc(100% - 29px); + + &:first-of-type { + left: 5px; + } + + &:last-of-type { + right: 5px; + } + } + + button.pic-dot { + margin: 12px 8px; + } + } + + #pics-scroller { + > div { + img { + margin-bottom: 20px; + } + + h3 { + padding: 5px 10px; + font-size: 14px; + height: 18px; + } + } + } + } +} + +@-webkit-keyframes sk-rotatePlane { + 0% { + -webkit-transform: perspective(120px) rotateX(0deg) rotateY(0deg); + transform: perspective(120px) rotateX(0deg) rotateY(0deg); + } + 50% { + -webkit-transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg); + transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg); + } + 100% { + -webkit-transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg); + transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg); + } +} +@keyframes sk-rotatePlane { + 0% { + -webkit-transform: perspective(120px) rotateX(0deg) rotateY(0deg); + transform: perspective(120px) rotateX(0deg) rotateY(0deg); + } + 50% { + -webkit-transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg); + transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg); + } + 100% { + -webkit-transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg); + transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg); + } +} diff --git a/src/components/draft-not-playable.jsx b/src/components/draft-not-playable.jsx new file mode 100644 index 000000000..1b463fc22 --- /dev/null +++ b/src/components/draft-not-playable.jsx @@ -0,0 +1,37 @@ +import React from 'react' +import SupportInfo from './support-info' +import Header from './header' + +const DraftNotPlayable = () => { + + let headerRender =
    + + let bodyRender = ( +
    +
    + +

    Sorry, drafts are not playable.

    + +

    You probably need to:

    +
      +
    • Preview instead of play.
    • +
    • Publish this widget to start collecting scores.
    • +
    • Check out our documentation.
    • +
    • Take a break, watch cat videos.
    • +
    + + + +
    +
    + ) + + return ( + <> + { headerRender } + { bodyRender } + + ) +} + +export default DraftNotPlayable diff --git a/src/components/drag-and-drop.jsx b/src/components/drag-and-drop.jsx new file mode 100644 index 000000000..cd4354830 --- /dev/null +++ b/src/components/drag-and-drop.jsx @@ -0,0 +1,43 @@ +/** + * It's a React component that takes in a parseMethod function and a children component, and returns a + * div that handles the drag and drop events. + * @param parseMethod is the method pass + * @param idStr the id for the div component. + */ +const DragAndDrop = ({ children, parseMethod, idStr }) => { + const handleDragEvent = (ev) => { + ev.preventDefault() + ev.stopPropagation() + } + + const handleDrop = (ev) => { + ev.preventDefault() + ev.stopPropagation() + parseMethod(ev) // parse function + } + + return ( +
    { + handleDragEvent(ev) + }} + onDragEnd={(ev) => { + handleDragEvent(ev) + }} + onDrag={(ev) => { + handleDragEvent(ev) + }} + onDragOver={(ev) => { + handleDragEvent(ev) + }} + onDrop={(ev) => { + handleDrop(ev) + }} + > + {children} +
    + ) +} + +export default DragAndDrop \ No newline at end of file diff --git a/src/components/embedded-only.jsx b/src/components/embedded-only.jsx new file mode 100644 index 000000000..4caf60eec --- /dev/null +++ b/src/components/embedded-only.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import Summary from './widget-summary' + +const EmbeddedOnly = () => { + return ( +
    +
    + + +
    +

    Not Playable Here

    + Your instructor has not made this widget available outside of the LMS. +
    +
    +
    + ) +} + +export default EmbeddedOnly diff --git a/src/components/extra-attempts-dialog.jsx b/src/components/extra-attempts-dialog.jsx new file mode 100644 index 000000000..28f8c2034 --- /dev/null +++ b/src/components/extra-attempts-dialog.jsx @@ -0,0 +1,218 @@ +import React, { useEffect, useState, useRef, useMemo } from 'react' +import { useQuery } from 'react-query' +import { apiGetExtraAttempts, apiGetUsers } from '../util/api' +import useSetAttempts from './hooks/useSetAttempts' +import Modal from './modal' +import ExtraAttemptsRow from './extra-attempts-row' +import LoadingIcon from './loading-icon' +import NoContentIcon from './no-content-icon' +import StudentSearch from './student-search' +import './extra-attempts-dialog.scss' + +// note: this module is originally intended for the admin panel +// and does not check user permissions + +const defaultState = () => ({ + extraAttempts: new Map(), + users: {}, + newIdCount: -1, + userIDs: [] +}) + +// Component for Extra Attempts Gui +const ExtraAttemptsDialog = ({onClose, inst}) => { + // map of extra attempt objects for a particular instance + // key: id of extra attempt row in the db + // when creating a new row, id's are negative increments (newIdCount) + // hold users that correlate to extra attempts + // new attempt object Id's are negative so as not to conflict with existing Id's + const [state, setState] = useState(defaultState()) + // display error above save button using the text from this hook: + const [saveError, setSaveError] = useState('') + const mounted = useRef(false) + const setExtraAttempts = useSetAttempts() + const { data: attempts, isLoading: attemptsLoading, isFetching, remove: removeAttempts } = useQuery({ + queryKey: 'extra-attempts', + queryFn: () => apiGetExtraAttempts(inst.id), + placeholderData: [], + staleTime: Infinity + }) + const { data: queryUsers, remove: removeUsers } = useQuery({ + queryKey: ['attempt-users', inst.id], + queryFn: () => apiGetUsers(state.userIDs), + enabled: !!state.userIDs && state.userIDs.length > 0 && attemptsLoading == false, + placeholderData: {}, + staleTime: Infinity + }) + + useEffect(() => { + mounted.current = true + return () => (mounted.current = false) + }, []) + + // Sets the users and attempts on load + useEffect(() => { + if (attempts instanceof Map && mounted.current) { + const idArr = [] + attempts.forEach(user => {idArr.push(user.user_id)}) + setState({...state, userIDs: idArr, extraAttempts: new Map(attempts)}) + } + }, [JSON.stringify(attempts), mounted.current]) + + useEffect(() => { + if (mounted.current) { + setState({...state, users: {...queryUsers}}) + } + }, [JSON.stringify(queryUsers)]) + + const addUser = (match) => { + // add user to users list if not already there + const tempUsers = {...state.users} + const tempAttempts = new Map(state.extraAttempts) + + if(!(match.id in state.users)){ + tempUsers[match.id] = match + + // add another extra attempts row if needed + tempAttempts.set( + state.newIdCount, + { + id: parseInt(state.newIdCount), + context_id: '', + extra_attempts: 1, + user_id: parseInt(match.id) + } + ) + } + else { + // Previously deleted user being re-added + for (const [attemptId, attemptVal] of tempAttempts) { + if (parseInt(attemptVal.user_id) === parseInt(match.id)) { + // Only changes when necessary + if (attemptVal.extra_attempts < 0 || attemptVal.disabled === true) { + tempAttempts.set(attemptId, + { + ...attemptVal, + extra_attempts: 1, + disabled: false + } + ) + } + break + } + } + } + + setState({...state, + extraAttempts: tempAttempts, + newIdCount: !(match.id in state.users) ? state.newIdCount-1 : state.newIdCount, + users: tempUsers + }) + } + + const onSave = () => { + let isError = false + state.extraAttempts.forEach((obj) => { + if(obj.context_id === '') { + setSaveError('Must fill in Course ID field') + isError = true + } + }) + + if (!isError) { + setExtraAttempts.mutate({ + instId: inst.id, + attempts: Array.from(state.extraAttempts.values()) + }) + + // Removed current queries from cache to force reload on next open + removeAttempts() + removeUsers() + + onClose() + } + } + + const containsUser = useMemo(() => { + for (const [id, val] of Array.from(state.extraAttempts)) { + if (val.extra_attempts >= 0) return true + } + + return false + },[inst, Array.from(state.extraAttempts)]) + + let contentRender = + if (!isFetching) { + let extraAttemptsRender = + if (containsUser) { + extraAttemptsRender = Array.from(state.extraAttempts).map(([attemptId, attemptObj]) => { + if (attemptObj.extra_attempts < 0) return + const user = state.users[attemptObj.user_id] + if (!user) return + + const attemptsForUserChangeHandler = (id, updatedAttempt) => setState((oldState) => { + const attemptsMap = new Map(oldState.extraAttempts) + attemptsMap.set(id, updatedAttempt) + return { + ...oldState, + extraAttempts: attemptsMap + } + }) + + return + }) + } + + contentRender = ( + <> + { extraAttemptsRender } + + ) + } + + let saveErrorRender = null + if (saveError) { + saveErrorRender =

    {saveError}

    + } + + return ( + +
    + Give Students Extra Attempts +
    + + +
    +
    + User + Course ID + Extra Attempts +
    + +
    + { contentRender } +
    +
    +
    + { saveErrorRender } +
    + +
    +
    +
    + ) +} + +export default ExtraAttemptsDialog diff --git a/src/components/extra-attempts-dialog.scss b/src/components/extra-attempts-dialog.scss new file mode 100644 index 000000000..291a4b8d7 --- /dev/null +++ b/src/components/extra-attempts-dialog.scss @@ -0,0 +1,244 @@ +.modal .extraAttemptsModal { + min-width: 580px; + + .title { + margin: 0; + padding: 0; + font-size: 1.3em; + color: #555; + border-bottom: #999 dotted 1px; + padding-bottom: 20px; + margin-bottom: 20px; + position: relative; + text-align: left; + display: block; + font-weight: bold; + } + + .search-container { + display: flex; + align-items: center; + + span { + margin: 0 auto; + } + + input { + margin: 0 auto; + } + } + + .attempts-input { + width: 445px; + height: 30px; + z-index: 2; + border: solid 1px #c9c9c9; + font-size: 16px; + } + + .attempts_search_list { + width: 447px; + position: absolute; + background-color: #ffffff; + border: #bfbfbf 1px solid; + padding-bottom: 5px; + overflow: auto; + z-index: 3; + text-align: left; + left: 114px; + display: flex; + flex-wrap: wrap; + align-items: flex-start; + + .attempts_search_match { + width: 200px; + height: 56px; + margin: 5px 5px 0 5px; + padding: 0 5px 5px 0; + border-radius: 3px; + display: inline-block; + background-color: #ffffff; + + .attempts_match_avatar { + width: 50px; + height: 50px; + -moz-border-radius: 3px; + border-radius: 3px; + display: inline-block; + float: left; + margin-right: 10px; + margin: 5px; + } + + .attempts_match_name { + font-size: 14px; + text-align: left; + height: 40px; + // font-family: 'Lucida Grande', sans-serif; + overflow: auto; + } + } + + .attempts_match_student { + position: relative; + } + .attempts_match_student:after { + content: 'Student'; + position: absolute; + bottom: 5px; + right: 0; + font-size: 10px; + } + + .attempts_search_match:hover { + background-color: #c5e7fa; + cursor: pointer; + } + } + + .attempts_list_container { + margin: 15px 0px; + background-color: #f2f2f2; + height: 250px; + border-radius: 5px; + // padding: 0 30px; + // text-align: right; + // overflow: auto; + position: relative; + + .headers { + background: #555555; + height: 30px; + color: white; + display: flex; + align-items: center; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + text-align: center; + padding: 0 2%; + + .user-header { + width: 50%; + } + .context-header { + width: 25%; + } + .attempts-header { + width: 25%; + } + } + + .attempts_list { + overflow: scroll; + height: 220px; + + &.no-content { + display: flex; + align-items: center; + justify-content: center; + } + + .disabled { + display: none !important; + } + + .extra_attempt { + display: flex; + align-items: center; + position: relative; + margin: 2%; + + .remove { + display: block; + color: #bfbfbf; + text-decoration: none; + font-size: 15px; + text-align: center; + padding: 0.5em; + margin: 0.5em; + user-select: none; + border: none; + background: transparent; + + &:hover { + color: black; + background: white; + border-radius: 5px; + cursor: pointer; + } + } + + .user_holder { + display: flex; + align-items: center; + width: 50%; + } + + .user { + display: flex; + align-items: center; + width: 250px; + + .avatar { + vertical-align: middle; + display: inline-block; + height: 40px; + width: 40px; + margin-right: 10px; + } + + .user_name { + font-size: 14px; + text-align: left; + position: relative; + overflow-wrap: break-word; + word-wrap: break-word; + hyphens: auto; + max-width: 60%; + } + } + + .context { + width: 25%; + text-align: center; + + input { + width: 60%; + text-align: center; + } + } + + .num_attempts { + width: 25%; + text-align: center; + + input { + width: 60%; + text-align: center; + } + } + } + } + } + + .save-error { + height: 20px; + + p { + color: red; + font-size: 0.8em; + margin: 0px 15px; + } + } + + .button-holder { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 5px; + } + + .cancel_button:hover { + cursor: pointer; + } +} diff --git a/src/components/extra-attempts-row.jsx b/src/components/extra-attempts-row.jsx new file mode 100644 index 000000000..1a0294a82 --- /dev/null +++ b/src/components/extra-attempts-row.jsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react' +import './extra-attempts-dialog.scss' + +// Component for each individual row in the Extra Attempts Gui +const ExtraAttemptsRow = ({extraAttempt, user, onChange}) => { + // holds updated state of each extra attempts object/row + // to send to parent if changed + // sets row to display:none if removed, until parent render updates + const [state, setState] = useState({...extraAttempt, disabled: false}) + + const onRemove = () => { + onChange(extraAttempt.id, {...state, extra_attempts: -1}) + setState({...state, extra_attempts: -1, disabled: true}) + } + + const onContextChange = e => { + onChange(extraAttempt.id, {...state, context_id: e.target.value}) + setState({...state, context_id: e.target.value }) + } + + const onAttemptsChange = e => { + if (e.target.value) { + onChange(extraAttempt.id, {...state, extra_attempts: parseInt(e.target.value)}) + setState({...state, extra_attempts: parseInt(e.target.value)}) + } + } + + return ( +
    +
    + +
    + + + + {`${user.first} ${user.last}`} + +
    +
    + +
    + +
    + +
    + +
    +
    + ) +} + +export default ExtraAttemptsRow diff --git a/src/components/extra-attempts.test.js b/src/components/extra-attempts.test.js new file mode 100644 index 000000000..bbcc1cb65 --- /dev/null +++ b/src/components/extra-attempts.test.js @@ -0,0 +1,121 @@ +/** + * @jest-environment jsdom + */ + +// Support page redirects to admin/user and admin/instances so this encompasses those basically + +import React from 'react' +import { QueryClient, QueryClientProvider } from 'react-query' +import { act } from 'react-dom/test-utils'; +import { render, screen, cleanup, fireEvent, waitFor, prettyDOM } from '@testing-library/react' +import '@testing-library/jest-dom' +import userEvent from "@testing-library/user-event"; + +import * as api from '../util/api' +import users from '../__test__/mockapi/users_get.json' +import ExtraAttemptsDialog from './extra-attempts-dialog' +import instances from '../__test__/mockapi/widget_instances_get.json' + +jest.mock('../util/api') + +// Enables testing with react query +const renderWithClient = (children) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Turns retries off + retry: false, + }, + }, + }) + + const { rerender, ...result } = render({children}) + + return { + ...result, + rerender: (rerenderUi) => + rerender({rerenderUi}) + } +} + +describe('ExtraAttemptsDialog', () => { + let rendered; + let mockApiSearchUsers; + let mockApiSetAttempts; + let mockApiGetUsers; + let mockApiGetExtraAttempts; + + // beforeEach(() => { + // mockApiSearchUsers = jest.spyOn(api, 'apiSearchUsers').mockResolvedValue(users); + // mockApiSetAttempts = jest.spyOn(api, 'apiSetAttempts').mockResolvedValue(true); + // mockApiGetUsers = jest.spyOn(api, 'apiGetUsers').mockResolvedValue(users); + // mockApiGetExtraAttempts = jest.spyOn(api, 'apiSetAttempts').mockResolvedValue(); + + // act(() => { + // rendered = renderWithClient() + // }) + // }) + + // afterEach(() => { + // cleanup(); + // jest.clearAllMocks(); + // }) + + // it('gives student extra attempts', async () => { + // // Shows copy dialog + // let btn = screen.getByText('Extra Attempts'); + // act(() => { + // userEvent.click(btn); + // }) + + // await waitFor(() => { + // expect(screen.getByText('Give Students Extra Attempts')).toBeInTheDocument(); + // }) + + // let search = screen.getByLabelText('Add students:'); + // await act(async() => { + // userEvent.type(search, "test"); + // }) + + // await waitFor(() => { + // expect(mockApiSearchUsers).toHaveBeenCalled(); + // expect(screen.getByText('Unofficial Test User 05f2db072c')).toBeInTheDocument(); + // expect(screen.getByText('Unofficial Test User 34f5b1afec')).toBeInTheDocument(); + // expect(screen.getByText('Unofficial Test User 6f17ffa34b')).toBeInTheDocument(); + // expect(screen.getByText('Unofficial Test User f664f64d7d')).toBeInTheDocument(); + // }) + + // act(() => { + // userEvent.click(screen.getByText('Unofficial Test User 05f2db072c')); + // }) + + // await waitFor(() => { + // expect(screen.getByText('Unofficial Test User 05f2db072c')).toBeInTheDocument(); + // }) + + // // Try saving without adding course ID + // let save_btn = screen.getByText('Save'); + // act(() => { + // userEvent.click(save_btn); + // }) + + // expect(screen.getByText('Must fill in Course ID field')).toBeInTheDocument(); + + // let course_id_text = screen.getByPlaceholderText("e.g. 'nGjdE'"); + // userEvent.type(course_id_text, 'fafsa'); + + // await waitFor(() => { + // expect(mockApiSetAttempts).toHaveBeenCalled(); + // expect(screen.getByText('Give Students Extra Attempts')).toBeNull(); + // }) + + // }) + + it('deletes widget', () => { + + }) + + it('undeletes widget', () => { + + }) +}) diff --git a/src/components/guide-page.jsx b/src/components/guide-page.jsx new file mode 100644 index 000000000..6ef5171bf --- /dev/null +++ b/src/components/guide-page.jsx @@ -0,0 +1,62 @@ +import React, { useState, useEffect} from 'react' +import { useQuery } from 'react-query' +import Header from './header' +import './guide-page.scss' + +const GuidePage = () => { + + const [name, setName] = useState(null) + const [type, setType] = useState(null) + const [hasPlayerGuide, setHasPlayerGuide] = useState(null) + const [hasCreatorGuide, setHasCreatorGuide] = useState(null) + const [docPath, setDocPath] = useState(null) + + useEffect(() => { + waitForWindow().then(() => { + setName(window.NAME) + setType(window.TYPE) + setHasPlayerGuide(window.HAS_PLAYER_GUIDE) + setHasCreatorGuide(window.HAS_CREATOR_GUIDE) + setDocPath(window.DOC_PATH) + }) + }) + + const waitForWindow = async () => { + while(!window.hasOwnProperty('NAME') + && !window.hasOwnProperty('TYPE') + && !window.hasOwnProperty('HAS_PLAYER_GUIDE') + && !window.hasOwnProperty('HAS_CREATOR_GUIDE') + && !window.hasOwnProperty('DOC_PATH')) { + await new Promise(resolve => setTimeout(resolve, 500)) + } + } + + let headerRender =
    + + let bodyRender = null + if (!!name) { + bodyRender = ( +
    +
    +

    { name }

    +
    + { hasPlayerGuide && Player Guide } + { hasCreatorGuide && Creator Guide} +
    +
    +
    + +
    +
    + ) + } + + return ( + <> + { headerRender } + { bodyRender } + + ) +} + +export default GuidePage diff --git a/src/components/guide-page.scss b/src/components/guide-page.scss new file mode 100644 index 000000000..85808a55a --- /dev/null +++ b/src/components/guide-page.scss @@ -0,0 +1,83 @@ +@import './include.scss'; + +.page { + margin: 0 5%; + background: white; + padding: 10px; + border-radius: 10px; + box-shadow: 1px 3px 10px #888; + + font-family: 'Lato', arial, sans-serif; +} + +#top { + display: flex; + + h1 { + flex-grow: 1; + margin: 10px 0 0 8px; + } + + #guide-tabs { + display: flex; + + &.players-guide { + a:first-of-type { + background: #1778af; + } + } + + &.creators-guide { + a:nth-of-type(2) { + background: #1778af; + } + } + + a { + padding: 8px 15px; + background: #004268; + color: white; + border-radius: 10px 10px 0 0; + margin: 15px 0 0 10px; + } + } +} + +#guide-container { + .guide { + height: calc(100vh - 200px); + width: 100%; + box-sizing: border-box; + border-radius: 3px 0 3px 3px; + border: 8px solid #1778af; + } +} + +// mobile +@media (max-width: 720px) { + .page { + margin: 5px; + } + + #top { + flex-direction: column; + + h1 { + margin: 0; + font-size: 20px; + } + + #guide-tabs { + justify-content: flex-end; + + a { + padding: 6px 15px; + background: lighten(#333, 20%); + color: white; + border-radius: 10px 10px 0 0; + margin: 10px 0 0 10px; + font-size: 14px; + } + } + } +} diff --git a/src/components/header.jsx b/src/components/header.jsx new file mode 100644 index 000000000..273c965a5 --- /dev/null +++ b/src/components/header.jsx @@ -0,0 +1,194 @@ +import React, { useState } from 'react' +import { useQuery } from 'react-query' +import { apiGetUser, apiAuthorSuper, apiAuthorSupport } from '../util/api' +import Notifications from './notifications' + +const Header = ({ + allowLogins = true +}) => { + const [menuOpen, setMenuOpen] = useState(false) + const [optionsOpen, setOptionsOpen] = useState(false) + + const { data: user, isLoading: userLoading} = useQuery({ + queryKey: 'user', + queryFn: apiGetUser, + staleTime: Infinity + }) + const { data: isAdmin} = useQuery({ + queryKey: 'isAdmin', + queryFn: apiAuthorSuper, + staleTime: Infinity + }) + const { data: isSupport} = useQuery({ + queryKey: 'isSupport', + queryFn: apiAuthorSupport, + staleTime: Infinity + }) + + const toggleMobileNavMenu = () => setMenuOpen(!menuOpen) + + const logoutUser = () => { + sessionStorage.clear() + window.location.href = '/users/logout' + } + + const showUserOptions = () => { + setOptionsOpen(!optionsOpen); + } + + let userDataRender = + + let profileNavRender = null + + let adminNavRender = null + if (isAdmin) { + adminNavRender = ( +
  • + Admin + +
  • + ) + } + + let supportNavRender = null + if (isSupport) { + supportNavRender = ( +
  • + Support + +
  • + ) + } + + /* + There will seemingly be two 'Logout' links when a user is logged in - one is inline with the + user name and avatar, the second is invisible unless the screen is extremely narrow, at which point + it becomes visible alongside all other nav options in the expanded hamburger menu. + This variable will account for the second Logout link. + */ + let logoutNavRender = null + let profileMenuRender = null + let notificationRender = null; + + let userRender = null + if (!userLoading) { + let userAvatarRender = null; + let loginRender = null; + + // this used to be !!user - not sure if the distinction was important + if (user) { + + notificationRender = + + profileNavRender = ( +
  • + My Profile +
  • + ) + userAvatarRender = ( + <> + + + + ) + + logoutNavRender = ( +
  • + + Logout + +
  • + ) + + // A dropdown menu for the profile icon + // Not being used + profileMenuRender = ( + + ) + + } else { + if (allowLogins) { + loginRender = Login + } + } + + userRender = ( +
    +
    + { notificationRender } +
    + { userAvatarRender } + { loginRender } +
    + ) + } + + return ( +
    +

    Materia

    + { userRender } +
    + { notificationRender } +
    + + + + +
    + ) +} + +export default Header diff --git a/src/components/help-page.jsx b/src/components/help-page.jsx new file mode 100644 index 000000000..de45170a7 --- /dev/null +++ b/src/components/help-page.jsx @@ -0,0 +1,63 @@ +import React from 'react' +import Header from './header' + +import './help-page.scss'; + +const HelpPage = () => { + return ( + <> +
    +
    +
    +

    Help & Support

    +
    + +

    Check out the Materia Quickstart Guide.

    +
    + +
    +

    Requirements

    +

    Materia requires that you use an up-to-date browser with javascript and cookies enabled.

    +
    + +
    +

    Login Issues

    +

    In many cases, problems logging in are a result of one of the following:

    + +

    Incorrect Password

    +

    You may need to reset your password.

    + +

    Expired Password

    +

    You may need to reset your password.

    + +

    User Account Doesn't exist

    +

    Your user account may not have been created yet.

    +
    + +
    + +

    View the docs for guides on using Materia.

    + +

    Player/Student Guide

    +

    Author/Instructor Guide

    +
    + +
    +

    Support

    +

    If you need help beyond what has been provided here, please contact support using one of the following:

    +
    +
    Support
    +
    http://website/support/
    +
    Email
    +
    support@website
    +
    Phone
    +
    PHONE NUMBER HERE
    +
    +
    +
    +
    + + ) +} + +export default HelpPage diff --git a/src/components/help-page.scss b/src/components/help-page.scss new file mode 100644 index 000000000..83529ad15 --- /dev/null +++ b/src/components/help-page.scss @@ -0,0 +1,145 @@ +@import './include.scss'; + +@media (max-width: 850px) { + .docs .container .page { + padding: 40px 20px 20px 20px !important; + margin: 15px 30px !important; + } + + .docs .container .page section.float.right { + float: none !important; + width: 100% !important; + } + + .docs .container .page section.float.left { + width: 100% !important; + } + + .docs .container .page section.float.left { + margin-bottom: 20px !important; + } +} + +.docs .container { + display: flex; + justify-content: center; + // margin: 0 auto; + // width: 970px; + position: relative; + + font-family: 'Lato', arial, serif; +} + +.docs .container .page { + border-radius: 4px; + border: #e4e4e4 1px solid; + box-shadow: 1px 3px 10px #dcdcdc; + + margin: 15px 50px; + z-index: 100; + min-height: 400px; + text-align: left; + font-size: 14px; + + overflow: auto; + + width: 685px; + // background: url('/img/question_mark_circle.png') 780px 30px no-repeat; + + background-color: #fff; + margin-bottom: 60px; + + padding: 40px 215px 20px 60px; +} + +.docs .container h1 { + + font-weight: 700; + font-size: 38px; + padding: 0 0 5px 0; + padding-bottom: 20px; + margin: 0 0 30px 0; + border-bottom: solid #dcdcdc .5px; +} + +.docs .container h2 { + font-weight: 400; + font-size: 20px; + margin: 20px 0 0 0; +} + +.docs .container .page section { + margin-bottom: 0px; + padding-bottom: 15px; + clear: both; +} + +.docs .container .page section.float { + width: 25%; + clear: none; + float: left; +} + +.docs .container .page section.float.right { + width: 40%; + float: right; +} + +.docs .container .page section.float.left { + width: 53%; + float: left; +} + +.docs .container .page section.bordered { + border-bottom: solid #dcdcdc .5px +} + +.docs .container .page section.bordered-top { + border-top: solid #dcdcdc .5px +} + +.docs .container h3 { + margin: 30px 0 0 0; + font-size: 12px; +} + +.docs .container h3 + p { + margin-top: 0.5em; +} + +.docs .container a { + text-decoration: underline; +} + +.get_flash { + background: url('/img/get_flashplayer.gif'); + width: 160px; + display: block; + margin: 0 auto; + text-indent: -9999px; + background-repeat: no-repeat; + height: 41px; +} + +.get_flash:active { + background-position: 0 2px; +} + +.docs .container dl dt { + font-weight: bold; + font-size: 12px; +} +.docs .container dl dd { + padding-bottom: 10px; + margin: 0.5em 0 0 0; +} + +.docs .no_flash, +.docs .flash_installed { + background: #f7f7f7; + border-radius: 5px; + padding: 15px 20px; + text-align: center; + font-size: 15px; + color: #4a4a4a; +} diff --git a/src/components/homepage.jsx b/src/components/homepage.jsx new file mode 100644 index 000000000..4d5e0b5ed --- /dev/null +++ b/src/components/homepage.jsx @@ -0,0 +1,99 @@ +import React from 'react' +import Header from './header' +import './homepage.scss' + +const Homepage = () => ( + <> +
    +
    +
    +
    +
    +
    +

    Create Engaging Apps!

    +
    +

    With Materia, you have the power to create effective, interactive learning tools called Widgets.

    +

    Browse our catalog and begin customizing in seconds. Widgets can be tailored to suit your needs, no matter the subject matter.

    +

    Best of all, widgets can be embedded directly in your LMS to enhance your online course.

    +

    + Get Started +

    +
    + homepage final banner design for materia +
    +
    +
    +
    +
    + +
    + + +
    +

    Materia is Open Source!

    +

    + Use Materia at your organization through UCF Open. +

    +

    + Get Materia +

    +
    +
    + +
    +
    +
    +
    +

    Create Quickly and Easily

    +

    + Materia's design philosophy is to be incredibly easy to use. + Every step of customizing and delivering apps has been finely tuned to be as clear and simple as possible. + Players are greeted with clean and simple interfaces. + We aim to get out of the way so your content can engage with students as quickly and clearly as possible. +

    +
    + screen shot of creating a crossword widget +
    +
    +
    +

    Engage Your Students

    +

    + Re-imagine your course filled with diverse and interesting experiences. + It can bring life to content modules, practice, study activities, and even assessments. + Engage students with game mechanics like: story-telling, competition, instant feedback, and instant reward systems. +

    +
    + screen shot of a sort it out widget +
    +
    +
    +

    Integrate with Your Course

    +

    + Materia integrates into Canvas seamlessly. + As an assignment, student's scores can automatically sync to the grade book. + Thanks to the magic of LTI, Students are logged in automatically! +

    +
    + screen shot of a widget score page +
    +
    + +
    +

    Built at UCF, for Everyone

    +

    + Materia is an open source project built by the University of Central Florida's Center for Distributed Learning. + Our team is a truly unique group of experts working directly with faculty and students to build enjoyable tools for teaching and learning. +

    +

    + We're committed to building a better tomorrow through better learning tools, so our team is constantly improving and re-inventing Materia. + If you have an idea for a new widget or simply would like to give us feedback, we'd love to hear from you on Github. +

    +

    + © {new Date().getFullYear()} University of Central Florida +

    +
    +
    + +) + +export default Homepage diff --git a/src/components/homepage.scss b/src/components/homepage.scss new file mode 100644 index 000000000..62f3be0c9 --- /dev/null +++ b/src/components/homepage.scss @@ -0,0 +1,321 @@ +@import 'include.scss'; + +.spotlight { + + .main_container { + position: relative; + // width: 100vw; + width: 100%; + height: 600px; + margin: 0 auto; + + background: #fff; + text-align: left; + + .store_main { + position: absolute; + left: 0; + display: block; + width: 100%; + padding: 0px; + transition: all 0.3s linear; + + p { + margin-left: 50%; + color: #000; + font-size: calc(16px + (24 - 16) * ((100vw - 300px) / (1600 - 300))); + line-height: 160%; + } + + section { + left: calc(50% - 50vw); + width: 98vw; + height: fit-content; + + padding: 30px 0px 100px; + overflow: hidden; + + background: linear-gradient(to right, rgba(255,255,255, 0), rgba(255, 255, 255,1) 55%), url(/img/banner_final.png) no-repeat; + background-size: contain; + + text-align: right; + + .store_content { + margin-right: 40px; + height: 400px; + + p { + text-align: left; + } + + h1 span.engage { + color: #0093e7; + } + + .mobile_spotlight_banner { + visibility: hidden; + } + } + } + } + } + + h1 { + font-weight: 500; + font-size: calc(35px + (40 - 35) * ((100vw - 300px) / (1600 - 300))); + margin: 0; + padding: 0 0 10px 0; + } + + a.more { + position: absolute; + top: 270px; + text-decoration: underline; + font-size: 19px; + font-weight: 700; + } + + @media (max-width: 800px) { + .main_container { + height: 600px; + + .store_main { + section { + background: none; + text-align: center; + overflow: hidden; + padding: 30px 0px 300px; + + .store_content { + margin: 0 40px; + + .mobile_spotlight_banner { + visibility: visible; + width: calc(50% - 40px); + } + } + } + + p { + margin-left: 0px; + } + } + } + } +} + +.front_bottom { + display: grid; + width: 100%; + background-color: #fbfbfb; + margin-bottom: 0; + padding-top: 65px; + + .wrapper { + max-width: 1400px; + margin: 0 auto; + } + div { + font-weight: 300; + font-size: 14px; + color: #000; + + margin: 10px 0px 20px 0px; + padding: 0; + text-align: center; + display: grid; + + div.wrapper_content { + margin: 0 0 20px 0; + } + + h2 { + margin-bottom: 6px; + font-size: 30px; + font-weight: 500; + color: #000; + border-bottom: dotted 1px #666; + } + + &.wrapper_first, &.wrapper_second, &.wrapper_third { + margin: 0 10% 20px 10%; + justify-items: center; + } + + &.wrapper_first, &.wrapper_third { + p { + margin-right: 25px; + } + } + + &.wrapper_second { + p { + margin-left: 25px; + } + } + + p { + font-size: 16px; + line-height: 150%; + } + } + + img { + max-width: 290px; + padding: 5px; + border: dotted 1px #666; + margin-top: 20px; + } + img:nth-of-type(2) { + float: left; + } +} +@media only screen and (min-width: 900px) { + + .p_s { + p { + text-align: left !important; + } + } + .front_bottom { + div { + margin: 0 0 20px 0; + // margin: 0px 110px 20px 50px; + text-align: left; + + &.wrapper_first, &.wrapper_second, &.wrapper_third { + display: inline-flex; + align-items: center; + } + + &.wrapper_second { + // margin: 0px 50px 20px 110px; + flex-direction: row-reverse; + + div { + text-align: right; + } + + img { + float: left; + } + } + } + } +} +.p_s { + max-width: 1400px; + text-align: center; + margin: 30px 10%; + + h2 { + font-size: 30px; + font-weight: 400; + color: #000; + } + p { + color: #000; + line-height: 150%; + font-weight: 300; + font-size: 16px; + text-align: center; + + &.copyright { + text-align: center !important; + margin: 150px auto 0; + padding-bottom: 30px; + } + } +} +@media (max-width: 700px) { + .get_started { + height: 280px !important; + img { + margin-top: calc(2% + 100px) !important; + right: calc(53% - 50vw) !important; + } + + h1 { + &.subHeader { + margin-left: 20px !important; + } + } + + p { + &.desc, &.button_wrap { + margin-left: 20px; + } + } + } +} + +@media (max-width: 370px) { + .get_started { + h1 { + margin-top: 30px !important; + } + } + .action_button { + font-size: calc(12px + (16 - 12) * ((100vw - 300px) / (1600 - 300))); + } +} + +.get_started { + font-size: 24px; + font-weight: 900; + color: #474747; + padding: 0; + vertical-align: middle; + width: 100%; + height: 240px; + background-image: linear-gradient(115deg, #3690E6 60%, #37A9E6 40%); + + img { + width: 30%; + max-width: 300px; + right: calc(55% - 50vw); + margin-top: 65px; + position: absolute; + } + + div { + display: grid; + } + h1 { + display: inline-block; + vertical-align: middle; + padding: 0; + margin-top: 50px; + margin-bottom: 0px; + + &.subHeader { + font-size: calc(30px + (40 - 30) * ((100vw - 300px) / (1600 - 300))); + font-weight: 400; + color: #ffffff; + text-align: left; + width: 50%; + float: left; + margin-left: 50px; + } + } + + p { + font-size: calc(16px + (24 - 16) * ((100vw - 300px) / (1600 - 300))); + display: inline-block; + vertical-align: middle; + padding: 0; + float: left; + margin-left: 50px; + + &.desc { + font-weight: 300; + color: #ffffff; + text-align: left; + width: 50%; + margin-bottom: 0px; + } + .bold { + font-weight: 700; + } + } +} \ No newline at end of file diff --git a/src/components/hooks/useCopyWidget.jsx b/src/components/hooks/useCopyWidget.jsx new file mode 100644 index 000000000..61811706a --- /dev/null +++ b/src/components/hooks/useCopyWidget.jsx @@ -0,0 +1,66 @@ +import { useMutation, useQueryClient } from 'react-query' +import { apiCopyWidget } from '../../util/api' + +/** + * It optimistically updates the cache value on mutate + * @returns The mutation function and the result of the mutation + */ +export default function useCopyWidget() { + const queryClient = useQueryClient() + + // Optimistically updates the cache value on mutate + return useMutation( + apiCopyWidget, + { + onMutate: async inst => { + await queryClient.cancelQueries('widgets', { exact: true, active: true, }) + const previousValue = queryClient.getQueryData('widgets') + + // dummy data that's appended to the query cache as an optimistic update + // this will be replaced with actual data returned from the API + const newInst = { + id: 'tmp', + widget: { + name: inst.widgetName, + dir: inst.dir + }, + name: inst.title, + is_draft: false, + is_fake: true + } + + // setQueryClient must treat the query cache as immutable!!! + // previous will contain the cached value, the function argument creates a new object from previous + queryClient.setQueryData('widgets', (previous) => ({ + ...previous, + pages: previous.pages.map((page, index) => { + if (index == 0) return { ...page, pagination: [ newInst, ...page.pagination] } + else return page + }) + })) + + return { previousValue } + }, + onSuccess: (data, variables) => { + // update the query cache, which previously contained a dummy instance, with the real instance info + queryClient.setQueryData('widgets', (previous) => ({ + ...previous, + pages: previous.pages.map((page, index) => { + if (index == 0) return { ...page, pagination: page.pagination.map((inst) => { + if (inst.id == 'tmp') inst = data + return inst + }) } + else return page + }) + })) + variables.successFunc(data) + }, + onError: (err, newWidget, context) => { + console.error(err) + queryClient.setQueryData('widgets', (previous) => { + return context.previousValue + }) + } + } + ) +} diff --git a/src/components/hooks/useCreatePlaySession.jsx b/src/components/hooks/useCreatePlaySession.jsx new file mode 100644 index 000000000..2264cfc39 --- /dev/null +++ b/src/components/hooks/useCreatePlaySession.jsx @@ -0,0 +1,22 @@ +import { useMutation } from 'react-query' +import { apiGetPlaySession } from '../../util/api' + + +export default function useCreatePlaySession() { + return useMutation( + apiGetPlaySession, + { + onSettled: (data, error, widgetData) => { + if (!!data) { + widgetData.successFunc(data) + } + else if (data === null) { + alert('Error: Widget demo failed to load content : is fatal') + } + else { + console.error(`failed to create play session with data: ${data}`) + } + } + } + ) +} diff --git a/src/components/hooks/useDebounce.jsx b/src/components/hooks/useDebounce.jsx new file mode 100644 index 000000000..fd000de99 --- /dev/null +++ b/src/components/hooks/useDebounce.jsx @@ -0,0 +1,19 @@ +import { useState, useEffect } from 'react' + +// Wait for delay then sets value +export default function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + // Updates the debounce after the user stops typing + return () => { + clearTimeout(handler) + } + }, [value]) + + return debouncedValue +} diff --git a/src/components/hooks/useDeleteNotification.jsx b/src/components/hooks/useDeleteNotification.jsx new file mode 100644 index 000000000..cd4692988 --- /dev/null +++ b/src/components/hooks/useDeleteNotification.jsx @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from 'react-query' +import { apiDeleteNotification } from '../../util/api' + +export default function useDeleteNotification() { + const queryClient = useQueryClient() + + return useMutation( + apiDeleteNotification, + { + // Handles the optomistic update for deleting a Notification + onMutate: async data => { + await queryClient.cancelQueries('notifications') + + const previousValue = queryClient.getQueryData('notifications') + + if (data.deleteAll) + { + queryClient.setQueryData('notifications', []) + } + else + { + queryClient.setQueryData('notifications', old => old.filter(notif => notif.id != data.delID)) + } + + // Stores the old value for use if there is an error + return { previousValue } + }, + onSuccess: (data, variables) => { + // queryClient.invalidateQueries('notifications') + if (data) variables.successFunc(data); + }, + onError: (err, newWidget, context) => { + queryClient.setQueryData('notifications', context.previousValue) + } + } + ) +} diff --git a/src/components/hooks/useDeleteWidget.jsx b/src/components/hooks/useDeleteWidget.jsx new file mode 100644 index 000000000..036b564cc --- /dev/null +++ b/src/components/hooks/useDeleteWidget.jsx @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from 'react-query' +import { apiDeleteWidget } from '../../util/api' + +export default function useDeleteWidget() { + const queryClient = useQueryClient() + + return useMutation( + apiDeleteWidget, + { + // Handles the optimistic update for deleting a widget + onMutate: async inst => { + await queryClient.cancelQueries('widgets') + const previousValue = queryClient.getQueryData('widgets') + + queryClient.setQueryData('widgets', previous => { + if (!previous || !previous.pages) return previous + return { + ...previous, + pages: previous.pages.map((page) => ({ + ...page, + pagination: page.pagination.filter(widget => widget.id !== inst.instId) + })) + } + }) + + // Stores the old value for use if there is an error + return { previousValue } + }, + onSuccess: (data, variables) => { + variables.successFunc(data) + }, + onError: (err, newWidget, context) => { + console.error(err) + queryClient.setQueryData('widgets', (previous) => { + return context.previousValue + }) + } + } + ) +} diff --git a/src/components/hooks/useFetchQueryData.jsx b/src/components/hooks/useFetchQueryData.jsx new file mode 100644 index 000000000..b534a7e77 --- /dev/null +++ b/src/components/hooks/useFetchQueryData.jsx @@ -0,0 +1,7 @@ +import { useQueryClient } from "react-query"; + +export const useFetchQueryData = (key) => { + const queryClient = useQueryClient(); + + return queryClient.getQueryData(key); +}; \ No newline at end of file diff --git a/src/components/hooks/useGetUsers.jsx b/src/components/hooks/useGetUsers.jsx new file mode 100644 index 000000000..dcca24bb6 --- /dev/null +++ b/src/components/hooks/useGetUsers.jsx @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from 'react-query' +import { apiGetUsers } from '../../util/api' + +export default function useGetUsers() { + const queryClient = useQueryClient() + + return useMutation( + { + mutationFn: (variables) => { + return apiGetUsers(variables.userIds) + }, + onSuccess: (data, variables) => { + variables.successFunc(data) + }, + onError: (err) => { + variables.errorFunc(err) + } + } + ) +} diff --git a/src/components/hooks/useInstanceList.jsx b/src/components/hooks/useInstanceList.jsx new file mode 100644 index 000000000..41577b6d2 --- /dev/null +++ b/src/components/hooks/useInstanceList.jsx @@ -0,0 +1,75 @@ +import { useState, useEffect, useMemo } from 'react' +import { useInfiniteQuery } from 'react-query' +import { apiGetWidgetInstances } from '../../util/api' +import { iconUrl } from '../../util/icon-url' + +export default function useInstanceList() { + + const [errorState, setErrorState] = useState(false) + + // Helper function to sort widgets + const _compareWidgets = (a, b) => { return (b.created_at - a.created_at) } + + // transforms data object returned from infinite query into one we can use in the my-widgets-page component + // this creates a flat list of instances from the paginated list that's subsequently sorted + const formatData = (list) => { + if (list?.type == 'error') { + console.error(`Widget instances failed to load with error: ${list.msg}`); + setErrorState(true) + return [] + } + if (list?.pages) { + let dataMap = [] + return [ + ...dataMap.concat( + ...list.pages.map(page => page.pagination.map(instance => { + // adding an 'img' property to widget instance objects for continued + // compatibility with any downstream LTIs using the widget picker + return { + ...instance, + img: iconUrl(BASE_URL + 'widget/', instance.widget.dir, 275) + } + })) + ) + ].sort(_compareWidgets) + } else return [] + } + + const getWidgetInstances = ({ pageParam = 0}) => { + return apiGetWidgetInstances(pageParam) + } + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + status, + refetch + } = useInfiniteQuery({ + queryKey: ['widgets'], + queryFn: getWidgetInstances, + getNextPageParam: (lastPage, pages) => lastPage.next_page, + refetchOnWindowFocus: false + }) + + useEffect(() => { + if (error != null && error != undefined) setErrorState(true) + },[error]) + + // memoize the instance list since this is a large, expensive query + const instances = useMemo(() => formatData(data), [data]) + + useEffect(() => { + if (hasNextPage) fetchNextPage() + },[instances]) + + return { + instances: instances, + isFetching: isFetching || hasNextPage, + refresh: () => refetch(), + ...(errorState == true ? {error: true} : {}) // the error value is only provided if errorState is true + } +} diff --git a/src/components/hooks/useKonamiCode.jsx b/src/components/hooks/useKonamiCode.jsx new file mode 100644 index 000000000..574aa82a9 --- /dev/null +++ b/src/components/hooks/useKonamiCode.jsx @@ -0,0 +1,46 @@ +import { useState, useEffect } from 'react'; + +// Helper function to compare two arrays +const compareArr = (arr1, arr2) => { + if (arr1.length !== arr2.length) return false + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) return false + } + return true +} + +export default function useKonamiCode() { + const [validCode, setValidCode] = useState(false) + const [currentCode, setCurrentCode] = useState([]) + const konamiCode = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65] + + // Adds and cleansup event listener + useEffect(() => { + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + // Detects correct code when code entered changes + useEffect(() => { + if (compareArr(currentCode, konamiCode)) setValidCode(true) + else if (validCode) setValidCode(false) + }, [currentCode]) + + const onKeyDown = (e) => { + if (konamiCode.includes(e.keyCode)) { + // Guaruntees non-stale state + setCurrentCode((oldCode) => { + const tmpCode = [...oldCode] + if (oldCode.length >= konamiCode.length) tmpCode.shift() + tmpCode.push(e.keyCode) + return tmpCode + }) + } + else if (currentCode.length > 0) { + // Clears the code if an invalid char was used + setCurrentCode([]) + } + } + + return validCode +} \ No newline at end of file diff --git a/src/components/hooks/usePlayLogSave.jsx b/src/components/hooks/usePlayLogSave.jsx new file mode 100644 index 000000000..b8e9954c2 --- /dev/null +++ b/src/components/hooks/usePlayLogSave.jsx @@ -0,0 +1,18 @@ +import { useMutation } from 'react-query' +import { apiSavePlayLogs } from '../../util/api' + +export default function usePlayLogSave() { + return useMutation( + apiSavePlayLogs, + { + onSettled: (data, error, widgetData) => { + if (!!data) { + widgetData.successFunc(data) + } + else { + widgetData.failureFunc() + } + } + } + ) +} diff --git a/src/components/hooks/usePlayStorageDataSave.jsx b/src/components/hooks/usePlayStorageDataSave.jsx new file mode 100644 index 000000000..1641a7e21 --- /dev/null +++ b/src/components/hooks/usePlayStorageDataSave.jsx @@ -0,0 +1,13 @@ +import { useMutation } from 'react-query' +import { apiSavePlayStorage } from '../../util/api' + +export default function usePlayStorageDataSave() { + return useMutation( + apiSavePlayStorage, + { + onSettled: (data, error, widgetData) => { + widgetData.successFunc(data) + } + } + ) +} diff --git a/src/components/hooks/useSetAttempts.jsx b/src/components/hooks/useSetAttempts.jsx new file mode 100644 index 000000000..2c5e1af98 --- /dev/null +++ b/src/components/hooks/useSetAttempts.jsx @@ -0,0 +1,11 @@ +import { useMutation } from 'react-query' +import { apiSetAttempts } from '../../util/api' + +export default function useSetAttempts() { + return useMutation( + apiSetAttempts, + { + onError: () => console.error('failed to update extra attempts') + } + ) +} diff --git a/src/components/hooks/useSetUserInstancePerms.jsx b/src/components/hooks/useSetUserInstancePerms.jsx new file mode 100644 index 000000000..ba13d59da --- /dev/null +++ b/src/components/hooks/useSetUserInstancePerms.jsx @@ -0,0 +1,15 @@ +import { useMutation } from 'react-query' +import { apiSetUserInstancePerms } from '../../util/api' + +export default function setUserInstancePerms() { + return useMutation( + apiSetUserInstancePerms, + { + onSuccess: (data, variables) => + { + variables.successFunc(data) + }, + onError: () => console.error('failed to update user perms') + } + ) +} diff --git a/src/components/hooks/useSupportCopyWidget.jsx b/src/components/hooks/useSupportCopyWidget.jsx new file mode 100644 index 000000000..743b2a9bd --- /dev/null +++ b/src/components/hooks/useSupportCopyWidget.jsx @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from 'react-query' +import { apiCopyWidget } from '../../util/api' + +export default function useSupportCopyWidget() { + const queryClient = useQueryClient() + + return useMutation( + apiCopyWidget, + { + onSuccess: (data, variables) => { + variables.successFunc(data) + queryClient.removeQueries('search-widgets', { + exact: false + }) + } + } + ) +} diff --git a/src/components/hooks/useSupportDeleteWidget.jsx b/src/components/hooks/useSupportDeleteWidget.jsx new file mode 100644 index 000000000..07e76fbec --- /dev/null +++ b/src/components/hooks/useSupportDeleteWidget.jsx @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from 'react-query' +import { apiDeleteWidget } from '../../util/api' + +export default function useSupportDeleteWidget() { + const queryClient = useQueryClient() + + return useMutation( + apiDeleteWidget, + { + onSuccess: (data, variables) => { + if (data !== null) { + variables.successFunc() + queryClient.invalidateQueries('widgets') + } + else { + console.error('failed to delete widget') + } + }, + onError: () => console.error('Failed to delete widget on backend') + } + ) +} diff --git a/src/components/hooks/useSupportUnDeleteWidget.jsx b/src/components/hooks/useSupportUnDeleteWidget.jsx new file mode 100644 index 000000000..5d481d76e --- /dev/null +++ b/src/components/hooks/useSupportUnDeleteWidget.jsx @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from 'react-query' +import { apiUnDeleteWidget } from '../../util/api' + +export default function useSupportUnDeleteWidget() { + const queryClient = useQueryClient() + + return useMutation( + apiUnDeleteWidget, + { + onSuccess: (data, variables) => { + if (data !== null) { + variables.successFunc() + queryClient.removeQueries('search-widgets', { + exact: false + }) + } + else { + console.error('failed to undelete widget') + } + }, + onError: () => console.error('Failed to undelete widget on backend') + } + ) +} diff --git a/src/components/hooks/useSupportUpdateWidget.jsx b/src/components/hooks/useSupportUpdateWidget.jsx new file mode 100644 index 000000000..5359721d3 --- /dev/null +++ b/src/components/hooks/useSupportUpdateWidget.jsx @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from 'react-query' +import { apiUpdateWidget } from '../../util/api' + +export default function useSupportUpdateWidget() { + const queryClient = useQueryClient() + + // Optimistically updates the cache value on mutate + return useMutation( + apiUpdateWidget, + { + onSuccess: (data, variables) => { + variables.successFunc() + + // Refresh widgets + queryClient.invalidateQueries('widgets') + + queryClient.removeQueries('search-widgets', { + exact: false + }) + }, + onError: (err, newWidget, context) => { + queryClient.setQueryData('widgets', context.previousValue) + + variables.errorFunc() + } + } + ) +} diff --git a/src/components/hooks/useUpdateUserRoles.jsx b/src/components/hooks/useUpdateUserRoles.jsx new file mode 100644 index 000000000..38ff42294 --- /dev/null +++ b/src/components/hooks/useUpdateUserRoles.jsx @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from 'react-query' +import { apiUpdateUserRoles } from '../../util/api' + +export default function useUpdateUserRoles() { + + const queryClient = useQueryClient() + + return useMutation( + apiUpdateUserRoles, + { + onMutate: async roles => { + await queryClient.cancelQueries('search-users') + const val = {...queryClient.getQueryData('search-users')} + const prior = queryClient.getQueryData('search-users') + + queryClient.setQueryData('search-users', () => val) + + return { prior } + }, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries('search-users') + variables.successFunc(data) + }, + onError: (err, newRoles, context) => { + queryClient.setQueryData('search-users', context.previousValue) + return err + } + } + ) +} \ No newline at end of file diff --git a/src/components/hooks/useUpdateUserSettings.jsx b/src/components/hooks/useUpdateUserSettings.jsx new file mode 100644 index 000000000..b3d4a04d6 --- /dev/null +++ b/src/components/hooks/useUpdateUserSettings.jsx @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from 'react-query' +import { apiUpdateUserSettings } from '../../util/api' + +export default function useUpdateUserSettings() { + + const queryClient = useQueryClient() + + return useMutation( + apiUpdateUserSettings, + { + onMutate: async settings => { + await queryClient.cancelQueries('user') + const val = {...queryClient.getQueryData('user')} + const prior = queryClient.getQueryData('user') + + queryClient.setQueryData('user', () => val) + + return { prior } + }, + onSuccess: (data, newSettings, context) => { + queryClient.invalidateQueries('user') + + }, + onError: (err, newSettings, context) => { + queryClient.setQueryData('user', context.previousValue) + } + } + ) +} \ No newline at end of file diff --git a/src/components/hooks/useUpdateWidget.jsx b/src/components/hooks/useUpdateWidget.jsx new file mode 100644 index 000000000..39321e331 --- /dev/null +++ b/src/components/hooks/useUpdateWidget.jsx @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from 'react-query' +import { apiUpdateWidget } from '../../util/api' + +export default function useUpdateWidget() { + const queryClient = useQueryClient() + + let widgetList = null + + // Optimistically updates the cache value on mutate + return useMutation( + apiUpdateWidget, + { + onMutate: async formData => { + // cancel any in-progress queries and grab the current query cache for widgets + await queryClient.cancelQueries('widgets') + widgetList = queryClient.getQueryData('widgets') + + // widgetList is passed to onSuccess or onError depending on resolution of mutation function + return { widgetList } + }, + onSuccess: (updatedInst, variables) => { + + // update successful - insert new values into our local copy of widgetList + for (const page of widgetList?.pages) { + for (const inst of page?.pagination) { + if (inst.id === variables.args[0]) { + inst.open_at = `${variables.args[4]}` + inst.close_at = `${variables.args[5]}` + inst.attempts = `${variables.args[6]}` + inst.guest_access = variables.args[7] + inst.embedded_only = variables.args[8] + break + } + } + } + + + // update query cache for widgets. This does NOT invalidate the cache, forcing a re-fetch!! + queryClient.setQueryData('widgets', widgetList) + queryClient.invalidateQueries(['user-perms', variables.args[0]]) + + variables.successFunc(updatedInst) + }, + onError: (err, variables, previous) => { + // write previously intact widget list into the query cache. This should be the same data as before. + queryClient.setQueryData('widgets', previous) + variables.successFunc(null) + } + } + ) +} diff --git a/src/components/hooks/useWindowSize.jsx b/src/components/hooks/useWindowSize.jsx new file mode 100644 index 000000000..890b02ed6 --- /dev/null +++ b/src/components/hooks/useWindowSize.jsx @@ -0,0 +1,14 @@ +import { useLayoutEffect, useState } from 'react'; + +export default function useWindowSize() { + const [size, setSize] = useState([0, 0]) + useLayoutEffect(() => { + function updateSize() { + setSize([window.innerWidth, window.innerHeight]) + } + window.addEventListener('resize', updateSize) + updateSize() + return () => window.removeEventListener('resize', updateSize) + }, []) + return size +} \ No newline at end of file diff --git a/src/components/include.scss b/src/components/include.scss new file mode 100644 index 000000000..1f1539917 --- /dev/null +++ b/src/components/include.scss @@ -0,0 +1,883 @@ + +$color-features: #0093e7; +$color-features-active: #0357a5; +$color-red: #ca3b3b; +$color-yellow: #ffd439; +$color-yellow-hover: #ffc904; +$color-orange: #ffba5d; +$color-green: #c5dd60; +$color-purple: #b944cc; + +$very-light-gray: #ececec; +$light-gray: #dadada; +$gray: #9a9a9a; +$extremely-dark-gray: #333; + + +body, +html { + margin: 0; + padding: 0; + font-size: 16px; +} + +body { + font-family: 'Lato', arial, sans-serif; + font-weight: 300; + overflow: auto; +} + +.action_button { + padding: 7px 23px 8px 23px; + background-color: $color-yellow; + transition: all 0.4s ease-in-out; + font-weight: 700; + color: $extremely-dark-gray; + font-size: 18px; + position: relative; + cursor: pointer; + display: inline-block; + user-select: none; + + border: none; + border-radius: 4px; + + &.green { + background: #c4dd61; + color: #525252; + + &:hover { + background: #d5ea7f; + } + } + + &:hover { + background-color: #ffc904; + color: #000; + box-shadow: 0px 1px 3px #979696; + text-decoration: none; + } + + .little-button-text { + font-size: 18px; + display: block; + } +} + +a { + color: #0093e7; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + img { + border: none; + } +} + +header { + position: relative; + z-index: 1000; + + padding: 35px 0 0 136px; + margin: 0 0 25px 0; + display: block; + + height: 49px; + + &.logged_in { + background: #fff url(/img/header_border_alt.png) 0% 100% repeat-x; + } + + ul, + p { + margin: 0; + padding: 0; + } + + h1.logo { + position: absolute; + left: 0; + top: 0; + margin: 0; + overflow: hidden; + + a { + display: block; + width: 100px; + height: 0; + padding: 60px 0 0 10px; + margin: 10px 0 0 10px; + background: url(/img/retina/materia_tmp_logo@2x.png) 0% 0% no-repeat; + background-size: 160px 68px; + } + } + + &#loginLink { + top: auto; + } + + .profile-bar + { + position: absolute; + top: 45px; + right: 30px; + display: flex; + flex-direction: row; + gap: 10px; + font-size: 14px; + + .profile-bar-options + { + display: flex; + flex-direction: column; + align-items: end; + } + + img:not(.noticeClose) { + width: 35px; + height: 35px; + border-radius: 2px; + + &:hover:not(.senderAvatar) { + cursor: pointer; + } + } + + a { + font-size: 14px; + font-weight: 300; + color: #4c4c4c; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2em; + max-width: 150px; + + &:hover { + text-decoration: underline; + cursor: pointer; + } + } + } + + // Not being used but kept just in case + .profile-menu + { + visibility: hidden; + position: absolute; + top: 100px; + right: 28px; + text-align: right; + background-color: white; + box-shadow: 1px 10px 10px 1px rgba(0,0,0,0.1); + padding: 15px 15px 15px 25px; + border: 1px solid white; + border-radius: 5px; + + ul { + display: flex; + flex-direction: column; + gap: 10px; + + li { + display: inline; + max-width: 100px; + + span { + font-size: 12px; + } + + a { + font-size: 14px; + font-weight: 100; + margin: 0; + padding: 0; + color: #4c4c4c; + transition: all 0.2s linear; + } + + a:hover { + border: none; + border-bottom: 2px solid #0093E7; + padding: 5px 0; + text-decoration: none; + } + } + } + + &.show + { + visibility: visible; + } + .arrow-top { + border-left: 15px solid transparent; + border-right: 15px solid transparent; + border-bottom: 15px solid white; + position: absolute; + right: 5px; + top: -10px; + } + } + + &.logged-in { + .user.avatar { + right: 20px; + } + } + + &.logged-out { + .user.avatar { + right: 35px; + } + } + + nav { + display: block; + margin-top: 16px; + + ul { + li { + display: inline; + padding: 0 10px 0 0; + + .logout a { + display: none; + position: absolute; + right: 20px; + bottom: 13px; + font-size: 14px; + } + + a { + font-size: 14px; + font-weight: 300; + margin: 0; + padding: 0; + } + } + } + } + + a { + color: #0093e7; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + img { + border: none; + } + } + + #mobile-menu-toggle { + display: none; + } + + .elevated { + color: #ffa52b; + } + + .nav_expandable { + color: #0093e7; + font-size: 17px; + font-weight: bold; + height: 27px; + position: relative; + margin-left: 20px; + + ul { + display: none; + background-color: #ffffff; + padding: 0; + position: absolute; + bottom: -150%; + left: -10px; + border-left: 1px solid #d3d3d3; + border-right: 1px solid #d3d3d3; + border-bottom: 1px solid #d3d3d3; + } + + &:hover > span { + display: inline-block; + height: 27px; + + &.admin { + padding-right: 165px; + } + + &.support { + padding-right: 75px; + } + } + + &:hover > ul { + display: flex; + + li { + padding: 0 10px 5px; + height: auto; + } + } + } + + #notices { + position:absolute; + display: flex; + flex-direction: column; + right: 20px; + top: 45px; + width: 400px; + box-shadow: 1px 10px 10px 1px rgba(0,0,0,0.1); + background-color: #fff; + border-radius: 3px; + overflow: auto; + max-height: 500px; + + h2 + { + color: rgba(0,0,0,0.3); + padding: 10px; + font-size: 1em; + } + } + .notice { + line-height: normal; + padding: 10px; + display: flex; + flex-direction: row; + // align-items: center; + gap: 10px; + + &:hover{ + background-color: rgb(250,250,250); + } + + .senderAvatar { + width: 50px; + height: 50px; + } + } + .notice.deleted { + background-color:red !important + } + #notifications_link { + cursor:pointer; + width: 45px; + height: 31px; + border: none; + background:url(../../../img/envelope.svg) 0% 0% no-repeat; + padding: 0; + box-sizing: border-box; + &:hover { + box-shadow: 0px 0px 5px 0px grey; + } + } + #notifications_link.notEmpty:after { + content: attr(data-notifications); + border-radius: 50%; + border: 1px solid red; + color: white; + display: inline-block; + width: 15px; + height: 15px; + background-color: red; + position: absolute; + left: 35px; + bottom: -5px; + font-size: 0.9em; + } + .noticeClose + { + border: none; + padding: 2px; + width: 20px; + height: 20px; + flex-shrink: 0; + + visibility: hidden; + &.show { + visibility: visible; + } + + &:hover { + cursor: pointer; + filter: invert(0.5) sepia(1) saturate(80) hue-rotate(340deg); + } + } + .notice_right_side + { + display: flex; + flex-direction: column; + gap: 5px; + + .subject { + line-height: 1.5em; + + a { + font-weight: bold; + text-decoration: underline; + } + } + } + .grantAccessTitle + { + margin-top: 5px; + margin-bottom: 0px; + padding: 5px 0; + border-top: 1px solid gray; + } + .notif-date + { + font-size: 12px; + color: rgb(100, 100, 100); + padding-top: 5px; + } + .mobile-notifications + { + display: none; + } + #removeAllNotifications + { + color:#a2a2a2; + overflow: visible; + padding: 10px; + align-self: end; + &:hover { + color: red; + } + } +} + +// mobile header +@media (max-width: 720px) { + header { + min-height: 49px; + height: auto; + // overflow: hidden; + padding: 0; + margin-bottom: 8px; + + h1.logo { + display: inline-block; + position: static; + } + + // mobile hamburger menu + #mobile-menu-toggle { + display: block; + position: absolute; + right: 15px; + top: 25px; + height: 35px; + width: 37px; + border: none; + padding: 2px 5px; + border-radius: 3px; + text-align: left; + background: none; + box-shadow: none; + + cursor: pointer; + + &.expanded { + ~ nav { + visibility: visible; + max-height: 175px; + margin-bottom: 25px; + opacity: 1; + width: 100%; + box-sizing: border-box; + } + + div { + transform: rotate(50deg) translate(6px, -8px); + width: 18px; + &:before { + opacity: 0; + width: 0; + transform: rotate(-50deg) translate(-6px, 8px); + } + &:after { + transform: rotate(-100deg) translate(0, -7px); + width: 18px; + } + } + } + + div { + transition: all 300ms ease; + height: 2px; + width: 25px; + background: #333; + position: relative; + + &:before, + &:after { + transition: all 300ms ease; + content: ' '; + height: 2px; + width: 25px; + background: #333; + position: absolute; + } + + &:before { + top: -8px; + } + + &:after { + top: 8px; + } + } + } + + .profile-bar { + gap: 20px; + right: 75px; + top: 25px; + + .desktop-notifications { + display: none; + } + + .profile-bar-options + { + display: none; + } + } + + .mobile-notifications { + display: block; + + #notifications_link { + margin-top: 5px; + position: absolute; + top: 25px; + right: 130px; + } + } + + nav { + visibility: hidden; + opacity: 0; + max-height: 0; + padding: 0 20px; + text-align: right; + transition: all ease 500ms; + + ul { + li { + display: block; + padding: 5px; + + &.nav_expandable { + &:hover ul li { + display: inline-block; + padding: 0 10px 5px; + height: auto; + } + + &:hover > span { + &.admin { + padding-right: 0; + } + + &.support { + padding-right: 0; + } + } + + ul { + top: 0; + bottom: 0; + right: 75px; + left: auto; + border-bottom: none; + border-left: none; + padding: 5px; + } + } + + .logout a { + display: inline; + font-size: 17px; + position: static; + } + + a { + font-size: 14px; + font-weight: 300; + margin: 0; + padding: 0; + } + } + } + } + .notifContainer + { + position: static; + right: auto; + top: auto; + + #notices { + top: auto; + right: auto; + width: 100%; + overflow: hidden; + max-height: inherit; + } + .noticeClose + { + visibility: visible; + } + } + + .profile-menu { + display: none; + } + + #loginLink { + top: 7.5px; + position: absolute; + right: 0; + } + } +} + +nav { + display: block; + + ul { + li { + display: inline; + padding: 0 20px 0 0; + + .logout a { + display: none; + position: absolute; + right: 20px; + bottom: 13px; + font-size: 14px; + } + + a { + font-size: 14px; + font-weight: 100; + margin: 0; + padding: 0; + color: #4c4c4c; + transition: all 0.2s linear; + } + + a:hover { + border: none; + border-bottom: 2px solid #0093E7; + padding: 5px 0; + color: #000; + text-decoration: none; + } + } + } +} + +h1.logo { + position: absolute; + left: 0; + top: 0; + margin: 0; + overflow: hidden; + + a { + display: block; + width: 100px; + height: 0; + padding: 60px 0 0 10px; + margin: 10px 0 0 10px; + background: url(/img/materia_tmp_logo.png) 0% 0% no-repeat; + } +} + +@media only screen and (min-device-pixel-ratio: 2), + only screen and (-webkit-min-device-pixel-ratio: 2) { + h1.logo a { + background: url(/img/retina/materia_tmp_logo@2x.png) 0% 0% no-repeat; + background-size: 160px 68px; + } + + header { + background: #fff; + background-size: 12px 6px; + } +} + +.action_button { + padding: 7px 23px 8px 23px; + background-color: #ffd439; + transition: all 0.4s ease-in-out; + + font-weight: 700; + color: #333; + font-size: 18px; + position: relative; + cursor: pointer; + display: inline-block; + user-select: none; + + border-radius: 4px; + border: none; + + .ng-modal & { + margin: 5px auto; + } + + &.green { + background: #c4dd61; + color: #525252; + + &:hover { + background: #d5ea7f; + } + } + + &.notification_action { + font-size: 14px; + display: none; + &.enabled { + display: block; + } + } +} + +.page, .widget { + position: relative; + z-index: 1; + + .alert-wrapper { + position: absolute; + top: 0; + left: 0; + z-index: 9998; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.66); + + .alert-dialog { + position: absolute; + top: 25%; + left: 50%; + width: 400px; + margin-left: -200px; + text-align: center; + padding: 15px; + background: #fff; + + h3 { + margin: 5px 0; + font-size: 1.2em; + } + + .buttons { + display: block; + width: 100%; + margin-top: 5px; + + .action_button { + display: inline-block; + margin: 10px 0 5px 0; + } + } + } + } +} + +section, +header, +nav, +article, +aside, +footer { + display: block; +} + +.cancel_button { + color: #555; + text-decoration: underline; + margin: 10px 15px; +} + +.popup h2, +.detail h2 { + color: #0a0a0a; + font-weight: 900; + font-size: 20pt; + margin: 10px auto; + padding: 0; + // text-shadow: 0px 1px 0px #ccc; +} + +.error-support span.subtitle { + font-size: 0.9em; + margin-top: 0px; + color: #666; +} + +.form-content ul li { + text-align: left; +} + +.qtip { + position: absolute; + background: #b944cc; + border: 4px solid #b944cc; + z-index: 101; + color: #fff; + padding: 10px; + border-radius: 5px; + width: 230px; + font-weight: bold; + + &.top:after, + &.top:before { + bottom: 100%; + left: 50%; + border: solid transparent; + content: ' '; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + } + + &.top:after { + border-color: rgba(185, 68, 204, 0); + border-bottom-color: #b944cc; + border-width: 20px; + margin-left: -20px; + } + &.top:before { + border-color: rgba(185, 68, 204, 0); + border-bottom-color: #b944cc; + border-width: 26px; + margin-left: -26px; + } + + &.right:after { + left: 100%; + top: 50%; + border: solid transparent; + content: ' '; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + border-color: rgba(185, 68, 204, 0); + border-left-color: #b944cc; + border-width: 28px; + margin-top: -28px; + } +} \ No newline at end of file diff --git a/src/components/keyboard-icon.jsx b/src/components/keyboard-icon.jsx new file mode 100644 index 000000000..5b599b5b6 --- /dev/null +++ b/src/components/keyboard-icon.jsx @@ -0,0 +1,14 @@ +import React from 'react' + +const KeyboardIcon = ({color}) => { + return ( + + + + ) +} + +export default KeyboardIcon diff --git a/src/components/loading-icon.jsx b/src/components/loading-icon.jsx new file mode 100644 index 000000000..9085c5ea3 --- /dev/null +++ b/src/components/loading-icon.jsx @@ -0,0 +1,20 @@ +import React from 'react' +import './loading-icon.scss' + +const LoadingIcon = ({size='med', width='100%', top= '0', left='0'}) => { + // Supported sizes: sm, med, lrg + return ( +
    +
    + + + + + + +
    +
    + ) +} + +export default LoadingIcon diff --git a/src/components/loading-icon.scss b/src/components/loading-icon.scss new file mode 100644 index 000000000..9d431313d --- /dev/null +++ b/src/components/loading-icon.scss @@ -0,0 +1,37 @@ +.icon-holder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + margin: auto; + + .loading-icon { + svg { + max-height: 300px; + width: auto; + //-webkit-animation: spin 2s linear infinite; + //-moz-animation: spin 2s linear infinite; + animation: spin 1s ease-in-out infinite; + + &.sm { + max-height: 20px; + } + &.med { + max-height: 60px; + } + &.lrg { + max-height: 80px; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + } + } +} diff --git a/src/components/login-page.jsx b/src/components/login-page.jsx new file mode 100644 index 000000000..4f9ebf07b --- /dev/null +++ b/src/components/login-page.jsx @@ -0,0 +1,116 @@ +import React, { useEffect, useRef, useState } from 'react' +import Header from './header' +import Summary from './widget-summary' +import './login-page.scss' + +const LoginPage = () => { + + const mounted = useRef(false) + const [state, setState] = useState({ + loginUser: '', + loginPw: '', + actionLogin: '', + actionRedirect: '', + bypass: false, + loginLinks: '', + errContent: '', + noticeContent: '' + }) + + useEffect(() => { + waitForWindow() + .then(() => { + let links = decodeURIComponent(window.LOGIN_LINKS).split('@@@').map((link, index) => { + let vals = link.split('***') + return
  • {`${vals[1]?.replace('+',' ')}`}
  • + }) + + let actionRedirect = window.location.search && window.location.search.split("?redirect=").length > 1 ? window.location.search.split("?redirect=")[1] : '' + + // If there is no redirect query in the url but there is a hash, it will redirect to my-widgets#hash + // Otherwise, it adds it onto the end of the redirect query + actionRedirect += (window.location.hash ? window.location.hash : '') + + setState({ + loginUser: window.LOGIN_USER, + loginPw: window.LOGIN_PW, + actionLogin: window.ACTION_LOGIN, + actionRedirect: actionRedirect.length > 0 ? actionRedirect : window.ACTION_REDIRECT, + is_embedded: window.EMBEDDED != undefined ? window.EMBEDDED : false, + bypass: window.BYPASS, + context: window.CONTEXT, + instName: window.NAME != undefined ? window.INST_NAME : null, + widgetName: window.WIDGET_NAME != undefined ? window.WIDGET_NAME : null, + isPreview: window.IS_PREVIEW != undefined ? window.IS_PREVIEW : null, + loginLinks: links, + errContent: window.ERR_LOGIN != undefined ?

    {`${window.ERR_LOGIN}`}

    : '', + noticeContent: window.NOTICE_LOGIN != undefined ?

    {`${window.NOTICE_LOGIN}`}

    : '' + }) + }) + }, []) + + const waitForWindow = async () => { + while(!window.hasOwnProperty('LOGIN_USER') + && !window.hasOwnProperty('LOGIN_PW') + && !window.hasOwnProperty('ACTION_LOGIN') + && !window.hasOwnProperty('ACTION_REDIRECT') + && !window.hasOwnProperty('BYPASS') + && !window.hasOwnProperty('LOGIN_LINKS') + && !window.hasOwnProperty('CONTEXT')) { + await new Promise(resolve => setTimeout(resolve, 500)) + } + } + + let detailContent = <> + if (!state.context || state.context == 'login') { + detailContent = +
    +

    Log In to Your Account

    + {`Using your ${state.loginUser} and ${state.loginPw} to access your Widgets.`} +
    + } else if (state.context && state.context == 'widget') { + detailContent = +
    +

    Log in to play this widget

    + {`Using your ${state.loginUser} and ${state.loginPw} to access your Widgets.`} +
    + } + + return ( + <> + { state.is_embedded ? '' :
    } +
    +
    + { state.context && state.context == 'widget' ? : ''} + { detailContent } + +
    + { state.errContent } + { state.noticeContent } +
    +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    + { state.bypass ? +
      + { state.loginLinks } +
    • Help
    • +
    + : '' } +
    +
    +
    +
    + + ) +} + +export default LoginPage diff --git a/src/components/login-page.scss b/src/components/login-page.scss new file mode 100644 index 000000000..3c1b7cfc4 --- /dev/null +++ b/src/components/login-page.scss @@ -0,0 +1,7 @@ +@import './pre-embed-common-styles.scss'; +@import './include.scss'; + +span.subtitle { + font-size: .7em; + font-weight: 300; +} diff --git a/src/components/lti/error-general.jsx b/src/components/lti/error-general.jsx new file mode 100644 index 000000000..22a76e226 --- /dev/null +++ b/src/components/lti/error-general.jsx @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from 'react' +import SupportInfo from '../support-info' + +const ErrorGeneral = () => { + const [title, setTitle] = useState('') + const [errorType, setErrorType] = useState('') + + useEffect(() => { + if (window.TITLE) { + setTitle(window.TITLE) + } + }, [window.TITLE]) + + useEffect(() => { + if (window.ERROR_TYPE) { + setErrorType(window.ERROR_TYPE) + } + }, [window.ERROR_TYPE]) + + let content = null; + + switch (errorType) + { + case 'error_unknown_assignment': + content = +
    +

    This Materia assignment hasn't been setup correctly in the system.

    +

    Your instructor will need to complete the setup process.

    +
    + break; + case 'error_unknown_user': + content = +
    +

    Materia can not determine who you are using the information provided by the system.

    +

    This may occur if you are using a non-standard account or if your information is missing from Materia due to recent changes to your account.

    +

    If you need help accessing this tool, contact support.

    +
    + break; + case 'error_autoplay_misconfigured': + content = +
    +

    This Materia assignment hasn't been setup correctly in the system.

    +

    Non-autoplaying widgets can not be used as graded assignments.

    +
    + break; + case 'error_lti_guest_mode': + content = +
    +

    This assignment has guest mode enabled.

    +

    This assignment can only record scores anonymously and therefore cannot be played as an embedded assignment.

    +

    Your instructor will need to disable guest mode or provide a link to play as a guest.

    +
    + break; + case 'error_invalid_oauth_request': + content = +
    +

    Invalid login.

    +

    If you need help accessing this tool, contact support.

    +
    + break; + default: + content = +
    +

    An error occurred.

    +

    If you need help accessing this tool, contact support.

    +
    + break; + } + + return <> +
    +

    {title}

    + +
    + + {content} + + + +} + +export default ErrorGeneral diff --git a/src/components/lti/open-preview.jsx b/src/components/lti/open-preview.jsx new file mode 100644 index 000000000..54b61f024 --- /dev/null +++ b/src/components/lti/open-preview.jsx @@ -0,0 +1,110 @@ +import React, { useState, useEffect } from 'react' +import { useQuery } from 'react-query' +import { apiGetWidgetInstance, apiRequestAccess } from '../../util/api' +import { iconUrl as getIconUrl } from '../../util/icon-url' + +const SelectItem = () => { + const nameArr = window.location.pathname.split('/') + const instID = nameArr[nameArr.length - 1] + const [iconUrl, setIconUrl] = useState('') + const [userOwnsInstance, setUserOwnsInstance] = useState(false) + const [previewEmbedUrl, setPreviewEmbedUrl] = useState('') + const [requestSuccess, setRequestSuccess] = useState(null) + const [requestSuccessID, setRequestSuccessID] = useState(null) + const [instanceOwners, setOwnerList] = useState([]) + + const { data: instance } = useQuery({ + queryKey: 'instance', + queryFn: () => apiGetWidgetInstance(instID), + placeholderData: {}, + staleTime: Infinity, + onSuccess: (data) => { + if (data && data.widget) + setIconUrl(getIconUrl('/widget/', data.widget.dir, 92)) + } + }) + + useEffect(() => { + if (window && window.OWNER_LIST) + { + setOwnerList(window.OWNER_LIST) + } + }, [window.OWNER_LIST]) + + useEffect(() => { + if (window && window.PREVIEW_EMBED_URL) { + setPreviewEmbedUrl(window.PREVIEW_EMBED_URL) + } + }, [window.PREVIEW_EMBED_URL]) + + useEffect(() => { + if (window && window.CURRENT_USER_OWNS) { + setUserOwnsInstance(!!window.CURRENT_USER_OWNS) + } + }, [window.CURRENT_USER_OWNS]) + + const requestAccess = async (ownerID) => { + await apiRequestAccess(instID, ownerID).then((data) => { + if (data) setRequestSuccess('Request succeeded') + else setRequestSuccess('Request Failed') + setRequestSuccessID(ownerID) + }) + } + + let ownerList = null + if (instanceOwners && Array.isArray(instanceOwners)) { + ownerList = instanceOwners.map((owner, index) => { + return
  • + {owner.first} {owner.last} + + {requestSuccess !== null && requestSuccessID == owner.id ? {requestSuccess} : <>} +
  • + }) + } + + let sectionRender = null + if (userOwnsInstance) + { + sectionRender = +
    +
    +
    +
    + {instance.name +
    +
    {instance.name}
    +
    +

    The widget is successfully embedded. When supported, Materia will synchronize scores.

    +

    This confirmation screen is displayed because Materia recognizes you as an author. Students will see the widget instead of this message. You may preview the widget using the button below.

    + Start Preview +
    +
    + } else { + sectionRender =
    +
    +
    +
    + {instance.name +
    +
    {instance.name}
    +
    +

    You don't own this widget!

    +

    Don't worry: students will see the widget instead of this message, and it will continue to synchronize scores if configured to do so.

    +

    You may contact one of the widget owners listed below to request access to this widget. Clicking the Request Access option will notify them and provide them the option to add you as a collaborator.

    +
      + {ownerList} +
    +
    +
    + } + + return (<> +
    +

    Materia Widget Embedded

    + +
    + {sectionRender} + ) +} + +export default SelectItem diff --git a/src/components/lti/post-login.jsx b/src/components/lti/post-login.jsx new file mode 100644 index 000000000..ae4fbe848 --- /dev/null +++ b/src/components/lti/post-login.jsx @@ -0,0 +1,58 @@ +import React, { useState, useEffect } from 'react' + +const PostLogin = () => { + const [staticURL, setStaticURL] = useState('') + + useEffect(() => { + waitForWindow().then(() => { + setStaticURL(window.STATIC_CROSSDOMAIN) + }) + }, []) + + const waitForWindow = async () => { + while (!window.hasOwnProperty('STATIC_CROSSDOMAIN')) { + await new Promise(resolve => setTimeout(resolve, 500)) + } + } + + return ( +
    +
    +

    + Materia: Enhance Your Course with Widgets +

    +
    +
    +
    + +

    + Browse the Widget Catalog + Peruse our catalog for a widget applicable to your course content. Some widgets are specialized for + particular disciplines, while others can be applied to just about any subject matter. +

    +
    +
    + +

    + Build Your Widget + Every widget includes a powerful creator interface to customize it to suit your needs, no technical expertise required. + Most widgets can be authored in just minutes. +

    +
    +
    + +

    Share With Your Students + Widgets can be shared directly or embedded as an assignment in your LMS. When set up as an external tool in an assignment, scores + will be automatically sent to the gradebook. +

    +
    + +
    +
    + ) +} + +export default PostLogin diff --git a/src/components/lti/select-item.jsx b/src/components/lti/select-item.jsx new file mode 100644 index 000000000..934d981f1 --- /dev/null +++ b/src/components/lti/select-item.jsx @@ -0,0 +1,255 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react' +import useInstanceList from '../hooks/useInstanceList' +import LoadingIcon from '../loading-icon'; + +const SelectItem = () => { + const [strHeader, setStrHeader] = useState('Select a Widget:'); + const [selectedInstance, setSelectedInstance] = useState(null); + const [searchText, setSearchText] = useState('') + const [easterMode, setEasterMode] = useState(false) + const [showRefreshArrow, setShowRefreshArrow] = useState(false) + const [displayState, setDisplayState] = useState('selectInstance') + const fillRef = useRef(null) + const [progressComplete, setProgressComplete] = useState(false) + + const instanceList = useInstanceList() + + useEffect(() => { + if (window.SYSTEM) { + setStrHeader(`Select a Widget for use in ${window.SYSTEM}:`) + } + }, [window.SYSTEM]) + + const hiddenSet = useMemo(() => { + const result = new Set() + if(searchText == '') return result + + const re = RegExp(searchText, 'i') + if (instanceList.instances && instanceList.instances.length > 0) + instanceList.instances.forEach(i => { + if(!re.test(`${i.name} ${i.widget.name} ${i.id}`)){ + result.add(i.id) + } + }) + + return result + }, [searchText, instanceList.instances]) + + const handleChange = (e) => { + setSearchText(e.target.value) + } + + const refreshListing = () => { + instanceList.refresh() + setShowRefreshArrow(false) + } + + const cancelProgress = () => { + setDisplayState('selectInstance') + setSelectedInstance(null) + } + + const embedInstance = (instance) => { + setDisplayState('progress') + setSelectedInstance(instance) + } + + useEffect(() => { + // End progress bar + if (progressComplete && !!selectedInstance) { + let pg = document.querySelector('.progress-container') + let pgSpan = document.querySelector('.progress-container span') + pg.classList.add('success') + pgSpan.innerText = 'Success!' + + if (JSON.stringify && parent.postMessage) { + parent.postMessage(JSON.stringify(selectedInstance), '*') + } + + if (!!window.RETURN_URL) { + // add a ? or & depending on window.RETURN_URL already containing query params + const separator = window.RETURN_URL.includes('?') ? '&' : '?' + // encode the url + const url = encodeURI(selectedInstance.embed_url) + // redirect the client to the return url with our new variables + window.location = `${window.RETURN_URL}${separator}embed_type=basic_lti&url=${url}` + } + } + // Start progress bar + else if (!!selectedInstance) { + + const easterModeListener = document.addEventListener('keyup', (event) => { + if (event.key == 'Shift') { + setEasterMode(true) + } + }) + + let stops = [] + let total = 0 + let stop = 0; + + // Create random stop points, each greater than the previous + while (total < 100) { + stop = Math.random() * 10 + stop + stops.push(stop + total) + total += stop + } + stops[stops.length - 1] -= (total - 100); + + let i = 0; + + // Progress bar increments every half second + const fillInterval = setInterval(() => { + fillRef.current.style.width = `${stops[i++]}%` + if (i == stops.length) { + clearInterval(fillInterval) + fillRef.current.style.width = '100%' + setProgressComplete(true) + } + }, 500) + + return () => { + clearInterval(fillInterval); + document.removeEventListener("keyup", easterModeListener) + }; + } + }, [selectedInstance, progressComplete]) + + let instanceListRender = null + if (instanceList.instances && instanceList.instances.length > 0) { + if (hiddenSet.size >= instanceList.instances.length) instanceListRender =

    No widgets match your search.

    + else { + instanceListRender = instanceList.instances.map((instance, index) => { + var classList = [] + if (instance.is_draft) classList.push('draft') + if (instance.selected) classList.push('selected') + if (instance.guest_access) classList.push('guest') + if (hiddenSet.has(instance.id)) classList.push('hidden') + + return
  • +
    + +

    {instance.name}

    +

    {instance.widget.name}

    + {instance.guest_access ?

    Guest instances cannot be embedded in courses.

    : <>} + {instance.is_draft ?

    You must publish this instance before embedding it.

    : <>} + {instance.is_draft ? Draft : <>} + {instance.guest_access && !instance.is_draft ? Guest : <>} +
    +
    + Preview + { + (instance.guest_access || instance.is_draft) ? + Edit at Materia + : + embedInstance(instance)}>Use this widget + } +
    +
  • + }) + } + } + + let noInstanceRender = null + let createNewInstanceLink = null + if (instanceList.instances && instanceList.instances.length < 1) { + noInstanceRender =
    +
    +

    You don't have any widgets yet. Click this button to create a widget, then return to this tab/window and select your new widget.

    + setShowRefreshArrow(true)} className="external action_button" target="_blank" href={window.BASE_URL + "/widgets"}>Create a widget at Materia +
    +
    + } else { + createNewInstanceLink = setShowRefreshArrow(true)} className="external action_button" target="_blank" href={window.BASE_URL + "widgets"}>Or, create a new widget at Materia + } + + let sectionRender = null + if (instanceList.isFetching) { + sectionRender = +
    + +
    + } else if (displayState == 'selectInstance' && noInstanceRender == null) { + sectionRender = +
    +
    +
    +
    + +
    + + + +
    +
    + +
    +
    +
      + {instanceListRender} +
    +
    + {createNewInstanceLink} +
    + } else if (displayState == 'selectInstance' && noInstanceRender != null) { + sectionRender = +
    +
    +
    +
    + +
    + + + +
    +
    + +
    + {noInstanceRender} +
    + } else if (displayState == 'progress') { + sectionRender =
    +
    +

    {selectedInstance.name}

    + +
    +
    + {!easterMode ? "Connecting your instance..." : "Reticulating splines..."} +
    +
    +
    +
    + + +
    + } + + let refreshArrow = null + if (showRefreshArrow) refreshArrow =
    Click to see your new widget
    + + return ( +
    +
    +

    {strHeader}

    + +
    + {sectionRender} + {refreshArrow} +
    + ) +} + +export default SelectItem diff --git a/src/components/materia-constants.js b/src/components/materia-constants.js new file mode 100644 index 000000000..8e5db942e --- /dev/null +++ b/src/components/materia-constants.js @@ -0,0 +1,32 @@ +export const creator = { + INTERVAL: 30000 +} + +export const player = { + LOG_INTERVAL: 10000, // How often to send logs to the server + RETRY_LIMIT: 15, // When the logs fail to send, retry how many times before switching to slow mode? + RETRY_FAST: 1000, + RETRY_SLOW: 10000, + EMBED_TARGET: 'container', +} + +export const objectTypes = { + QUESTION: 1, + ASSET: 2, + WIDGET: 3, + WIDGET_INSTANCE: 4, +} + +export const access = { + VISIBLE: 1, + PLAY: 5, + SCORE: 10, + DATA: 15, + EDIT: 20, + COPY: 25, + FULL: 30, + SHARE: 35, + SU: 90, +} + +export const WIDGET_URL = window.location.origin + '/widget/' diff --git a/src/components/media-importer.jsx b/src/components/media-importer.jsx new file mode 100644 index 000000000..11c5d9379 --- /dev/null +++ b/src/components/media-importer.jsx @@ -0,0 +1,381 @@ +import React, { useState, useEffect, useRef } from 'react' +import { useQuery, useQueryClient } from 'react-query' +import DragAndDrop from './drag-and-drop' +import LoadingIcon from './loading-icon' +import { apiDeleteAsset, apiGetAssets, apiRestoreAsset } from '../util/api' +import './media.scss' + +const sortString = (field, a, b) => a[field].toLowerCase().localeCompare(b[field].toLowerCase()) +const sortNumber = (field, a, b) => a[field] - b[field] + +const REQUESTED_FILE_TYPES = window.location.hash.substring(1).split(',') + +// Approx 20 MB +const FILE_MAX_SIZE = 20000000 + +// generic media type definitions and substitutions for compatibility +const MIME_MAP = { + // generic types, preferred + image: ['image/jpg', 'image/jpeg', 'image/gif', 'image/png'], + audio: ['audio/mp3', 'audio/mpeg', 'audio/mpeg3', 'audio/mp4', 'audio/x-m4a', 'audio/wave', 'audio/wav', 'audio/x-wav', 'audio/m4a'], + video: [], // placeholder + model: ['model/obj'], + + // incompatibility prevention, not preferred + jpg: ['image/jpg'], + jpeg: ['image/jpeg'], + gif: ['image/gif'], + png: ['image/png'], + mp3: ['audio/mp3', 'audio/mpeg', 'audio/mpeg3'], + m4a: ['audio/mp4', 'audio/x-m4a', 'audio/m4a'], + wav: ['audio/wave', 'audio/wav', 'audio/x-wav'], + obj: ['application/octet-stream', 'model/obj'], +} + +const SORT_OPTIONS = [ + { + sortMethod: sortString.bind(null, 'name'), // bind the field name to the sort method + name: 'Title', + field: 'name', + }, + { + sortMethod: sortString.bind(null, 'type'), // bind the field name to the sort method + name: 'Type', + field: 'type', + }, + { + sortMethod: sortNumber.bind(null, 'timestamp'), // bind the field name to the sort method + name: 'Date', + field: 'timestamp', + }, +] + +const MediaImporter = () => { + const queryClient = useQueryClient() + const [errorState, setErrorState] = useState(null) + const [assetLoadingProgress, setAssetLoadingProgress] = useState(0) + const [selectedAsset, setSelectedAsset] = useState(null) + const [sortAssets, setSortAssets] = useState(null) // Display assets list + const [sortState, setSortState] = useState({ + sortAsc: false, // Sorted list in asc or desc + sortOrder: 0, // List sorting options + }) + const [assetList, setAssetList] = useState({}) + const [showDeletedAssets, setShowDeletedAssets] = useState(false) + const [filterSearch, setFilterSearch] = useState('') // Search bar filter + + const { data: listOfAssets } = useQuery({ + queryKey: ['media-assets', selectedAsset], + queryFn: () => apiGetAssets(), + staleTime: Infinity, + onSettled: (data) => { + if (!data || data.type == 'error') console.error(`Asset request failed with error: ${data.msg}`) + else { + const list = data.map(asset => { + const creationDate = new Date(asset.created_at * 1000) + return { + id: asset.id, + type: asset.type, + name: asset.title.split('.').shift(), + timestamp: asset.created_at, + thumb: _thumbnailUrl(asset.id, asset.type), + created: [creationDate.getMonth(), creationDate.getDate(), creationDate.getFullYear()].join('/'), + is_deleted: parseInt(asset.is_deleted) + } + }) + + list.forEach((asset) => { + if (asset.id == selectedAsset) _loadPickedAsset(asset) + }) + + setAssetList(list) + } + } + }) + + /****** hooks ******/ + + // Asset list, sorting, search filter, or show delete flag is updated + // Processes the list sequentially based on the state of each + useEffect(() => { + if (!assetList || !assetList.length) return + + const allowed = _getAllowedFileTypes().map((type) => type.split('/')[1]) + + // first pass: filter out by allowed media types as well as deleted assets, if we're not displaying them + let listStageOne = assetList.filter((asset) => ((!showDeletedAssets && !asset.is_deleted) || showDeletedAssets) && allowed.indexOf(asset.type) != -1) + + // second pass: filter assets based on search string, if present + let listStageTwo = filterSearch.length ? listStageOne.filter((asset) => asset.name.toLowerCase().match( filterSearch.toLowerCase() )) : listStageOne + + // third and final pass: sort assets based on the currently selected sort method and direction + let listStageThree = sortState.sortAsc ? + listStageTwo.sort(SORT_OPTIONS[sortState.sortOrder].sortMethod) : + listStageTwo.sort(SORT_OPTIONS[sortState.sortOrder].sortMethod).reverse() + + setSortAssets( + listStageThree.map((asset, index) => { + return () + }) + ) + + }, [assetList, showDeletedAssets, filterSearch, sortState]) + + /****** internal helper functions ******/ + + const _thumbnailUrl = (data, type) => { + switch (type) { + case 'jpg': // intentional case fall-through + case 'jpeg': // intentional case fall-through + case 'png': // intentional case fall-through + case 'gif': // intentional case fall-through + return `${MEDIA_URL}/${data}/thumbnail` + + case 'mp3': // intentional case fall-through + case 'wav': // intentional case fall-through + case 'm4a': // intentional case fall-through + return '/img/audio.png' + } + } + + const _loadPickedAsset = (asset) => { + window.parent.Materia.Creator.onMediaImportComplete([asset]) + } + + const _updateDeleteStatus = (asset) => { + if (!asset.is_deleted) { + asset.is_deleted = 1 + apiDeleteAsset(asset.id) + } else { + asset.is_deleted = 0 + apiRestoreAsset(asset.id) + } + + let list = assetList.map((item) => { + if (item.id == asset.id) item.is_deleted = asset.is_deleted + return item + }) + + setAssetList(list) + } + + const _uploadFile = (e) => { + if (assetLoadingProgress > 0 && assetLoadingProgress < 100) return false + const file = (e.target.files && e.target.files[0]) || (e.dataTransfer.files && e.dataTransfer.files[0]) + + // file doesn't exist or isn't of type File + if (!file || !(file instanceof File)) { + setErrorState('The file you selected was invalid.') + } + // selected file was too big + else if (_getMimeType(file.type) && file.size > FILE_MAX_SIZE) { + setErrorState('The file you selected is too large. Maximum file size is 20MB.') + } + // selected file was not an accepted MIME type + else if (!_getMimeType(file.type) && file.size <= FILE_MAX_SIZE) { + setErrorState(`Invalid file type. Accepted media types are: ${_getAllowedFileTypes().join(', ')}`) + } + // file OK - proceed to upload + else { + _upload(file) + } + } + + const _upload = (fileData) => { + + const fd = new FormData() + fd.append('name', fileData.name) + fd.append('Content-Type', fileData.type) + fd.append('file', fileData, fileData.name || 'Unnamed file') + + const request = new XMLHttpRequest() + + request.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const progress = Math.round((e.loaded / e.total) * 100); + setAssetLoadingProgress(progress); + } + }) + + request.onreadystatechange = () => { + if (request.readyState == 4) { + if (request.status == 200) { + const res = JSON.parse(request.response) + setSelectedAsset(res.id) + } else { + setErrorState('Something went wrong with uploading your file.') + _onCancel() + return + } + } + } + + request.open('POST', MEDIA_UPLOAD_URL, true) + request.send(fd) + } + + const _getAllowedFileTypes = () => { + let allowedFileExtensions = [] + REQUESTED_FILE_TYPES.forEach((type) => { + if (MIME_MAP[type]) { + allowedFileExtensions = [...allowedFileExtensions, ...MIME_MAP[type]] + } + }) + return allowedFileExtensions + } + + const _getMimeType = (mime) => { + const allowed = _getAllowedFileTypes() + if (mime == null || allowed.indexOf(mime) === -1) return null + else return mime + } + + // Update the filterSearch state + const _filterFiles = (ev) => { + ev.preventDefault() + setFilterSearch(ev.target.value) + } + + const _onCancel = () => { + window.parent.Materia.Creator.onMediaImportComplete(null) + } + + let uploadingRender = null + if (assetLoadingProgress > 0 && assetLoadingProgress < 100) { + uploadingRender =
    {assetLoadingProgress}%
    + } + + /****** internally defined components ******/ + + const AssetCard = ({ name, thumb, created, type, asset, is_deleted }) => { + return ( +
    _loadPickedAsset(asset)}> + + {name} + + + {name} + {type} + + + {created} +
    + +
    +
    + ) + } + + // Options available based on SORT_OPTIONS + const SortOption = ({ sortTypeIndex }) => { + return ( +
    { + setSortState({ + ...sortState, + sortAsc: !sortState.sortAsc, + sortOrder: sortTypeIndex, + }) + }} + > + {SORT_OPTIONS[sortTypeIndex].name} +
    + ) + } + + return ( +
    +
    +
    Upload a new file
    + + +
    Drag a file here to upload
    +
    +
    + +
    + { uploadingRender } +
    + +
    +
    + Your Media Library + +
    + +
    +
    + + + +
    + +
    + +
    +
    + +
    + +
    + + + +
    +
    + +
    + { (!sortAssets || !sortAssets.length) ?
    No files available!
    : sortAssets } +
    +
    +
    + + { errorState ? errorState : `Accepted media types: ${REQUESTED_FILE_TYPES.join(', ')}`} + +
    +
    + ) +} + +export default MediaImporter diff --git a/src/components/media.scss b/src/components/media.scss new file mode 100644 index 000000000..a956efef9 --- /dev/null +++ b/src/components/media.scss @@ -0,0 +1,295 @@ +@import './include.scss'; + +$arrow_size: 4px; + +html { + background: #fff; +} + +body.import { + margin: 0; + padding: 0; + overflow: hidden; +} + +.media-importer { + display: flex; + flex-flow: row nowrap; + width: 800px; + + > section { + position: relative; + height: calc(100vh - 35px); + width: 45%; + padding: 0 2.5% 35px 2.5%; + + &:first-child { + &:before { + color: #dadada; + content: 'or'; + position: absolute; + top: 4vh; + left: 50vw; + transform: translateX(-50%); + } + + &:after { + content: ''; + border-left: thin solid #dadada; + height: 85vh; + position: absolute; + top: 10vh; + right: 50vw; + } + } + + &:last-child { + display: flex; + width: 47%; + flex-flow: column; + padding: 0 0.5% 0 2.5%; + } + + .loading-icon-holder { + z-index: 100; + position: absolute; + top: calc(50vh - 100px); + left: 50%; + display: flex; + flex-direction: column-reverse; + width: 180px; + height: 200px; + margin-left: -90px; + + background: rgba(255,255,255,0.95); + border-radius: 10px; + + .progress { + display: block; + padding-bottom: 10px; + text-align: center; + font-weight: bold; + } + } + } + + .pane-header { + text-align: center; + margin-top: 10px; + + &.darker { + font-size: 16px; + color: #484848; + font-weight: 700; + margin: 10px auto 10px; + } + + .close-button { + background: transparent; + position: absolute; + right: 10px; + top: 5px; + + &:after { + content: 'X'; + font-size: 15px; + color: #000; + cursor: pointer; + } + } + } + + input[type='file'] { + display: none; + } + + .select_file_button { + margin: 5px 0px; + font-weight: 400; + } + + .pane-footer { + position: absolute; + width: 100%; + bottom: 0px; + + span.content { + display: inline-block; + padding: 6px 2.5%; + background: rgba(255,255,255,0.95); + + font-size: 14px; + + &.error-state { + font-weight: bold; + color: #730000; + } + } + } +} + +#drag-wrapper { + position: relative; + height: 333px; + margin: 45px 55px 30px; + border: 2px dashed #dadada; + border-radius: 5px; + background: #f5f5f5; + + .drag-text { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + left: 50%; + text-align: center; + } + + &.drag-is-dragover { + background-color: grey; + } +} + +#sort-bar { + display: flex; + flex-flow: row; + margin-left: 20px; + + font-size: 14px; + + label { + margin-left: 30px; + } +} + +.sort-bar { + display: flex; + position: relative; + flex-flow: row; + margin-left: 20px; + padding-top: 7px; + + input { + padding-right: 125px; + padding-left: 25px; + height: 20px; + background: #fff; + border: 1px solid #b0b0b0; + border-radius: 12px; + + background-repeat: no-repeat; + background-position: left; + background-position-x: 5px; + } + + .search-icon { + position: absolute; + top: 10px; + left: 6px; + height: 16px; + width: 20px; + fill: #898686; + svg { + height: 80%; + width: 80%; + } + } +} + +#sort-options { + display: flex; + flex-flow: row; + + .sort-option { + margin-right: 20px; + cursor: pointer; + position: relative; + } + + .sort-asc:after, + .sort-desc:after { + position: absolute; + top: 0; + right: -5px; + content: ''; + width: 0; + height: 0; + border-left: $arrow_size solid transparent; + border-right: $arrow_size solid transparent; + } + + .sort-asc:after { + border-bottom: $arrow_size solid #000; + } + + .sort-desc:after { + border-top: $arrow_size solid #f00; + } +} + +#file-display { + width: calc(100% - 20px); + max-width: calc(100% - 20px); + height: calc(100% - 65px); + overflow-y: auto; + margin-top: 10px; + + .file-info { + display: flex; + max-width: calc(100% - 20px); + min-height: 0%; + flex-flow: row; + align-content: space-between; + cursor: pointer; + + color: #6b6b6b; + font-size: 13px; + padding: 10px; + + &:nth-child(odd) { + background-color: #eaeaea; + } + + &:hover { + background-color: #f9d991; + } + + span { + vertical-align: top; + } + + .file-thumbnail { + width: 65px; + + img { + max-width: 65px; + max-height: 65px; + border-radius: 5px; + } + } + + .file-name { + display: block; + flex-grow: 1; + padding: 0 10px; + min-width: 1%; // forces word-wrap + word-wrap: break-word; + + color: #484848; + + + font-weight: bold; + + .file-type { + display: block; + color: #6b6b6b; + + font-weight: normal; + } + } + + .action_button { + padding: 6px 12px; + font-size: 12px; + } + } +} \ No newline at end of file diff --git a/src/components/modal.jsx b/src/components/modal.jsx new file mode 100644 index 000000000..a93719b8a --- /dev/null +++ b/src/components/modal.jsx @@ -0,0 +1,60 @@ +import React from 'react' +import { createPortal } from 'react-dom'; +import './modal.scss' + +class Modal extends React.Component { + constructor( props ) { + super( props ) + // create an element div for this modal + this.modalRef = React.createRef() + this.element = document.createElement( 'div' ) + this.clickOutsideListener = this.clickOutsideListener.bind(this) + + // We get hold of the div with the id modal that we have created in index.html + this.modalRoot = document.getElementById( 'modal' ) + } + + clickOutsideListener(event){ + // Do nothing if clicking ref's element or descendent elements + if (!this.modalRef.current || this.modalRef.current.contains(event.target)) { + return + } + + if (this.props.ignoreClose !== true) { + this.props.onClose() + } + }; + + componentDidMount() { + this.modalRoot.appendChild( this.element ) + document.addEventListener('mousedown', this.clickOutsideListener) + document.addEventListener('touchstart', this.clickOutsideListener) + } + + componentWillUnmount() { + this.modalRoot.removeChild( this.element ) + document.removeEventListener('mousedown', this.clickOutsideListener) + document.removeEventListener('touchstart', this.clickOutsideListener) + } + + render() { + const stuff = ( + <> + + +
    + X +
    + {this.props.children} +
    +
    + + ) + return createPortal( stuff, this.element ); + } +} + +export default Modal diff --git a/src/components/modal.scss b/src/components/modal.scss new file mode 100644 index 000000000..5be5cd45e --- /dev/null +++ b/src/components/modal.scss @@ -0,0 +1,143 @@ +.modal { + /* This way it could be display flex or grid or whatever also. */ + display: flex; + + max-width: 100%; + max-height: 100%; + min-width: 500px; + min-height: 300px; + + position: fixed; + + z-index: 1001; + + left: 50%; + top: 50%; + + /* Use this for centering if unknown width/height */ + transform: translate(-50%, -50%); + + /* If known, negative margins are probably better (less chance of blurry text). */ + /* margin: -200px 0 0 -200px; */ + + background: white; + border-radius: 3px; + border: #f0f0f0 3px solid; + box-shadow: none !important; + font-family: Lato, arial, serif; + + &.no-gutter { + border: 0; + } + + &.small { + z-index: 1003; + width: 540px; + min-height: 200px; + } +} +.closed { + display: none; +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; + + &.alert { + z-index: 1002; + } + + background: rgba(0, 0, 0, 0.5); +} +.modal-guts { + position: relative; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 10px 10px 10px 10px; + box-sizing: border-box; + + &.no-gutter { + padding: 0; + } +} + +.modal .close-button { + position: absolute; + font-weight: bold; + font-family: Arial, sans-serif; + cursor: pointer; + font-size: 120%; + + /* don't need to go crazy with z-index here, just sits over .modal-guts */ + z-index: 1; + + top: 10px; + + /* needs to look OK with or without scrollbar */ + right: 20px; + + border: 0; + color: black; +} + +.open-button { + border: 0; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: lightgreen; + color: white; + padding: 10px 20px; + border-radius: 10px; + font-size: 21px; +} + +.alert-title { + border-bottom: solid 1px #bebebe; + text-align: left; + position: static; + display: block; + font-weight: bold; + font-size: 24px; + margin: 9px 4px 4px 4px; + padding-bottom: 7px; +} + +.alert-description { + display: block; + font-size: 16px; + padding-top: 4px; +} + +.alert-btn { + position: relative; + display: block; + align-items: flex-start; + text-align: center; + cursor: pointer; + background-color: #ffba5d; + color: rgb(105, 77, 40); + border: 1px solid rgb(82, 82, 82); + border-radius: 4px; + font-size: 18px; + font-weight: 700; + margin: 5px auto; + padding-bottom: 8px; + padding-left: 23px; + padding-right: 23px; + padding-top: 8px; + box-shadow: rgb(136, 136, 136) 1px 2px 4px 0px; + text-shadow: rgba(255, 255, 255, 0.5) 1px 1px 1px; + + &:hover { + background-color: #eca444; + } +} diff --git a/src/components/my-widgets-collaborate-dialog.jsx b/src/components/my-widgets-collaborate-dialog.jsx new file mode 100644 index 000000000..ea0c0d28d --- /dev/null +++ b/src/components/my-widgets-collaborate-dialog.jsx @@ -0,0 +1,333 @@ +import React, { useEffect, useState, useRef, useMemo } from 'react' +import { useQuery, useQueryClient } from 'react-query' +import { apiGetUsers, apiSearchUsers } from '../util/api' +import setUserInstancePerms from './hooks/useSetUserInstancePerms' +import Modal from './modal' +import useDebounce from './hooks/useDebounce' +import LoadingIcon from './loading-icon' +import NoContentIcon from './no-content-icon' +import CollaborateUserRow from './my-widgets-collaborate-user-row' +import './my-widgets-collaborate-dialog.scss' +import { access } from './materia-constants' + +const initDialogState = (state) => { + return ({ + searchText: '', + shareNotAllowed: false, + updatedAllUserPerms: new Map() + }) +} + +const MyWidgetsCollaborateDialog = ({onClose, inst, myPerms, otherUserPerms, setOtherUserPerms, currentUser, setInvalidLogin}) => { + const [state, setState] = useState(initDialogState()) + const debouncedSearchTerm = useDebounce(state.searchText, 250) + const queryClient = useQueryClient() + const setUserPerms = setUserInstancePerms() + const mounted = useRef(false) + const popperRef = useRef(null) + const { data: collabUsers, remove: clearUsers, isFetching} = useQuery({ + queryKey: ['collab-users', inst.id, (otherUserPerms != null ? Array.from(otherUserPerms.keys()) : otherUserPerms)], // check for changes in otherUserPerms + enabled: !!otherUserPerms, + queryFn: () => apiGetUsers(Array.from(otherUserPerms.keys())), + staleTime: Infinity, + placeholderData: {} + }) + + const { data: searchResults, remove: clearSearch, refetch: refetchSearch } = useQuery({ + queryKey: 'user-search', + enabled: !!debouncedSearchTerm, + queryFn: () => apiSearchUsers(debouncedSearchTerm), + staleTime: Infinity, + placeholderData: [], + retry: false, + onSuccess: (data) => { + if (!data || (data.type == 'error')) + { + console.error(`User search failed with error: ${data.msg}`); + if (data.title =="Invalid Login") + { + setInvalidLogin(true) + } + } + } + }) + + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + } + }, []) + + // Handles the search with debounce + useEffect(() => { + if(debouncedSearchTerm === '') clearSearch() + else refetchSearch() + }, [debouncedSearchTerm]) + + // updatedAllUserPerms is assigned the value of otherUserPerms (a read-only prop) when the component loads + useEffect(() => { + if (otherUserPerms != null) + { + const map = new Map([...state.updatedAllUserPerms, ...otherUserPerms]) + map.forEach((key, pair) => { + key.remove = false + }) + setState({...state, updatedAllUserPerms: map}) + } + }, [otherUserPerms]) + + // Handles clicking a search result + const onClickMatch = match => { + const tempPerms = new Map(state.updatedAllUserPerms) + let shareNotAllowed = false + + if(!inst.guest_access && match.is_student && !match.is_support_user){ + shareNotAllowed = true + setState({...state, searchText: '', updatedAllUserPerms: tempPerms, shareNotAllowed: shareNotAllowed}) + return + } + + if(!state.updatedAllUserPerms.get(match.id) || state.updatedAllUserPerms.get(match.id).remove === true) + { + // Adds user to query data + let tmpMatch = {} + tmpMatch[match.id] = match + queryClient.setQueryData(['collab-users', inst.id], old => ({...old, ...tmpMatch})) + if (!collabUsers[match.id]) + collabUsers[match.id] = match + + // Updateds the perms + tempPerms.set( + match.id, + { + accessLevel: 1, + expireTime: null, + editable: false, + shareable: false, + can: { + view: true, + copy: false, + edit: false, + delete: false, + share: false + }, + remove: false + } + ) + } + + setState({...state, + searchText: '', + updatedAllUserPerms: tempPerms, + shareNotAllowed: shareNotAllowed + }) + } + + // does the perms set contain the current user? + // supportUsers always have implicit access. Otherwise, verify the user is in the perms set and isn't pending removal. + const containsUser = () => { + if (myPerms?.isSupportUser) return true + for (const [id, val] of Array.from(state.updatedAllUserPerms)) { + if (id == currentUser.id) return !val.remove + } + return false + } + + const onSave = () => { + let delCurrUser = false + if (state.updatedAllUserPerms.get(currentUser.id)?.remove) { + delCurrUser = true + } + + let permsObj = []; + + if (delCurrUser && myPerms.accessLevel != access.FULL) + { + // Only send a request to update current user perms so that it doesn't get no-perm'd by the server + let currentUserPerms = state.updatedAllUserPerms.get(currentUser.id); + permsObj.push({ + user_id: currentUser.id, + expiration: currentUserPerms.expireTime, + perms: {[currentUserPerms.accessLevel]: !currentUserPerms.remove} + }) + } + else + { + // else send a request to update all perms + permsObj = Array.from(state.updatedAllUserPerms).map(([userId, userPerms]) => { + return { + user_id: userId, + expiration: userPerms.expireTime, + perms: {[userPerms.accessLevel]: !userPerms.remove} + } + }) + } + + setUserPerms.mutate({ + instId: inst.id, + permsObj: permsObj, + successFunc: () => { + if (mounted.current) { + if (delCurrUser) { + queryClient.invalidateQueries('widgets') + } + queryClient.invalidateQueries('search-widgets') + queryClient.invalidateQueries(['user-perms', inst.id]) + queryClient.invalidateQueries(['user-search', inst.id]) + queryClient.removeQueries(['collab-users', inst.id]) + + setOtherUserPerms(state.updatedAllUserPerms) + customClose() + } + } + }) + + let tmpPerms = new Map(state.updatedAllUserPerms) + + tmpPerms.forEach((value, key) => { + if(value.remove === true) { + tmpPerms.delete(key) + } + }) + + setState({...state, updatedAllUserPerms: tmpPerms}) + } + + const customClose = () => { + clearUsers() + clearSearch() + onClose() + } + + const updatePerms = (userId, perms) => { + let newPerms = new Map(state.updatedAllUserPerms) + newPerms.set(parseInt(userId), perms) + setState({...state, updatedAllUserPerms: newPerms}) + } + + // Can't search unless you have full access. + let searchContainerRender = null + if (myPerms?.shareable || myPerms?.isSupportUser) { + let searchResultsRender = null + if (debouncedSearchTerm !== '' && state.searchText !== '' && searchResults.length && searchResults?.length !== 0) { + const searchResultElements = searchResults?.map(match => +
    onClickMatch(match)}> + +

    + {match.first} {match.last} +

    +
    + ) + + searchResultsRender = ( +
    + { searchResultElements } +
    + ) + } + + searchContainerRender = ( +
    + + Add people: + + setState({...state, searchText: e.target.value})} + className='user-add' + type='text' + placeholder="Enter a Materia user's name or e-mail"/> +
    + { searchResultsRender } +
    +
    + ) + } + + let mainContentRender = + if (!isFetching) { + mainContentRender = + + if (containsUser) { + const mainContentElements = Array.from(state.updatedAllUserPerms).map(([userId, userPerms]) => { + if (userPerms.remove === true) return + + let user = collabUsers[userId] + if (!user) + { + return
    + } + + return updatePerms(userId, perms)} + readOnly={myPerms?.shareable === false} + /> + }) + + mainContentRender = ( + <> + { mainContentElements } + + ) + } + } + + const disableShareNotAllowed = () => setState({...state, shareNotAllowed: false}) + let noShareWarningRender = null + if (state.shareNotAllowed === true) { + noShareWarningRender = ( + + Share Not Allowed +

    Access must be set to "Guest Mode" to collaborate with students.

    + +
    + ) + } + + return ( + +
    + Collaborate +
    +
    + { searchContainerRender } +
    + { mainContentRender } +
    + {/* Calendar portal used to bring calendar popup out of access-list to avoid cutting off the overflow */} +
    +

    + Users with full access can edit or copy this widget and can + add or remove people in this list. +

    + +
    +
    +
    + { noShareWarningRender } + + ) +} + +export default MyWidgetsCollaborateDialog diff --git a/src/components/my-widgets-collaborate-dialog.scss b/src/components/my-widgets-collaborate-dialog.scss new file mode 100644 index 000000000..1b1356d3c --- /dev/null +++ b/src/components/my-widgets-collaborate-dialog.scss @@ -0,0 +1,399 @@ +// Collaborate dialog +.modal .collaborate-modal { + width: 620px; + height: 500px; + + font-family: 'Lato', arial, serif; + + .title { + margin: 0; + padding: 0; + font-size: 1.3em; + color: #555; + border-bottom: #999 dotted 1px; + padding-bottom: 20px; + margin-bottom: 20px; + position: relative; + text-align: left; + display: block; + + font-weight: bold; + } + + .search-container { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + margin-bottom: 20px; + text-align: left; + + .collab-input-label { + font-size: 19px; + margin-right: auto; + } + } + + .user-add { + width: 445px; + height: 30px; + z-index: 2; + border: solid 1px #c9c9c9; + font-size: 16px; + } + + .shareNotAllowed { + display: none; + + &.show { + display: block; + height: 25px; + } + + p { + padding-top: 5px; + text-align: center; + margin: 0px; + font-size: 0.8em; + color: red; + } + } + + .collab-search-list { + z-index: 3; + position: absolute; + display: flex; + flex-wrap: wrap; + align-items: flex-start; + + top: 32px; + right: 0px; + width: 447px; + padding-bottom: 5px; + overflow: auto; + + background-color: #ffffff; + border: #bfbfbf 1px solid; + + text-align: left; + + .collab-search-match { + display: flex; + flex-basis: 45%; + align-items: flex-start; + margin: 5px 5px 0 5px; + padding: 0 5px 5px 0; + border-radius: 3px; + background-color: #ffffff; + + .collab-match-avatar { + width: 50px; + height: 50px; + -moz-border-radius: 3px; + border-radius: 3px; + display: inline-block; + margin-right: 10px; + margin: 5px; + } + + .collab-match-name { + margin: 5px 0 0 5px; + font-size: 14px; + text-align: left; + + font-family: 'Lucida Grande', sans-serif; + } + } + + .collab-match-student { + position: relative; + } + .collab-match-student:after { + content: 'Student'; + position: absolute; + bottom: -15px; + left: 0; + font-size: 10px; + } + + .collab-search-match:hover { + background-color: #c5e7fa; + cursor: pointer; + } + } + + .collab-container { + margin: 0 20px; + + .access-list { + height: 250px; + padding: 0 30px; + margin-top: 0px; + + overflow: auto; + + background-color: #f2f2f2; + border-radius: 5px; + + &.no-content { + display: flex; + align-items: center; + justify-content: center; + } + } + + .btn-box { + align-items: flex-end; + justify-content: center; + display: flex; + margin-top: 35px; + + a { + cursor: pointer; + } + } + } + + .disclaimer { + text-align: center; + color: #575757; + font-size: 14px; + margin-top: 10px; + } + + .access-list { + .deleted { + display: none !important; + } + + .user-perm { + display: flex; + align-items: center; + position: relative; + margin: 25px 10px; + + &::after { + content: ' '; + width: 70%; + margin-left: 15%; // (100% - 70%) / 2 + display: block; + border-bottom: 1px solid #d2d2d2; + position: absolute; + bottom: -12.5px; + } + + &:last-child::after { + display: none; + } + + .demote-dialog { + border-radius: 4px; + box-shadow: 0 2px 2px rgba(0, 0, 0, 0.3); + padding: 1em; + width: 310px; + font-family: 'Lucida Grande', sans-serif; + font-size: 9pt; + color: black; + text-align: center; + background: #fcdbdb; + height: 40px; + z-index: 10000; + position: absolute; + margin-left: 125px; + + .arrow { + background: url(/img/pink-arrow-left.png) no-repeat 0 center; + width: 13px; + //height: 23px; + display: inline-block; + top: 0; + left: -13px; + position: absolute; + height: 100%; + } + + .warning { + display: block; + margin: -4px 0 6px 0; + text-align: center; + } + + .button { + text-align: center; + margin: 0 10px 0 10px; + } + + .no-button { + color: #555; + text-decoration: underline; + font-size: 12pt; + cursor: pointer; + } + + .yes-button { + background: #e10000; + border-color: #747474; + color: #ffffff; + padding: 3px 15px; + } + + .yes-button:hover { + background: #ca0000; + text-decoration: none; + } + } + + .remove { + display: block; + color: #bfbfbf; + text-decoration: none; + font-size: 15px; + text-align: center; + padding: 0.5em; + margin: 0.5em; + user-select: none; + border: none; + background: transparent; + + &:hover:enabled { + color: black; + background: white; + border-radius: 5px; + cursor: pointer; + } + + &:disabled { + color: transparent; + } + } + + .about { + display: flex; + flex-direction: row; + align-items: center; + + .avatar { + vertical-align: middle; + display: inline-block; + height: 50px; + width: 50px; + margin-right: 10px; + } + .name { + display: inline-block; + font-weight: bold; + font-size: 15px; + text-align: left; + position: relative; + + &.user-match-student:after { + content: 'Student'; + position: absolute; + top: -12px; + left: 0; + font-size: 11px; + color: gray; + } + } + } + + .options { + margin-left: auto; + margin-right: 10%; + text-align: left; + + select { + cursor: pointer; + display: inline-block; + margin-bottom: 5px; + } + + .expires { + display: block; + font-size: 8pt; + position: relative; + .remove { + display: inline; + font-size: 8pt; + } + .expire-open-button { + text-transform: capitalize; + display: inline-block; + // border-bottom: 1px #1778af solid; + border: none; + background: transparent; + color: #1778af; + padding: 0 0 1px 0; + font-weight: bold; + font-size: 8pt; + text-decoration: underline; + cursor: pointer; + + &:hover { + border-bottom: 1px #1778af solid; + color: #135c85; + padding: 0 0 1px 0; + } + } + .expire-open-button-disabled { + text-transform: capitalize; + display: inline-block; + border: none; + background: transparent; + padding: 0 0 1px 0; + font-size: 8pt; + color: #6d6d6d; + font-weight: bold; + cursor: auto; + } + .expire-date-container { + position: absolute; + display: block; + right: 0; + bottom: -10px; + background: white; + padding: 6px; + border-radius: 5px; + min-height: 25px; + min-width: 150px; + + .remove { + color: #7b7b7b; + margin-right: 10px; + &:hover { + color: black; + } + } + + input { + height: 100%; + width: 100%; + box-sizing: border-box; + display: block; + } + + .date-finish { + cursor: pointer; + padding: 4px; + display: inline-block; + background: #1778af; + border-radius: 2px; + color: white; + font-weight: bold; + margin-top: 5px; + + &:hover { + background: #43a2d7; + } + } + } + } + } + } + } +} + +.react-datepicker__tab-loop .react-datepicker-popper { + z-index: 9000; +} diff --git a/src/components/my-widgets-collaborate-dialog.test.js b/src/components/my-widgets-collaborate-dialog.test.js new file mode 100644 index 000000000..b8c29d33e --- /dev/null +++ b/src/components/my-widgets-collaborate-dialog.test.js @@ -0,0 +1,450 @@ +/** + * @jest-environment jsdom + */ + + import React from 'react'; +import { render, screen, fireEvent, getByPlaceholderText, queryByTestId } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from 'react-query' +import MyWidgetsCollaborateDialog from './my-widgets-collaborate-dialog.jsx' +import { getInst } from '../util/test-helpers' +import '@testing-library/jest-dom' + +// Mocks API calls for react query +jest.mock('../util/api', () => ({ + ...jest.requireActual('../util/api').default, + apiGetUsers: () => (new Promise((resolve, reject) => { + resolve({ + 999: { + id: "999", + is_student: false, + avatar: "", + first: "Test_Creator_First", + last: "Test_Creator_Last" + }, + 3: { + id: "3", + is_student: false, + avatar: "", + first: "Test_Student_One", + last: "Test_Lastname_One" + }, + 6: { + id: "6", + is_student: false, + avatar: "", + first: "Test_Student_Two", + last: "Test_Lastname_Two" + }, + }) + })), + apiSearchUsers: () => (new Promise((resolve, reject) => { + resolve([ + { + id: "10", + is_student: true, + avatar: "", + first: "Person_S", + last: "Name" + }, + { + id: "11", + is_student: false, + avatar: "", + first: "Not_S", + last: "Person" + }, + ]) + })) +})) + +const makeOtherUserPerms = () => { + const keyValPermUsers = [ + [ + '3', + { + accessLevel: "1", + expireTime: null, + editable: false, + shareable: false, + can: { + view: true, + copy: false, + edit: false, + delete: false, + share: false + }, + remove: false + } + ], + [ + '6', + { + accessLevel: "1", + expireTime: null, + editable: false, + shareable: false, + can: { + view: true, + copy: false, + edit: false, + delete: false, + share: false + }, + remove: false + } + ], + [ + '999', + { + accessLevel: "30", + expireTime: null, + editable: true, + shareable: true, + can: { + view: true, + copy: true, + edit: true, + delete: true, + share: true + }, + remove: false + } + ] + ] + + const othersPerms = new Map(keyValPermUsers) + + return othersPerms +} + +const dateToStr = (date) => { + if (!date) return "" + return date.getFullYear() + '-' + ((date.getMonth() > 8) ? (date.getMonth() + 1) : ('0' + (date.getMonth() + 1))) + '-' + ((date.getDate() > 9) ? date.getDate() : ('0' + date.getDate())) +} + +const dateToPickerStr = (date) => { + if (!date) return "" + return ((date.getMonth() > 8) ? (date.getMonth() + 1) : ('0' + (date.getMonth() + 1))) + '/' + ((date.getDate() > 9) ? date.getDate() : ('0' + date.getDate())) + '/' + date.getFullYear() +} + +// Enables testing with react query +const renderWithClient = (children) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Turns retries off + retry: false, + }, + }, + }) + + const { rerender, ...result } = render({children}) + + return { + ...result, + rerender: (rerenderUi) => + rerender({rerenderUi}) + } +} + +const mockOnClose = jest.fn() + +const mockSetOtherPerms = jest.fn() + +describe('MyWidgetsSettingsDialog', () => { + + beforeEach(() => { + const div = document.createElement('div') + div.setAttribute('id', 'modal') + document.body.appendChild(div) + }) + + afterEach(() => { + const div = document.getElementById('modal') + if (div) { + document.body.removeChild(div) + } + }) + + it('renders correctly', async () => { + const testInst = getInst() + const rendered = renderWithClient() + // Waits for data to load + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + expect(screen.getByText('Add people:')).not.toBeNull() + expect(screen.getByText('Test_Creator_First Test_Creator_Last')).not.toBeNull() + expect(screen.getByText('Test_Student_One Test_Lastname_One')).not.toBeNull() + expect(screen.getByText('Test_Student_Two Test_Lastname_Two')).not.toBeNull() + expect(screen.queryByText('Student')).toBeNull() + }) + + it('disables content when user does not have full access', async () => { + const testInst = getInst() + const rendered = renderWithClient() + // Waits for data to load + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + // Does not detect aria-hidden buttons of other collaborators + expect(screen.getAllByRole('button', { + name: /x/i + }).length).toBe(1) + + // Confirms that all elements are disabled + const scoreDropDowns = screen.getAllByText(/View Scores/i) + for (const dropDown of scoreDropDowns) { + expect(dropDown).toBeDisabled() + } + + const expireBtns = screen.getAllByRole('button', { + name: /never/i + }) + for (const btn of expireBtns) { + expect(btn).toBeDisabled() + } + + // Confirms the search bar isn't present + expect(screen.queryByText('Add people:')).toBeNull() + }) + + test('search works properly', async () => { + let testInst = getInst() + const rendered = renderWithClient() + // Waits for data to load + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + // Searches for the student Person_S + const searchInput = screen.getByPlaceholderText(`Enter a Materia user's name or e-mail`) + fireEvent.change(searchInput, { target: { value: 'Person_S' } }) + + // Confirms the student is present in the search + const student = await screen.findAllByText(/Person_S/i) + expect(student.length).not.toBe(0) + }) + + it('displays modal when trying to add student without guest and prevents adding student', async () => { + let testInst = getInst() + testInst.guest_access = false + const rendered = renderWithClient() + // Waits for data to load + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + // Searches for the student Person_S + const searchInput = screen.getByPlaceholderText(`Enter a Materia user's name or e-mail`) + fireEvent.change(searchInput, { target: { value: 'Person_S' } }) + + // Clicks on the student + const student = await screen.findAllByText(/Person_S/i) + fireEvent.click(student[0]) + + // Confirms the modal popup and clicks the Okay button to dismiss it + const modal = await screen.findAllByText(/Share Not Allowed/i) + fireEvent.click(screen.getByRole('button', { name: /Okay/i })) + + // Confirms the modal closes and the student wasn't added + expect(screen.queryByText(/Share Not Allowed/i)).toBeNull() + expect(screen.queryByText(/Person_S/i)).toBeNull() + }) + + it('allows user to add non student', async () => { + let testInst = getInst() + testInst.guest_access = false + const rendered = renderWithClient() + // Waits for data to load + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + // Searches for the student Person_S + const searchInput = screen.getByPlaceholderText(`Enter a Materia user's name or e-mail`) + fireEvent.change(searchInput, { target: { value: 'S' } }) + + // Clicks on the student + const staffUser = await screen.findAllByText(/Not_S/i) + fireEvent.click(staffUser[0]) + + // Confirms the staff member was added, and since person_s is gone, the search list is also closed + expect(screen.queryByText(/Not_S/i)).not.toBeNull() + expect(screen.queryByText(/Person_S/i)).toBeNull() + }) + + it('allows user to add student when guest access is enabled', async () => { + let testInst = getInst() + const rendered = renderWithClient() + // Waits for data to load + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + // Searches for the student Person_S + const searchInput = screen.getByPlaceholderText(`Enter a Materia user's name or e-mail`) + fireEvent.change(searchInput, { target: { value: 'S' } }) + + // Clicks on the student + const staffUser = await screen.findAllByText(/Person_S/i) + fireEvent.click(staffUser[0]) + + // Confirms the student was added, and since not_s is gone, the search list is also closed + expect(screen.queryByText(/Not_S/i)).toBeNull() + expect(screen.queryByText(/Person_S/i)).not.toBeNull() + }) + + it('allows changing Permissions', async () => { + const testInst = getInst() + const rendered = renderWithClient() + // Waits for data to load + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + // Confirms only the current user has full access + let comboBoxes = screen.getAllByRole('combobox') + for (const val of comboBoxes) { + if (val.dataset.testid === '999-select') expect(val.value).toBe('30') + else expect(val.value).toBe('1') + } + + // Changes combo box value to full access + fireEvent.change(screen.getByTestId('6-select'), { target: { value: '30' } }) + + // Confirms the user was given full access + for (const val of comboBoxes) { + if (val.dataset.testid === '999-select' || val.dataset.testid === '6-select') expect(val.value).toBe('30') + else expect(val.value).toBe('1') + } + }) + + it('allows changing Expiration date', async () => { + let testInst = getInst() + const curData = new Date() + const newDate = new Date(curData.getTime() + 86400000) + const rendered = renderWithClient() + // Waits for data to load + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + // Confirms current user's expire is disabled and other user's is not + expect(screen.getByTestId('999-never-expire')).toBeDisabled() + expect(screen.getByTestId('6-never-expire')).not.toBeDisabled() + + // Clicks on other user's expire button + fireEvent.click(screen.getByTestId('6-never-expire')) + + // Confirms the date picker is open + expect(screen.getByPlaceholderText(/Date/i).value).toBe(dateToPickerStr(curData)) + + // Changes the date to a new date + fireEvent.change(screen.getByPlaceholderText(/Date/i), { target: { value: dateToPickerStr(newDate) } }) + + // Confirms the date was updated + expect(screen.getByPlaceholderText(/Date/i).value).toBe(dateToPickerStr(newDate)) + + // Clicks on the Done button to confirm the new date + fireEvent.click(screen.getByText(/Done/i)) + + // Waits for the date text to update + await screen.findAllByText(dateToStr(newDate)) + + // Confirms the date was updated properly + expect(screen.getByTestId('6-expire').innerHTML).toBe(dateToStr(newDate)) + }) + + it('allows cancelling changing Expiration date', async () => { + let testInst = getInst() + const curData = new Date() + const newDate = new Date(curData.getTime() + 86400000) + const rendered = renderWithClient() + // Waits for data to load + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + // Confirms current user's expire is disabled and other user's is not + expect(screen.getByTestId('999-never-expire')).toBeDisabled() + expect(screen.getByTestId('6-never-expire')).not.toBeDisabled() + + // Clicks on other user's expire button + fireEvent.click(screen.getByTestId('6-never-expire')) + + // Confirms the date picker is open + expect(screen.getByPlaceholderText(/Date/i).value).toBe(dateToPickerStr(curData)) + + // Changes the date to a new date + fireEvent.change(screen.getByPlaceholderText(/Date/i), { target: { value: dateToPickerStr(newDate) } }) + + // Confirms the date was updated + expect(screen.getByPlaceholderText(/Date/i).value).toBe(dateToPickerStr(newDate)) + + // Clicks on the Cancel button + fireEvent.click(screen.getByText(/Set to Never/i)) + + // Confirms the date wasn't changed + expect(screen.getByTestId('6-never-expire')).not.toBeNull() + }) + + it('allows deletion of collaboration users', async () => { + let testInst = getInst() + const rendered = renderWithClient() + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + // Does not detect aria-hidden buttons of other collaborators + expect(screen.getAllByRole('button', { + name: /x/i + }).length).toBe(3) + + fireEvent.click(screen.getByTestId('6-delete-user')) + + // Does not detect aria-hidden buttons of other collaborators + expect(screen.getAllByRole('button', { + name: /x/i + }).length).toBe(2) + }) + + it('allows deletion of owner', async () => { + let testInst = getInst() + const rendered = renderWithClient() + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + // Confirms three buttons exist + expect(screen.getAllByRole('button', { + name: /x/i + }).length).toBe(3) + + // Tries to delete current user + fireEvent.click(screen.getByTestId('999-delete-user')) + + // Checks confirmation modal appears + expect(screen.queryByTestId('accept-remove-access')).not.toBeNull() + + // Clicks on accept button + fireEvent.click(screen.getByTestId('accept-remove-access')) + + // Checks confirmation modal appears + expect(screen.queryByTestId('accept-remove-access')).toBeNull() + + // Confirms owner leaves + expect(screen.getAllByRole('button', { + name: /x/i + }).length).toBe(2) + }) + + it('allows canceling deletion of owner', async () => { + let testInst = getInst() + const rendered = renderWithClient() + await screen.findAllByText('Test_Student_One Test_Lastname_One') + + // Confirms three buttons exist + expect(screen.getAllByRole('button', { + name: /x/i + }).length).toBe(3) + + // Tries to delete current user + fireEvent.click(screen.getByTestId('999-delete-user')) + + // Checks confirmation modal appears + expect(screen.queryByTestId('cancel-remove-access')).not.toBeNull() + + // Clicks on cancel button + fireEvent.click(screen.getByTestId('cancel-remove-access')) + + // Checks confirmation modal appears + expect(screen.queryByTestId('cancel-remove-access')).toBeNull() + + // Confirms owner stays + expect(screen.getAllByRole('button', { + name: /x/i + }).length).toBe(3) + }) + +}) \ No newline at end of file diff --git a/src/components/my-widgets-collaborate-user-row.jsx b/src/components/my-widgets-collaborate-user-row.jsx new file mode 100644 index 000000000..5b6c29bad --- /dev/null +++ b/src/components/my-widgets-collaborate-user-row.jsx @@ -0,0 +1,178 @@ +import React, { useEffect, useState, useRef } from 'react' +import { Portal } from 'react-overlays' +import { access } from './materia-constants' +import DatePicker from 'react-datepicker' +import './my-widgets-collaborate-dialog.scss' + +const accessLevels = { + [access.VISIBLE]: { value: access.VISIBLE, text: 'View Scores' }, + [access.FULL]: { value: access.FULL, text: 'Full' } +} + +const initRowState = () => { + return({ + remove: false, + showDemoteDialog: false + }) +} + +const dateToStr = (date) => { + if (!date) return '' + const monthString = `${date.getMonth() + 1}`.padStart(2, '0') + const dayString = `${date.getDate()}`.padStart(2, '0') + return `${date.getFullYear()}-${monthString}-${dayString}` +} + +// convert time in ms to a displayable format for the component +const timestampToDisplayDate = (timestamp) => { + if(!timestamp) return (new Date()) + return new Date(timestamp*1000); +} + +// Portal so date picker doesn't have to worry about overflow +const CalendarContainer = ({children}) => { + const el = document.getElementById('calendar-portal') + + return ( + + {children} + + ) +} + +const CollaborateUserRow = ({user, perms, myPerms, isCurrentUser, onChange, readOnly}) => { + const [state, setState] = useState({...initRowState(), ...perms, expireDate: timestampToDisplayDate(perms.expireTime)}) + const ref = useRef() + + // updates parent everytime local state changes + useEffect(() => { + onChange(user.id, { + accessLevel: state.accessLevel, + expireTime: state.expireTime, + editable: state.editable, + shareable: state.shareable, + can: state.can, + remove: state.remove + }) + }, [state]) + + const checkForWarning = () => { + if(isCurrentUser) { + setState({...state, showDemoteDialog: true}) + } + else removeAccess() + } + + const cancelSelfDemote = () => setState({...state, showDemoteDialog: false}) + + const removeAccess = () => setState({...state, remove: true, showDemoteDialog: false}) + + const toggleShowExpire = () => { + if (!isCurrentUser) + setState({...state, showExpire: !state.showExpire}) + } + + const clearExpire = () => setState({...state, showExpire: false, expireDate: timestampToDisplayDate(), expireTime: null}) + + const changeLevel = e => setState({...state, accessLevel: e.target.value}) + + const onExpireChange = date => { + const timestamp = date.getTime()/1000 + setState({...state, expireDate: date, expireTime: timestamp}) + } + + let selfDemoteWarningRender = null + if (state.showDemoteDialog) { + selfDemoteWarningRender = ( +
    +
    +
    + Are you sure you want to limit your access? +
    + No + Yes +
    + ) + } + + const selectOptionElements = Object.values(accessLevels).map(level => ( + + )) + + let expirationSettingRender = null + + if (state.showExpire) { + expirationSettingRender = ( + + + Set to Never + Done + + ) + } else { + if (state.expireTime !== null) { + expirationSettingRender = ( + + ) + } else { + expirationSettingRender = ( + + ) + } + } + + return ( +
    + + +
    + + + + {`${user.first} ${user.last}`} + +
    + { selfDemoteWarningRender } +
    + +
    + Expires: + { expirationSettingRender } +
    +
    +
    + ) +} + +export default CollaborateUserRow diff --git a/src/components/my-widgets-copy-dialog.jsx b/src/components/my-widgets-copy-dialog.jsx new file mode 100644 index 000000000..fbb88dcf9 --- /dev/null +++ b/src/components/my-widgets-copy-dialog.jsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react' +import Modal from './modal' +import './my-widgets-copy-dialog.scss' + +const MyWidgetsCopyDialog = ({onClose, onCopy, name}) => { + const [newTitle, setNewTitle] = useState(`${name} (Copy)`) + const [copyPermissions, setCopyPermissions] = useState(false) + + const handleTitleChange = e => setNewTitle(e.target.value) + const handleOwnerAccessChange = e => setCopyPermissions(e.target.checked) + const handleCopyClick = () => onCopy(newTitle, copyPermissions) + + return ( + +
    + Make a Copy +
    +
    +
    + + +
    +
    + +

    + If checked, all users who have access to the original widget will continue to have access to the new copy. + Note that the rules for sharing widgets with students will still apply. +

    +
    + +
    +
    +
    +
    + ) +} + +export default MyWidgetsCopyDialog diff --git a/src/components/my-widgets-copy-dialog.scss b/src/components/my-widgets-copy-dialog.scss new file mode 100644 index 000000000..5e1ca37fa --- /dev/null +++ b/src/components/my-widgets-copy-dialog.scss @@ -0,0 +1,82 @@ +.modal .copy-modal { + width: 620px; + height: 330px; + padding: 10px 10px 0 10px; + + .title { + margin: 0; + padding: 0; + font-size: 1.3em; + color: #555; + border-bottom: #999 dotted 1px; + padding-bottom: 20px; + margin-bottom: 20px; + position: relative; + text-align: left; + display: block; + font-weight: bold; + } + + a.close { + display: block; + position: absolute; + right: 5px; + top: 4px; + margin: 4px; + text-indent: -10000px; + width: 19px; + height: 19px; + cursor: pointer; + } + + .container { + width: 100%; + } + + .input_desc { + padding: 10px; + margin-bottom: 0; + background: #f2f2f2; + border-radius: 5px; + margin: 0.5em 1em 1em; + } + + .title_container label { + margin: 0 25px 0 0; + } + + .new-title { + width: 345px; + height: 30px; + padding-left: 10px; + position: relative; + z-index: 2; + } + + .options_container { + margin: 20px 0 0 0; + padding: 20px 0 0 0; + text-align: left; + } + + .options_container { + input { + margin-right: 8px; + cursor: pointer; + } + + label { + cursor: pointer; + } + } + + .bottom_buttons { + margin-top: 20px; + text-align: center; + width: 100%; + + .cancel_button { + cursor: pointer; + } + } +} diff --git a/src/components/my-widgets-embed.jsx b/src/components/my-widgets-embed.jsx new file mode 100644 index 000000000..9817a890c --- /dev/null +++ b/src/components/my-widgets-embed.jsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react' + +const getEmbedLink = (inst, autoplayToggle = true) => { + if (inst === null) return '' + + let footerHeight = 38 + + const width = String(inst.widget.width) !== '0' ? inst.widget.width : 800 + const height = String(inst.widget.height) !== '0' ? inst.widget.height + footerHeight : 600 + + // This is kind of nasty, but cleaner alternatives are not currently worth the effort. + return `` +} + +const MyWidgetEmbedInfo = ({inst}) => { + const [autoplay, setAutoplay] = useState(true) + + return ( +
    +

    Embed Code

    +

    Paste this HTML into a webpage to embed it.

    + + + {setAutoplay(!autoplay)}} + /> + + { autoplay ? 'Widget starts automatically' : 'Widget starts after clicking play' } + +
    + ) +} + +export default MyWidgetEmbedInfo diff --git a/src/components/my-widgets-export.jsx b/src/components/my-widgets-export.jsx new file mode 100644 index 000000000..de69bd6b7 --- /dev/null +++ b/src/components/my-widgets-export.jsx @@ -0,0 +1,188 @@ +import React, { useEffect, useState } from 'react' +import Modal from './modal' +import './my-widgets-export.scss' + +const DEFAULT_OPTIONS = ['Questions and Answers'] + +const initState = () => ({ + header: 'No semester selected', + selectedSemesters: '', + checkAll: false, + exportOptions: DEFAULT_OPTIONS, + exportType: 'Questions and Answers', + semesterOptions: [] +}) + +const MyWidgetsExport = ({onClose, inst, scores}) => { + const [state, setState] = useState(initState()) + const [showOptions, setShowOptions] = useState(false) + + // Initializes data + useEffect (() => { + let hasScores = false + let tmpOps = DEFAULT_OPTIONS + + scores.forEach((val) => { + if (val.distribution) hasScores = true + }) + + if (scores.length === 0 || !hasScores) { + setState({...state, exportOptions: DEFAULT_OPTIONS, exportType: tmpOps[0], header: 'Export Options Limited, No Scores Available'}) + } + else { + const scores_only = inst.guest_access ? 'All Scores' : 'High Scores' + + tmpOps = [scores_only, 'Full Event Log', 'Questions and Answers', 'Referrer URLs'] + if (inst.widget.meta_data.playdata_exporters?.length > 0) { + tmpOps = tmpOps.concat(inst.widget.meta_data.playdata_exporters) + } + + let options = new Array(scores.length).fill(false) + options[0] = true + setState({...state, exportOptions: tmpOps, exportType: tmpOps[0], semesterOptions: options}) + } + }, []) + + // Sets selected semesters and their respective header text + useEffect(() => { + if (state.semesterOptions.length > 0) { + let str = '' // creates: 2020 Fall, 2020 Spring + let str_cpy = '' // creates: 2020-Fall,2020-Spring + let _checkAll = false + + for (let i = 0; i < state.semesterOptions.length; i++) { + if (state.semesterOptions[i]) { + if (str !== '') { + str += ', ' + str_cpy += ',' + } + + str += scores[i].year + ' ' + scores[i].term + str_cpy += scores[i].year + '-' + scores[i].term + } + } + + if (str === '') { + str = 'No semester selected' + } + + // sets check all if all options are checked/unchecked + if (state.semesterOptions.includes(true) && !state.semesterOptions.includes(false)) { + _checkAll = true + } + + setState({...state, checkAll: _checkAll, header: str, selectedSemesters: str_cpy}) + } + }, [state.semesterOptions]) + + const checkAllVals = () => { + if (state.semesterOptions.length > 0) { + const arr = new Array(scores.length).fill(!state.checkAll) + setState({...state, semesterOptions: arr, checkAll: !state.checkAll}) + } + } + + // Used on semester selection + const semesterCheck = (index) => { + let arr = [... state.semesterOptions] + arr[index] = !arr[index] + setState({...state, semesterOptions: arr}) + } + + const exportOptionElements = state.exportOptions.map((val, index) => ) + + let canvasDataDisclaimer = null + if (state.exportType === 'All Scores' || state.exportType === 'High Scores') { + canvasDataDisclaimer = ( +

    + You don't need to export scores and import them into Canvas if you have embedded a widget as a graded assignment. + + {' '}See how! + +

    + ) + } + + const semesterOptionElements = scores.map((val, index) => ( +
  • + {semesterCheck(index)}}> + +
  • + )) + + return ( + +
    +
    +
    + Export + {setShowOptions(!showOptions)}}> + { showOptions + ? 'Hide' + : 'Semesters' + } + +
    +
    +
    +
    +

    {state.header}

    +
    +

    + Export Options{' '} + provide a means of exporting student performance information in .CSV format, much like an excel spreadsheet. + Use exported data to analyze, compare, and gauge class performance. + Additionally, export options are provided for Question and Answer data + as well as Referrer URLs for the selected widget. Full Event Log {' '} + is intended for advanced users performing data analysis. Note that some widgets may provide unique or specialized export options. +

    +
    + +

    + + Download {state.exportType} + +

    + { canvasDataDisclaimer } +
    +
    +

    + + Cancel + +

    +
    +
    +
    +

    Semesters

    +

    Export which semesters?

    +

    + No semesters available +

    +
      +
    • 1 ? 'active' : ''}`}> + + +
    • + { semesterOptionElements } +
    +
    +
    + ) +} + +export default MyWidgetsExport diff --git a/src/components/my-widgets-export.scss b/src/components/my-widgets-export.scss new file mode 100644 index 000000000..2c3a455ce --- /dev/null +++ b/src/components/my-widgets-export.scss @@ -0,0 +1,190 @@ +@import './include.scss'; + +.export-modal { + .top-bar { + display: flex; + flex-direction: column; + margin-bottom: 20px; + margin-top: 35px; + + .content { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + padding-bottom: 20px; + + .title { + font-size: 1.5em; + color: #484848; + font-weight: bold; + margin-left: 15px; + } + + .semester-btn { + color: white; + font-size: 0.9em; + min-width: 75px; + text-align: center; + background-color: #f1824c; + padding: 10px; + margin-left: auto; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + } + + .underline { + margin: 0 10px; + border-bottom: #999 dotted 2px; + //width: 100%; + } + } + + .semester-content { + display: flex; + flex-direction: column; + padding: 0 10px; + + h3 { + margin: 0 0 15px 0; + color: #0b97e8; + font-size: 1.2em; + text-align: center; + } + + .score-table { + background: #f5f5f5; + width: 570px; + margin: 0 5px; + border: 1px solid #c1c1c1; + border-radius: 3px; + overflow-y: auto; + + #export-scores-description { + text-align: left; + padding: 0px 15px 0px 15px; + line-height: 1.5em; + margin-top: 20px; + + .highlight { + color: #0b97e8; + } + } + + .download-controls { + position: relative; + z-index: 1; + margin: 15px 0px 10px 0px; + padding: 0px 10px; + text-align: center; + + select { + cursor: pointer; + } + + .see-how { + padding: 0px 5px; + text-align: left; + } + + .download { + margin: 20px 0; + + a { + padding: 7px 12px 8px; + background-image: none; + } + + .arrow-down { + margin: 0; + width: 21px; + height: 21px; + display: inline-block; + vertical-align: middle; + margin-top: -5px; + margin-right: 10px; + } + } + } + } + + .cancel { + font-size: 16px; + font-family: 'Lato', arial, sans-serif; + text-align: center; + + a { + background: none; + border: none; + cursor: pointer; + } + } + } +} +.download-options { + display: none; + background: #f1824c; + color: white; + vertical-align: top; + height: 100%; + margin-left: 40px; + position: absolute; + top: 0; + right: -340px; + width: 300px; + padding-left: 20px; + padding-right: 20px; + + ul { + list-style: none; + padding: 0; + } + + h4 { + font-size: 1.3rem; + margin: 1.375em 0 0 0; + } + + li { + margin-bottom: 10px; + + &:first-of-type { + border-bottom: 1px #d6d6d6 solid; + padding-bottom: 5px; + display: none; + + &.active { + display: list-item; + } + } + + input { + margin-right: 15px; + cursor: pointer; + } + + label { + cursor: pointer; + } + } + + p { + font-weight: bold; + } + + .export-none { + display: none; + + &.active { + display: inline; + } + } + + &.active { + display: inline-block; + } +} diff --git a/src/components/my-widgets-instance-card.jsx b/src/components/my-widgets-instance-card.jsx new file mode 100644 index 000000000..a0100cabb --- /dev/null +++ b/src/components/my-widgets-instance-card.jsx @@ -0,0 +1,45 @@ +import React from 'react' +import createHighlightSpan from '../util/create-highlight-span' + +const MyWidgetsInstanceCard = ({inst, indexVal, hidden = false, selected = false, onClick = () => {}, beard = null, searchText = null}) => { + const {id, widget, name, is_draft, img} = inst + // Handle multiple conditional classes by keeping an array of all classes to apply, then imploding it in the render + const classes = ['my-widgets-instance-card', 'widget'] + if (hidden) classes.push('hidden') + if (is_draft) classes.push('is_draft') + if (beard) classes.push('bearded', `small_${beard}`) + if (selected) classes.push('selected') + + // Default widget/instance names to plain text + let nameTextRender = name + let widgetNameTextRender = widget.name + // This will convert one or both of the regular strings into HTML strings containing + // spans with styled classes on them, requiring us to use dangerouslySetInnerHTML below + if ( !hidden && searchText) { + nameTextRender = createHighlightSpan(nameTextRender, searchText) + widgetNameTextRender = createHighlightSpan(widgetNameTextRender, searchText) + } + + const clickHandler = () => onClick(inst, indexVal) + + return ( +
    + +
      +
    • +
    • +
    • +
    • +
    • + {is_draft ? 'Draft' : ''} +
    • +
    +
    + ) +} + +export default MyWidgetsInstanceCard diff --git a/src/components/my-widgets-page.jsx b/src/components/my-widgets-page.jsx new file mode 100644 index 000000000..50cb2bdcd --- /dev/null +++ b/src/components/my-widgets-page.jsx @@ -0,0 +1,404 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { useQuery } from 'react-query' +import { apiGetUser, readFromStorage, apiGetUserPermsForInstance } from '../util/api' +import rawPermsToObj from '../util/raw-perms-to-object' +import Header from './header' +import MyWidgetsSideBar from './my-widgets-side-bar' +import MyWidgetSelectedInstance from './my-widgets-selected-instance' +import LoadingIcon from './loading-icon' +import useInstanceList from './hooks/useInstanceList' +import useCopyWidget from './hooks/useCopyWidget' +import useDeleteWidget from './hooks/useDeleteWidget' +import useKonamiCode from './hooks/useKonamiCode' +import './css/beard-mode.scss' +import './my-widgets-page.scss' + +function getRandomInt(min, max) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +const randomBeard = () => { + const beard_vals = ['black_chops', 'dusty_full', 'grey_gandalf', 'red_soul'] + return beard_vals[getRandomInt(0, 3)] +} + +const localBeard = window.localStorage.beardMode + +const MyWidgetsPage = () => { + readFromStorage() + const [state, setState] = useState({ + selectedInst: null, + otherUserPerms: null, + myPerms: null, + noAccess: false, + widgetHash: window.location.href.split('#')[1]?.split('-')[0], + currentBeard: '' + }) + + const instanceList = useInstanceList() + const [invalidLogin, setInvalidLogin] = useState(false) + const [showCollab, setShowCollab] = useState(false) + + const [beardMode, setBeardMode] = useState(!!localBeard ? localBeard === 'true' : false) + const validCode = useKonamiCode() + const copyWidget = useCopyWidget() + const deleteWidget = useDeleteWidget() + + const { data: user } = useQuery({ + queryKey: 'user', + queryFn: apiGetUser, + staleTime: Infinity + }) + + const { data: permUsers } = useQuery({ + queryKey: ['user-perms', state.selectedInst?.id, state.widgetHash], + queryFn: () => apiGetUserPermsForInstance(state.selectedInst?.id), + enabled: !!state.selectedInst && !!state.selectedInst.id && state.selectedInst?.id !== undefined, + placeholderData: null, + staleTime: Infinity + }) + + // konami code activate (or deactivate) + useEffect(() => { + if (validCode) { + window.localStorage.beardMode = !beardMode + setBeardMode(!beardMode) + } + }, [validCode]) + + // hook associated with the invalidLogin error + useEffect(() => { + if (invalidLogin) window.location.reload(); + }, [invalidLogin]) + + // hook to attach the hashchange event listener to the window + useEffect(() => { + window.addEventListener('hashchange', listenToHashChange) + + // check for collab hash on page load + setShowCollab(hashContainsCollab()) + + return () => { + window.removeEventListener('hashchange', listenToHashChange) + } + }, []) + + // checks whether "-collab" is contained in hash id + const hashContainsCollab = () => { + const match = window.location.hash.match(/#(?:[A-Za-z0-9]{5})(-collab)*$/) + + if (match != null && match[1] != null) + { + return match[1] == '-collab' + } + return false + } + + // hook associated with updates to the selected instance and perms associated with that instance + useEffect(() => { + if (state.selectedInst && permUsers && permUsers.user_perms?.hasOwnProperty(user.id)) { + const isEditable = state.selectedInst.widget.is_editable === "1" + const othersPerms = new Map() + for (const i in permUsers.widget_user_perms) { + othersPerms.set(parseInt(i), rawPermsToObj(permUsers.widget_user_perms[i], isEditable)) + } + let _myPerms + for (const i in permUsers.user_perms) { + _myPerms = rawPermsToObj(permUsers.user_perms[i], isEditable) + } + setState({ ...state, otherUserPerms: othersPerms, myPerms: _myPerms }) + } + else if (state.selectedInst && permUsers) { + setState({...state, noAccess: true}) + } + }, [state.selectedInst, JSON.stringify(permUsers)]) + + // hook associated with updates to the widget list OR an update to the widget hash + // if there is a widget hash present AND the selected instance does not match the hash, perform an update to the selected widget state info + useEffect(() => { + if (instanceList.error) setInvalidLogin(true) + + // if a widget hash exists in the URL OR a widget is already selected in state + if ((state.widgetHash && state.widgetHash.length > 0) || state.selectedInst) { + + // the desired ID defaults to the widget hash + // selectedInst may lag behind for several reasons, including loading of the list or changes to the hash in the url + let desiredId = state.widgetHash ? state.widgetHash : state.selectedInst.id + + let hashParams = desiredId.split('-') + if (hashParams.length > 1) + { + desiredId = hashParams[0]; + } + + // if the selected widget is loaded, go ahead and display it. The remaining widget list can comtinue to load concurrently + if (selectedInstanceHasLoaded(desiredId)) { + // prompt the new instance to be selected if it's different from the one in the hash (or not selected at all) + let selectWidget = state.widgetHash && (!state.selectedInst || state.selectedInst.id != state.widgetHash) + + if (selectWidget) { + // locate the desired widget instance from the widgetList + let widgetFound = null + instanceList.instances.forEach((widget, index) => { + if (widget.id == desiredId) { + widgetFound = widget + if (selectWidget) onSelect(widget, index) + } + }) + + setState({ + ...state, + selectedInst: widgetFound, + noAccess: widgetFound == null, + }) + } + } + else if (!instanceList.isFetching) { + // widgetList is fully loaded and the selected instance is not found + // let the user know it's missing or unavailable + setState({ + ...state, + selectedInst: null, + noAccess: true, + }) + } + } + }, [instanceList.instances, state.widgetHash, showCollab]) + + // hook to watch otherUserPerms (which despite the name also includes the current user perms) + // if the current user is no longer in the perms list, purge the selected instance & force a re-fetch of the list + useEffect(() => { + if (state.selectedInst && !state.otherUserPerms?.get(user.id)) { + setState({ + ...state, + selectedInst: null, + widgetHash: null + }) + } + },[state.otherUserPerms]) + + // event listener to listen to hash changes in the URL, so the selected instance can be updated appropriately + const listenToHashChange = () => { + const match = window.location.hash.match(/#([A-Za-z0-9]{5})(-collab)*$/) + if (match != null && match[1] != null) + { + setShowCollab(hashContainsCollab()) + setState({...state, widgetHash: match[1]}) + } + } + // boolean to verify if the current instance list in state contains the specified instance + const selectedInstanceHasLoaded = (inst) => { + if (!inst) return false + return instanceList.instances.some(instance => instance.id == inst) + } + + // updates necessary state information for a newly selected widget + const onSelect = (inst, index) => { + if (inst.is_fake) return + setState({ ...state, selectedInst: inst, widgetHash: inst.id, noAccess: false, currentBeard: beards[index] }) + + // updates window URL history with current widget hash + window.history.pushState(document.body.innerHTML, document.title, `#${inst.id}`) + } + + // an instance has been copied: the mutation will optimistically update the widget list while the list is re-fetched from the server + const onCopy = (instId, newTitle, newPerm, inst) => { + setState({ ...state, selectedInst: null }) + + copyWidget.mutate( + { + instId: instId, + title: newTitle, + copyPermissions: newPerm, + widgetName: inst.widget.name, + dir: inst.widget.dir, + successFunc: (data) => { + if (!data || (data.type == 'error')) + { + console.error(`Failed to copy widget with error: ${data.msg}`); + if (data.title == "Invalid Login") + { + setInvalidLogin(true) + } + } + } + }, + { + // Still waiting on the widget list to refresh, return to a 'loading' state and indicate a post-fetch change is coming. + onSettled: newInst => { + setState({ + ...state, + selectedInst: null, + widgetHash: newInst.id + }) + } + } + ) + } + + // an instance has been deleted: the mutation will optimistically update the widget list while the list is re-fetched from the server + const onDelete = inst => { + + deleteWidget.mutate( + { + instId: inst.id, + successFunc: (data) => { + if (!data || (data.type == 'error')) + { + console.error(`Deletion failed with error: ${data.msg}`); + if (data.title =="Invalid Login") + { + setInvalidLogin(true) + } + } + } + }, + { + // Still waiting on the widget list to refresh, return to a 'loading' state and indicate a post-fetch change is coming. + onSettled: () => { + setState({ + ...state, + selectedInst: null, + widgetHash: null + }) + } + } + ) + } + + // Note this method is only used when a widget setting is updated via the settings dialog (attempts, availability, guest mode) + // It is NOT called when actually editing a widget (going to the creator) + const onEdit = (inst) => { + setState({ + ...state, + selectedInst: inst, + widgetHash: inst.id + }) + } + + const beards = useMemo( + () => { + const result = [] + instanceList.instances?.forEach(() => { + result.push(randomBeard()) + }) + return result + }, + [instanceList.instances] + ) + + let widgetCatalogCalloutRender = null + if (!instanceList.isFetching && instanceList.instances?.length === 0) { + widgetCatalogCalloutRender = ( +
    + Click here to start making a new widget! +
    + ) + } + + /** + * If the user is loading, show a loading screen. If the user is fetching, show a loading screen. If + * the user has no widgets, show a message. If the user has no selected widget, show a message. If the + * user has a selected widget, show the widget + * @returns The main content of the page. + */ + const mainContentRender = () => { + // Go through a series of cascading conditional checks to determine what will be rendered on the right side of the page + + const widgetSpecified = (state.widgetHash || state.selectedInst) + + // A widget is selected, we're in the process of fetching it but it hasn't returned from the API yet + if (instanceList.isFetching && widgetSpecified && !selectedInstanceHasLoaded(widgetSpecified)) { + return
    +

    Loading Your Widget

    +
    + } + + // No widget specified, fetch in progress + if (instanceList.isFetching && !widgetSpecified) { + return
    +

    Loading

    +
    + } + + // A widget was specified but we don't have access rights to it + if (state.noAccess) { + return
    +
    + You do not have access to this widget or this widget does not exist. +
    +
    + } + + // Not loading anything and no widgets returned from the API + if (!instanceList.isFetching && instanceList.instances?.length < 1) { + return
    +

    You have no widgets!

    +

    Make a new widget in the widget catalog.

    +
    + } + + // Not loading anything, widgets are waiting to be selected + if (!widgetSpecified) { + return
    +

    Your Widgets

    +

    Choose a widget from the list on the left.

    +
    + } + + // Not loading anything, a widget is currently selected + if (state.selectedInst) { + return setState({ ...state, otherUserPerms: p })} + beardMode={beardMode} + beard={state.currentBeard} + setInvalidLogin={setInvalidLogin} + showCollab={showCollab} + setShowCollab={setShowCollab} + /> + } + + // Fallback to keep the selected instance content area intact (presumably some other state is forthcoming) + else { + return
    +

    Loading

    +
    + } + } + + return ( + <> +
    +
    + + {widgetCatalogCalloutRender} + +
    +
    + {mainContentRender()} +
    + +
    +
    + + ) +} + +export default MyWidgetsPage \ No newline at end of file diff --git a/src/components/my-widgets-page.scss b/src/components/my-widgets-page.scss new file mode 100644 index 000000000..84808a4f0 --- /dev/null +++ b/src/components/my-widgets-page.scss @@ -0,0 +1,902 @@ +@import 'include.scss'; + +.my_widgets { + display: flex; + justify-content: center; + + .container { + position: relative; + display: flex; + align-items: flex-start; + margin: 0 auto 100px auto; + // width: 970px; + overflow-x: visible; + + .container_main-content { + top: 0px; + box-shadow: 1px 3px 10px #dbdbdb; + } + + section.directions { + text-align: center; + + &.unchosen { + &:not(.bearded) { + background: #fff url('/img/kogneato_mywidgets.svg') 50% 30%/250px auto no-repeat; + } + min-height: 850px; + } + + .error-nowidget { + width: 80%; + margin: 50px auto 5px auto; + font-weight: bold; + font-size: 1.5em; + } + + h1 { + font-size: 3em; + font-weight: bold; + padding: 0; + margin: 50px 0 5px 0; + + &.loading-text { + margin-bottom: 2em; + } + } + + h2 { + font-size: 2em; + font-weight: bold; + padding: 0; + margin: 30px 0 5px 0; + + &.loading-text { + margin-bottom: 2em; + } + } + + p { + font-size: 1em; + font-weight: 300; + padding: 0; + margin: 0; + } + } + + section.page { + position: relative; + z-index: 100; + width: 700px; + min-height: 850px; + margin: 0 0 0 260px; + + background: #fff; + } + + aside { + // background: #fff; + // border: rgba(0, 0, 0, 0.11) 1px solid; + // box-shadow: 1px 3px 10px #888; + width: 250px; + height: 100%; + margin: 0; + // padding: 0 20px 0 0; + position: absolute; + top: 0; + // left: 5px; + z-index: 80; + // border-top-left-radius: 10px; + background: #ebebeb; + min-height: 200px; + // border-bottom-right-radius: 4px; + // border-bottom-left-radius: 4px; + + .top { + color: #a6a6a6; + height: 34px; + // border-top-left-radius: 4px; + // border-bottom: 1px solid #0276b9; + + background: #3fa7e3; + // background: linear-gradient(#83caf3 16%, #37a9e6 89%); + } + + .search { + position: relative; + padding: 10px 0 30px 0; + + background: #ebebeb; + + transition: background 1s; + + &.loading { + background: #fff; + } + + .loading-message { + position: absolute; + left: 40px; + top: 10px; + + font-size: 14px; + font-weight: 700; + } + + .search-icon { + position: absolute; + top: 12px; + left: 13px; + height: 14px; + width: 18px; + fill: #9a9a9a; + svg { + height: 100%; + width: 100%; + } + } + + .search-close { + position: absolute; + top: 8px; + right: 40px; + margin-top: 1px; + color: rgb(221, 221, 221); + cursor: pointer; + } + + .textbox { + position: absolute; + border: none; + outline: none; + width: 165px; + left: 30px; + background: transparent; + padding-top: 2px; + } + + .textbox::-ms-clear { + display: none; + } + + .textbox-background { + position: absolute; + width: 205px; + height: 20px; + left: 9px; + background: #fff; + border: solid 1px #b0b0b0; + border-radius: 12px; + } + } + + // .courses { + // // width: 240px; + // height: 700px; + // overflow: auto; + // // border-top: 1px solid rgba(100, 100, 100, 0.2); + + .widget_list { + height: calc(100% - 74px); // combined height of top and search sections + overflow: auto; + + .icon { + background-repeat: no-repeat; + background-size: 60px; + margin: 0; + padding: 0; + width: 60px; + height: 60px; + display: inline-block; + margin-right: 5px; + } + + .widget { + cursor: pointer; + user-select: none; + + &:hover { + // background-color: #ffe0a5; + background: #d0eeff; + } + + .highlighted { + background-color: #ffd800; + } + } + + .my-widgets-instance-card { + position: relative; + transition: background 0.5s; + + &:nth-child(odd):not(:hover) { + background: #fff; + } + + &.selected:not(:hover) { + // background: #ffcb68; + // color: #fff; + background: #86d3ff; + } + + &.hidden { + display: none; + } + + &.is_draft ul li.score { + border-radius: 3px; + + width: 45px; + height: 15px; + + text-align: center; + font-size: 12px; + font-weight: 900; + color: #ffffff !important; + + background: #b944cc; + } + } + + h2 { + font-size: 0.7em; + margin: 0 0 0 1px; + padding: 3px 7px; + background-color: #d3d3d3; + border-bottom: 1px #aaa solid; + border-top: 1px #ccc solid; + color: #2a2a2a; + } + + div { + padding: 10px; + } + + div > ul { + display: inline-block; + width: 130px; + vertical-align: top; + } + + ul { + margin: 0; + padding: 0; + + li { + margin: 0; + padding: 0; + list-style: none; + overflow: hidden; + + &.title { + font-weight: bold; + font-size: 0.9em; + } + + &.type { + color: #444; + font-size: 0.7em; + font-size: 11px; + } + + &.score { + color: #444; + margin-top: 10px; + font-size: 12px; + } + } + } + } + // } + + h1 { + padding: 7px 0 0 11px; + margin: 0; + font-size: 15px; + color: #ffffff; + text-shadow: #184863 1px 1px 1px; + font-weight: 900; + } + } + + .description h1 { + padding: 7px 0 0 11px; + margin: 0; + font-size: 15px; + color: #ffffff; + text-shadow: #184863 1px 1px 1px; + font-weight: 900; + } + } + + // Selected instance page + .page { + .header { + padding: 15px 10px 0 10px; + margin: 0; + + h1 { + margin: 0; + padding: 0; + display: inline-block; + font-weight: 900; + font-size: 25px; + padding-right: 10px; + word-break: break-all; + } + + h2 { + margin: 0; + padding: 0; + color: #999999; + font-size: 23px; + font-weight: 900; + white-space: nowrap; + display: inline; + } + } + + .header + p { + padding: 0 0 0 10px; + margin: 0; + font-size: 13px; + } + + .overview { + display: inline-block; + // background: #ededed; + margin: 10px; + width: 678px; + vertical-align: top; + border-radius: 4px; + // border: solid 1px #dadada; + border-width: 1px; + + .icon_container { + position: relative; + display: inline-block; + padding: 10px; + width: 275px; + } + + .controls { + display: inline-block; + margin: 10px 0 0 0; + padding: 0; + height: 288px; + width: 374px; + vertical-align: top; + + .preview-svg { + -webkit-filter: drop-shadow(1px 2px 4px #ececec); + filter: drop-shadow(1px 2px 4px #ececec); + } + + .button-list { + display: flex; + justify-content: flex-start; + align-items: center; + + .preview_holder { + display: flex; + align-items: center; + justify-content: center; + } + + .action-button { + padding: 7px 23px 8px 23px; + background: $color-yellow; + font-weight: 700; + color: $extremely-dark-gray; + text-shadow: 1px 1px 1px rgba(255, 255, 255, 0.5); + box-shadow: 1px 2px 4px #ececec; + font-size: 18px; + position: relative; + display: inline-block; + user-select: none; + // border: 1px solid #525252; + border-radius: 4px; + text-decoration: none; + + &:hover:not(.disabled) { + background: $color-yellow-hover; + cursor: pointer; + } + + &.disabled { + background: #d4d4d4; + color: #545454; + } + + &.green { + border: 0; + border-radius: 0; + background: none; + color: $extremely-dark-gray; + fill: #c4dd61; + box-shadow: none; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + margin-right: 20px; + + span { + position: absolute; + right: 0.7em; + } + + &:hover { + fill: #a2c129; + background: none; + } + } + + .little-button-text { + font-size: 18px; + display: block; + } + } + + span.pencil { + background: url(/img/pencil.svg) no-repeat center bottom; + margin: 0; + width: 21px; + height: 21px; + display: inline-block; + vertical-align: middle; + margin-top: -5px; + margin-right: 5px; + } + } + + ul { + margin: 10px 0 0 0; + + &.options { + padding: 0; + padding-bottom: 5px; + text-align: center; + font-size: 15px; + + li a { + font-weight: bold; + padding: 0 0 2px 0; + border-bottom: 1px #1778af solid; + margin: 0 10px; + color: #1778af; + + &:hover:not(.disabled) { + text-decoration: none; + color: #0093e7; + border-bottom: 1px #0093e7 solid; + cursor: pointer; + } + } + + li { + .disabled, + .disabled:hover { + background: none; + cursor: default; + color: #c5c5c5; + text-decoration: none; + border: none; + opacity: 0.5; + } + } + } + + li { + display: inline; + list-style: none; + } + } + + a.disabled, + a.disabled:hover { + background: #d4d4d4; + border-color: #747474; + color: #545454; + opacity: 0.5; + cursor: auto; + } + } + } + + .controls .delete_dialogue { + text-align: center; + width: 345px; + margin: 0; + position: absolute; + padding: 21px 10px 17px; + box-sizing: border-box; + background: white; + border-radius: 10px; + box-shadow: 3px 1px 6px rgba(0, 0, 0, 0.3); + + // css triangle arrow to point at delete link + &:before { + content: ' '; + height: 0; + position: absolute; + width: 0; + top: -35px; + right: 20px; + border: 20px solid transparent; + border-bottom-color: white; + } + + .bottom_buttons { + margin-top: 18px; + } + + .action_button { + margin-top: 5px; + } + + .red { + background: #ca0000; + background-image: linear-gradient(#e10000, #ca0000); + border-color: #747474; + color: #ffffff; + } + + .red:hover { + background: #ca0000; + background-image: linear-gradient(#ca0000, #e10000); + text-decoration: none; + } + + .gray:hover { + background: #a2c129; + background-image: linear-gradient(#b8b8b8, #e4e4e4); + text-decoration: none; + } + } + + .controls div.additional_options { + border-top: #8e8e8e dotted 1px; + margin: 20px 10px 0px 5px; + + .attempts_parent { + width: 90%; + padding-top: 5px; + + dt { + float: left; + margin: 0 10px 0 0; + width: 33px; + font-weight: bold; + } + + &.disabled dt { + color: #c5c5c5; + float: left; + margin: 0 10px 0 0; + width: 33px; + font-weight: bold; + } + + &.disabled dd { + color: #c5c5c5; + margin: 0 0 10px 0; + padding-left: 88px; + } + + dd { + margin: 0 0 10px 0; + padding-left: 88px; + cursor: pointer; + + .available-after { + margin-left: -5px; + + .available_date, + .available_time { + font-weight: bold; + } + + span { + padding-left: 5px; + } + } + + .open-until { + margin-left: -5px; + + .available_date, + .available_time { + font-weight: bold; + } + + span { + padding-left: 5px; + } + } + + .available-from { + display: flex; + flex-wrap: wrap; + margin-left: -5px; + + .available_date, + .available_time { + font-weight: bold; + } + + span { + padding-left: 5px; + } + } + + &:hover { + color: #0093e7; + text-decoration: underline; + } + + &.disabled { + color: #c5c5c5; + cursor: default; + } + + &.disabled:hover { + color: #c5c5c5; + text-decoration: none; + } + } + } + + .attempts_parent.disabled { + width: 90%; + padding-top: 5px; + background: none; + cursor: default; + } + + #edit-availability-button { + font-family: 'Lucida Grande', sans-serif; + font-size: 10pt; + font-weight: 400; + // text-decoration: underline; + margin-left: 98px; + color: #0093e7; + cursor: pointer; + + &:hover { + color: #0093e7; + } + + &.disabled { + background: none; + text-decoration: none; + color: #c5c5c5; + cursor: default; + } + + &.disabled:hover { + color: #c5c5c5; + } + } + } + + .controls h3 { + color: #b7b7b7; + text-transform: uppercase; + font-size: 0.9em; + margin: 10px 0 0 10px; + display: inline-block; + } + .controls dl { + margin: 10px 0 0 10px; + font-size: 0.8em; + } + + .share-widget-container { + // border-top: 1px solid rgba(0, 0, 0, 0.3); + display: inline-block; + padding: 22px 20px 4px 20px; + width: 100%; + font-size: 10pt; + margin: 0; + // padding-left: 100px; + position: relative; + box-sizing: border-box; + // background: #fff url(/img/widget_link_icon.png) no-repeat 20px + // 20px; + + .share-widget-options-first { + padding-left: 10px; + border-left: 8px solid rgba(239, 129, 82, 0.5); + } + + .share-widget-options-second { + padding-left: 10px; + border-left: 8px solid rgba(117, 191, 91, 0.5); + } + + .share-widget-options-third { + padding-left: 10px; + border-left: 8px solid rgba(93, 163, 203, 0.5); + } + + .view-more { + margin-top: 20px; + } + // + // #first-share-widget-option { + // border-left: 8px solid #EF8152; + // } + // + // #second-share-widget-option { + // border-left: 8px solid #75BF5B; + // } + // + // #third-share-widget-option { + // border-left: 8px solid #5DA3CB; + // } + // } + + a { + font-weight: 400; + } + + h4 { + margin-top: 2px; + margin-bottom: 5px; + } + + p { + margin-top: 6px; + } + + h3 { + font-size: 1.4em; + margin: 0; + margin-bottom: 8px; + + a { + font-size: 13px; + margin-left: 10px; + font-weight: 400; + } + } + + .share-help { + position: absolute; + top: 10px; + right: 10px; + } + + .link { + cursor: pointer; + // color: #1778af; + color: #0093e7; + // text-decoration: underline; + font-weight: 400; + + // &:hover { + // // color: #0093e7; + // } + } + + .play_link { + width: 100%; + margin: 0; + height: 1.5em; + font-size: 1.1em; + padding-left: 3px; + cursor: text; + + &::-ms-clear { + display: none; + } + + &:disabled { + cursor: default; + user-select: none; + } + } + + .draft { + padding-bottom: 46px; + cursor: default; + + opacity: 0.5; + user-select: none; + + p { + display: none; + } + } + + .embed-options { + vertical-align: top; + color: black; + position: relative; + + #embed_link { + width: 100%; + height: auto; + margin: 0; + font-size: 0.9em; + font-family: monospace; + margin-bottom: 6px; + } + + h4 { + display: inline-block; + margin: 12px 0px 0px; + } + + input { + display: inline-block; + width: auto; + margin-left: 0.5em; + cursor: pointer; + vertical-align: middle; + } + } + } + } + + .hide { + display: none; + } + + .aux_button { + padding: 7px 12px 8px 12px; + } + + div.link { + display: inline-block; + margin: 0 10px; + padding: 0 0 2px 0; + + border-bottom: 1px #0093e7 solid; + color: #0093e7; + + font-size: 14px; + font-weight: 700; + cursor: pointer; + + &.disabled { + cursor: default; + pointer-events: none; + } + } + + // Shown when there are no widgets + .qtip-wrapper { + border-radius: 5px; + box-shadow: 0 3px 3px rgba(0, 0, 0, 0.3); + } + + .qtip-content { + font-weight: 900; + } + + .qtip.nowidgets { + top: 110px; + left: 74px; + } +} + +.locked-modal { + display: flex; + position: fixed; + flex-direction: column; + height: 75%; + width: calc(100% - 20px); + justify-content: center; + align-items: center; + + p { + margin: 30px 0; + } + + a { + margin-top: auto; + width: fit-content; + } +} diff --git a/src/components/my-widgets-score-semester-graph.jsx b/src/components/my-widgets-score-semester-graph.jsx new file mode 100644 index 000000000..9b7f5e0eb --- /dev/null +++ b/src/components/my-widgets-score-semester-graph.jsx @@ -0,0 +1,20 @@ +import React from 'react' +import BarGraph from './bar-graph' +import MyWidgetScoreSemesterSummary from './my-widgets-score-semester-summary' + +const MyWidgetScoreSemesterGraph = ({ semester }) => ( + <> +
    + +
    + + +) + +export default MyWidgetScoreSemesterGraph \ No newline at end of file diff --git a/src/components/my-widgets-score-semester-individual.jsx b/src/components/my-widgets-score-semester-individual.jsx new file mode 100644 index 000000000..ce6994872 --- /dev/null +++ b/src/components/my-widgets-score-semester-individual.jsx @@ -0,0 +1,156 @@ + +import React, { useState, useEffect, useCallback, useRef } from 'react' +import { useQueryClient, useQuery } from 'react-query' +import { apiGetPlayLogs } from '../util/api' +import MyWidgetScoreSemesterSummary from './my-widgets-score-semester-summary' +import LoadingIcon from './loading-icon' + +const showScore = (instId, playId) => window.open(`/scores/single/${playId}/${instId}`) +const _compareScores = (a, b) => { return (parseInt(b.created_at) - parseInt(a.created_at)) } + +const timestampToDateDisplay = timestamp => { + const d = new Date(parseInt(timestamp, 10) * 1000) + return d.getMonth() + 1 + '/' + d.getDate() + '/' + d.getFullYear() +} + +const initState = () => ({ + isLoading: true, + searchText: '', + selectedUser: {}, + logs: [], + filteredLogs: [] +}) + +const MyWidgetScoreSemesterIndividual = ({ semester, instId }) => { + const [state, setState] = useState(initState()) + const [page, setPage] = useState(1) + const { + data, + refetch + } = useQuery( + ['play-logs', instId, semester], + () => apiGetPlayLogs(instId, semester.term, semester.year, page), + { + keepPreviousData: true, + enabled: !!instId && !!semester && !!semester.term && !!semester.year, + placeholderData: [], + refetchOnWindowFocus: false, + onSuccess: (result) => { + if (page <= result?.total_num_pages) setPage(page + 1) + if (result && result.pagination) { + let newLogs = state.logs + + result.pagination.forEach((record) => { + if (newLogs[record.userId]) newLogs[record.userId].scores.push({...record.scores}) + else newLogs[record.userId] = { userId: record.userId, name: record.name, searchableName: record.searchableName, scores: record.scores } + newLogs[record.userId].scores.sort(_compareScores) + }) + + setState({ ...state, logs: newLogs, filteredLogs: newLogs }) + } + } + } + ) + + useEffect(() => { + if (page < data?.total_num_pages) { refetch() } + else setState({ ...state, isLoading: false }) + }, [page]) + + const onSearchInput = useCallback(search => { + search = search.toLowerCase() + const filteredLogs = state.logs.filter(item => item.searchableName.includes(search)) + + const newState = { + ...state, + filteredLogs: filteredLogs, + searchText: search + } + + // unselect user if not in filtered results + const isSelectedInResults = filteredLogs.includes(state.selectedUser) + if (!isSelectedInResults) { newState.selectedUser = {} } + setState(newState) + + }, [state.searchText, state.selectedUser, state.logs]) + + const handleSearchChange = e => onSearchInput(e.target.value) + + let mainContentRender = + if (!state.isLoading) { + const userRowElements = state.filteredLogs.map(user => ( + { setState({ ...state, selectedUser: user }) }} + className={{ rowSelected: state.selectedUser.userId === user.userId }} + > + + {user.name} + + + )) + + let selectedUserRender = null + if (state.selectedUser.userId != undefined) { + const selectedUserScoreRows = state.selectedUser.scores.map(score => ( + { showScore(instId, score.playId) }} + > + {timestampToDateDisplay(score.created_at)} + {score.score} + {score.elapsed} + + )) + + selectedUserRender = ( +
    + + + {selectedUserScoreRows} + +
    +
    + ) + } + + mainContentRender = ( + <> +
    + +
    + +

    Select a student to view their scores.

    +
    +
    + + + {userRowElements} + +
    +
    +
    + {selectedUserRender} + + ) + } + + return ( + <> +
    + {mainContentRender} +
    + + + ) +} + +export default MyWidgetScoreSemesterIndividual + diff --git a/src/components/my-widgets-score-semester-storage.jsx b/src/components/my-widgets-score-semester-storage.jsx new file mode 100644 index 000000000..cbffa3ca5 --- /dev/null +++ b/src/components/my-widgets-score-semester-storage.jsx @@ -0,0 +1,232 @@ +import React, { useState, useEffect, useRef } from 'react' +import { useQuery } from 'react-query' +import { apiGetStorageData } from '../util/api' +import LoadingIcon from './loading-icon' +import PaginateButtons from './score-storage-paginate-buttons' +import StorageRows from './score-storage-rows' + +const initState = () => ({ + pageNumber: 0, + startIndex: 0, + endIndex: 10, + rowsPerPage: 10, + storageData: [], + selectedValues: null, + selectedTableName: null, + anonymous: false, + tableNames: [], + pages: [1], + tableKeys: [], + isTruncated: false, + isFiltered: false, + isGuest: false, + isLoading: true +}) + +const MAX_ROWS = 100 + +const MyWidgetScoreSemesterStorage = ({semester, instId}) => { + const [state, setState] = useState(initState()) + const [searchInput, setSearchInput] = useState('') + const mounted = useRef(false) + const { data: results } = useQuery({ + queryKey: ['score-storage', instId], + queryFn: () => apiGetStorageData(instId), + enabled: !!instId, + staleTime: Infinity, + placeholderData: {} + }) + + useEffect(() => { + mounted.current = true + return () => (mounted.current = false) + }, []) + + // Gets the storage data from db and loads it as well as filters based on search val + useEffect(() => { + if (results && Object.keys(results).length > 0) { + const tableNames = Object.keys(results) + const selectedTableName = state.selectedTableName !== null && tableNames.includes(state.selectedTableName) ? state.selectedTableName : tableNames[0] + const selectedTable = results[selectedTableName] + const tableKeys = Object.keys(selectedTable[0].data) + const tmpResults = selectedTable.length <= MAX_ROWS ? selectedTable : selectedTable.slice(0, MAX_ROWS) + const filteredRes = tmpResults.filter(val => getFilter(val)) + const selectedValues = filteredRes.slice(0, state.rowsPerPage) + const isTruncated = selectedTable.length > MAX_ROWS + const pageLen = Math.min(filteredRes.length, state.rowsPerPage) + const pages = Array.from({length: Math.ceil(filteredRes.length/pageLen)}, (_, i) => i + 1) + const isFiltered = filteredRes.length < tmpResults.length + + if (mounted.current) { + setState({ + ...state, + pageNumber: 0, + startIndex: 0, + endIndex: pageLen, + storageData: filteredRes, + tableNames: tableNames, + selectedValues: selectedValues, + selectedTableName: selectedTableName, + isTruncated: isTruncated, + isFiltered: isFiltered, + totalEntries: tmpResults.length, + pages: pages, + tableKeys: tableKeys, + isLoading: false + }) + } + } + }, [JSON.stringify(results), semester, instId, searchInput, state.selectedTableName]) + + const onChangePageCount = (newValue) => { + const numPages = Math.ceil(state.storageData?.length/newValue) + const pagesArr = Array.from({length: numPages}, (_, i) => i + 1) // Generates list of ints 1 to numPages + const startIndex = 0 + const endIndex = Math.min(state.storageData?.length, newValue) + const selectedValues = state.storageData?.slice(startIndex, endIndex) + setState({...state, + rowsPerPage: newValue, + selectedValues: selectedValues, + startIndex: startIndex, + endIndex: endIndex, + pageNumber: 0, + pages: pagesArr + }) + } + + // Filter used in search + const getFilter = (val) => { + const firstLast = val.play.firstName + val.play.lastName + const sanitizedSearch = searchInput.replace(/\s+/g, '').toUpperCase() + + if (searchInput.length === 0) return true + + // Matches by user + if (val.play.user.replace(/\s+/g, '').toUpperCase().includes(sanitizedSearch)) + return true + + // Matches by first and last + if (firstLast.replace(/\s+/g, '').toUpperCase().includes(sanitizedSearch)) + return true + + return false + } + + const handleAnonymizeChange = e => setState({...state, anonymous: e.target.checked}) + + const handlePerPageChange = e => onChangePageCount(parseInt(e.target.value, 10)) + + const handleSearchChange = e => setSearchInput(e.target.value) + + let contentRender = ( +
    + +
    + ) + if (!state.isLoading) { + let tableNamesRender = '' + if (state.tableNames.length > 1) { + const tableNamesOptionElements = state.tableNames.map(name => ( + + )) + + tableNamesRender = ( + + ) + } else if (Array.isArray(state.tableNames)) { + tableNamesRender = ( + {state.tableNames[0]} + ) + } + + let truncatedTableRender = null + if (state.isTruncated) { + truncatedTableRender = ( +

    + Showing only the first { MAX_ROWS } entries of this table. + Download the table to see all entries. +

    + ) + } + + // This URL string is otherwise huge and nasty, so it's built in multiple steps for readability. + const downloadUrlBase = `/data/export/${instId}?type=storage` + const tableValue = `table=${encodeURIComponent(state.selectedTableName)}` + const semestersValue = `semesters=${semester.year}-${semester.term}` + const downloadUrlString = `${downloadUrlBase}&${tableValue}&${semestersValue}&anonymized=${state.anonymous}` + + contentRender = ( + <> +
    + + + Download Table + +
    +
    + + + { truncatedTableRender } +
    +
    + +
    + +
    + +
    +
    + + +
    + + ) + } + + return ( +
    + { contentRender } +
    + ) +} + +export default MyWidgetScoreSemesterStorage diff --git a/src/components/my-widgets-score-semester-summary.jsx b/src/components/my-widgets-score-semester-summary.jsx new file mode 100644 index 000000000..abda735db --- /dev/null +++ b/src/components/my-widgets-score-semester-summary.jsx @@ -0,0 +1,27 @@ +import React from 'react' + +const MyWidgetScoreSemesterSummary = ({students, totalScores, average}) => ( +
      +
    • +

      Students

      +

      + {students} +

      +
    • +
    • +

      Scores

      +

      + {totalScores} +

      +
    • +
    • +

      Avg Final Score

      +

      + {average} +

      +
    • +
    +) + +export default MyWidgetScoreSemesterSummary diff --git a/src/components/my-widgets-score-semester.jsx b/src/components/my-widgets-score-semester.jsx new file mode 100644 index 000000000..74e498b57 --- /dev/null +++ b/src/components/my-widgets-score-semester.jsx @@ -0,0 +1,106 @@ +import React, { useState, useMemo, useEffect } from 'react' +import MyWidgetScoreSemesterIndividual from './my-widgets-score-semester-individual' +import MyWidgetScoreSemesterStorage from './my-widgets-score-semester-storage' +import MyWidgetScoreSemesterGraph from './my-widgets-score-semester-graph' + +const TAB_GRAPH = 'TAB_GRAPH' +const TAB_INDIVIDUAL = 'TAB_INDIVIDUAL' +const TAB_STORAGE = 'TAB_STORAGE' + +const MyWidgetScoreSemester = ({semester, instId, hasScores}) => { + const initData = hasScores ? TAB_GRAPH : TAB_STORAGE + const [scoreTab, setScoreTab] = useState(initData) + + useEffect(() => { + if (hasScores) { + setScoreTab(TAB_GRAPH) + } + }, [hasScores, instId]) + + const activeTab = useMemo(() => { + let curTab = scoreTab + + // Tests if there are scores to show + if ((scoreTab === TAB_INDIVIDUAL || scoreTab === TAB_GRAPH) && hasScores === false) { + // Has storage data so switch to that + if (semester.storage) { + curTab = TAB_STORAGE + setScoreTab(TAB_STORAGE) + } + else { + curTab = '' + setScoreTab('') + } + } + + switch (curTab) { + case TAB_GRAPH: + return + + case TAB_INDIVIDUAL: + return ( + + ) + + case TAB_STORAGE: + return ( + + ) + + default: + return null + } + }, [scoreTab, semester, hasScores]) + + let standardTabElementsRender = null + if (hasScores) { + standardTabElementsRender = ( + <> +
  • + {setScoreTab(TAB_GRAPH)}}> + Graph + +
  • +
  • + {setScoreTab(TAB_INDIVIDUAL)}}> + Individual Scores + +
  • + + ) + } + + let storageTabRender = null + if (semester.storage) { + storageTabRender = ( +
  • + {setScoreTab(TAB_STORAGE)}}> + Data + +
  • + ) + } + + return ( +
    +

    {semester.term} {semester.year}

    +
      + { standardTabElementsRender } + { storageTabRender } +
    + { activeTab } +
    + ) +} + +export default MyWidgetScoreSemester diff --git a/src/components/my-widgets-scores.jsx b/src/components/my-widgets-scores.jsx new file mode 100644 index 000000000..c13e6429b --- /dev/null +++ b/src/components/my-widgets-scores.jsx @@ -0,0 +1,113 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { useQuery } from 'react-query' +import { apiGetScoreSummary } from '../util/api' +import MyWidgetScoreSemester from './my-widgets-score-semester' +import MyWidgetsExport from './my-widgets-export' +import LoadingIcon from './loading-icon' +import NoScoreContent from'./no-score-content' +import './my-widgets-scores.scss' + +const MyWidgetsScores = ({inst, beardMode}) => { + const [state, setState] = useState({ + isShowingAll: false, + hasScores: false, + showExport: false + }) + const { data: currScores, isFetched } = useQuery({ + queryKey: ['score-summary', inst.id], + queryFn: () => apiGetScoreSummary(inst.id), + enabled: !!inst && !!inst.id, + staleTime: Infinity, + placeholderData: [] + }) + + // Initializes the data when widget changes + useEffect(() => { + let hasScores = false + + currScores.map(val => { + if (val.distribution) hasScores = true + }) + + setState({ + hasScores: hasScores, + showExport: false + }) + }, [JSON.stringify(currScores)]) + + const displayedSemesters = useMemo(() => { + if (currScores && (state.isShowingAll || currScores.length < 2)) return currScores // all semester being displayed + return currScores.slice(0,1) // show just one semester, gracefully handles empty array + }, [currScores, state.isShowingAll]) + + const openExport = () => { + if (!inst.is_draft) setState({...state, showExport: true}) + } + const closeExport = () => { + setState({...state, showExport: false}) + } + + const containsStorage = () => { + let hasStorageData = false + for(const semester of displayedSemesters) { + if (semester.storage) { + hasStorageData = true + } + } + + return hasStorageData + } + + const handleShowOlderClick = () => setState({...state, isShowingAll: !state.isShowingAll}) + + let contentRender = + if (isFetched) { + contentRender = + if (state.hasScores || containsStorage()) { + const semesterElements = displayedSemesters.map(semester => ( + + )) + + contentRender = ( + + ) + } + } + + let exportRender = null + if (state.showExport) { + exportRender = ( + + ) + } + + return ( +
    +

    Student Activity

    + + + Export Options + + { contentRender } + { exportRender } +
    + ) +} + +export default MyWidgetsScores diff --git a/src/components/my-widgets-scores.scss b/src/components/my-widgets-scores.scss new file mode 100644 index 000000000..1dd65f970 --- /dev/null +++ b/src/components/my-widgets-scores.scss @@ -0,0 +1,568 @@ +@import './include.scss'; + +.scores { + position: relative; + display: flex; + flex-direction: column; + min-height: 300px; + margin: 10px; + overflow-x: hidden; + overflow-y: auto; + padding-bottom: 1em; + + #export_scores_button.disabled, + #export_scores_button.disabled:hover { + background: #d4d4d4; + color: #545454; + cursor: default; + opacity: 0.5; + } + + .graph { + rect.vx-bar { + fill: #0093e7; + + &:hover { + fill: #2aabf5; + } + } + + .vx-axis tspan { + fill: #666; + } + + .vx-line { + stroke: #a9a9a9; + } + + .vx-graph { + fill: white; + } + } + + h2 { + border-bottom: #999 dotted 1px; + padding-bottom: 14px; + padding-top: 2px; + padding-bottom: 13px; + margin-bottom: 0; + } + + h3 { + color: #000000; + } + + #export_scores_button { + padding: 7px 23px 8px 23px; + background-color: $color-yellow; + font-weight: 700; + color: $extremely-dark-gray; + text-shadow: 1px 1px 1px rgba(255, 255, 255, 0.5); + box-shadow: 1px 2px 4px #ececec; + font-size: 18px; + cursor: pointer; + display: inline-block; + user-select: none; + // border: 1px solid #525252; + border-radius: 4px; + position: absolute; + top: 13px; + right: 2px; + + &:hover:not(.disabled) { + background: $color-yellow-hover; + text-decoration: none; + } + + &.disabled { + color: #545454; + cursor: auto; + } + } + + .scoreWrapper { + display: inline-block; + position: relative; + margin-top: 1em; + + ul.choices { + display: inline-block; + margin: 15px 0 0 0; + padding: 0; + height: 23px; + + li { + list-style: none; + display: inline-block; + padding-right: 3px; + + a { + color: #000000; + padding: 6px 10px 4px 10px; + border-left: solid #dbdbdb 1px; + border-top: solid #dbdbdb 1px; + border-right: solid #dbdbdb 1px; + padding-bottom: 5px; + cursor: pointer; + + &:hover { + text-decoration: none; + } + } + } + + li.scoreTypeSelected a { + background: #f3f3f3; + } + } + + .storage { + font-weight: bold; + padding: 0 0 2px 0; + border-bottom: 1px #1778af solid; + margin: 0 10px; + color: #1778af; + float: right; + margin: 0; + + &:hover { + text-decoration: none; + color: #0093e7; + border-bottom: 1px #0093e7 solid; + cursor: pointer; + } + } + + h3.view { + display: inline-block; + margin: 15px 0.5em 0 0; + padding: 0; + } + + .display.graph { + background: #ffffff; + margin: 0; + padding: 20px 0 0 20px; + border: solid #dbdbdb 1px; + width: 550px; + float: left; + + .type { + padding: 0; + margin: 35px 0 0 0; + float: left; + + li { + list-style: none; + margin: 0 0 10px 0; + } + } + + .chart { + width: 475px; + height: 300px; + float: left; + margin: 0 10px 15px 10px; + } + } + + .display.table { + background: #f3f3f3; + margin: 0; + padding: 20px; + border: solid #dbdbdb 1px; + float: left; + width: 570px; + min-height: 300px; + padding: 0 0 25px 0; + display: block; + + &.loading { + display: flex; + } + } + + .display.data { + font-family: 'Lucida Grande', sans-serif; + width: 638px; + background: #f3f3f3; + margin: 0; + padding: 20px; + border: solid #dbdbdb 1px; + float: left; + min-height: 393px; + display: block; + + &.loading { + display: flex; + } + + .loading-holder { + width: 100%; + } + + .anonymize-box { + cursor: pointer; + } + + input[type='checkbox'], + select { + cursor: pointer; + } + + .table.label { + select, + span { + margin-left: 5px; + } + } + + .truncated-table { + background: #fff389; + padding: 10px; + margin-top: 20px; + font-size: 9pt; + text-align: center; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.2); + } + + .anonymize-box { + margin-right: 5px; + } + + .dataTable { + margin-bottom: 0.755em; + } + + table { + margin-top: 20px; + } + + .data_tables_info_holder { + display: flex; + align-items: center; + flex-wrap: wrap; + + .data_tables_info { + color: #333; + margin-right: auto; + padding-bottom: 5px; + } + + .data_tables_paginate { + color: #333; + margin-left: auto; + + .ellipsis { + padding: 0 1em; + } + + .paginate_button { + box-sizing: border-box; + display: inline-block; + min-width: 1.5em; + padding: 0.5em 1em; + margin-left: 2px; + text-align: center; + text-decoration: none !important; + cursor: pointer; + *cursor: hand; + color: #333; + border: 1px solid transparent; + border-radius: 2px; + opacity: 1; + + &.current { + border: 1px solid #979797; + background-color: #fdfdfd; + } + + &:not(.current):not(.disable):hover { + color: white; + border: 1px solid #111; + background-color: #353535; + } + + &.disable { + color: #555; + opacity: 0.5; + } + } + } + } + + .null { + color: #bbb; + } + + .table-label { + border-bottom: 1px solid #bbb; + margin-top: 0; + padding-bottom: 1em; + margin-bottom: 1em; + + h4 { + display: inline; + margin-right: 0.5em; + } + + select { + font-size: 14pt; + } + } + } + + .table { + h3 { + font-family: 'Lucida Grande', sans-serif; + margin-top: 10px; + margin-left: 20px; + font-size: 1em; + font-weight: normal; + color: #666; + } + + .score-search { + background: rgba(0, 0, 0, 0.05); + width: 100%; + padding: 6px 10px 10px 10px; + + input[type='text'] { + width: 190px; + margin: 7px 10px 0 10px; + border: solid 1px #b0b0b0; + border-radius: 12px; + height: 19px; + background: #fff url('/img/magnifyingglass.png') 5px 50% no-repeat; + padding-left: 23px; + padding-right: 10px; + outline: none; + } + } + + .scoreListContainer { + position: relative; + z-index: 3; + float: left; + width: 40%; + margin-left: 20px; + + background: #ffffff; + border-radius: 3px; + + box-shadow: 0 3px 3px rgba(0, 0, 0, 0.3); + + .scoreListScrollContainer { + max-height: 550px; + width: 100%; + overflow: auto; + + .scoreListTable { + width: 100%; + + .listName { + &.selected { + background: #ffe0a5; + } + } + } + } + + .scoreListHead { + display: block; + height: 1.5em; + padding: 3px 0 0 5px; + background-color: #cdcdcd; + color: #000000; + text-decoration: none; + } + + tbody tr:hover { + background: #ffe0a5; + cursor: pointer; + } + } + + .scoreTableContainer { + position: relative; + z-index: 1; + float: left; + width: 55%; + right: 12px; + + margin-top: 15px; + padding: 10px 0 10px 0; + + background: #ffffff; + border-radius: 3px; + box-shadow: 0 3px 3px rgba(0, 0, 0, 0.3); + + .scoreTable { + width: 95%; + margin-left: 10px; + border-collapse: collapse; + + .elapsed { + text-align: right; + } + + td { + border: none; + border-bottom: solid #dbdbdb 1px; + } + + tr { + cursor: pointer; + + &:hover { + background: #ffe0a5; + } + + &:last-child td { + border-bottom: none; + } + } + } + } + + .scoreTableTitle { + height: 1.5em; + } + + thead tr { + background: #cdcdcd; + } + + td { + height: 1.5em; + padding: 6px 3px; + } + + tbody tr.rowSelected { + background: #ffcb68; + } + } + + .numeric { + float: left; + margin: 0; + padding: 0; + width: 105px; + + li { + list-style: none; + text-align: center; + background: #e9edef; + padding: 5px; + margin: 0 0 10px 0; + + p { + padding: 0; + margin: 0; + color: #0093e7; + text-align: center; + font-size: 2em; + font-weight: 900; + font-family: 'Kameron', Georgia, 'Times New Roman', Times, serif; + } + + h4 { + padding: 0; + margin: 0; + } + } + } + } + + .show-older-scores-button { + border-radius: 2px; + background: #f3f3f3; + border: 1px solid #dbdbdb; + display: inline-block; + padding: 4px 10px 4px 10px; + font-size: 10pt; + text-decoration: underline; + width: 654px; + text-align: center; + margin-top: 1em; + font-weight: bold; + cursor: pointer; + + &.hide { + display: none; + } + + &:hover { + opacity: 0.8; + } + } + + .dataTables_info_holder { + display: flex; + align-items: center; + + .dataTables_length { + margin-right: auto; + margin-bottom: 5px; + + select { + margin: 0 5px; + } + } + + .dataTables_filter { + margin-left: auto; + + label input { + margin-left: 5px; + } + } + } + + .dataTable { + width: 100%; + overflow: auto; + display: block; + } + + .paginate_button { + margin-right: 5px; + cursor: pointer; + user-select: none; + } + + table.storage_table { + // font-family: 'Lucida Grande', sans-serif; + // width: 638px; + margin-top: 5px; + border-spacing: 0; + border-bottom: 1px solid #111; + font-size: 13px; + + thead { + th { + padding: 3px 18px 3px 10px; + border-bottom: 1px solid black; + font-weight: bold; + } + } + tbody { + display: table-row-group; + vertical-align: middle; + border-color: inherit; + tr:nth-child(odd) { + background-color: #eee; + } + tr { + background-color: white; + text-align: left; + } + td { + padding: 5px 10px; + margin: 0; + position: relative; + } + } + } +} diff --git a/src/components/my-widgets-selected-instance.jsx b/src/components/my-widgets-selected-instance.jsx new file mode 100644 index 000000000..b4d8b96f1 --- /dev/null +++ b/src/components/my-widgets-selected-instance.jsx @@ -0,0 +1,507 @@ +import React, { useEffect, useRef, useCallback, useState, useMemo} from 'react' +import { useQuery } from 'react-query' +import { apiCanEditWidgets } from '../util/api' +import { iconUrl } from '../util/icon-url' +import parseTime from '../util/parse-time' +import MyWidgetsScores from './my-widgets-scores' +import MyWidgetEmbedInfo from './my-widgets-embed' +import parseObjectToDateString from '../util/object-to-date-string' +import MyWidgetsCollaborateDialog from './my-widgets-collaborate-dialog' +import MyWidgetsCopyDialog from './my-widgets-copy-dialog' +import MyWidgetsWarningDialog from './my-widgets-warning-dialog' +import MyWidgetsSettingsDialog from './my-widgets-settings-dialog' +import Modal from './modal' + +const convertAvailibilityDates = (startDateInt, endDateInt) => { + let endDate, endTime, open_at, startTime + startDateInt = ~~startDateInt + endDateInt = ~~endDateInt + endDate = endTime = 0 + open_at = startTime = 0 + + if (endDateInt > 0) { + endDate = parseObjectToDateString(endDateInt) + endTime = parseTime(endDateInt) + } + + if (startDateInt > 0) { + open_at = parseObjectToDateString(startDateInt) + startTime = parseTime(startDateInt) + } + + return { + start: { + date: open_at, + time: startTime, + }, + end: { + date: endDate, + time: endTime, + }, + } +} + +const initState = () => ({ + perms: {}, + can: {}, + playUrl: '', + availabilityMode: '', + showDeleteDialog: false +}) + +const MyWidgetSelectedInstance = ({ + inst = {}, + currentUser, + myPerms, + otherUserPerms, + setOtherUserPerms, + onDelete, + onCopy, + onEdit, + beardMode, + beard, + setInvalidLogin, + showCollab, + setShowCollab +}) => { + const [state, setState] = useState(initState()) + const [showEmbed, setShowEmbed] = useState(false) + const [showCopy, setShowCopy] = useState(false) + const [showLocked, setShowLocked] = useState(false) + const [showWarning, setShowWarning] = useState(false) + const [showSettings, setShowSettings] = useState(false) + const [collabLabel, setCollabLabel] = useState('Collaborate') + const attempts = parseInt(inst.attempts, 10) + const shareLinkRef = useRef(null) + const { data: editPerms, isFetching: permsFetching} = useQuery({ + queryKey: ['widget-perms', inst.id], + queryFn: () => apiCanEditWidgets(inst.id), + placeholderData: null, + enabled: !!inst.id, + staleTime: Infinity, + onSuccess: (data) => { + if (!data || data.type == 'error') + { + console.error(`Error: ${data.msg}`); + if (data.title =="Invalid Login") + { + setInvalidLogin(true) + } + } + } + }) + + // Initializes the data when widgets changes + useEffect(() => { + let playUrl = inst.play_url + // Sets the play url + if (inst.is_draft) { + const regex = /preview/i + playUrl = inst.preview_url.replace(regex, 'play') + } + + // Sets the availability mode + let availabilityMode = '' + + if (`${inst.close_at}` === '-1' && `${inst.open_at}` === '-1') { + availabilityMode = 'anytime' + } else if (`${inst.open_at}` === '-1') { + availabilityMode = 'open until' + } else if (`${inst.close_at}` === '-1') { + availabilityMode = 'anytime after' + } else { + availabilityMode = 'from' + } + + setState(prevState => ({...prevState, playUrl: playUrl, availabilityMode: availabilityMode, showDeleteDialog: false})) + + }, [JSON.stringify(inst)]) + + // Gets the collab label + useEffect(() => { + let usersList = [] + + if (!otherUserPerms) return + + // Filters out the current user for the collab label + for (let [key, user] of otherUserPerms) { + if (key != currentUser?.id) { + usersList.push(user) + } + } + + const collaborationCountString = usersList && usersList.length > 0 ? `(${usersList.length})` : '' + setCollabLabel(`Collaborate ${collaborationCountString}`) + }, [otherUserPerms, inst]) + + useEffect(() => { + if (myPerms) { + setState((prevState) => ({...prevState, can: myPerms.can, perms: myPerms})) + } + }, [myPerms, inst]) + + const makeCopy = useCallback((title, copyPermissions) => { + setShowCopy(false) + onCopy(inst.id, title, copyPermissions, inst) + }, [inst, setShowCopy]) + + const onEditClick = inst => { + if (inst.widget.is_editable && state.perms.editable && editPerms && !permsFetching) { + const editUrl = `${window.location.origin}/widgets/${inst.widget.dir}create#${inst.id}` + + if(editPerms.is_locked){ + setShowLocked(true) + return + } + if(inst.is_draft){ + window.location = editUrl + return + } + + if (editPerms.can_publish){ + // show editPublished warning + showModal(setShowWarning) + return + } + else { + // show restricted publish warning + return + } + } + } + + const onPopup = () => { + if (state.can.edit && state.can.share && !inst.is_draft) { + showModal(setShowSettings) + } + } + + const closeModal = setModal => { + if (setModal !== undefined) { + setModal(false) + } + } + + const showModal = setModal => { + if (setModal !== undefined) { + setModal(true) + } + } + + const editClickHandler = () => onEditClick(inst) + const collaborateClickHandler = () => showModal(setShowCollab) + const copyClickHandler = () => showModal(setShowCopy) + const deleteClickHandler = () => setState(prevState => ({...prevState, showDeleteDialog: !state.showDeleteDialog})) + const deleteCancelClickHandler = () => setState(prevState => ({...prevState, showDeleteDialog: false})) + const deleteConfirmClickHandler = () => onDelete(inst) + + const editWidget = () => { + const editUrl = window.location.origin + `/widgets/${inst.widget.dir}create#${inst.id}` + window.location = editUrl + } + + const availability = useMemo(() => { + return convertAvailibilityDates(inst.open_at, inst.close_at) + }, [inst.open_at, inst.close_at]) + + let deleteDialogRender = null + if (state.showDeleteDialog) { + deleteDialogRender = ( +
    + Are you sure you want to delete this widget? + +
    + ) + } + + let openAnytimeRender = null + if (state.availabilityMode === 'anytime') { + openAnytimeRender = Anytime + } + + let openUntilRender = null + if (state.availabilityMode === 'open until') { + openUntilRender = ( + + Open until + { availability.end.date } + at + { availability.end.time } + + ) + } + + let anytimeAfterRender = null + if (state.availabilityMode === 'anytime after') { + anytimeAfterRender = ( + + Anytime after + { availability.start.date } + at + { availability.start.time } + + ) + } + + let fromToRender = null + if (state.availabilityMode === 'from') { + fromToRender = ( + + From + { availability.start.date } + at + { availability.start.time } + until + { availability.end.date } + at + { availability.end.time } + + ) + } + + const shareInputMouseDownHandler = e => { + if (inst.is_draft) e.preventDefault() + } + + let embedInfoRender = null + if (showEmbed) embedInfoRender = + + const toggleShowEmbed = () => setShowEmbed(!showEmbed) + + const copyDialogOnClose = () => closeModal(setShowCopy) + let copyDialogRender = null + if (showCopy) { + copyDialogRender = ( + + ) + } + + const modalDialogOnClose = () => closeModal(setShowCollab) + let collaborateDialogRender = null + if (showCollab) { + collaborateDialogRender = ( + + ) + } + + const warningDialogOnClose = () => closeModal(setShowWarning) + let warningDialogRender = null + if (showWarning) { + warningDialogRender = ( + + ) + } + + const settingsDialogOnClose = () => closeModal(setShowSettings) + let settingsDialogRender = null + if (showSettings) { + settingsDialogRender = ( + + ) + } + + const lockedDialogOnClose = () => setShowLocked(false) + let lockedDialogRender = null + if (showLocked) { + lockedDialogRender = ( + +
    +

    This widget is currently locked, you will be able to edit this widget when it is no longer being edited by somebody else.

    + + Okay + +
    +
    + ) + } + + return ( +
    +
    +

    {inst.name}

    +
    +
    +
    + {inst.widget.name} +
    +
    + +
      +
    • +
      + {collabLabel} +
      +
    • +
    • + +
    • +
    • + +
    • +
    + + { deleteDialogRender } + +
    +

    Settings:

    +
    +
    Attempts:
    +
    + { attempts > 0 ? attempts : 'Unlimited' } +
    +
    Available:
    +
    + { openAnytimeRender } + { openUntilRender } + { anytimeAfterRender } + { fromToRender } +
    +
    Access:
    +
    + + {inst.guest_access ? 'Guest Mode - No Login Required' : 'Staff and Students only'} + +
    +
    + + Edit settings + +
    +
    + +
    +

    + {inst.is_draft ? 'Publish to share' : 'Share'} with your students +

    +
    +

    Via LMS / LTI Setup

    +

    Integrate your widget into your LMS. You can use External Tools in Canvas to embed directly into Webcourses, allowing for immediate authentication, automatic grade passback, and more. Learn more + here. +

    +
    +
    +

    Via Embed Code

    +

    + Provides a snippet of HTML to embed your widget directly in a webpage. Widgets + embedded in such a way do not synchronize scores or other data. + +  Show the embed code + . +

    + + { embedInfoRender } +
    +
    +

    Via URL

    +

    Quickly share access to your widget by copying the link below.

    +
    + +
    +
    + +

    + View all sharing options. +

    + +
    +
    + { copyDialogRender } + { collaborateDialogRender } + { warningDialogRender } + { settingsDialogRender } + { lockedDialogRender } + +
    + ) +} + +export default MyWidgetSelectedInstance diff --git a/src/components/my-widgets-settings-dialog.jsx b/src/components/my-widgets-settings-dialog.jsx new file mode 100644 index 000000000..db5fe6733 --- /dev/null +++ b/src/components/my-widgets-settings-dialog.jsx @@ -0,0 +1,563 @@ +import React, { useState, useEffect, useRef } from 'react' +import { useQuery } from 'react-query' +import { apiGetUsers } from '../util/api' +import useUpdateWidget from './hooks/useUpdateWidget' +import Modal from './modal' +import PeriodSelect from './period-select' +import AttemptsSlider from './attempts-slider' +import './my-widgets-settings-dialog.scss' + +const initState = () => { + return({ + sliderVal: '100', + errorLabel: '', + lastActive: 8, + showWarning: false, + warningType: 'normal', + availability: [{}, {}], + formData: { + data: {}, + changes: { + radios: [true, true], + dates: [new Date(), new Date()], + times: ['',''], + periods: ['',''], + access: '' + }, + errors: { + date: [false, false], + time: [false, false] + } + } + }) +} + +const valueToAttempts = (val) => { + switch(true){ + case val <= 3: return 1 + case val <= 7: return 2 + case val <= 11: return 3 + case val <= 15: return 4 + case val <= 27.5: return 5 + case val <= 48: return 10 + case val <= 68: return 15 + case val <= 89: return 20 + default: return -1 + } +} + +const attemptsToValue = (attempts) => { + switch(attempts){ + case 1: return 1 + case 2: return 5 + case 3: return 9 + case 4: return 13 + case 5: return 17 + case 10: return 39 + case 15: return 59 + case 20: return 79 + default: return 100 + } +} + +const attemptsToIndex = (attempts) => { + switch(attempts){ + case 1: return 0 + case 2: return 1 + case 3: return 2 + case 4: return 3 + case 5: return 4 + case 10: return 5 + case 15: return 6 + case 20: return 7 + default: return 8 + } +} + +const MyWidgetsSettingsDialog = ({ onClose, inst, currentUser, otherUserPerms, onEdit, setInvalidLogin }) => { + const [state, setState] = useState(initState()) + const mounted = useRef(false) + const mutateWidget = useUpdateWidget() + const { data: fetchedUsers } = useQuery({ + queryKey: ['user-search', inst.id], + queryFn: () => apiGetUsers(Array.from(otherUserPerms.keys())), + placeholderData: {}, + enabled: !!otherUserPerms && Array.from(otherUserPerms.keys())?.length > 0, + staleTime: Infinity, + onSuccess: (data) => { + if (!data || (data.type == 'error')) + { + console.error(`Error: ${data.msg}`); + if (data.title =="Invalid Login") + { + setInvalidLogin(true) + } + } + } + }) + + // Used for initialization + useEffect(() => { + mounted.current = true + const open = inst.open_at + const close = inst.close_at + const dates = [ + open > -1 ? new Date(open * 1000) : null, + close > -1 ? new Date(close * 1000) : null, + ] + let _availability = [] + let access = inst.guest_access === true ? 'guest' : 'normal' + access = inst.embedded_only === true ? 'embed' : access + + // Gets the initila date, time, & period data + dates.forEach((date, i) => { + let data = { + header: i === 0 ? 'Available' : 'Closes', + anytimeLabel: i === 0 ? 'Now' : 'Never' + } + + if (date) { + const ye = new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date) + const mo = new Intl.DateTimeFormat('en', { month: '2-digit' }).format(date) + const da = new Intl.DateTimeFormat('en', { day: '2-digit' }).format(date) + const min = (date.getMinutes() < 10 ? '0' : '') + date.getMinutes() + let hr = date.getHours() > 12 ? date.getHours() - 12 : date.getHours() + if (hr === 0) hr = 12 + + data.date = `${mo}/${da}/${ye}` + data.time = `${hr}:${min}` + data.period = date.getHours() >= 12 ? 'pm' : 'am' + data.anytime = false + } else { + data.date = '' + data.time = '' + data.period = '' + data.anytime = true + } + + _availability.push(data) + }) + + // Gets the dates in a format the DatePicker can understand + let dateOpen = new Date(_availability[0].date) + let dateClosed = new Date(_availability[1].date) + dateOpen = isNaN(dateOpen) ? '' : dateOpen + dateClosed = isNaN(dateClosed) ? '' : dateClosed + + // Initializes the form data + const _formData = { + data: { + inst_id: inst.id, + open_at: inst.open_at, + close_at: inst.close_at, + attempts: inst.attempts, + guest_access: inst.guest_access, + embedded_only: inst.embedded_only + }, + changes: { + radios: [ + parseInt(inst.open_at) === -1 ? true : false, + parseInt(inst.close_at) === -1 ? true : false + ], + dates: [ + dateOpen, + dateClosed + ], + times: [ + _availability[0].time, + _availability[1].time + ], + periods: [ + _availability[0].period, + _availability[1].period + ], + access: access, + }, + errors: { + date: [false, false], + time: [false, false] + } + } + + setState({...state, + sliderVal: attemptsToValue(parseInt(inst.attempts)), + lastActive: attemptsToIndex(parseInt(inst.attempts)), + availability: _availability, + formData: _formData + }) + + return () => (mounted.current = false) + }, []) + + // Disables the slider if guest access is enabled + useEffect(() => { + if (state.formData.changes.access === 'guest') { + setState({...state, + sliderVal: '100', + lastActive: 8, + formData: {...state.formData, data: {...state.formData.data, attempts: -1}} + }) + } + }, [inst.guest_access, JSON.stringify(state.formData)]) + + const accessChange = (val) => { + // Warns the user if doing this will remove students from collaboration + let _showWarning = false + + if (val !== 'guest' && inst.guest_access) { + for (const key in fetchedUsers) { + if (fetchedUsers.hasOwnProperty(key) && fetchedUsers[key].is_student) { + _showWarning = true + break + } + } + } + + if (!_showWarning) { + setState({...state, showWarning: _showWarning, formData: {...state.formData, changes: {...state.formData.changes, access: val}}}) + } + else { + setState({...state, showWarning: _showWarning, warningType: val}) + } + } + + const submitForm = () => { + const changes = state.formData.changes + const openClose = validateFormData(changes.dates, changes.times, changes.periods) + const errInfo = getErrorInfo(openClose[2]) // Creates an error message if needed + const errMsg = errInfo.msg + const errors = errInfo.errors + let form = { + inst_id: inst.id, + open_at: openClose[0], + close_at: openClose[1], + attempts: valueToAttempts(state.sliderVal), + guest_access: false, + embedded_only: false + } + + if (state.formData.changes.access === 'embed') { + form.embedded_only = true + } + else if (state.formData.changes.access === 'guest') { + form.guest_access = true + form.attempts = -1 + } + + // Submits the form if there are no errors + if (errMsg.length === 0) { + let args = [ + form.inst_id, + undefined, + null, + null, + form.open_at, + form.close_at, + form.attempts, + form.guest_access, + form.embedded_only, + ] + + mutateWidget.mutate({ + args: args, + successFunc: (updatedInst) => { + + if (!updatedInst || updatedInst.type == "error") { + + if (updatedInst.title == "Invalid Login") { + setInvalidLogin(true); + } + else { + console.error(`Error: ${updatedInst.msg}`); + setState({...state, errorLabel: 'Something went wrong, and your changes were not saved.'}) + } + } + else { + onEdit(updatedInst) + if (mounted.current) onClose() + } + } + }) + } + else { + setState({...state, errorLabel: errMsg, formData: {...state.formData, errors: errors}}) + } + } + + // Returns an array of the two dates followed by the error list + const validateFormData = (dates, times, periods) => { + let newDates = [] + let errors = { + dateErrors: [false, false], + timeErrors: [false, false], + startTimeError: false + } + + // Gets the formatted new dates and validates them + for (let index = 0; index < 2; index++) { + const date = dates[index] + const time = times[index] + const period = periods[index] + let dateError = false + let timeError = false + let newDate = new Date() + + // It is anytime + if (state.formData.changes.radios[index] === true) { + newDates.push(-1) + continue + } + + // Validates the time + const reTime = /^\d{1,2}:\d\d$/ + const val = reTime.exec(time) + + // Regex wasn't matched + if (val === null) { + timeError = true + } + else { + const hr = parseInt(val.splice(':')[0]) + const min = parseInt(val.splice(':')[0]) + + // Invalid time + if (hr <= 0 || hr > 12 || min < 0 || min > 59) { + timeError = true + } + } + + if (date === '' || isNaN(Date.parse(date))) { + dateError = true + } + else { + let dateStr = (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear() + newDate = Date.parse(dateStr + ' ' + time + ' ' + period) / 1000 + } + + errors.dateErrors[index] = dateError + errors.timeErrors[index] = timeError + newDates.push(newDate) + } + + if (state.formData.changes.radios[1] !== true && dates[0] > dates[1]) { + errors.startTimeError = true + } + + newDates.push(errors) + + return newDates + } + + const getErrorInfo = (formErrors) => { + let errMsg = '' + let dateErrCount = 0 + let timeErrCount = 0 + let numMissing = false + let errors = { + date: [false, false], + time: [false, false] + } + + dateErrCount += formErrors.dateErrors[0] === true ? 1 : 0 + dateErrCount += formErrors.dateErrors[1] === true ? 1 : 0 + timeErrCount += formErrors.timeErrors[0] === true ? 1 : 0 + timeErrCount += formErrors.timeErrors[1] === true ? 1 : 0 + + // Sets the input error color + errors.date[0] = formErrors.dateErrors[0] + errors.date[1] = formErrors.dateErrors[1] + errors.time[0] = formErrors.timeErrors[0] + errors.time[1] = formErrors.timeErrors[1] + + // Gets if missing or invalid + numMissing += state.formData.changes.dates[0].length === 0 ? 1 : 0 + numMissing += state.formData.changes.dates[1].length === 0 ? 1 : 0 + numMissing += state.formData.changes.times[0].length === 0 ? 1 : 0 + numMissing += state.formData.changes.times[1].length === 0 ? 1 : 0 + + // Handles the many different cases of the error message + if (dateErrCount !== 0 || timeErrCount !== 0) { + errMsg = 'The ' + + switch(dateErrCount) { + case 1: + errMsg += 'date ' + break + case 2: + errMsg += 'dates ' + break + } + + errMsg += dateErrCount !== 0 && timeErrCount !== 0 ? 'and ' : '' + + switch(timeErrCount) { + case 1: + errMsg += 'time ' + break + case 2: + errMsg += 'times ' + break + } + + errMsg += (dateErrCount !== 0 && timeErrCount !== 0) || (dateErrCount > 1 || timeErrCount > 1) ? + 'are ' : + 'is ' + + if (numMissing >= timeErrCount + dateErrCount) + errMsg += 'missing.' + else if (numMissing !== 0) + errMsg += 'invalid/missing.' + else + errMsg += 'invalid.' + } + else if (formErrors.startTimeError) { + errMsg = 'The widget cannot be closed before it becomes available.' + } + + return {msg: errMsg, errors: errors} + } + + const warningSuccess = () => { + setState({...state, showWarning: false, formData: {...state.formData, changes: {...state.formData.changes, access: state.warningType}}}) + } + + let errorLabelRender = null + if (state.errorLabel.length > 0) { + errorLabelRender = ( +

    + {state.errorLabel} +

    + ) + } + + let studentLimitWarningRender = null + if ( currentUser.is_student) { + studentLimitWarningRender = ( +

    + You are viewing a limited version of this page due to your current role as a student. + Students do not have permission to change certain settings like attempt limits or access levels. +

    + ) + } + + const handlePeriodSelectFormDataChange = data => setState({...state, formData: data}) + const periodSelectElements = state.availability.map((val, index) => ( + + )) + + const handleGuestModeWarningClose = () => setState({...state, showWarning: false}) + let guestModeWarningRender = null + if (state.showWarning === true) { + guestModeWarningRender = ( + + + Students with access will be removed. + +

    + Warning: Disabling Guest Mode will automatically revoke access to this widget for any students it has been shared with! +

    + +
    + ) + } + + return ( + +
    +
    + Settings + { errorLabelRender } +
    + { studentLimitWarningRender } +
      +
    • +

      Attempts

      + +
    • +
        + { periodSelectElements } +
      • +

        Access

        +
          + {currentUser.is_student && !inst.is_student_made ?
        • Access settings are currently disabled because of your student status.
        • : ''} +
        • + accessChange('normal')} /> + +
          + Only students and users who can log into Materia can access this widget. + If the widget collects scores, those scores will be associated with the user. + The widget can be distributed via URL, embed code, or as an assignment in your LMS. +
          +
        • +
        • + accessChange('guest')} /> + +
          + Anyone with a link can play this widget without logging in. + All recorded scores will be anonymous. Can't use in an + external system. +
          + Guest Mode is always on for widgets created by students. +
          +
          +
        • +
        • + {accessChange('embed')}} + /> + +
          + This widget will not be playable outside of the classes + it is embedded within. +
          +
        • +
        +
      • +
      +
    + +
    + { guestModeWarningRender } +
    + ) +} + +export default MyWidgetsSettingsDialog diff --git a/src/components/my-widgets-settings-dialog.scss b/src/components/my-widgets-settings-dialog.scss new file mode 100644 index 000000000..419709eef --- /dev/null +++ b/src/components/my-widgets-settings-dialog.scss @@ -0,0 +1,432 @@ +.settings-modal { + display: flex; + flex-direction: column; + width: 680px; + max-height: 675px; + padding: 10px 10px 0 10px; + + .top-bar { + display: flex; + flex-direction: row; + border-bottom: #999 dotted 1px; + padding-bottom: 20px; + margin-bottom: 20px; + align-items: center; + + .title { + font-size: 1.3em; + color: #555; + font-weight: bold; + } + + .availability-error { + margin: 0 25px 0 auto; + padding: 0; + color: #f00; + font-weight: bold; + font-size: 16px; + max-width: 500px; + } + } + + .student-role-notice { + margin: 0 auto 15px auto; + padding: 10px; + font-size: 0.9em; + width: 90%; + background: #eeeeee; + border-radius: 5px; + } + + .attemptsPopup { + display: flex; + flex-direction: column; + align-items: baseline; + justify-content: center; + padding: 0; + margin: 0 20px 0 20px; + + .attempt-content { + display: flex; + margin-bottom: 5px; + + &.hide { + display: none; + } + + h3 { + margin: 0; + width: 5.1em; + min-width: 5.1em; + max-width: 5.1em; + margin-right: 30px; + text-align: end; + } + + .data-holder { + display: flex; + flex-direction: column; + + .selector { + position: relative; + width: 410px; + display: inline-block; + + &.disabled { + opacity: 0.35; + } + + &:not(.disabled) { + input { + cursor: pointer; + } + } + + input { + width: 100%; + } + + input[type='range'] { + -webkit-appearance: none; + width: 100%; + } + input[type='range']:focus { + outline: none; + } + input[type='range']::-webkit-slider-runnable-track { + width: 100%; + height: 8.4px; + border: 1px solid #ccc; + background: #f9f9f9; + border-radius: 4px; + padding-top: 1px; + padding-bottom: 1px; + height: 1px; + } + input[type='range']::-webkit-slider-thumb { + border: 1px solid #4380ad; + height: 18px; + width: 18px; + border-radius: 100%; + background: #519bd1; + -webkit-appearance: none; + margin-top: -9px; + } + input[type='range']:focus::-webkit-slider-runnable-track { + background: #367ebd; + } + input[type='range']::-moz-range-track { + width: 100%; + height: 8.4px; + border: 1px solid #ccc; + background: #f9f9f9; + border-radius: 4px; + padding-top: 1px; + padding-bottom: 1px; + height: 1px; + } + input[type='range']::-moz-range-thumb { + border: 1px solid #4380ad; + height: 18px; + width: 18px; + border-radius: 100%; + background: #519bd1; + margin-top: -9px; + } + input[type='range']::-ms-track { + width: 100%; + height: 1px; + background: transparent; + border-color: transparent; + border-width: 16px 0; + color: transparent; + } + input[type='range']::-ms-fill-lower { + border: 1px solid #ccc; + background: #f9f9f9; + border-radius: 4px; + } + input[type='range']::-ms-fill-upper { + border: 1px solid #ccc; + background: #f9f9f9; + border-radius: 4px; + } + input[type='range']::-ms-thumb { + border: 1px solid #4380ad; + height: 18px; + width: 18px; + border-radius: 100%; + background: #519bd1; + margin-top: -9px; + } + input[type='range']:focus::-ms-fill-lower { + background: #3071a9; + } + input[type='range']:focus::-ms-fill-upper { + background: #367ebd; + } + } + + .attempt-holder { + list-style-type: none; + display: flex; + padding: 0; + width: 400px; + margin: -5px 0 35px 6px; + margin-bottom: 35px; + margin-top: -5px; + position: relative; + color: rgba(0, 0, 0, 0.3); + + &.disabled { + opacity: 0.35; + + span { + cursor: default; + color: rgba(0, 0, 0, 1); + } + } + + span { + position: absolute; + top: 10px; + cursor: pointer; + + &:hover { + color: rgba(0, 0, 0, 1); + } + + &.active { + color: rgba(0, 0, 0, 1); + } + } + + // Entire bar is 67% so 2.68% per 1 attempt + span:nth-child(1) { + left: 0%; + } + span:nth-child(2) { + left: 4%; + } + span:nth-child(3) { + left: 8%; + } + span:nth-child(4) { + left: 12%; + } + span:nth-child(5) { + left: 16%; + } + span:nth-child(6) { + left: 36%; + } + span:nth-child(7) { + left: 56%; + } + span:nth-child(8) { + left: 76%; + } + span:nth-child(9) { + left: 90.5%; + } + } + + .data-explanation { + &.embedded { + .input-desc { + font-size: 0.8em; + } + } + + .input-desc { + padding: 10px; + margin-bottom: 0; + background-color: #f2f2f2; + border-radius: 5px; + + font-weight: 400; + font-size: 13px; + } + } + } + } + + .to-from { + padding: 0; + + .access { + margin-top: 20px; + display: flex; + + h3 { + width: 5.1em; + min-width: 5.1em; + max-width: 5.1em; + text-align: end; + margin: 0 30px 0 0; + } + + .access-options { + padding: 0; + list-style: none; + + &.embedded { + .input-desc { + font-size: 0.8em; + } + } + + &.limited-because-student { + + li.normal.show, li.guest-mode { + filter: blur(3px); + user-select: none; + pointer-events: none; + } + + li.studentWarningListItem { + margin: 0 0 15px 0; + } + } + + .input-desc { + padding: 10px; + margin-bottom: 0; + background: #f2f2f2; + border-radius: 5px; + margin-top: 0.5em; + margin-bottom: 1em; + + font-weight: 400; + font-size: 13px; + } + + .normal { + display: none; + + &.show { + display: inline; + } + } + + input { + margin: 0; + } + + label { + margin-left: 5px; + } + + .guest-mode { + &.disabled { + label { + color: #b5b3b3; + } + } + } + } + + #embedded-only { + display: none; + + &.show { + display: block; + } + } + } + } + } + + .bottom-buttons { + display: inline-flex; + list-style: none; + justify-content: center; + align-items: center; + margin: 4px 0 10px 0; + + .cancel_button { + cursor: pointer; + } + } + + input[type='radio'] { + cursor: pointer; + } +} + +.from-picker { + margin-top: 20px; + display: flex; + align-items: baseline; + + h3 { + margin: 0; + width: 5.1em; + min-width: 5.1em; + max-width: 5.1em; + margin-right: 30px; + text-align: end; + } + + .date-picker { + list-style: none; + padding: 0; + + .date-list-elem { + display: flex; + justify-content: center; + flex-wrap: nowrap; + align-items: center; + } + + label { + margin-left: 5px; + } + + input[type='radio'] { + margin: 0; + } + + input[type='text'] { + margin: 0 5px; + border: 1px solid rgba(0, 0, 0, 0.2); + padding: 7px; + font-size: 0.8em; + border-radius: 7px; + + &.error { + background-color: rgba(255, 0, 0, 0.2); + } + } + + .am { + border: 1px solid rgba(0, 0, 0, 0.2); + border-right: none; + border-radius: 4px 0 0 4px; + } + + .pm { + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 0 4px 4px 0; + } + + .am, + .pm { + color: rgba(0, 0, 0, 0.2); + padding: 5px; + margin: 0; + text-transform: uppercase; + font-size: 0.8em; + font-weight: bold; + cursor: pointer; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + + &.selected { + background: #0094e2; + color: #ffffff; + } + } + } +} diff --git a/src/components/my-widgets-settings-dialog.test.js b/src/components/my-widgets-settings-dialog.test.js new file mode 100644 index 000000000..17ef49ec4 --- /dev/null +++ b/src/components/my-widgets-settings-dialog.test.js @@ -0,0 +1,323 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider, QueryCache, useQuery } from 'react-query' +import MyWidgetsSettingsDialog from './my-widgets-settings-dialog.jsx'; +import rawPermsToObj from '../util/raw-perms-to-object' +import '@testing-library/jest-dom' + +const getInst = () => ({ + id: '12345', + user_id: 1, + widget_id: 1, + published_by: 1, + name: 'Test Widget', + created_at: 1611851557, + updated_at: 1617943440, + open_at: 1611851888, + close_at: 1611858888, + height: 0, + width: 0, + attempts: -1, + is_draft: false, + is_deleted: false, + guest_access: true, + is_student_made: false, + embedded_only: false +}) + +const makeOtherUserPerms = () => { + const othersPerms = new Map() + const permUsers = { + 3: [ + "1", + null + ], + 6: [ + "30", + null + ] + } + for (const i in permUsers) { + othersPerms.set(i, rawPermsToObj(permUsers[i], true)) + } + + return othersPerms +} + +// Mocks the API call +jest.mock('react-query', () => ({ + ...jest.requireActual('react-query'), + useQuery: jest.fn(() => ({ + data: { + 1: { + is_student: false + } + } + })) +})) + +// Enables testing with react query +const renderWithClient = (children) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Turns retries off + retry: false, + }, + }, + }) + + const { rerender, ...result } = render({children}) + + return { + ...result, + rerender: (rerenderUi) => + rerender({rerenderUi}) + } +} + +const mockOnClose = jest.fn() + +// MOCK API CALL figure out otherUsers +describe('MyWidgetsSettingsDialog', () => { + + beforeEach(() => { + const div = document.createElement('div') + div.setAttribute('id', 'modal') + document.body.appendChild(div) + }) + + afterEach(() => { + const div = document.getElementById('modal') + if (div) { + document.body.removeChild(div) + } + }) + + it('Renders correctly depending on guest mode', () => { + const testInst = getInst() + const rendered = renderWithClient() + + // Guest Mode enabled + expect(screen.getByLabelText(/Normal/i).checked).toBe(false) + expect(screen.getByLabelText(/Guest Mode/i).checked).toBe(true) + expect(screen.getByText('20').closest('div').classList.contains('disabled')).toBe(true) + expect(screen.getByLabelText('attempts-input')).toBeDisabled() + expect(screen.queryByText('Attempts are unlimited when Guest Mode is enabled.')).not.toBeNull() + + // disables Guest Mode + fireEvent.click(screen.getByLabelText(/Normal/i)) + + // Guest Mode disabled + expect(screen.getByLabelText(/Normal/i).checked).toBe(true) + expect(screen.getByLabelText(/Guest Mode/i).checked).toBe(false) + expect(screen.getByText('20').closest('div').classList.contains('disabled')).toBe(false) + expect(screen.getByLabelText('attempts-input')).not.toBeDisabled() + expect(screen.queryByText('Attempts are unlimited when Guest Mode is enabled.')).toBeNull() + }) + + it('Should select On input when time is selected and AM input when time is blurred', () => { + let testInst = getInst() + testInst.open_at = -1 + testInst.close_at = -1 + const rendered = renderWithClient() + + // Anytime checkbox should start checked and am/pm input should both be off + expect(screen.getByLabelText('anytime-input-0').checked).toBe(true) + expect(screen.getByLabelText('on-input-0').checked).toBe(false) + expect(screen.getByLabelText('am-input-0').classList.contains('selected')).toBe(false) + expect(screen.getByLabelText('pm-input-0').classList.contains('selected')).toBe(false) + + // Click on time input + fireEvent.click(screen.getByLabelText('time-input-0')) + + // On checkbox should be checked and not anytime + expect(screen.getByLabelText('anytime-input-0').checked).toBe(false) + expect(screen.getByLabelText('on-input-0').checked).toBe(true) + + // Blurs the time input + fireEvent.blur(screen.getByLabelText('time-input-0')) + + // Am should only be selected + expect(screen.getByLabelText('am-input-0').classList.contains('selected')).toBe(true) + expect(screen.getByLabelText('pm-input-0').classList.contains('selected')).toBe(false) + }) + + it('Displays a warning when changing from guest mode with a student collaborator', () => { + // Changes the returned value of useQuery for this test only + useQuery.mockImplementation(() => ({ + data: { + 1: { + is_student: true + } + } + })) + const testInst = getInst() + const rendered = renderWithClient() + + // Modal should start closed and guest mode should be checked + expect(screen.getByLabelText(/Normal/i).checked).toBe(false) + expect(screen.getByLabelText(/Guest Mode/i).checked).toBe(true) + expect(screen.queryByText('Students with access will be removed')).toBeNull() + + // Opens warning modal + fireEvent.click(screen.getByLabelText(/Normal/i)) + + // Checkboxes should remain the same + expect(screen.getByLabelText(/Normal/i).checked).toBe(false) + expect(screen.getByLabelText(/Guest Mode/i).checked).toBe(true) + expect(screen.queryByText('Students with access will be removed')).not.toBeNull() + + // Accepts warning modal + fireEvent.click(screen.getByLabelText('remove-student')) + + // Normal checkbox should be checked and the modal should be closed + expect(screen.getByLabelText(/Normal/i).checked).toBe(true) + expect(screen.getByLabelText(/Guest Mode/i).checked).toBe(false) + expect(screen.queryByText('Students with access will be removed')).toBeNull() + }) + + test('Rejecting the warning when changing from guest mode with a student collaborator', () => { + // Changes the returned value of useQuery for this test only + useQuery.mockImplementation(() => ({ + data: { + 1: { + is_student: true + } + } + })) + const testInst = getInst() + const rendered = renderWithClient() + + // Modal should start close and guest mode should be active + expect(screen.getByLabelText(/Normal/i).checked).toBe(false) + expect(screen.getByLabelText(/Guest Mode/i).checked).toBe(true) + expect(screen.queryByText('Students with access will be removed')).toBeNull() + + // Opens warning modal + fireEvent.click(screen.getByLabelText(/Normal/i)) + + // Modal should popup + expect(screen.queryByText('Students with access will be removed')).not.toBeNull() + + // Rejects warning modal + fireEvent.click(screen.getByLabelText('close-warning-modal')) + + // Checkboxes should remain the same and modal should be closed + expect(screen.getByLabelText(/Normal/i).checked).toBe(false) + expect(screen.getByLabelText(/Guest Mode/i).checked).toBe(true) + expect(screen.queryByText('Students with access will be removed')).toBeNull() + }) + + test('Setting the slider input should change the highlighted value', () => { + let testInst = getInst() + testInst.guest_access = false + const rendered = renderWithClient() + + const attemptBtns = screen.getByLabelText('attempts-choices-container').children + const attemptsValue = '59' + + // Unlimited should be the only active span + for (const attemptButton of attemptBtns) { + const btnText = attemptButton.textContent + if (btnText !== 'Unlimited') { + expect(attemptButton.classList.contains('active')).toBe(false) + } + else { + expect(attemptButton.classList.contains('active')).toBe(true) + } + } + + // Selected 15 attempts + fireEvent.change(screen.getByLabelText("attempts-input"), { + target: { + value: attemptsValue, + }, + }) + + // Lifts mouse from input + fireEvent.mouseUp(screen.getByLabelText("attempts-input")) + + // Value should be + expect(screen.getByLabelText("attempts-input").value).toBe(attemptsValue) + + // 15 should be the only active span + for (const attemptButton of attemptBtns) { + const btnText = attemptButton.textContent + if (btnText !== '15') { + expect(attemptButton.classList.contains('active')).toBe(false) + } + else { + expect(attemptButton.classList.contains('active')).toBe(true) + } + } + }) + + test('Setting the slider input in guest mode should not change the slider', () => { + let testInst = getInst() + const rendered = renderWithClient() + + const attemptBtns = screen.getByLabelText('attempts-choices-container').children + const attemptsValue = '59' + + // Unlimited should be the only active span + for (const attemptButton of attemptBtns) { + const btnText = attemptButton.textContent + if (btnText !== 'Unlimited') { + expect(attemptButton.classList.contains('active')).toBe(false) + } + else { + expect(attemptButton.classList.contains('active')).toBe(true) + } + } + + // Selected 15 attempts + fireEvent.change(screen.getByLabelText("attempts-input"), { + target: { + value: attemptsValue, + }, + }) + + // Lifts mouse from input + fireEvent.mouseUp(screen.getByLabelText("attempts-input")) + + // Value shouldn't change + expect(screen.getByLabelText("attempts-input").value).toBe('100') // 100 is the slider value of Unlimited + + // Unlimited should still be the only active span + for (const attemptButton of attemptBtns) { + const btnText = attemptButton.textContent + if (btnText !== 'Unlimited') { + expect(attemptButton.classList.contains('active')).toBe(false) + } + else { + expect(attemptButton.classList.contains('active')).toBe(true) + } + } + }) + + it('should switch between am/pm when the respective span is clicked', () => { + let testInst = getInst() + testInst.open_at = -1 + testInst.close_at = -1 + const rendered = renderWithClient() + const amBtn = screen.getByLabelText('am-input-0') + const pmBtn = screen.getByLabelText('pm-input-0') + + expect(amBtn.classList.contains('selected')).toBe(false) + expect(pmBtn.classList.contains('selected')).toBe(false) + + fireEvent.click(amBtn) + + expect(amBtn.classList.contains('selected')).toBe(true) + expect(pmBtn.classList.contains('selected')).toBe(false) + + fireEvent.click(pmBtn) + + expect(amBtn.classList.contains('selected')).toBe(false) + expect(pmBtn.classList.contains('selected')).toBe(true) + }) +}) diff --git a/src/components/my-widgets-side-bar.jsx b/src/components/my-widgets-side-bar.jsx new file mode 100644 index 000000000..14e933b0a --- /dev/null +++ b/src/components/my-widgets-side-bar.jsx @@ -0,0 +1,84 @@ +import React, { useState, useMemo } from 'react' +import MyWidgetsInstanceCard from './my-widgets-instance-card' +import LoadingIcon from './loading-icon' + +const MyWidgetsSideBar = ({ instances, isFetching, selectedId, onClick, beardMode, beards }) => { + const [searchText, setSearchText] = useState('') + + const hiddenSet = useMemo(() => { + const result = new Set() + if (searchText == '') return result + + const re = RegExp(searchText, 'i') + instances.forEach(i => { + if (!re.test(`${i.name} ${i.widget.name} ${i.id}`)) { + result.add(i.id) + } + }) + + return result + }, [instances, searchText]) + + const handleSearchInputChange = e => setSearchText(e.target.value) + const handleSearchCloseClick = () => setSearchText('') + + let widgetInstanceElementsRender = null + if (!isFetching || instances?.length > 0) { + widgetInstanceElementsRender = instances?.map((inst, index) => ( +