From 27eae209fdbff5dded05570f14502d3959302d9a Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Wed, 2 Dec 2020 14:48:03 -0600 Subject: [PATCH] Move fleetdm.com into main Fleet repo (#83) * rename dir * no need to install website or docs from npm At some point, would also be nice to be able to exclude assets/ as well, and to only install a pre-built version of Fleet's frontend code * Bring in fleetdm.com website From https://github.com/fleetdm/fleetdm.com as of https://github.com/fleetdm/fleetdm.com/releases/tag/v0.0.21 * add procfile for heroku Using https://github.com/timanovsky/subdir-heroku-buildpack * avoid getting anybody's hopes up * Create deploy-fleet-website.yml (#82) * Create deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * update pjs with SPDX-like license expressions. also fix repo URL and remove package lock * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * remove dummy uri * Dissect deploy script * Update deploy-fleet-website.yml * workaround for eslintrc nesting issue * lint fixes * forgot the .js * add per-commit git config * Update deploy-fleet-website.yml * might as well remove that * cleanup * connect w/ heroku app and have it actually push * fix bug I introduced in 578a1a01ffb8404aae869e05005e30a6ba2b2a95 * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * the beauty, the glory, of javascript * GH actions don't like "\n" * Update deploy-fleet-website.yml * restore \n chars from 0d45e568f693efba7d7072085bc98d72a482d9ae hoping I was wrong in 0d45e568f693efba7d7072085bc98d72a482d9ae but see also https://github.community/t/what-is-the-correct-character-escaping-for-workflow-command-values-e-g-echo-xxxx/118465/5 * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * Update deploy-fleet-website.yml * rename script to prevent duplicate building * Configure the real website * clean up * a test of the deploy workflow * add handbook to npmignore * I guess you could call this fixing a typo * point workflow at master branch * now clearly bogus: this completely unused version string --- .github/workflows/deploy-fleet-website.yml | 71 + .npmignore | 3 + package.json | 10 +- website/.editorconfig | 31 + website/.eslintignore | 3 + website/.eslintrc | 90 + website/.gitignore | 134 + website/.htmlhintrc | 27 + website/.lesshintrc | 46 + website/.npmrc | 11 + website/.sailsrc | 9 + website/Gruntfile.js | 23 + website/Procfile | 1 + website/README.md | 30 + website/api/controllers/account/logout.js | 50 + .../account/update-billing-card.js | 79 + .../controllers/account/update-password.js | 35 + .../api/controllers/account/update-profile.js | 160 + .../account/view-account-overview.js | 30 + .../controllers/account/view-edit-password.js | 26 + .../controllers/account/view-edit-profile.js | 26 + .../api/controllers/dashboard/view-welcome.js | 27 + .../deliver-contact-form-message.js | 79 + .../api/controllers/entrance/confirm-email.js | 147 + website/api/controllers/entrance/login.js | 113 + .../entrance/send-password-recovery-email.js | 66 + website/api/controllers/entrance/signup.js | 121 + .../entrance/update-password-and-login.js | 74 + .../entrance/view-confirmed-email.js | 27 + .../entrance/view-forgot-password.js | 36 + .../api/controllers/entrance/view-login.js | 35 + .../controllers/entrance/view-new-password.js | 57 + .../api/controllers/entrance/view-signup.js | 35 + website/api/controllers/legal/view-privacy.js | 27 + website/api/controllers/legal/view-terms.js | 27 + website/api/controllers/view-contact.js | 27 + website/api/controllers/view-faq.js | 27 + .../controllers/view-homepage-or-redirect.js | 37 + website/api/helpers/redact-user.js | 33 + website/api/helpers/send-template-email.js | 282 + website/api/hooks/custom/index.js | 251 + website/api/models/User.js | 171 + website/api/policies/is-logged-in.js | 26 + website/api/policies/is-super-admin.js | 28 + website/api/responses/expired.js | 37 + website/api/responses/unauthorized.js | 43 + website/app.js | 54 + website/assets/.eslintrc | 61 + .../bootstrap-4/bootstrap-4.bundle.js | 6461 ++++++++ .../dependencies/bootstrap-4/bootstrap-4.css | 9030 +++++++++++ website/assets/dependencies/cloud.js | 1987 +++ website/assets/dependencies/fontawesome.css | 2337 +++ website/assets/dependencies/jquery.min.js | 4 + website/assets/dependencies/lodash.js | 12596 ++++++++++++++++ website/assets/dependencies/moment.js | 4535 ++++++ website/assets/dependencies/parasails.js | 1207 ++ website/assets/dependencies/sails.io.js | 1739 +++ website/assets/dependencies/vue-router.js | 2513 +++ website/assets/dependencies/vue.js | 10979 ++++++++++++++ website/assets/favicon.ico | Bin 0 -> 106009 bytes website/assets/fonts/fontawesome-webfont.eot | Bin 0 -> 165742 bytes website/assets/fonts/fontawesome-webfont.svg | 2671 ++++ website/assets/fonts/fontawesome-webfont.ttf | Bin 0 -> 165548 bytes website/assets/fonts/fontawesome-webfont.woff | Bin 0 -> 98024 bytes .../assets/fonts/fontawesome-webfont.woff2 | Bin 0 -> 77160 bytes .../assets/images/check-circle-16x16@2x.png | Bin 0 -> 713 bytes .../feature-device-status-172x121@2x.png | Bin 0 -> 20509 bytes .../images/feature-forensics-170x122@2x.png | Bin 0 -> 15883 bytes .../feature-threat-hunting-168x120@2x.png | Bin 0 -> 16642 bytes website/assets/images/fleetctl-800x460@2x.png | Bin 0 -> 61182 bytes website/assets/images/hero-bg-3000x700@2x.png | Bin 0 -> 250882 bytes website/assets/images/hero-bg-3424x700@2x.png | Bin 0 -> 274060 bytes website/assets/images/icon-close.png | Bin 0 -> 1509 bytes website/assets/images/logo-120x48@2x.png | Bin 0 -> 4118 bytes .../images/logo-atlassian-194x24@2x.png | Bin 0 -> 3586 bytes .../assets/images/logo-osquery-89x24@2x.png | Bin 0 -> 2589 bytes website/assets/images/logo-slack-28x28@2x.png | Bin 0 -> 1501 bytes .../assets/images/logo-twitter-32x26@2x.png | Bin 0 -> 990 bytes website/assets/images/os-apple-52x56@2x.png | Bin 0 -> 2559 bytes website/assets/images/os-centos-60x56@2x.png | Bin 0 -> 3834 bytes website/assets/images/os-linux-50x56@2x.png | Bin 0 -> 4185 bytes website/assets/images/os-ubuntu-54x56@2x.png | Bin 0 -> 4171 bytes website/assets/images/os-windows-59x56@2x.png | Bin 0 -> 1201 bytes website/assets/images/setup-customize.png | Bin 0 -> 18256 bytes website/assets/images/setup-email.png | Bin 0 -> 11872 bytes website/assets/images/setup-payment.png | Bin 0 -> 10419 bytes website/assets/js/cloud.setup.js | 19 + .../js/components/ajax-button.component.js | 69 + .../js/components/ajax-form.component.js | 379 + .../js/components/cloud-error.component.js | 93 + .../js/components/js-timestamp.component.js | 130 + .../assets/js/components/modal.component.js | 226 + .../stripe-card-element.component.js | 192 + website/assets/js/pages/498.page.js | 25 + .../js/pages/account/account-overview.page.js | 106 + .../js/pages/account/edit-password.page.js | 50 + .../js/pages/account/edit-profile.page.js | 52 + website/assets/js/pages/contact.page.js | 54 + .../assets/js/pages/dashboard/welcome.page.js | 59 + .../js/pages/entrance/confirmed-email.page.js | 25 + .../js/pages/entrance/forgot-password.page.js | 49 + .../assets/js/pages/entrance/login.page.js | 53 + .../js/pages/entrance/new-password.page.js | 52 + .../assets/js/pages/entrance/signup.page.js | 62 + website/assets/js/pages/faq.page.js | 25 + website/assets/js/pages/homepage.page.js | 25 + website/assets/js/pages/legal/privacy.page.js | 25 + website/assets/js/pages/legal/terms.page.js | 25 + .../js/utilities/open-stripe-checkout.js | 98 + website/assets/logo.png | Bin 0 -> 7621 bytes website/assets/logo.png.README.md | 1 + .../assets/styles/bootstrap-overrides.less | 73 + .../components/ajax-button.component.less | 41 + .../components/cloud-error.component.less | 9 + .../styles/components/modal.component.less | 92 + .../stripe-card-element.component.less | 65 + website/assets/styles/importer.less | 42 + website/assets/styles/layout.less | 77 + .../mixins-and-variables/animations.less | 270 + .../styles/mixins-and-variables/buttons.less | 13 + .../styles/mixins-and-variables/colors.less | 17 + .../mixins-and-variables/containers.less | 13 + .../styles/mixins-and-variables/index.less | 6 + .../styles/mixins-and-variables/truncate.less | 5 + .../mixins-and-variables/typography.less | 7 + website/assets/styles/pages/404.less | 5 + website/assets/styles/pages/498.less | 5 + website/assets/styles/pages/500.less | 5 + .../pages/account/account-overview.less | 9 + .../styles/pages/account/edit-password.less | 5 + .../styles/pages/account/edit-profile.less | 5 + website/assets/styles/pages/contact.less | 5 + .../styles/pages/dashboard/welcome.less | 5 + .../pages/entrance/confirmed-email.less | 5 + .../pages/entrance/forgot-password.less | 5 + .../assets/styles/pages/entrance/login.less | 5 + .../styles/pages/entrance/new-password.less | 5 + .../assets/styles/pages/entrance/signup.less | 5 + website/assets/styles/pages/faq.less | 11 + website/assets/styles/pages/homepage.less | 89 + .../assets/styles/pages/legal/privacy.less | 5 + website/assets/styles/pages/legal/terms.less | 5 + website/assets/templates/.gitkeep | 0 website/config/blueprints.js | 41 + website/config/bootstrap.js | 79 + website/config/custom.js | 105 + website/config/datastores.js | 57 + website/config/env/production.js | 396 + website/config/env/staging.js | 92 + website/config/globals.js | 52 + website/config/http.js | 60 + website/config/i18n.js | 45 + website/config/locales/de.json | 4 + website/config/locales/en.json | 4 + website/config/locales/es.json | 4 + website/config/locales/fr.json | 4 + website/config/log.js | 29 + website/config/models.js | 124 + website/config/policies.js | 25 + website/config/routes.js | 94 + website/config/security.js | 49 + website/config/session.js | 39 + website/config/sockets.js | 82 + website/config/views.js | 41 + website/package.json | 44 + website/scripts/rebuild-cloud-sdk.js | 134 + website/tasks/config/babel.js | 54 + website/tasks/config/clean.js | 52 + website/tasks/config/concat.js | 50 + website/tasks/config/copy.js | 65 + website/tasks/config/cssmin.js | 47 + website/tasks/config/hash.js | 62 + website/tasks/config/less.js | 50 + website/tasks/config/sails-linker.js | 184 + website/tasks/config/sync.js | 49 + website/tasks/config/uglify.js | 64 + website/tasks/config/watch.js | 56 + website/tasks/pipeline.js | 158 + website/tasks/register/build.js | 22 + website/tasks/register/buildProd.js | 30 + website/tasks/register/compileAssets.js | 16 + website/tasks/register/default.js | 27 + website/tasks/register/linkAssets.js | 15 + website/tasks/register/linkAssetsBuild.js | 15 + website/tasks/register/linkAssetsBuildProd.js | 15 + website/tasks/register/polyfill.js | 28 + website/tasks/register/prod.js | 26 + website/tasks/register/syncAssets.js | 15 + website/views/.eslintrc | 21 + website/views/404.ejs | 10 + website/views/498.ejs | 11 + website/views/500.ejs | 11 + website/views/emails/email-reset-password.ejs | 9 + website/views/emails/email-verify-account.ejs | 9 + .../views/emails/email-verify-new-email.ejs | 9 + .../emails/internal/email-contact-form.ejs | 16 + website/views/layouts/layout-email.ejs | 22 + website/views/layouts/layout.ejs | 226 + .../views/pages/account/account-overview.ejs | 108 + website/views/pages/account/edit-password.ejs | 36 + website/views/pages/account/edit-profile.ejs | 37 + website/views/pages/contact.ejs | 41 + website/views/pages/dashboard/welcome.ejs | 101 + .../views/pages/entrance/confirmed-email.ejs | 11 + .../views/pages/entrance/forgot-password.ejs | 27 + website/views/pages/entrance/login.ejs | 29 + website/views/pages/entrance/new-password.ejs | 26 + website/views/pages/entrance/signup.ejs | 51 + website/views/pages/faq.ejs | 151 + website/views/pages/homepage.ejs | 172 + website/views/pages/legal/privacy.ejs | 82 + website/views/pages/legal/terms.ejs | 38 + www.fleetdm.com/README.md | 5 - 213 files changed, 65658 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/deploy-fleet-website.yml create mode 100644 .npmignore create mode 100644 website/.editorconfig create mode 100644 website/.eslintignore create mode 100644 website/.eslintrc create mode 100644 website/.gitignore create mode 100644 website/.htmlhintrc create mode 100644 website/.lesshintrc create mode 100644 website/.npmrc create mode 100644 website/.sailsrc create mode 100644 website/Gruntfile.js create mode 100644 website/Procfile create mode 100644 website/README.md create mode 100644 website/api/controllers/account/logout.js create mode 100644 website/api/controllers/account/update-billing-card.js create mode 100644 website/api/controllers/account/update-password.js create mode 100644 website/api/controllers/account/update-profile.js create mode 100644 website/api/controllers/account/view-account-overview.js create mode 100644 website/api/controllers/account/view-edit-password.js create mode 100644 website/api/controllers/account/view-edit-profile.js create mode 100644 website/api/controllers/dashboard/view-welcome.js create mode 100644 website/api/controllers/deliver-contact-form-message.js create mode 100644 website/api/controllers/entrance/confirm-email.js create mode 100644 website/api/controllers/entrance/login.js create mode 100644 website/api/controllers/entrance/send-password-recovery-email.js create mode 100644 website/api/controllers/entrance/signup.js create mode 100644 website/api/controllers/entrance/update-password-and-login.js create mode 100644 website/api/controllers/entrance/view-confirmed-email.js create mode 100644 website/api/controllers/entrance/view-forgot-password.js create mode 100644 website/api/controllers/entrance/view-login.js create mode 100644 website/api/controllers/entrance/view-new-password.js create mode 100644 website/api/controllers/entrance/view-signup.js create mode 100644 website/api/controllers/legal/view-privacy.js create mode 100644 website/api/controllers/legal/view-terms.js create mode 100644 website/api/controllers/view-contact.js create mode 100644 website/api/controllers/view-faq.js create mode 100644 website/api/controllers/view-homepage-or-redirect.js create mode 100644 website/api/helpers/redact-user.js create mode 100644 website/api/helpers/send-template-email.js create mode 100644 website/api/hooks/custom/index.js create mode 100644 website/api/models/User.js create mode 100644 website/api/policies/is-logged-in.js create mode 100644 website/api/policies/is-super-admin.js create mode 100644 website/api/responses/expired.js create mode 100644 website/api/responses/unauthorized.js create mode 100644 website/app.js create mode 100644 website/assets/.eslintrc create mode 100644 website/assets/dependencies/bootstrap-4/bootstrap-4.bundle.js create mode 100644 website/assets/dependencies/bootstrap-4/bootstrap-4.css create mode 100644 website/assets/dependencies/cloud.js create mode 100644 website/assets/dependencies/fontawesome.css create mode 100644 website/assets/dependencies/jquery.min.js create mode 100644 website/assets/dependencies/lodash.js create mode 100644 website/assets/dependencies/moment.js create mode 100644 website/assets/dependencies/parasails.js create mode 100644 website/assets/dependencies/sails.io.js create mode 100644 website/assets/dependencies/vue-router.js create mode 100644 website/assets/dependencies/vue.js create mode 100644 website/assets/favicon.ico create mode 100644 website/assets/fonts/fontawesome-webfont.eot create mode 100644 website/assets/fonts/fontawesome-webfont.svg create mode 100644 website/assets/fonts/fontawesome-webfont.ttf create mode 100644 website/assets/fonts/fontawesome-webfont.woff create mode 100644 website/assets/fonts/fontawesome-webfont.woff2 create mode 100644 website/assets/images/check-circle-16x16@2x.png create mode 100644 website/assets/images/feature-device-status-172x121@2x.png create mode 100644 website/assets/images/feature-forensics-170x122@2x.png create mode 100644 website/assets/images/feature-threat-hunting-168x120@2x.png create mode 100644 website/assets/images/fleetctl-800x460@2x.png create mode 100644 website/assets/images/hero-bg-3000x700@2x.png create mode 100644 website/assets/images/hero-bg-3424x700@2x.png create mode 100644 website/assets/images/icon-close.png create mode 100644 website/assets/images/logo-120x48@2x.png create mode 100644 website/assets/images/logo-atlassian-194x24@2x.png create mode 100644 website/assets/images/logo-osquery-89x24@2x.png create mode 100644 website/assets/images/logo-slack-28x28@2x.png create mode 100644 website/assets/images/logo-twitter-32x26@2x.png create mode 100644 website/assets/images/os-apple-52x56@2x.png create mode 100644 website/assets/images/os-centos-60x56@2x.png create mode 100644 website/assets/images/os-linux-50x56@2x.png create mode 100644 website/assets/images/os-ubuntu-54x56@2x.png create mode 100644 website/assets/images/os-windows-59x56@2x.png create mode 100644 website/assets/images/setup-customize.png create mode 100644 website/assets/images/setup-email.png create mode 100644 website/assets/images/setup-payment.png create mode 100644 website/assets/js/cloud.setup.js create mode 100644 website/assets/js/components/ajax-button.component.js create mode 100644 website/assets/js/components/ajax-form.component.js create mode 100644 website/assets/js/components/cloud-error.component.js create mode 100644 website/assets/js/components/js-timestamp.component.js create mode 100644 website/assets/js/components/modal.component.js create mode 100644 website/assets/js/components/stripe-card-element.component.js create mode 100644 website/assets/js/pages/498.page.js create mode 100644 website/assets/js/pages/account/account-overview.page.js create mode 100644 website/assets/js/pages/account/edit-password.page.js create mode 100644 website/assets/js/pages/account/edit-profile.page.js create mode 100644 website/assets/js/pages/contact.page.js create mode 100644 website/assets/js/pages/dashboard/welcome.page.js create mode 100644 website/assets/js/pages/entrance/confirmed-email.page.js create mode 100644 website/assets/js/pages/entrance/forgot-password.page.js create mode 100644 website/assets/js/pages/entrance/login.page.js create mode 100644 website/assets/js/pages/entrance/new-password.page.js create mode 100644 website/assets/js/pages/entrance/signup.page.js create mode 100644 website/assets/js/pages/faq.page.js create mode 100644 website/assets/js/pages/homepage.page.js create mode 100644 website/assets/js/pages/legal/privacy.page.js create mode 100644 website/assets/js/pages/legal/terms.page.js create mode 100644 website/assets/js/utilities/open-stripe-checkout.js create mode 100644 website/assets/logo.png create mode 100644 website/assets/logo.png.README.md create mode 100644 website/assets/styles/bootstrap-overrides.less create mode 100644 website/assets/styles/components/ajax-button.component.less create mode 100644 website/assets/styles/components/cloud-error.component.less create mode 100644 website/assets/styles/components/modal.component.less create mode 100644 website/assets/styles/components/stripe-card-element.component.less create mode 100644 website/assets/styles/importer.less create mode 100644 website/assets/styles/layout.less create mode 100644 website/assets/styles/mixins-and-variables/animations.less create mode 100644 website/assets/styles/mixins-and-variables/buttons.less create mode 100644 website/assets/styles/mixins-and-variables/colors.less create mode 100644 website/assets/styles/mixins-and-variables/containers.less create mode 100644 website/assets/styles/mixins-and-variables/index.less create mode 100644 website/assets/styles/mixins-and-variables/truncate.less create mode 100644 website/assets/styles/mixins-and-variables/typography.less create mode 100644 website/assets/styles/pages/404.less create mode 100644 website/assets/styles/pages/498.less create mode 100644 website/assets/styles/pages/500.less create mode 100644 website/assets/styles/pages/account/account-overview.less create mode 100644 website/assets/styles/pages/account/edit-password.less create mode 100644 website/assets/styles/pages/account/edit-profile.less create mode 100644 website/assets/styles/pages/contact.less create mode 100644 website/assets/styles/pages/dashboard/welcome.less create mode 100644 website/assets/styles/pages/entrance/confirmed-email.less create mode 100644 website/assets/styles/pages/entrance/forgot-password.less create mode 100644 website/assets/styles/pages/entrance/login.less create mode 100644 website/assets/styles/pages/entrance/new-password.less create mode 100644 website/assets/styles/pages/entrance/signup.less create mode 100644 website/assets/styles/pages/faq.less create mode 100644 website/assets/styles/pages/homepage.less create mode 100644 website/assets/styles/pages/legal/privacy.less create mode 100644 website/assets/styles/pages/legal/terms.less create mode 100644 website/assets/templates/.gitkeep create mode 100644 website/config/blueprints.js create mode 100644 website/config/bootstrap.js create mode 100644 website/config/custom.js create mode 100644 website/config/datastores.js create mode 100644 website/config/env/production.js create mode 100644 website/config/env/staging.js create mode 100644 website/config/globals.js create mode 100644 website/config/http.js create mode 100644 website/config/i18n.js create mode 100644 website/config/locales/de.json create mode 100644 website/config/locales/en.json create mode 100644 website/config/locales/es.json create mode 100644 website/config/locales/fr.json create mode 100644 website/config/log.js create mode 100644 website/config/models.js create mode 100644 website/config/policies.js create mode 100644 website/config/routes.js create mode 100644 website/config/security.js create mode 100644 website/config/session.js create mode 100644 website/config/sockets.js create mode 100644 website/config/views.js create mode 100644 website/package.json create mode 100644 website/scripts/rebuild-cloud-sdk.js create mode 100644 website/tasks/config/babel.js create mode 100644 website/tasks/config/clean.js create mode 100644 website/tasks/config/concat.js create mode 100644 website/tasks/config/copy.js create mode 100644 website/tasks/config/cssmin.js create mode 100644 website/tasks/config/hash.js create mode 100644 website/tasks/config/less.js create mode 100644 website/tasks/config/sails-linker.js create mode 100644 website/tasks/config/sync.js create mode 100644 website/tasks/config/uglify.js create mode 100644 website/tasks/config/watch.js create mode 100644 website/tasks/pipeline.js create mode 100644 website/tasks/register/build.js create mode 100644 website/tasks/register/buildProd.js create mode 100644 website/tasks/register/compileAssets.js create mode 100644 website/tasks/register/default.js create mode 100644 website/tasks/register/linkAssets.js create mode 100644 website/tasks/register/linkAssetsBuild.js create mode 100644 website/tasks/register/linkAssetsBuildProd.js create mode 100644 website/tasks/register/polyfill.js create mode 100644 website/tasks/register/prod.js create mode 100644 website/tasks/register/syncAssets.js create mode 100644 website/views/.eslintrc create mode 100644 website/views/404.ejs create mode 100644 website/views/498.ejs create mode 100644 website/views/500.ejs create mode 100644 website/views/emails/email-reset-password.ejs create mode 100644 website/views/emails/email-verify-account.ejs create mode 100644 website/views/emails/email-verify-new-email.ejs create mode 100644 website/views/emails/internal/email-contact-form.ejs create mode 100644 website/views/layouts/layout-email.ejs create mode 100644 website/views/layouts/layout.ejs create mode 100644 website/views/pages/account/account-overview.ejs create mode 100644 website/views/pages/account/edit-password.ejs create mode 100644 website/views/pages/account/edit-profile.ejs create mode 100644 website/views/pages/contact.ejs create mode 100644 website/views/pages/dashboard/welcome.ejs create mode 100644 website/views/pages/entrance/confirmed-email.ejs create mode 100644 website/views/pages/entrance/forgot-password.ejs create mode 100644 website/views/pages/entrance/login.ejs create mode 100644 website/views/pages/entrance/new-password.ejs create mode 100644 website/views/pages/entrance/signup.ejs create mode 100644 website/views/pages/faq.ejs create mode 100644 website/views/pages/homepage.ejs create mode 100644 website/views/pages/legal/privacy.ejs create mode 100644 website/views/pages/legal/terms.ejs delete mode 100644 www.fleetdm.com/README.md diff --git a/.github/workflows/deploy-fleet-website.yml b/.github/workflows/deploy-fleet-website.yml new file mode 100644 index 000000000000..57b8d4b669cc --- /dev/null +++ b/.github/workflows/deploy-fleet-website.yml @@ -0,0 +1,71 @@ +name: Deploy Fleet website + +on: + push: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x] + + steps: + - uses: actions/checkout@v2 + + # Configure our access credentials for the Heroku CLI + - uses: akhileshns/heroku-deploy@v3.6.8 + with: + heroku_api_key: ${{secrets.HEROKU_API_TOKEN_FOR_BOT_USER}} + heroku_app_name: "" # this has to be blank or it doesn't work + heroku_email: ${{secrets.HEROKU_EMAIL_FOR_BOT_USER}} + justlogin: true + - run: heroku auth:whoami + + # Set the Node.js version + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + # Now start building! + # > …but first, get a little crazy for a sec and delete the top-level package.json file + # > i.e. the one used by the Fleet server. This is because require() in node will go + # > hunting in ancestral directories for missing dependencies, and since some of the + # > bundled transpiler tasks sniff for package availability using require(), this trips + # > up when it encounters another Node universe in the parent directory. + - run: rm -rf package.json package-lock.json node_modules/ + # > Turns out there's a similar issue with how eslint plugins are looked up, so we + # > delete the top level .eslintrc file too. + - run: rm -f .eslintrc.js + + # Get dependencies (including dev deps) + - run: cd website/ && npm install + + # Run sanity checks + - run: cd website/ && npm test + + # Compile assets + - run: cd website/ && npm run build-for-prod + + # Commit newly-built assets locally so we can push them to Heroku below. + # (This commit will never be pushed to GitHub- only to Heroku.) + # > The local config flags make this work in GitHub's environment. + - run: git add website/.www + - run: git -c "user.name=Fleetwood" -c "user.email=github@example.com" commit -am 'AUTOMATED COMMIT - Deployed the latest, including modified HTML layouts and .sailsrc file that reference minified assets.' + + # Configure the Heroku app we'll be deploying to + - run: heroku git:remote -a production-fleetdm-website + - run: git remote -v + + # Deploy to Heroku (by pushing) + # > Since a shallow clone was grabbed, we have to "unshallow" it before forcepushing. + - run: echo "Unshallowing local repository…" + - run: git fetch --prune --unshallow + - run: echo "Deploying branch '${GITHUB_REF##*/}' to Heroku…" + - run: git push heroku +${GITHUB_REF##*/}:master + - name: 🌐 https://fleetdm.com + run: echo '' && echo '--' && echo 'OK, done. It should be live momentarily.' && echo '(if you get impatient, check the Heroku dashboard for status)' && echo && echo ' 🌐–• https://fleetdm.com' diff --git a/.npmignore b/.npmignore new file mode 100644 index 000000000000..947bfcca61a9 --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +website +docs +handbook diff --git a/package.json b/package.json index 8f516b13a916..f8a6299e5e7f 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,7 @@ { - "name": "@kolide/fleet", - "version": "0.1.0", - "description": "Kolide, Black Box Security. Unboxed", - "author": "Kolide, Inc.", + "name": "@fleetdm/fleet", + "version": "0.99.99", + "description": "The premier osquery fleet manager.", "private": true, "sasslintConfig": ".sass-lint.yml", "scripts": { @@ -195,5 +194,6 @@ ], "clearMocks": true, "testURL": "http://localhost:8080" - } + }, + "license": "SEE LICENSE IN ./LICENSE" } diff --git a/website/.editorconfig b/website/.editorconfig new file mode 100644 index 000000000000..6d7fa7039c28 --- /dev/null +++ b/website/.editorconfig @@ -0,0 +1,31 @@ +################################################ +# ╔═╗╔╦╗╦╔╦╗╔═╗╦═╗┌─┐┌─┐┌┐┌┌─┐┬┌─┐ +# ║╣ ║║║ ║ ║ ║╠╦╝│ │ ││││├┤ ││ ┬ +# o╚═╝═╩╝╩ ╩ ╚═╝╩╚═└─┘└─┘┘└┘└ ┴└─┘ +# +# > Formatting conventions for your Sails app. +# +# This file (`.editorconfig`) exists to help +# maintain consistent formatting throughout the +# files in your Sails app. +# +# For the sake of convention, the Sails team's +# preferred settings are included here out of the +# box. You can also change this file to fit your +# team's preferences (for example, if all of the +# developers on your team have a strong preference +# for tabs over spaces), +# +# To review what each of these options mean, see: +# http://editorconfig.org/ +# +################################################ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/website/.eslintignore b/website/.eslintignore new file mode 100644 index 000000000000..f190c2ae4fe6 --- /dev/null +++ b/website/.eslintignore @@ -0,0 +1,3 @@ +assets/dependencies/**/*.js +views/**/*.ejs + diff --git a/website/.eslintrc b/website/.eslintrc new file mode 100644 index 000000000000..de92b9c3fb6c --- /dev/null +++ b/website/.eslintrc @@ -0,0 +1,90 @@ +{ + // ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐ + // ║╣ ╚═╗║ ║║║║ ║ ├┬┘│ + // o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘ + // A set of basic code conventions designed to encourage quality and consistency + // across your Sails app's code base. These rules are checked against + // automatically any time you run `npm test`. + // + // > An additional eslintrc override file is included in the `assets/` folder + // > right out of the box. This is specifically to allow for variations in acceptable + // > global variables between front-end JavaScript code designed to run in the browser + // > vs. backend code designed to run in a Node.js/Sails process. + // + // > Note: If you're using mocha, you'll want to add an extra override file to your + // > `test/` folder so that eslint will tolerate mocha-specific globals like `before` + // > and `describe`. + // Designed for ESLint v4. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + // For more information about any of the rules below, check out the relevant + // reference page on eslint.org. For example, to get details on "no-sequences", + // you would visit `http://eslint.org/docs/rules/no-sequences`. If you're unsure + // or could use some advice, come by https://sailsjs.com/support. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + "env": { + "node": true + }, + + "parserOptions": { + "ecmaVersion": 2018 + }, + + "globals": { + // If "no-undef" is enabled below, be sure to list all global variables that + // are used in this app's backend code (including the globalIds of models): + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + "Promise": true, + "sails": true, + "_": true, + + // Models: + "User": true + + // …and any others. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + }, + + "rules": { + "block-scoped-var": ["error"], + "callback-return": ["error", ["done", "proceed", "next", "onwards", "callback", "cb"]], + "camelcase": ["warn", {"properties":"always"}], + "comma-style": ["warn", "last"], + "curly": ["warn"], + "eqeqeq": ["error", "always"], + "eol-last": ["warn"], + "handle-callback-err": ["error"], + "indent": ["warn", 2, { + "SwitchCase": 1, + "MemberExpression": "off", + "FunctionDeclaration": {"body":1, "parameters":"off"}, + "FunctionExpression": {"body":1, "parameters":"off"}, + "CallExpression": {"arguments":"off"}, + "ArrayExpression": 1, + "ObjectExpression": 1, + "ignoredNodes": ["ConditionalExpression"] + }], + "linebreak-style": ["error", "unix"], + "no-dupe-keys": ["error"], + "no-duplicate-case": ["error"], + "no-extra-semi": ["warn"], + "no-labels": ["error"], + "no-mixed-spaces-and-tabs": [2, "smart-tabs"], + "no-redeclare": ["warn"], + "no-return-assign": ["error", "always"], + "no-sequences": ["error"], + "no-trailing-spaces": ["warn"], + "no-undef": ["error"], + "no-unexpected-multiline": ["warn"], + "no-unreachable": ["warn"], + "no-unused-vars": ["warn", {"caughtErrors":"all", "caughtErrorsIgnorePattern": "^unused($|[A-Z].*$)", "argsIgnorePattern": "^unused($|[A-Z].*$)", "varsIgnorePattern": "^unused($|[A-Z].*$)" }], + "no-use-before-define": ["error", {"functions":false}], + "one-var": ["warn", "never"], + "prefer-arrow-callback": ["warn", {"allowNamedFunctions":true}], + "quotes": ["warn", "single", {"avoidEscape":false, "allowTemplateLiterals":true}], + "semi": ["warn", "always"], + "semi-spacing": ["warn", {"before":false, "after":true}], + "semi-style": ["warn", "last"] + } + +} diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 000000000000..a1ddb80e79e8 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,134 @@ +################################################ +# ┌─┐┬┌┬┐╦╔═╗╔╗╔╔═╗╦═╗╔═╗ +# │ ┬│ │ ║║ ╦║║║║ ║╠╦╝║╣ +# o└─┘┴ ┴ ╩╚═╝╝╚╝╚═╝╩╚═╚═╝ +# +# > Files to exclude from your app's repo. +# +# This file (`.gitignore`) is only relevant if +# you are using git. +# +# It exists to signify to git that certain files +# and/or directories should be ignored for the +# purposes of version control. +# +# This keeps tmp files and sensitive credentials +# from being uploaded to your repository. And +# it allows you to configure your app for your +# machine without accidentally committing settings +# which will smash the local settings of other +# developers on your team. +# +# Some reasonable defaults are included below, +# but, of course, you should modify/extend/prune +# to fit your needs! +# +################################################ + + +################################################ +# Local Configuration +# +# Explicitly ignore files which contain: +# +# 1. Sensitive information you'd rather not push to +# your git repository. +# e.g., your personal API keys or passwords. +# +# 2. Developer-specific configuration +# Basically, anything that would be annoying +# to have to change every time you do a +# `git pull` on your laptop. +# e.g. your local development database, or +# the S3 bucket you're using for file uploads +# during development. +# +################################################ + +config/local.js + + +################################################ +# Dependencies +# +# +# When releasing a production app, you _could_ +# hypothetically include your node_modules folder +# in your git repo, but during development, it +# is always best to exclude it, since different +# developers may be working on different kernels, +# where dependencies would need to be recompiled +# anyway. +# +# Most of the time, the node_modules folder can +# be excluded from your code repository, even +# in production, thanks to features like the +# package-lock.json file / NPM shrinkwrap. +# +# But no matter what, since this is a Sails app, +# you should always push up the package-lock.json +# or shrinkwrap file to your repository, to avoid +# accidentally pulling in upgraded dependencies +# and breaking your code. +# +# That said, if you are having trouble with +# dependencies, (particularly when using +# `npm link`) this can be pretty discouraging. +# But rather than just adding the lockfile to +# your .gitignore, try this first: +# ``` +# rm -rf node_modules +# rm package-lock.json +# npm install +# ``` +# +# [?] For more tips/advice, come by and say hi +# over at https://sailsjs.com/support +# +################################################ + +node_modules + + +################################################ +# +# > Do you use bower? +# > re: the bower_components dir, see this: +# > http://addyosmani.com/blog/checking-in-front-end-dependencies/ +# > (credit Addy Osmani, @addyosmani) +# +################################################ + + +################################################ +# Temporary files generated by Sails/Waterline. +################################################ + +.tmp + + +################################################ +# Miscellaneous +# +# Common files generated by text editors, +# operating systems, file systems, dbs, etc. +################################################ + +*~ +*# +.DS_STORE +.netbeans +nbproject +.idea +*.iml +.vscode +.node_history +dump.rdb + +npm-debug.log +lib-cov +*.seed +*.log +*.out +*.pid + diff --git a/website/.htmlhintrc b/website/.htmlhintrc new file mode 100644 index 000000000000..c9b2ee72b6df --- /dev/null +++ b/website/.htmlhintrc @@ -0,0 +1,27 @@ +{ + "alt-require": true, + "attr-lowercase": ["viewBox"], + "attr-no-duplication": true, + "attr-unsafe-chars": true, + "attr-value-double-quotes": true, + "attr-value-not-empty": false, + "csslint": false, + "doctype-first": false, + "doctype-html5": true, + "head-script-disabled": false, + "href-abs-or-rel": false, + "id-class-ad-disabled": true, + "id-class-value": false, + "id-unique": true, + "inline-script-disabled": true, + "inline-style-disabled": false, + "jshint": false, + "space-tab-mixed-disabled": "space", + "spec-char-escape": false, + "src-not-empty": true, + "style-disabled": false, + "tag-pair": true, + "tag-self-close": false, + "tagname-lowercase": true, + "title-require": false +} diff --git a/website/.lesshintrc b/website/.lesshintrc new file mode 100644 index 000000000000..6dcb60f2787f --- /dev/null +++ b/website/.lesshintrc @@ -0,0 +1,46 @@ +{ + // ╦ ╔═╗╔═╗╔═╗╦ ╦╦╔╗╔╔╦╗┬─┐┌─┐ + // ║ ║╣ ╚═╗╚═╗╠═╣║║║║ ║ ├┬┘│ + // o╩═╝╚═╝╚═╝╚═╝╩ ╩╩╝╚╝ ╩ ┴└─└─┘ + // Configuration designed for the lesshint linter. Describes a loose set of LESS + // conventions that help avoid typos, unexpected failed builds, and hard-to-debug + // selector and CSS rule issues. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + // For more information about any of the rules below, check out the reference page + // of all rules at https://github.com/lesshint/lesshint/blob/v6.3.6/lib/linters/README.md + // If you're unsure or could use some advice, come by https://sailsjs.com/support. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + "singleLinePerSelector": false, + "singleLinePerProperty": false, + "zeroUnit": false, + "idSelector": false, + "propertyOrdering": false, + "spaceAroundBang": false, + "fileExtensions": [".less", ".css"], + "excludedFiles": ["vendor.less"], + "importPath": false, + "borderZero": false, + "hexLength": false, + "hexNotation": false, + "newlineAfterBlock": false, + "spaceBeforeBrace": { + "style": "one_space" + }, + "spaceAfterPropertyName": false, + "spaceAfterPropertyColon": { + "enabled": true, + "style": "one_space" + }, + "maxCharPerLine": false, + "emptyRule": false, + "importantRule": true, + "qualifyingElement": false + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + // ^^ This last one is only disabled because the lesshint parser seems to have + // a hard time distinguishing between things like `div.bar` and `&.bar`. + // In this case, the ampersand has a distinct meaning, and it does not refer + // to an element. (It's referring to the case where that class is matched at + // the parent level, rather than talking about a descendant.) + // https://github.com/lesshint/lesshint/blob/v6.3.6/lib/linters/README.md#qualifyingelement + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +} diff --git a/website/.npmrc b/website/.npmrc new file mode 100644 index 000000000000..43601ce8db37 --- /dev/null +++ b/website/.npmrc @@ -0,0 +1,11 @@ +###################### +# ╔╗╔╔═╗╔╦╗┬─┐┌─┐ # +# ║║║╠═╝║║║├┬┘│ # +# o╝╚╝╩ ╩ ╩┴└─└─┘ # +###################### + +# Hide NPM log output unless it is related to an error of some kind: +loglevel=error + +# Make "npm audit" an opt-in thing for subsequent installs within this app: +audit=false diff --git a/website/.sailsrc b/website/.sailsrc new file mode 100644 index 000000000000..413dbcd8df65 --- /dev/null +++ b/website/.sailsrc @@ -0,0 +1,9 @@ +{ + "generators": { + "modules": {} + }, + "_generatedWith": { + "sails": "1.2.5", + "sails-generate": "2.0.0" + } +} diff --git a/website/Gruntfile.js b/website/Gruntfile.js new file mode 100644 index 000000000000..e3b284733824 --- /dev/null +++ b/website/Gruntfile.js @@ -0,0 +1,23 @@ +/** + * Gruntfile + * + * This Node script is executed when you run `grunt`-- and also when + * you run `sails lift` (provided the grunt hook is installed and + * hasn't been disabled). + * + * WARNING: + * Unless you know what you're doing, you shouldn't change this file. + * Check out the `tasks/` directory instead. + * + * For more information see: + * https://sailsjs.com/anatomy/Gruntfile.js + */ +module.exports = function(grunt) { + + var loadGruntTasks = require('sails-hook-grunt/accessible/load-grunt-tasks'); + + // Load Grunt task configurations (from `tasks/config/`) and Grunt + // task registrations (from `tasks/register/`). + loadGruntTasks(__dirname, grunt); + +}; diff --git a/website/Procfile b/website/Procfile new file mode 100644 index 000000000000..2cbe96dff64f --- /dev/null +++ b/website/Procfile @@ -0,0 +1 @@ +web: NODE_ENV=production node app.js diff --git a/website/README.md b/website/README.md new file mode 100644 index 000000000000..266e6f54883a --- /dev/null +++ b/website/README.md @@ -0,0 +1,30 @@ +# fleetdm.com + +This is where the code for the public https://fleetdm.com website lives. + + +a [Sails v1](https://sailsjs.com) application + + +### Links + ++ [Sails framework documentation](https://sailsjs.com/get-started) ++ [Version notes / upgrading](https://sailsjs.com/documentation/upgrading) ++ [Deployment tips](https://sailsjs.com/documentation/concepts/deployment) ++ [Community support options](https://sailsjs.com/support) ++ [Professional / enterprise options](https://sailsjs.com/enterprise) + + +### Version info + +This app was originally generated on Wed Aug 26 2020 04:48:44 GMT-0500 (Central Daylight Time) using Sails v1.2.5. + + + + +This project's boilerplate is based on an expanded seed app provided by the [Sails core team](https://sailsjs.com/about) to make it easier for you to build on top of ready-made features like authentication, enrollment, email verification, and billing. For more information, [drop us a line](https://sailsjs.com/support). + + + diff --git a/website/api/controllers/account/logout.js b/website/api/controllers/account/logout.js new file mode 100644 index 000000000000..5bc82691f212 --- /dev/null +++ b/website/api/controllers/account/logout.js @@ -0,0 +1,50 @@ +module.exports = { + + + friendlyName: 'Logout', + + + description: 'Log out of this app.', + + + extendedDescription: +`This action deletes the \`req.session.userId\` key from the session of the requesting user agent. +Actual garbage collection of session data depends on this app's session store, and +potentially also on the [TTL configuration](https://sailsjs.com/docs/reference/configuration/sails-config-session) +you provided for it. + +Note that this action does not check to see whether or not the requesting user was +actually logged in. (If they weren't, then this action is just a no-op.)`, + + + exits: { + + success: { + description: 'The requesting user agent has been successfully logged out.' + }, + + redirect: { + description: 'The requesting user agent looks to be a web browser.', + extendedDescription: 'After logging out from a web browser, the user is redirected away.', + responseType: 'redirect' + } + + }, + + + fn: async function () { + + // Clear the `userId` property from this session. + delete this.req.session.userId; + + // Then finish up, sending an appropriate response. + // > Under the covers, this persists the now-logged-out session back + // > to the underlying session store. + if (!this.req.wantsJSON) { + throw {redirect: '/login'}; + } + + } + + +}; diff --git a/website/api/controllers/account/update-billing-card.js b/website/api/controllers/account/update-billing-card.js new file mode 100644 index 000000000000..7cbeac5bab10 --- /dev/null +++ b/website/api/controllers/account/update-billing-card.js @@ -0,0 +1,79 @@ +module.exports = { + + + friendlyName: 'Update billing card', + + + description: 'Update the credit card for the logged-in user.', + + + inputs: { + + stripeToken: { + type: 'string', + example: 'tok_199k3qEXw14QdSnRwmsK99MH', + description: 'The single-use Stripe Checkout token identifier representing the user\'s payment source (i.e. credit card.)', + extendedDescription: 'Omit this (or use "") to remove this user\'s payment source.', + whereToGet: { + description: 'This Stripe.js token is provided to the front-end (client-side) code after completing a Stripe Checkout or Stripe Elements flow.' + } + }, + + billingCardLast4: { + type: 'string', + example: '4242', + description: 'Omit if removing card info.', + whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' } + }, + + billingCardBrand: { + type: 'string', + example: 'visa', + description: 'Omit if removing card info.', + whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' } + }, + + billingCardExpMonth: { + type: 'string', + example: '08', + description: 'Omit if removing card info.', + whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' } + }, + + billingCardExpYear: { + type: 'string', + example: '2023', + description: 'Omit if removing card info.', + whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' } + }, + + }, + + + fn: async function ({stripeToken, billingCardLast4, billingCardBrand, billingCardExpMonth, billingCardExpYear}) { + + // Add, update, or remove the default payment source for the logged-in user's + // customer entry in Stripe. + var stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({ + stripeCustomerId: this.req.me.stripeCustomerId, + token: stripeToken || '', + }).timeout(5000).retry(); + + // Update (or clear) the card info we have stored for this user in our database. + // > Remember, never store complete card numbers-- only the last 4 digits + expiration! + // > Storing (or even receiving) complete, unencrypted card numbers would require PCI + // > compliance in the U.S. + await User.updateOne({ id: this.req.me.id }) + .set({ + stripeCustomerId, + hasBillingCard: stripeToken ? true : false, + billingCardBrand: stripeToken ? billingCardBrand : '', + billingCardLast4: stripeToken ? billingCardLast4 : '', + billingCardExpMonth: stripeToken ? billingCardExpMonth : '', + billingCardExpYear: stripeToken ? billingCardExpYear : '' + }); + + } + + +}; diff --git a/website/api/controllers/account/update-password.js b/website/api/controllers/account/update-password.js new file mode 100644 index 000000000000..00a53a38d676 --- /dev/null +++ b/website/api/controllers/account/update-password.js @@ -0,0 +1,35 @@ +module.exports = { + + + friendlyName: 'Update password', + + + description: 'Update the password for the logged-in user.', + + + inputs: { + + password: { + description: 'The new, unencrypted password.', + example: 'abc123v2', + required: true + } + + }, + + + fn: async function ({password}) { + + // Hash the new password. + var hashed = await sails.helpers.passwords.hashPassword(password); + + // Update the record for the logged-in user. + await User.updateOne({ id: this.req.me.id }) + .set({ + password: hashed + }); + + } + + +}; diff --git a/website/api/controllers/account/update-profile.js b/website/api/controllers/account/update-profile.js new file mode 100644 index 000000000000..afc5fd1b5477 --- /dev/null +++ b/website/api/controllers/account/update-profile.js @@ -0,0 +1,160 @@ +module.exports = { + + + friendlyName: 'Update profile', + + + description: 'Update the profile for the logged-in user.', + + + inputs: { + + fullName: { + type: 'string' + }, + + emailAddress: { + type: 'string' + }, + + }, + + + exits: { + + emailAlreadyInUse: { + statusCode: 409, + description: 'The provided email address is already in use.', + }, + + }, + + + fn: async function ({fullName, emailAddress}) { + + var newEmailAddress = emailAddress; + if (newEmailAddress !== undefined) { + newEmailAddress = newEmailAddress.toLowerCase(); + } + + // Determine if this request wants to change the current user's email address, + // revert her pending email address change, modify her pending email address + // change, or if the email address won't be affected at all. + var desiredEmailEffect;// ('change-immediately', 'begin-change', 'cancel-pending-change', 'modify-pending-change', or '') + if ( + newEmailAddress === undefined || + (this.req.me.emailStatus !== 'change-requested' && newEmailAddress === this.req.me.emailAddress) || + (this.req.me.emailStatus === 'change-requested' && newEmailAddress === this.req.me.emailChangeCandidate) + ) { + desiredEmailEffect = ''; + } else if (this.req.me.emailStatus === 'change-requested' && newEmailAddress === this.req.me.emailAddress) { + desiredEmailEffect = 'cancel-pending-change'; + } else if (this.req.me.emailStatus === 'change-requested' && newEmailAddress !== this.req.me.emailAddress) { + desiredEmailEffect = 'modify-pending-change'; + } else if (!sails.config.custom.verifyEmailAddresses || this.req.me.emailStatus === 'unconfirmed') { + desiredEmailEffect = 'change-immediately'; + } else { + desiredEmailEffect = 'begin-change'; + } + + + // If the email address is changing, make sure it is not already being used. + if (_.contains(['begin-change', 'change-immediately', 'modify-pending-change'], desiredEmailEffect)) { + let conflictingUser = await User.findOne({ + or: [ + { emailAddress: newEmailAddress }, + { emailChangeCandidate: newEmailAddress } + ] + }); + if (conflictingUser) { + throw 'emailAlreadyInUse'; + } + } + + + // Start building the values to set in the db. + // (We always set the fullName if provided.) + var valuesToSet = { + fullName, + }; + + switch (desiredEmailEffect) { + + // Change now + case 'change-immediately': + _.extend(valuesToSet, { + emailAddress: newEmailAddress, + emailChangeCandidate: '', + emailProofToken: '', + emailProofTokenExpiresAt: 0, + emailStatus: this.req.me.emailStatus === 'unconfirmed' ? 'unconfirmed' : 'confirmed' + }); + break; + + // Begin new email change, or modify a pending email change + case 'begin-change': + case 'modify-pending-change': + _.extend(valuesToSet, { + emailChangeCandidate: newEmailAddress, + emailProofToken: await sails.helpers.strings.random('url-friendly'), + emailProofTokenExpiresAt: Date.now() + sails.config.custom.emailProofTokenTTL, + emailStatus: 'change-requested' + }); + break; + + // Cancel pending email change + case 'cancel-pending-change': + _.extend(valuesToSet, { + emailChangeCandidate: '', + emailProofToken: '', + emailProofTokenExpiresAt: 0, + emailStatus: 'confirmed' + }); + break; + + // Otherwise, do nothing re: email + } + + // Save to the db + await User.updateOne({id: this.req.me.id }) + .set(valuesToSet); + + // If this is an immediate change, and billing features are enabled, + // then also update the billing email for this user's linked customer entry + // in the Stripe API to make sure they receive email receipts. + // > Note: If there was not already a Stripe customer entry for this user, + // > then one will be set up implicitly, so we'll need to persist it to our + // > database. (This could happen if Stripe credentials were not configured + // > at the time this user was originally created.) + if(desiredEmailEffect === 'change-immediately' && sails.config.custom.enableBillingFeatures) { + let didNotAlreadyHaveCustomerId = (! this.req.me.stripeCustomerId); + let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({ + stripeCustomerId: this.req.me.stripeCustomerId, + emailAddress: newEmailAddress + }).timeout(5000).retry(); + if (didNotAlreadyHaveCustomerId){ + await User.updateOne({ id: this.req.me.id }) + .set({ + stripeCustomerId + }); + } + } + + // If an email address change was requested, and re-confirmation is required, + // send the "confirm account" email. + if (desiredEmailEffect === 'begin-change' || desiredEmailEffect === 'modify-pending-change') { + await sails.helpers.sendTemplateEmail.with({ + to: newEmailAddress, + subject: 'Your account has been updated', + template: 'email-verify-new-email', + templateData: { + fullName: fullName||this.req.me.fullName, + token: valuesToSet.emailProofToken + } + }); + } + + } + + +}; diff --git a/website/api/controllers/account/view-account-overview.js b/website/api/controllers/account/view-account-overview.js new file mode 100644 index 000000000000..f5841f99813d --- /dev/null +++ b/website/api/controllers/account/view-account-overview.js @@ -0,0 +1,30 @@ +module.exports = { + + + friendlyName: 'View account overview', + + + description: 'Display "Account Overview" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/account/account-overview', + } + + }, + + + fn: async function () { + + // If billing features are enabled, include our configured Stripe.js + // public key in the view locals. Otherwise, leave it as undefined. + return { + stripePublishableKey: sails.config.custom.enableBillingFeatures? sails.config.custom.stripePublishableKey : undefined, + }; + + } + + +}; diff --git a/website/api/controllers/account/view-edit-password.js b/website/api/controllers/account/view-edit-password.js new file mode 100644 index 000000000000..208a1d6a3fbc --- /dev/null +++ b/website/api/controllers/account/view-edit-password.js @@ -0,0 +1,26 @@ +module.exports = { + + + friendlyName: 'View edit password', + + + description: 'Display "Edit password" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/account/edit-password' + } + + }, + + + fn: async function () { + + return {}; + + } + + +}; diff --git a/website/api/controllers/account/view-edit-profile.js b/website/api/controllers/account/view-edit-profile.js new file mode 100644 index 000000000000..baea0f7c206d --- /dev/null +++ b/website/api/controllers/account/view-edit-profile.js @@ -0,0 +1,26 @@ +module.exports = { + + + friendlyName: 'View edit profile', + + + description: 'Display "Edit profile" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/account/edit-profile', + } + + }, + + + fn: async function () { + + return {}; + + } + + +}; diff --git a/website/api/controllers/dashboard/view-welcome.js b/website/api/controllers/dashboard/view-welcome.js new file mode 100644 index 000000000000..989a7f1f7c4d --- /dev/null +++ b/website/api/controllers/dashboard/view-welcome.js @@ -0,0 +1,27 @@ +module.exports = { + + + friendlyName: 'View welcome page', + + + description: 'Display the dashboard "Welcome" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/dashboard/welcome', + description: 'Display the welcome page for authenticated users.' + }, + + }, + + + fn: async function () { + + return {}; + + } + + +}; diff --git a/website/api/controllers/deliver-contact-form-message.js b/website/api/controllers/deliver-contact-form-message.js new file mode 100644 index 000000000000..7668439757d1 --- /dev/null +++ b/website/api/controllers/deliver-contact-form-message.js @@ -0,0 +1,79 @@ +module.exports = { + + + friendlyName: 'Deliver contact form message', + + + description: 'Deliver a contact form message to the appropriate internal channel(s).', + + + inputs: { + + emailAddress: { + required: true, + type: 'string', + description: 'A return email address where we can respond.', + example: 'hermione@hogwarts.edu' + }, + + topic: { + required: true, + type: 'string', + description: 'The topic from the contact form.', + example: 'I want to buy stuff.' + }, + + fullName: { + required: true, + type: 'string', + description: 'The full name of the human sending this message.', + example: 'Hermione Granger' + }, + + message: { + required: true, + type: 'string', + description: 'The custom message, in plain text.' + } + + }, + + + exits: { + + success: { + description: 'The message was sent successfully.' + } + + }, + + + fn: async function({emailAddress, topic, fullName, message}) { + + if (!sails.config.custom.internalEmailAddress) { + throw new Error( +`Cannot deliver incoming message from contact form because there is no internal +email address (\`sails.config.custom.internalEmailAddress\`) configured for this +app. To enable contact form emails, you'll need to add this missing setting to +your custom config -- usually in \`config/custom.js\`, \`config/staging.js\`, +\`config/production.js\`, or via system environment variables.` + ); + } + + await sails.helpers.sendTemplateEmail.with({ + to: sails.config.custom.internalEmailAddress, + subject: 'New contact form message', + template: 'internal/email-contact-form', + layout: false, + templateData: { + contactName: fullName, + contactEmail: emailAddress, + topic, + message, + } + }); + + } + + +}; diff --git a/website/api/controllers/entrance/confirm-email.js b/website/api/controllers/entrance/confirm-email.js new file mode 100644 index 000000000000..ef7fa5b6c2a6 --- /dev/null +++ b/website/api/controllers/entrance/confirm-email.js @@ -0,0 +1,147 @@ +module.exports = { + + + friendlyName: 'Confirm email', + + + description: +`Confirm a new user's email address, or an existing user's request for an email address change, +then redirect to either a special landing page (for newly-signed up users), or the account page +(for existing users who just changed their email address).`, + + + inputs: { + + token: { + description: 'The confirmation token from the email.', + example: '4-32fad81jdaf$329' + } + + }, + + + exits: { + + success: { + description: 'Email address confirmed and requesting user logged in.' + }, + + redirect: { + description: 'Email address confirmed and requesting user logged in. Since this looks like a browser, redirecting...', + responseType: 'redirect' + }, + + invalidOrExpiredToken: { + responseType: 'expired', + description: 'The provided token is expired, invalid, or already used up.', + }, + + emailAddressNoLongerAvailable: { + statusCode: 409, + viewTemplatePath: '500', + description: 'The email address is no longer available.', + extendedDescription: 'This is an edge case that is not always anticipated by websites and APIs. Since it is pretty rare, the 500 server error page is used as a simple catch-all. If this becomes important in the future, this could easily be expanded into a custom error page or resolution flow. But for context: this behavior of showing the 500 server error page mimics how popular apps like Slack behave under the same circumstances.', + } + + }, + + + fn: async function ({token}) { + + // If no token was provided, this is automatically invalid. + if (!token) { + throw 'invalidOrExpiredToken'; + } + + // Get the user with the matching email token. + var user = await User.findOne({ emailProofToken: token }); + + // If no such user exists, or their token is expired, bail. + if (!user || user.emailProofTokenExpiresAt <= Date.now()) { + throw 'invalidOrExpiredToken'; + } + + if (user.emailStatus === 'unconfirmed') { + // ┌─┐┌─┐┌┐┌┌─┐┬┬─┐┌┬┐┬┌┐┌┌─┐ ╔═╗╦╦═╗╔═╗╔╦╗ ╔╦╗╦╔╦╗╔═╗ ╦ ╦╔═╗╔═╗╦═╗ ┌─┐┌┬┐┌─┐┬┬ + // │ │ ││││├┤ │├┬┘││││││││ ┬ ╠╣ ║╠╦╝╚═╗ ║───║ ║║║║║╣ ║ ║╚═╗║╣ ╠╦╝ ├┤ │││├─┤││ + // └─┘└─┘┘└┘└ ┴┴└─┴ ┴┴┘└┘└─┘ ╚ ╩╩╚═╚═╝ ╩ ╩ ╩╩ ╩╚═╝ ╚═╝╚═╝╚═╝╩╚═ └─┘┴ ┴┴ ┴┴┴─┘ + // If this is a new user confirming their email for the first time, + // then just update the state of their user record in the database, + // store their user id in the session (just in case they aren't logged + // in already), and then redirect them to the "email confirmed" page. + await User.updateOne({ id: user.id }).set({ + emailStatus: 'confirmed', + emailProofToken: '', + emailProofTokenExpiresAt: 0 + }); + this.req.session.userId = user.id; + + if (this.req.wantsJSON) { + return; + } else { + throw { redirect: '/email/confirmed' }; + } + + } else if (user.emailStatus === 'change-requested') { + // ┌─┐┌─┐┌┐┌┌─┐┬┬─┐┌┬┐┬┌┐┌┌─┐ ╔═╗╦ ╦╔═╗╔╗╔╔═╗╔═╗╔╦╗ ┌─┐┌┬┐┌─┐┬┬ + // │ │ ││││├┤ │├┬┘││││││││ ┬ ║ ╠═╣╠═╣║║║║ ╦║╣ ║║ ├┤ │││├─┤││ + // └─┘└─┘┘└┘└ ┴┴└─┴ ┴┴┘└┘└─┘ ╚═╝╩ ╩╩ ╩╝╚╝╚═╝╚═╝═╩╝ └─┘┴ ┴┴ ┴┴┴─┘ + if (!user.emailChangeCandidate){ + throw new Error(`Consistency violation: Could not update Stripe customer because this user record's emailChangeCandidate ("${user.emailChangeCandidate}") is missing. (This should never happen.)`); + } + + // Last line of defense: since email change candidates are not protected + // by a uniqueness constraint in the database, it's important that we make + // sure no one else managed to grab this email in the mean time since we + // last checked its availability. (This is a relatively rare edge case-- + // see exit description.) + if (await User.count({ emailAddress: user.emailChangeCandidate }) > 0) { + throw 'emailAddressNoLongerAvailable'; + } + + // If billing features are enabled, also update the billing email for this + // user's linked customer entry in the Stripe API to make sure they receive + // email receipts. + // > Note: If there was not already a Stripe customer entry for this user, + // > then one will be set up implicitly, so we'll need to persist it to our + // > database. (This could happen if Stripe credentials were not configured + // > at the time this user was originally created.) + if(sails.config.custom.enableBillingFeatures) { + let didNotAlreadyHaveCustomerId = (! user.stripeCustomerId); + let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({ + stripeCustomerId: user.stripeCustomerId, + emailAddress: user.emailChangeCandidate + }).timeout(5000).retry(); + if (didNotAlreadyHaveCustomerId){ + await User.updateOne({ id: user.id }).set({ + stripeCustomerId + }); + } + } + + // Finally update the user in the database, store their id in the session + // (just in case they aren't logged in already), then redirect them to + // their "my account" page so they can see their updated email address. + await User.updateOne({ id: user.id }) + .set({ + emailStatus: 'confirmed', + emailProofToken: '', + emailProofTokenExpiresAt: 0, + emailAddress: user.emailChangeCandidate, + emailChangeCandidate: '', + }); + this.req.session.userId = user.id; + if (this.req.wantsJSON) { + return; + } else { + throw { redirect: '/account' }; + } + + } else { + throw new Error(`Consistency violation: User ${user.id} has an email proof token, but somehow also has an emailStatus of "${user.emailStatus}"! (This should never happen.)`); + } + + } + + +}; diff --git a/website/api/controllers/entrance/login.js b/website/api/controllers/entrance/login.js new file mode 100644 index 000000000000..6437d7c6a919 --- /dev/null +++ b/website/api/controllers/entrance/login.js @@ -0,0 +1,113 @@ +module.exports = { + + + friendlyName: 'Login', + + + description: 'Log in using the provided email and password combination.', + + + extendedDescription: +`This action attempts to look up the user record in the database with the +specified email address. Then, if such a user exists, it uses +bcrypt to compare the hashed password from the database with the provided +password attempt.`, + + + inputs: { + + emailAddress: { + description: 'The email to try in this attempt, e.g. "irl@example.com".', + type: 'string', + required: true + }, + + password: { + description: 'The unencrypted password to try in this attempt, e.g. "passwordlol".', + type: 'string', + required: true + }, + + rememberMe: { + description: 'Whether to extend the lifetime of the user\'s session.', + extendedDescription: +`Note that this is NOT SUPPORTED when using virtual requests (e.g. sending +requests over WebSockets instead of HTTP).`, + type: 'boolean' + } + + }, + + + exits: { + + success: { + description: 'The requesting user agent has been successfully logged in.', + extendedDescription: +`Under the covers, this stores the id of the logged-in user in the session +as the \`userId\` key. The next time this user agent sends a request, assuming +it includes a cookie (like a web browser), Sails will automatically make this +user id available as req.session.userId in the corresponding action. (Also note +that, thanks to the included "custom" hook, when a relevant request is received +from a logged-in user, that user's entire record from the database will be fetched +and exposed as \`req.me\`.)` + }, + + badCombo: { + description: `The provided email and password combination does not + match any user in the database.`, + responseType: 'unauthorized' + // ^This uses the custom `unauthorized` response located in `api/responses/unauthorized.js`. + // To customize the generic "unauthorized" response across this entire app, change that file + // (see api/responses/unauthorized). + // + // To customize the response for _only this_ action, replace `responseType` with + // something else. For example, you might set `statusCode: 498` and change the + // implementation below accordingly (see http://sailsjs.com/docs/concepts/controllers). + } + + }, + + + fn: async function ({emailAddress, password, rememberMe}) { + + // Look up by the email address. + // (note that we lowercase it to ensure the lookup is always case-insensitive, + // regardless of which database we're using) + var userRecord = await User.findOne({ + emailAddress: emailAddress.toLowerCase(), + }); + + // If there was no matching user, respond thru the "badCombo" exit. + if(!userRecord) { + throw 'badCombo'; + } + + // If the password doesn't match, then also exit thru "badCombo". + await sails.helpers.passwords.checkPassword(password, userRecord.password) + .intercept('incorrect', 'badCombo'); + + // If "Remember Me" was enabled, then keep the session alive for + // a longer amount of time. (This causes an updated "Set Cookie" + // response header to be sent as the result of this request -- thus + // we must be dealing with a traditional HTTP request in order for + // this to work.) + if (rememberMe) { + if (this.req.isSocket) { + sails.log.warn( + 'Received `rememberMe: true` from a virtual request, but it was ignored\n'+ + 'because a browser\'s session cookie cannot be reset over sockets.\n'+ + 'Please use a traditional HTTP request instead.' + ); + } else { + this.req.session.cookie.maxAge = sails.config.custom.rememberMeCookieMaxAge; + } + }//fi + + // Modify the active session instance. + // (This will be persisted when the response is sent.) + this.req.session.userId = userRecord.id; + + } + +}; diff --git a/website/api/controllers/entrance/send-password-recovery-email.js b/website/api/controllers/entrance/send-password-recovery-email.js new file mode 100644 index 000000000000..21d8f0efa287 --- /dev/null +++ b/website/api/controllers/entrance/send-password-recovery-email.js @@ -0,0 +1,66 @@ +module.exports = { + + + friendlyName: 'Send password recovery email', + + + description: 'Send a password recovery notification to the user with the specified email address.', + + + inputs: { + + emailAddress: { + description: 'The email address of the alleged user who wants to recover their password.', + example: 'rydahl@example.com', + type: 'string', + required: true + } + + }, + + + exits: { + + success: { + description: 'The email address might have matched a user in the database. (If so, a recovery email was sent.)' + }, + + }, + + + fn: async function ({emailAddress}) { + + // Find the record for this user. + // (Even if no such user exists, pretend it worked to discourage sniffing.) + var userRecord = await User.findOne({ emailAddress }); + if (!userRecord) { + return; + }//• + + // Come up with a pseudorandom, probabilistically-unique token for use + // in our password recovery email. + var token = await sails.helpers.strings.random('url-friendly'); + + // Store the token on the user record + // (This allows us to look up the user when the link from the email is clicked.) + await User.updateOne({ id: userRecord.id }) + .set({ + passwordResetToken: token, + passwordResetTokenExpiresAt: Date.now() + sails.config.custom.passwordResetTokenTTL, + }); + + // Send recovery email + await sails.helpers.sendTemplateEmail.with({ + to: emailAddress, + subject: 'Password reset instructions', + template: 'email-reset-password', + templateData: { + fullName: userRecord.fullName, + token: token + } + }); + + } + + +}; diff --git a/website/api/controllers/entrance/signup.js b/website/api/controllers/entrance/signup.js new file mode 100644 index 000000000000..add3a4509256 --- /dev/null +++ b/website/api/controllers/entrance/signup.js @@ -0,0 +1,121 @@ +module.exports = { + + + friendlyName: 'Signup', + + + description: 'Sign up for a new user account.', + + + extendedDescription: +`This creates a new user record in the database, signs in the requesting user agent +by modifying its [session](https://sailsjs.com/documentation/concepts/sessions), and +(if emailing with Mailgun is enabled) sends an account verification email. + +If a verification email is sent, the new user's account is put in an "unconfirmed" state +until they confirm they are using a legitimate email address (by clicking the link in +the account verification message.)`, + + + inputs: { + + emailAddress: { + required: true, + type: 'string', + isEmail: true, + description: 'The email address for the new account, e.g. m@example.com.', + extendedDescription: 'Must be a valid email address.', + }, + + password: { + required: true, + type: 'string', + maxLength: 200, + example: 'passwordlol', + description: 'The unencrypted password to use for the new account.' + }, + + fullName: { + required: true, + type: 'string', + example: 'Frida Kahlo de Rivera', + description: 'The user\'s full name.', + } + + }, + + + exits: { + + success: { + description: 'New user account was created successfully.' + }, + + invalid: { + responseType: 'badRequest', + description: 'The provided fullName, password and/or email address are invalid.', + extendedDescription: 'If this request was sent from a graphical user interface, the request '+ + 'parameters should have been validated/coerced _before_ they were sent.' + }, + + emailAlreadyInUse: { + statusCode: 409, + description: 'The provided email address is already in use.', + }, + + }, + + + fn: async function ({emailAddress, password, fullName}) { + + var newEmailAddress = emailAddress.toLowerCase(); + + // Build up data for the new user record and save it to the database. + // (Also use `fetch` to retrieve the new ID so that we can use it below.) + var newUserRecord = await User.create(_.extend({ + fullName, + emailAddress: newEmailAddress, + password: await sails.helpers.passwords.hashPassword(password), + tosAcceptedByIp: this.req.ip + }, sails.config.custom.verifyEmailAddresses? { + emailProofToken: await sails.helpers.strings.random('url-friendly'), + emailProofTokenExpiresAt: Date.now() + sails.config.custom.emailProofTokenTTL, + emailStatus: 'unconfirmed' + }:{})) + .intercept('E_UNIQUE', 'emailAlreadyInUse') + .intercept({name: 'UsageError'}, 'invalid') + .fetch(); + + // If billing feaures are enabled, save a new customer entry in the Stripe API. + // Then persist the Stripe customer id in the database. + if (sails.config.custom.enableBillingFeatures) { + let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({ + emailAddress: newEmailAddress + }).timeout(5000).retry(); + await User.updateOne({id: newUserRecord.id}) + .set({ + stripeCustomerId + }); + } + + // Store the user's new id in their session. + this.req.session.userId = newUserRecord.id; + + if (sails.config.custom.verifyEmailAddresses) { + // Send "confirm account" email + await sails.helpers.sendTemplateEmail.with({ + to: newEmailAddress, + subject: 'Please confirm your account', + template: 'email-verify-account', + templateData: { + fullName, + token: newUserRecord.emailProofToken + } + }); + } else { + sails.log.info('Skipping new account email verification... (since `verifyEmailAddresses` is disabled)'); + } + + } + +}; diff --git a/website/api/controllers/entrance/update-password-and-login.js b/website/api/controllers/entrance/update-password-and-login.js new file mode 100644 index 000000000000..a9bb5c333290 --- /dev/null +++ b/website/api/controllers/entrance/update-password-and-login.js @@ -0,0 +1,74 @@ +module.exports = { + + + friendlyName: 'Update password and login', + + + description: 'Finish the password recovery flow by setting the new password and '+ + 'logging in the requesting user, based on the authenticity of their token.', + + + inputs: { + + password: { + description: 'The new, unencrypted password.', + example: 'abc123v2', + required: true + }, + + token: { + description: 'The password token that was generated by the `sendPasswordRecoveryEmail` endpoint.', + example: 'gwa8gs8hgw9h2g9hg29hgwh9asdgh9q34$$$$$asdgasdggds', + required: true + } + + }, + + + exits: { + + success: { + description: 'Password successfully updated, and requesting user agent is now logged in.' + }, + + invalidToken: { + description: 'The provided password token is invalid, expired, or has already been used.', + responseType: 'expired' + } + + }, + + + fn: async function ({password, token}) { + + if(!token) { + throw 'invalidToken'; + } + + // Look up the user with this reset token. + var userRecord = await User.findOne({ passwordResetToken: token }); + + // If no such user exists, or their token is expired, bail. + if (!userRecord || userRecord.passwordResetTokenExpiresAt <= Date.now()) { + throw 'invalidToken'; + } + + // Hash the new password. + var hashed = await sails.helpers.passwords.hashPassword(password); + + // Store the user's new password and clear their reset token so it can't be used again. + await User.updateOne({ id: userRecord.id }) + .set({ + password: hashed, + passwordResetToken: '', + passwordResetTokenExpiresAt: 0 + }); + + // Log the user in. + // (This will be persisted when the response is sent.) + this.req.session.userId = userRecord.id; + + } + + +}; diff --git a/website/api/controllers/entrance/view-confirmed-email.js b/website/api/controllers/entrance/view-confirmed-email.js new file mode 100644 index 000000000000..0602f10e9936 --- /dev/null +++ b/website/api/controllers/entrance/view-confirmed-email.js @@ -0,0 +1,27 @@ +module.exports = { + + + friendlyName: 'View confirmed email', + + + description: 'Display "Confirmed email" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/entrance/confirmed-email' + } + + }, + + + fn: async function () { + + // Respond with view. + return {}; + + } + + +}; diff --git a/website/api/controllers/entrance/view-forgot-password.js b/website/api/controllers/entrance/view-forgot-password.js new file mode 100644 index 000000000000..e6b5404c9f3d --- /dev/null +++ b/website/api/controllers/entrance/view-forgot-password.js @@ -0,0 +1,36 @@ +module.exports = { + + + friendlyName: 'View forgot password', + + + description: 'Display "Forgot password" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/entrance/forgot-password', + }, + + redirect: { + description: 'The requesting user is already logged in.', + extendedDescription: 'Logged-in users should change their password in "Account settings."', + responseType: 'redirect', + } + + }, + + + fn: async function () { + + if (this.req.me) { + throw {redirect: '/'}; + } + + return {}; + + } + + +}; diff --git a/website/api/controllers/entrance/view-login.js b/website/api/controllers/entrance/view-login.js new file mode 100644 index 000000000000..1d1c590b53bc --- /dev/null +++ b/website/api/controllers/entrance/view-login.js @@ -0,0 +1,35 @@ +module.exports = { + + + friendlyName: 'View login', + + + description: 'Display "Login" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/entrance/login', + }, + + redirect: { + description: 'The requesting user is already logged in.', + responseType: 'redirect' + } + + }, + + + fn: async function () { + + if (this.req.me) { + throw {redirect: '/'}; + } + + return {}; + + } + + +}; diff --git a/website/api/controllers/entrance/view-new-password.js b/website/api/controllers/entrance/view-new-password.js new file mode 100644 index 000000000000..4532fa5feff0 --- /dev/null +++ b/website/api/controllers/entrance/view-new-password.js @@ -0,0 +1,57 @@ +module.exports = { + + + friendlyName: 'View new password', + + + description: 'Display "New password" page.', + + + inputs: { + + token: { + description: 'The password reset token from the email.', + example: '4-32fad81jdaf$329' + } + + }, + + + exits: { + + success: { + viewTemplatePath: 'pages/entrance/new-password' + }, + + invalidOrExpiredToken: { + responseType: 'expired', + description: 'The provided token is expired, invalid, or has already been used.', + } + + }, + + + fn: async function ({token}) { + + // If password reset token is missing, display an error page explaining that the link is bad. + if (!token) { + sails.log.warn('Attempting to view new password (recovery) page, but no reset password token included in request! Displaying error page...'); + throw 'invalidOrExpiredToken'; + }//• + + // Look up the user with this reset token. + var userRecord = await User.findOne({ passwordResetToken: token }); + // If no such user exists, or their token is expired, display an error page explaining that the link is bad. + if (!userRecord || userRecord.passwordResetTokenExpiresAt <= Date.now()) { + throw 'invalidOrExpiredToken'; + } + + // Grab token and include it in view locals + return { + token, + }; + + } + + +}; diff --git a/website/api/controllers/entrance/view-signup.js b/website/api/controllers/entrance/view-signup.js new file mode 100644 index 000000000000..be43753770d4 --- /dev/null +++ b/website/api/controllers/entrance/view-signup.js @@ -0,0 +1,35 @@ +module.exports = { + + + friendlyName: 'View signup', + + + description: 'Display "Signup" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/entrance/signup', + }, + + redirect: { + description: 'The requesting user is already logged in.', + responseType: 'redirect' + } + + }, + + + fn: async function () { + + if (this.req.me) { + throw {redirect: '/'}; + } + + return {}; + + } + + +}; diff --git a/website/api/controllers/legal/view-privacy.js b/website/api/controllers/legal/view-privacy.js new file mode 100644 index 000000000000..6960be873f16 --- /dev/null +++ b/website/api/controllers/legal/view-privacy.js @@ -0,0 +1,27 @@ +module.exports = { + + + friendlyName: 'View privacy', + + + description: 'Display "Privacy policy" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/legal/privacy' + } + + }, + + + fn: async function () { + + // All done. + return; + + } + + +}; diff --git a/website/api/controllers/legal/view-terms.js b/website/api/controllers/legal/view-terms.js new file mode 100644 index 000000000000..643f04408afd --- /dev/null +++ b/website/api/controllers/legal/view-terms.js @@ -0,0 +1,27 @@ +module.exports = { + + + friendlyName: 'View terms', + + + description: 'Display "Legal terms" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/legal/terms' + } + + }, + + + fn: async function () { + + // All done. + return; + + } + + +}; diff --git a/website/api/controllers/view-contact.js b/website/api/controllers/view-contact.js new file mode 100644 index 000000000000..2db945ec50f8 --- /dev/null +++ b/website/api/controllers/view-contact.js @@ -0,0 +1,27 @@ +module.exports = { + + + friendlyName: 'View contact', + + + description: 'Display "Contact" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/contact' + } + + }, + + + fn: async function () { + + // Respond with view. + return {}; + + } + + +}; diff --git a/website/api/controllers/view-faq.js b/website/api/controllers/view-faq.js new file mode 100644 index 000000000000..ffe4a79daedd --- /dev/null +++ b/website/api/controllers/view-faq.js @@ -0,0 +1,27 @@ +module.exports = { + + + friendlyName: 'View faq', + + + description: 'Display "FAQ" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/faq' + } + + }, + + + fn: async function () { + + // Respond with view. + return {}; + + } + + +}; diff --git a/website/api/controllers/view-homepage-or-redirect.js b/website/api/controllers/view-homepage-or-redirect.js new file mode 100644 index 000000000000..b37f3beef932 --- /dev/null +++ b/website/api/controllers/view-homepage-or-redirect.js @@ -0,0 +1,37 @@ +module.exports = { + + + friendlyName: 'View homepage or redirect', + + + description: 'Display or redirect to the appropriate homepage, depending on login status.', + + + exits: { + + success: { + statusCode: 200, + description: 'Requesting user is a guest, so show the public landing page.', + viewTemplatePath: 'pages/homepage' + }, + + redirect: { + responseType: 'redirect', + description: 'Requesting user is logged in, so redirect to the internal welcome page.' + }, + + }, + + + fn: async function () { + + if (this.req.me) { + throw {redirect:'/welcome'}; + } + + return {}; + + } + + +}; diff --git a/website/api/helpers/redact-user.js b/website/api/helpers/redact-user.js new file mode 100644 index 000000000000..28d9a7c44f72 --- /dev/null +++ b/website/api/helpers/redact-user.js @@ -0,0 +1,33 @@ +module.exports = { + + + friendlyName: 'Redact user', + + + description: 'Destructively remove properties from the provided User record to prepare it for publication.', + + + sync: true, + + + inputs: { + + user: { + type: 'ref', + readOnly: false + } + + }, + + + fn: function ({ user }) { + for (let [attrName, attrDef] of Object.entries(User.attributes)) { + if (attrDef.protect) { + delete user[attrName]; + }//fi + }//∞ + } + + +}; + diff --git a/website/api/helpers/send-template-email.js b/website/api/helpers/send-template-email.js new file mode 100644 index 000000000000..f03b44746281 --- /dev/null +++ b/website/api/helpers/send-template-email.js @@ -0,0 +1,282 @@ +module.exports = { + + + friendlyName: 'Send template email', + + + description: 'Send an email using a template.', + + + extendedDescription: 'To ease testing and development, if the provided "to" email address ends in "@example.com", '+ + 'then the email message will be written to the terminal instead of actually being sent.'+ + '(Thanks [@simonratner](https://github.com/simonratner)!)', + + + inputs: { + + + template: { + description: 'The relative path to an EJS template within our `views/emails/` folder -- WITHOUT the file extension.', + extendedDescription: 'Use strings like "foo" or "foo/bar", but NEVER "foo/bar.ejs" or "/foo/bar". For example, '+ + '"internal/email-contact-form" would send an email using the "views/emails/internal/email-contact-form.ejs" template.', + example: 'email-reset-password', + type: 'string', + required: true + }, + + templateData: { + description: 'A dictionary of data which will be accessible in the EJS template.', + extendedDescription: 'Each key will be a local variable accessible in the template. For instance, if you supply '+ + 'a dictionary with a \`friends\` key, and \`friends\` is an array like \`[{name:"Chandra"}, {name:"Mary"}]\`),'+ + 'then you will be able to access \`friends\` from the template:\n'+ + '\`\`\`\n'+ + '\n'+ + '\`\`\`'+ + '\n'+ + 'This is EJS, so use \`<%= %>\` to inject the HTML-escaped content of a variable, \`<%= %>\` to skip HTML-escaping '+ + 'and inject the data as-is, or \`<% %>\` to execute some JavaScript code such as an \`if\` statement or \`for\` loop.', + type: {}, + defaultsTo: {} + }, + + to: { + description: 'The email address of the primary recipient.', + extendedDescription: 'If this is any address ending in "@example.com", then don\'t actually deliver the message. '+ + 'Instead, just log it to the console.', + example: 'nola.thacker@example.com', + required: true, + isEmail: true, + }, + + toName: { + description: 'Name of the primary recipient as displayed in their inbox.', + example: 'Nola Thacker', + }, + + subject: { + description: 'The subject of the email.', + example: 'Hello there.', + defaultsTo: '' + }, + + from: { + description: 'An override for the default "from" email that\'s been configured.', + example: 'anne.martin@example.com', + isEmail: true, + }, + + fromName: { + description: 'An override for the default "from" name.', + example: 'Anne Martin', + }, + + layout: { + description: 'Set to `false` to disable layouts altogether, or provide the path (relative '+ + 'from `views/layouts/`) to an override email layout.', + defaultsTo: 'layout-email', + custom: (layout)=>layout===false || _.isString(layout) + }, + + ensureAck: { + description: 'Whether to wait for acknowledgement (to hear back) that the email was successfully sent (or at least queued for sending) before returning.', + extendedDescription: 'Otherwise by default, this returns immediately and delivers the request to deliver this email in the background.', + type: 'boolean', + defaultsTo: false + }, + + bcc: { + description: 'The email addresses of recipients secretly copied on the email.', + example: ['jahnna.n.malcolm@example.com'], + }, + + attachments: { + description: 'Attachments to include in the email, with the file content encoded as base64.', + whereToGet: { + description: 'If you have `sails-hook-uploads` installed, you can use `sails.reservoir` to get an attachment into the expected format.', + }, + example: [ + { + contentBytes: 'iVBORw0KGgoAA…', + name: 'sails.png', + type: 'image/png', + } + ], + defaultsTo: [], + }, + + }, + + + exits: { + + success: { + outputFriendlyName: 'Email delivery report', + outputDescription: 'A dictionary of information about what went down.', + outputType: { + loggedInsteadOfSending: 'boolean' + } + } + + }, + + + fn: async function({template, templateData, to, toName, subject, from, fromName, layout, ensureAck, bcc, attachments}) { + + var path = require('path'); + var url = require('url'); + var util = require('util'); + + + if (!_.startsWith(path.basename(template), 'email-')) { + sails.log.warn( + 'The "template" that was passed in to `sendTemplateEmail()` does not begin with '+ + '"email-" -- but by convention, all email template files in `views/emails/` should '+ + 'be namespaced in this way. (This makes it easier to look up email templates by '+ + 'filename; e.g. when using CMD/CTRL+P in Sublime Text.)\n'+ + 'Continuing regardless...' + ); + } + + if (_.startsWith(template, 'views/') || _.startsWith(template, 'emails/')) { + throw new Error( + 'The "template" that was passed in to `sendTemplateEmail()` was prefixed with\n'+ + '`emails/` or `views/` -- but that part is supposed to be omitted. Instead, please\n'+ + 'just specify the path to the desired email template relative from `views/emails/`.\n'+ + 'For example:\n'+ + ' template: \'email-reset-password\'\n'+ + 'Or:\n'+ + ' template: \'admin/email-contact-form\'\n'+ + ' [?] If you\'re unsure or need advice, see https://sailsjs.com/support' + ); + }//• + + // Determine appropriate email layout and template to use. + var emailTemplatePath = path.join('emails/', template); + var emailTemplateLayout; + if (layout) { + emailTemplateLayout = path.relative(path.dirname(emailTemplatePath), path.resolve('layouts/', layout)); + } else { + emailTemplateLayout = false; + } + + // Compile HTML template. + // > Note that we set the layout, provide access to core `url` package (for + // > building links and image srcs, etc.), and also provide access to core + // > `util` package (for dumping debug data in internal emails). + var htmlEmailContents = await sails.renderView( + emailTemplatePath, + _.extend({layout: emailTemplateLayout, url, util }, templateData) + ) + .intercept((err)=>{ + err.message = + 'Could not compile view template.\n'+ + '(Usually, this means the provided data is invalid, or missing a piece.)\n'+ + 'Details:\n'+ + err.message; + return err; + }); + + // Sometimes only log info to the console about the email that WOULD have been sent. + // Specifically, if the "To" email address is anything "@example.com". + // + // > This is used below when determining whether to actually send the email, + // > for convenience during development, but also for safety. (For example, + // > a special-cased version of "user@example.com" is used by Trend Micro Mars + // > scanner to "check apks for malware".) + var isToAddressConsideredFake = Boolean(to.match(/@example\.com$/i)); + + // If that's the case, or if we're in the "test" environment, then log + // the email instead of sending it: + var dontActuallySend = ( + sails.config.environment === 'test' || isToAddressConsideredFake + ); + if (dontActuallySend) { + sails.log( + 'Skipped sending email, either because the "To" email address ended in "@example.com"\n'+ + 'or because the current \`sails.config.environment\` is set to "test".\n'+ + '\n'+ + 'But anyway, here is what WOULD have been sent:\n'+ + '-=-=-=-=-=-=-=-=-=-=-=-=-= Email log =-=-=-=-=-=-=-=-=-=-=-=-=-\n'+ + 'To: '+to+'\n'+ + 'Subject: '+subject+'\n'+ + '\n'+ + 'Body:\n'+ + htmlEmailContents+'\n'+ + '-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-' + ); + } else { + // Otherwise, we'll check that all required Mailgun credentials are set up + // and, if so, continue to actually send the email. + + if (!sails.config.custom.sendgridSecret) { + throw new Error( + 'Cannot deliver email to "'+to+'" because:\n'+ + (()=>{ + let problems = []; + if (!sails.config.custom.sendgridSecret) { + problems.push(' • Sendgrid secret is missing from this app\'s configuration (`sails.config.custom.sendgridSecret`)'); + } + return problems.join('\n'); + })()+ + '\n'+ + 'To resolve these configuration issues, add the missing config variables to\n'+ + '\`config/custom.js\`-- or in staging/production, set them up as system\n'+ + 'environment vars. (If you don\'t have a Sendgrid secret, you can\n'+ + 'sign up for free at https://sendgrid.com to receive credentials.)\n'+ + '\n'+ + '> Note that, for convenience during development, there is another alternative:\n'+ + '> In lieu of setting up real Sendgrid credentials, you can "fake" email\n'+ + '> delivery by using any email address that ends in "@example.com". This will\n'+ + '> write automated emails to your logs rather than actually sending them.\n'+ + '> (To simulate clicking on a link from an email, just copy and paste the link\n'+ + '> from the terminal output into your browser.)\n'+ + '\n'+ + '[?] If you\'re unsure, visit https://sailsjs.com/support' + ); + } + + var subjectLinePrefix = sails.config.environment === 'production' ? '' : sails.config.environment === 'staging' ? '[FROM STAGING] ' : '[FROM LOCALHOST] '; + var messageData = { + htmlMessage: htmlEmailContents, + to: to, + toName: toName, + bcc: bcc, + subject: subjectLinePrefix+subject, + from: from, + fromName: fromName, + attachments + }; + + var deferred = sails.helpers.sendgrid.sendHtmlEmail.with(messageData); + if (ensureAck) { + await deferred; + } else { + // FUTURE: take advantage of .background() here instead (when available) + deferred.exec((err)=>{ + if (err) { + sails.log.error( + 'Background instruction failed: Could not deliver email:\n'+ + util.inspect({template, templateData, to, toName, subject, from, fromName, layout, ensureAck, bcc, attachments},{depth:null})+'\n', + 'Error details:\n'+ + util.inspect(err) + ); + } else { + sails.log.info( + 'Background instruction complete: Email sent via email delivery service (or at least queued):\n'+ + util.inspect({to, toName, subject, from, fromName, bcc},{depth:null}) + ); + } + });//_∏_ + }//fi + }//fi + + // All done! + return { + loggedInsteadOfSending: dontActuallySend, + }; + + } + +}; diff --git a/website/api/hooks/custom/index.js b/website/api/hooks/custom/index.js new file mode 100644 index 000000000000..57f778604981 --- /dev/null +++ b/website/api/hooks/custom/index.js @@ -0,0 +1,251 @@ +/** + * @description :: The conventional "custom" hook. Extends this app with custom server-start-time and request-time logic. + * @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks + */ + +module.exports = function defineCustomHook(sails) { + + return { + + /** + * Runs when a Sails app loads/lifts. + */ + initialize: async function () { + + sails.log.info('Initializing project hook... (`api/hooks/custom/`)'); + + // Check Stripe/Sendgrid configuration (for billing and emails). + var IMPORTANT_STRIPE_CONFIG = ['stripeSecret', 'stripePublishableKey']; + var IMPORTANT_SENDGRID_CONFIG = ['sendgridSecret', 'internalEmailAddress']; + var isMissingStripeConfig = _.difference(IMPORTANT_STRIPE_CONFIG, Object.keys(sails.config.custom)).length > 0; + var isMissingSendgridConfig = _.difference(IMPORTANT_SENDGRID_CONFIG, Object.keys(sails.config.custom)).length > 0; + + if (isMissingStripeConfig || isMissingSendgridConfig) { + + let missingFeatureText = isMissingStripeConfig && isMissingSendgridConfig ? 'billing and email' : isMissingStripeConfig ? 'billing' : 'email'; + let suffix = ''; + if (_.contains(['silly'], sails.config.log.level)) { + suffix = +` +> Tip: To exclude sensitive credentials from source control, use: +> • config/local.js (for local development) +> • environment variables (for production) +> +> If you want to check them in to source control, use: +> • config/custom.js (for development) +> • config/env/staging.js (for staging) +> • config/env/production.js (for production) +> +> (See https://sailsjs.com/docs/concepts/configuration for help configuring Sails.) +`; + } + + let problems = []; + if (sails.config.custom.stripeSecret === undefined) { + problems.push('No `sails.config.custom.stripeSecret` was configured.'); + } + if (sails.config.custom.stripePublishableKey === undefined) { + problems.push('No `sails.config.custom.stripePublishableKey` was configured.'); + } + if (sails.config.custom.sendgridSecret === undefined) { + problems.push('No `sails.config.custom.sendgridSecret` was configured.'); + } + if (sails.config.custom.internalEmailAddress === undefined) { + problems.push('No `sails.config.custom.internalEmailAddress` was configured.'); + } + + sails.log.verbose( +`Some optional settings have not been configured yet: +--------------------------------------------------------------------- +${problems.join('\n')} + +Until this is addressed, this app's ${missingFeatureText} features +will be disabled and/or hidden in the UI. + + [?] If you're unsure or need advice, come by https://sailsjs.com/support +---------------------------------------------------------------------${suffix}`); + }//fi + + // Set an additional config keys based on whether Stripe config is available. + // This will determine whether or not to enable various billing features. + sails.config.custom.enableBillingFeatures = !isMissingStripeConfig; + + // After "sails-hook-organics" finishes initializing, configure Stripe + // and Sendgrid packs with any available credentials. + sails.after('hook:organics:loaded', ()=>{ + + sails.helpers.stripe.configure({ + secret: sails.config.custom.stripeSecret + }); + + sails.helpers.sendgrid.configure({ + secret: sails.config.custom.sendgridSecret, + from: sails.config.custom.fromEmailAddress, + fromName: sails.config.custom.fromName, + }); + + });//_∏_ + + // ... Any other app-specific setup code that needs to run on lift, + // even in production, goes here ... + + }, + + + routes: { + + /** + * Runs before every matching route. + * + * @param {Ref} req + * @param {Ref} res + * @param {Function} next + */ + before: { + '/*': { + skipAssets: true, + fn: async function(req, res, next){ + + var url = require('url'); + + // First, if this is a GET request (and thus potentially a view), + // attach a couple of guaranteed locals. + if (req.method === 'GET') { + + // The `_environment` local lets us do a little workaround to make Vue.js + // run in "production mode" without unnecessarily involving complexities + // with webpack et al.) + if (res.locals._environment !== undefined) { + throw new Error('Cannot attach Sails environment as the view local `_environment`, because this view local already exists! (Is it being attached somewhere else?)'); + } + res.locals._environment = sails.config.environment; + + // The `me` local is set explicitly to `undefined` here just to avoid having to + // do `typeof me !== 'undefined'` checks in our views/layouts/partials. + // > Note that, depending on the request, this may or may not be set to the + // > logged-in user record further below. + if (res.locals.me !== undefined) { + throw new Error('Cannot attach view local `me`, because this view local already exists! (Is it being attached somewhere else?)'); + } + res.locals.me = undefined; + }//fi + + // Next, if we're running in our actual "production" or "staging" Sails + // environment, check if this is a GET request via some other host, + // for example a subdomain like `webhooks.` or `click.`. If so, we'll + // automatically go ahead and redirect to the corresponding path under + // our base URL, which is environment-specific. + // > Note that we DO NOT redirect virtual socket requests and we DO NOT + // > redirect non-GET requests (because it can confuse some 3rd party + // > platforms that send webhook requests.) We also DO NOT redirect + // > requests in other environments to allow for flexibility during + // > development (e.g. so you can preview an app running locally on + // > your laptop using a local IP address or a tool like ngrok, in + // > case you want to run it on a real, physical mobile/IoT device) + var configuredBaseHostname; + try { + configuredBaseHostname = url.parse(sails.config.custom.baseUrl).host; + } catch (unusedErr) { /*…*/} + if ((sails.config.environment === 'staging' || sails.config.environment === 'production') && !req.isSocket && req.method === 'GET' && req.hostname !== configuredBaseHostname) { + sails.log.info('Redirecting GET request from `'+req.hostname+'` to configured expected host (`'+configuredBaseHostname+'`)...'); + return res.redirect(sails.config.custom.baseUrl+req.url); + }//• + + // No session? Proceed as usual. + // (e.g. request for a static asset) + if (!req.session) { return next(); } + + // Not logged in? Proceed as usual. + if (!req.session.userId) { return next(); } + + // Otherwise, look up the logged-in user. + var loggedInUser = await User.findOne({ + id: req.session.userId + }); + + // If the logged-in user has gone missing, log a warning, + // wipe the user id from the requesting user agent's session, + // and then send the "unauthorized" response. + if (!loggedInUser) { + sails.log.warn('Somehow, the user record for the logged-in user (`'+req.session.userId+'`) has gone missing....'); + delete req.session.userId; + return res.unauthorized(); + } + + // Add additional information for convenience when building top-level navigation. + // (i.e. whether to display "Dashboard", "My Account", etc.) + if (!loggedInUser.password || loggedInUser.emailStatus === 'unconfirmed') { + loggedInUser.dontDisplayAccountLinkInNav = true; + } + + // Expose the user record as an extra property on the request object (`req.me`). + // > Note that we make sure `req.me` doesn't already exist first. + if (req.me !== undefined) { + throw new Error('Cannot attach logged-in user as `req.me` because this property already exists! (Is it being attached somewhere else?)'); + } + req.me = loggedInUser; + + // If our "lastSeenAt" attribute for this user is at least a few seconds old, then set it + // to the current timestamp. + // + // (Note: As an optimization, this is run behind the scenes to avoid adding needless latency.) + var MS_TO_BUFFER = 60*1000; + var now = Date.now(); + if (loggedInUser.lastSeenAt < now - MS_TO_BUFFER) { + User.updateOne({id: loggedInUser.id}) + .set({ lastSeenAt: now }) + .exec((err)=>{ + if (err) { + sails.log.error('Background task failed: Could not update user (`'+loggedInUser.id+'`) with a new `lastSeenAt` timestamp. Error details: '+err.stack); + return; + }//• + sails.log.verbose('Updated the `lastSeenAt` timestamp for user `'+loggedInUser.id+'`.'); + // Nothing else to do here. + });//_∏_ (Meanwhile...) + }//fi + + + // If this is a GET request, then also expose an extra view local (`<%= me %>`). + // > Note that we make sure a local named `me` doesn't already exist first. + // > Also note that we strip off any properties that correspond with protected attributes. + if (req.method === 'GET') { + if (res.locals.me !== undefined) { + throw new Error('Cannot attach logged-in user as the view local `me`, because this view local already exists! (Is it being attached somewhere else?)'); + } + + // Exclude any fields corresponding with attributes that have `protect: true`. + var sanitizedUser = _.extend({}, loggedInUser); + sails.helpers.redactUser(sanitizedUser); + + // If there is still a "password" in sanitized user data, then delete it just to be safe. + // (But also log a warning so this isn't hopelessly confusing.) + if (sanitizedUser.password) { + sails.log.warn('The logged in user record has a `password` property, but it was still there after pruning off all properties that match `protect: true` attributes in the User model. So, just to be safe, removing the `password` property anyway...'); + delete sanitizedUser.password; + }//fi + + res.locals.me = sanitizedUser; + + // Include information on the locals as to whether billing features + // are enabled for this app, and whether email verification is required. + res.locals.isBillingEnabled = sails.config.custom.enableBillingFeatures; + res.locals.isEmailVerificationRequired = sails.config.custom.verifyEmailAddresses; + + }//fi + + // Prevent the browser from caching logged-in users' pages. + // (including w/ the Chrome back button) + // > • https://mixmax.com/blog/chrome-back-button-cache-no-store + // > • https://madhatted.com/2013/6/16/you-do-not-understand-browser-history + res.setHeader('Cache-Control', 'no-cache, no-store'); + + return next(); + } + } + } + } + + + }; + +}; diff --git a/website/api/models/User.js b/website/api/models/User.js new file mode 100644 index 000000000000..983627db7aaf --- /dev/null +++ b/website/api/models/User.js @@ -0,0 +1,171 @@ +/** + * User.js + * + * A user who can log in to this application. + */ + +module.exports = { + + attributes: { + + // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ + // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ + // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ + + emailAddress: { + type: 'string', + required: true, + unique: true, + isEmail: true, + maxLength: 200, + example: 'mary.sue@example.com' + }, + + emailStatus: { + type: 'string', + isIn: ['unconfirmed', 'change-requested', 'confirmed'], + defaultsTo: 'confirmed', + description: 'The confirmation status of the user\'s email address.', + extendedDescription: +`Users might be created as "unconfirmed" (e.g. normal signup) or as "confirmed" (e.g. hard-coded +admin users). When the email verification feature is enabled, new users created via the +signup form have \`emailStatus: 'unconfirmed'\` until they click the link in the confirmation email. +Similarly, when an existing user changes their email address, they switch to the "change-requested" +email status until they click the link in the confirmation email.` + }, + + emailChangeCandidate: { + type: 'string', + isEmail: true, + description: 'A still-unconfirmed email address that this user wants to change to (if relevant).' + }, + + password: { + type: 'string', + required: true, + description: 'Securely hashed representation of the user\'s login password.', + protect: true, + example: '2$28a8eabna301089103-13948134nad' + }, + + fullName: { + type: 'string', + required: true, + description: 'Full representation of the user\'s name.', + maxLength: 120, + example: 'Mary Sue van der McHenst' + }, + + isSuperAdmin: { + type: 'boolean', + description: 'Whether this user is a "super admin" with extra permissions, etc.', + extendedDescription: +`Super admins might have extra permissions, see a different default home page when they log in, +or even have a completely different feature set from normal users. In this app, the \`isSuperAdmin\` +flag is just here as a simple way to represent two different kinds of users. Usually, it's a good idea +to keep the data model as simple as possible, only adding attributes when you actually need them for +features being built right now. + +For example, a "super admin" user for a small to medium-sized e-commerce website might be able to +change prices, deactivate seasonal categories, add new offerings, and view live orders as they come in. +On the other hand, for an e-commerce website like Walmart.com that has undergone years of development +by a large team, those administrative features might be split across a few different roles. + +So, while this \`isSuperAdmin\` demarcation might not be the right approach forever, it's a good place to start.` + }, + + passwordResetToken: { + type: 'string', + description: 'A unique token used to verify the user\'s identity when recovering a password. Expires after 1 use, or after a set amount of time has elapsed.' + }, + + passwordResetTokenExpiresAt: { + type: 'number', + description: 'A JS timestamp (epoch ms) representing the moment when this user\'s `passwordResetToken` will expire (or 0 if the user currently has no such token).', + example: 1502844074211 + }, + + emailProofToken: { + type: 'string', + description: 'A pseudorandom, probabilistically-unique token for use in our account verification emails.' + }, + + emailProofTokenExpiresAt: { + type: 'number', + description: 'A JS timestamp (epoch ms) representing the moment when this user\'s `emailProofToken` will expire (or 0 if the user currently has no such token).', + example: 1502844074211 + }, + + stripeCustomerId: { + type: 'string', + protect: true, + description: 'The id of the customer entry in Stripe associated with this user (or empty string if this user is not linked to a Stripe customer -- e.g. if billing features are not enabled).', + extendedDescription: +`Just because this value is set doesn't necessarily mean that this user has a billing card. +It just means they have a customer entry in Stripe, which might or might not have a billing card.` + }, + + hasBillingCard: { + type: 'boolean', + description: 'Whether this user has a default billing card hooked up as their payment method.', + extendedDescription: +`More specifically, this indcates whether this user record's linked customer entry in Stripe has +a default payment source (i.e. credit card). Note that a user have a \`stripeCustomerId\` +without necessarily having a billing card.` + }, + + billingCardBrand: { + type: 'string', + example: 'Visa', + description: 'The brand of this user\'s default billing card (or empty string if no billing card is set up).', + extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.' + }, + + billingCardLast4: { + type: 'string', + example: '4242', + description: 'The last four digits of the card number for this user\'s default billing card (or empty string if no billing card is set up).', + extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.' + }, + + billingCardExpMonth: { + type: 'string', + example: '08', + description: 'The two-digit expiration month from this user\'s default billing card, formatted as MM (or empty string if no billing card is set up).', + extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.' + }, + + billingCardExpYear: { + type: 'string', + example: '2023', + description: 'The four-digit expiration year from this user\'s default billing card, formatted as YYYY (or empty string if no credit card is set up).', + extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.' + }, + + tosAcceptedByIp: { + type: 'string', + description: 'The IP (ipv4) address of the request that accepted the terms of service.', + extendedDescription: 'Useful for certain types of businesses and regulatory requirements (KYC, etc.)', + moreInfoUrl: 'https://en.wikipedia.org/wiki/Know_your_customer' + }, + + lastSeenAt: { + type: 'number', + description: 'A JS timestamp (epoch ms) representing the moment at which this user most recently interacted with the backend while logged in (or 0 if they have not interacted with the backend at all yet).', + example: 1502844074211 + }, + + // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ + // ║╣ ║║║╠╩╗║╣ ║║╚═╗ + // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ + // n/a + + // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ + // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ + // n/a + + }, + + +}; diff --git a/website/api/policies/is-logged-in.js b/website/api/policies/is-logged-in.js new file mode 100644 index 000000000000..0c03b1a689c1 --- /dev/null +++ b/website/api/policies/is-logged-in.js @@ -0,0 +1,26 @@ +/** + * is-logged-in + * + * A simple policy that allows any request from an authenticated user. + * + * For more about how to use policies, see: + * https://sailsjs.com/config/policies + * https://sailsjs.com/docs/concepts/policies + * https://sailsjs.com/docs/concepts/policies/access-control-and-permissions + */ +module.exports = async function (req, res, proceed) { + + // If `req.me` is set, then we know that this request originated + // from a logged-in user. So we can safely proceed to the next policy-- + // or, if this is the last policy, the relevant action. + // > For more about where `req.me` comes from, check out this app's + // > custom hook (`api/hooks/custom/index.js`). + if (req.me) { + return proceed(); + } + + //--• + // Otherwise, this request did not come from a logged-in user. + return res.unauthorized(); + +}; diff --git a/website/api/policies/is-super-admin.js b/website/api/policies/is-super-admin.js new file mode 100644 index 000000000000..09c473aa9d88 --- /dev/null +++ b/website/api/policies/is-super-admin.js @@ -0,0 +1,28 @@ +/** + * is-super-admin + * + * A simple policy that blocks requests from non-super-admins. + * + * For more about how to use policies, see: + * https://sailsjs.com/config/policies + * https://sailsjs.com/docs/concepts/policies + * https://sailsjs.com/docs/concepts/policies/access-control-and-permissions + */ +module.exports = async function (req, res, proceed) { + + // First, check whether the request comes from a logged-in user. + // > For more about where `req.me` comes from, check out this app's + // > custom hook (`api/hooks/custom/index.js`). + if (!req.me) { + return res.unauthorized(); + }//• + + // Then check that this user is a "super admin". + if (!req.me.isSuperAdmin) { + return res.forbidden(); + }//• + + // IWMIH, we've got ourselves a "super admin". + return proceed(); + +}; diff --git a/website/api/responses/expired.js b/website/api/responses/expired.js new file mode 100644 index 000000000000..c71239adbf17 --- /dev/null +++ b/website/api/responses/expired.js @@ -0,0 +1,37 @@ +/** + * expired.js + * + * A custom response that content-negotiates the current request to either: + * • serve an HTML error page about the specified token being invalid or expired + * • or send back 498 (Token Expired/Invalid) with no response body. + * + * Example usage: + * ``` + * return res.expired(); + * ``` + * + * Or with actions2: + * ``` + * exits: { + * badToken: { + * description: 'Provided token was expired, invalid, or already used up.', + * responseType: 'expired' + * } + * } + * ``` + */ +module.exports = function expired() { + + var req = this.req; + var res = this.res; + + sails.log.verbose('Ran custom response: res.expired()'); + + if (req.wantsJSON) { + return res.status(498).send('Token Expired/Invalid'); + } + else { + return res.status(498).view('498'); + } + +}; diff --git a/website/api/responses/unauthorized.js b/website/api/responses/unauthorized.js new file mode 100644 index 000000000000..650cb992f21a --- /dev/null +++ b/website/api/responses/unauthorized.js @@ -0,0 +1,43 @@ +/** + * unauthorized.js + * + * A custom response that content-negotiates the current request to either: + * • log out the current user and redirect them to the login page + * • or send back 401 (Unauthorized) with no response body. + * + * Example usage: + * ``` + * return res.unauthorized(); + * ``` + * + * Or with actions2: + * ``` + * exits: { + * badCombo: { + * description: 'That email address and password combination is not recognized.', + * responseType: 'unauthorized' + * } + * } + * ``` + */ +module.exports = function unauthorized() { + + var req = this.req; + var res = this.res; + + sails.log.verbose('Ran custom response: res.unauthorized()'); + + if (req.wantsJSON) { + return res.sendStatus(401); + } + // Or log them out (if necessary) and then redirect to the login page. + else { + + if (req.session.userId) { + delete req.session.userId; + } + + return res.redirect('/login'); + } + +}; diff --git a/website/app.js b/website/app.js new file mode 100644 index 000000000000..f2c5f4eba84a --- /dev/null +++ b/website/app.js @@ -0,0 +1,54 @@ +/** + * app.js + * + * Use `app.js` to run your app without `sails lift`. + * To start the server, run: `node app.js`. + * + * This is handy in situations where the sails CLI is not relevant or useful, + * such as when you deploy to a server, or a PaaS like Heroku. + * + * For example: + * => `node app.js` + * => `npm start` + * => `forever start app.js` + * => `node debug app.js` + * + * The same command-line arguments and env vars are supported, e.g.: + * `NODE_ENV=production node app.js --port=80 --verbose` + * + * For more information see: + * https://sailsjs.com/anatomy/app.js + */ + + +// Ensure we're in the project directory, so cwd-relative paths work as expected +// no matter where we actually lift from. +// > Note: This is not required in order to lift, but it is a convenient default. +process.chdir(__dirname); + + + +// Attempt to import `sails` dependency, as well as `rc` (for loading `.sailsrc` files). +var sails; +var rc; +try { + sails = require('sails'); + rc = require('sails/accessible/rc'); +} catch (err) { + console.error('Encountered an error when attempting to require(\'sails\'):'); + console.error(err.stack); + console.error('--'); + console.error('To run an app using `node app.js`, you need to have Sails installed'); + console.error('locally (`./node_modules/sails`). To do that, just make sure you\'re'); + console.error('in the same directory as your app and run `npm install`.'); + console.error(); + console.error('If Sails is installed globally (i.e. `npm install -g sails`) you can'); + console.error('also run this app with `sails lift`. Running with `sails lift` will'); + console.error('not run this file (`app.js`), but it will do exactly the same thing.'); + console.error('(It even uses your app directory\'s local Sails install, if possible.)'); + return; +}//-• + + +// Start server +sails.lift(rc('sails')); diff --git a/website/assets/.eslintrc b/website/assets/.eslintrc new file mode 100644 index 000000000000..74e9449912ba --- /dev/null +++ b/website/assets/.eslintrc @@ -0,0 +1,61 @@ +{ + // ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐ ┌─┐┬ ┬┌─┐┬─┐┬─┐┬┌┬┐┌─┐ + // ║╣ ╚═╗║ ║║║║ ║ ├┬┘│ │ │└┐┌┘├┤ ├┬┘├┬┘│ ││├┤ + // o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘ └─┘ └┘ └─┘┴└─┴└─┴─┴┘└─┘ + // ┌─ ┌─┐┌─┐┬─┐ ┌┐ ┬─┐┌─┐┬ ┬┌─┐┌─┐┬─┐ ┬┌─┐ ┌─┐┌─┐┌─┐┌─┐┌┬┐┌─┐ ─┐ + // │ ├┤ │ │├┬┘ ├┴┐├┬┘│ ││││└─┐├┤ ├┬┘ │└─┐ ├─┤└─┐└─┐├┤ │ └─┐ │ + // └─ └ └─┘┴└─ └─┘┴└─└─┘└┴┘└─┘└─┘┴└─ └┘└─┘ ┴ ┴└─┘└─┘└─┘ ┴ └─┘ ─┘ + // > An .eslintrc configuration override for use in the `assets/` directory. + // + // This extends the top-level .eslintrc file, primarily to change the set of + // supported globals, as well as any other relevant settings. (Since JavaScript + // code in the `assets/` folder is intended for the browser habitat, a different + // set of globals is supported. For example, instead of Node.js/Sails globals + // like `sails` and `process`, you have access to browser globals like `window`.) + // + // (See .eslintrc in the root directory of this Sails app for more context.) + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + "extends": [ + "../.eslintrc" + ], + + "env": { + "browser": true, + "node": false + }, + + "parserOptions": { + "ecmaVersion": 8 + //^ If you are not using a transpiler like Babel, change this to `5`. + }, + + "globals": { + + // Allow any window globals you're relying on here; e.g. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + "SAILS_LOCALS": true, + "io": true, + "Cloud": true, + "parasails": true, + "$": true, + "_": true, + "bowser": true, + "StripeCheckout": true, + "Stripe": true, + "Vue": true, + "VueRouter": true, + "moment": true, + // "google": true, + // ...etc. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + // Make sure backend globals aren't indadvertently tolerated in our client-side JS: + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + "sails": false, + "User": false + // ...and any other backend globals (e.g. `"Organization": false`) + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + } + +} diff --git a/website/assets/dependencies/bootstrap-4/bootstrap-4.bundle.js b/website/assets/dependencies/bootstrap-4/bootstrap-4.bundle.js new file mode 100644 index 000000000000..e8b832da6bdf --- /dev/null +++ b/website/assets/dependencies/bootstrap-4/bootstrap-4.bundle.js @@ -0,0 +1,6461 @@ +/*! + * Bootstrap v4.1.3 (https://getbootstrap.com/) + * Copyright 2011-2018 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('jquery')) : + typeof define === 'function' && define.amd ? define(['exports', 'jquery'], factory) : + (factory((global.bootstrap = {}),global.jQuery)); +}(this, (function (exports,$) { 'use strict'; + + $ = $ && $.hasOwnProperty('default') ? $['default'] : $; + + function _defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + function _createClass(Constructor, protoProps, staticProps) { + if (protoProps) _defineProperties(Constructor.prototype, protoProps); + if (staticProps) _defineProperties(Constructor, staticProps); + return Constructor; + } + + function _defineProperty(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + + return obj; + } + + function _objectSpread(target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i] != null ? arguments[i] : {}; + var ownKeys = Object.keys(source); + + if (typeof Object.getOwnPropertySymbols === 'function') { + ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { + return Object.getOwnPropertyDescriptor(source, sym).enumerable; + })); + } + + ownKeys.forEach(function (key) { + _defineProperty(target, key, source[key]); + }); + } + + return target; + } + + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + subClass.__proto__ = superClass; + } + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): util.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Util = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Private TransitionEnd Helpers + * ------------------------------------------------------------------------ + */ + var TRANSITION_END = 'transitionend'; + var MAX_UID = 1000000; + var MILLISECONDS_MULTIPLIER = 1000; // Shoutout AngusCroll (https://goo.gl/pxwQGp) + + function toType(obj) { + return {}.toString.call(obj).match(/\s([a-z]+)/i)[1].toLowerCase(); + } + + function getSpecialTransitionEndEvent() { + return { + bindType: TRANSITION_END, + delegateType: TRANSITION_END, + handle: function handle(event) { + if ($$$1(event.target).is(this)) { + return event.handleObj.handler.apply(this, arguments); // eslint-disable-line prefer-rest-params + } + + return undefined; // eslint-disable-line no-undefined + } + }; + } + + function transitionEndEmulator(duration) { + var _this = this; + + var called = false; + $$$1(this).one(Util.TRANSITION_END, function () { + called = true; + }); + setTimeout(function () { + if (!called) { + Util.triggerTransitionEnd(_this); + } + }, duration); + return this; + } + + function setTransitionEndSupport() { + $$$1.fn.emulateTransitionEnd = transitionEndEmulator; + $$$1.event.special[Util.TRANSITION_END] = getSpecialTransitionEndEvent(); + } + /** + * -------------------------------------------------------------------------- + * Public Util Api + * -------------------------------------------------------------------------- + */ + + + var Util = { + TRANSITION_END: 'bsTransitionEnd', + getUID: function getUID(prefix) { + do { + // eslint-disable-next-line no-bitwise + prefix += ~~(Math.random() * MAX_UID); // "~~" acts like a faster Math.floor() here + } while (document.getElementById(prefix)); + + return prefix; + }, + getSelectorFromElement: function getSelectorFromElement(element) { + var selector = element.getAttribute('data-target'); + + if (!selector || selector === '#') { + selector = element.getAttribute('href') || ''; + } + + try { + return document.querySelector(selector) ? selector : null; + } catch (err) { + return null; + } + }, + getTransitionDurationFromElement: function getTransitionDurationFromElement(element) { + if (!element) { + return 0; + } // Get transition-duration of the element + + + var transitionDuration = $$$1(element).css('transition-duration'); + var floatTransitionDuration = parseFloat(transitionDuration); // Return 0 if element or transition duration is not found + + if (!floatTransitionDuration) { + return 0; + } // If multiple durations are defined, take the first + + + transitionDuration = transitionDuration.split(',')[0]; + return parseFloat(transitionDuration) * MILLISECONDS_MULTIPLIER; + }, + reflow: function reflow(element) { + return element.offsetHeight; + }, + triggerTransitionEnd: function triggerTransitionEnd(element) { + $$$1(element).trigger(TRANSITION_END); + }, + // TODO: Remove in v5 + supportsTransitionEnd: function supportsTransitionEnd() { + return Boolean(TRANSITION_END); + }, + isElement: function isElement(obj) { + return (obj[0] || obj).nodeType; + }, + typeCheckConfig: function typeCheckConfig(componentName, config, configTypes) { + for (var property in configTypes) { + if (Object.prototype.hasOwnProperty.call(configTypes, property)) { + var expectedTypes = configTypes[property]; + var value = config[property]; + var valueType = value && Util.isElement(value) ? 'element' : toType(value); + + if (!new RegExp(expectedTypes).test(valueType)) { + throw new Error(componentName.toUpperCase() + ": " + ("Option \"" + property + "\" provided type \"" + valueType + "\" ") + ("but expected type \"" + expectedTypes + "\".")); + } + } + } + } + }; + setTransitionEndSupport(); + return Util; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): alert.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Alert = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'alert'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.alert'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var Selector = { + DISMISS: '[data-dismiss="alert"]' + }; + var Event = { + CLOSE: "close" + EVENT_KEY, + CLOSED: "closed" + EVENT_KEY, + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + ALERT: 'alert', + FADE: 'fade', + SHOW: 'show' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Alert = + /*#__PURE__*/ + function () { + function Alert(element) { + this._element = element; + } // Getters + + + var _proto = Alert.prototype; + + // Public + _proto.close = function close(element) { + var rootElement = this._element; + + if (element) { + rootElement = this._getRootElement(element); + } + + var customEvent = this._triggerCloseEvent(rootElement); + + if (customEvent.isDefaultPrevented()) { + return; + } + + this._removeElement(rootElement); + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + this._element = null; + }; // Private + + + _proto._getRootElement = function _getRootElement(element) { + var selector = Util.getSelectorFromElement(element); + var parent = false; + + if (selector) { + parent = document.querySelector(selector); + } + + if (!parent) { + parent = $$$1(element).closest("." + ClassName.ALERT)[0]; + } + + return parent; + }; + + _proto._triggerCloseEvent = function _triggerCloseEvent(element) { + var closeEvent = $$$1.Event(Event.CLOSE); + $$$1(element).trigger(closeEvent); + return closeEvent; + }; + + _proto._removeElement = function _removeElement(element) { + var _this = this; + + $$$1(element).removeClass(ClassName.SHOW); + + if (!$$$1(element).hasClass(ClassName.FADE)) { + this._destroyElement(element); + + return; + } + + var transitionDuration = Util.getTransitionDurationFromElement(element); + $$$1(element).one(Util.TRANSITION_END, function (event) { + return _this._destroyElement(element, event); + }).emulateTransitionEnd(transitionDuration); + }; + + _proto._destroyElement = function _destroyElement(element) { + $$$1(element).detach().trigger(Event.CLOSED).remove(); + }; // Static + + + Alert._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var $element = $$$1(this); + var data = $element.data(DATA_KEY); + + if (!data) { + data = new Alert(this); + $element.data(DATA_KEY, data); + } + + if (config === 'close') { + data[config](this); + } + }); + }; + + Alert._handleDismiss = function _handleDismiss(alertInstance) { + return function (event) { + if (event) { + event.preventDefault(); + } + + alertInstance.close(this); + }; + }; + + _createClass(Alert, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }]); + + return Alert; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.CLICK_DATA_API, Selector.DISMISS, Alert._handleDismiss(new Alert())); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Alert._jQueryInterface; + $$$1.fn[NAME].Constructor = Alert; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Alert._jQueryInterface; + }; + + return Alert; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): button.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Button = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'button'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.button'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var ClassName = { + ACTIVE: 'active', + BUTTON: 'btn', + FOCUS: 'focus' + }; + var Selector = { + DATA_TOGGLE_CARROT: '[data-toggle^="button"]', + DATA_TOGGLE: '[data-toggle="buttons"]', + INPUT: 'input', + ACTIVE: '.active', + BUTTON: '.btn' + }; + var Event = { + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY, + FOCUS_BLUR_DATA_API: "focus" + EVENT_KEY + DATA_API_KEY + " " + ("blur" + EVENT_KEY + DATA_API_KEY) + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Button = + /*#__PURE__*/ + function () { + function Button(element) { + this._element = element; + } // Getters + + + var _proto = Button.prototype; + + // Public + _proto.toggle = function toggle() { + var triggerChangeEvent = true; + var addAriaPressed = true; + var rootElement = $$$1(this._element).closest(Selector.DATA_TOGGLE)[0]; + + if (rootElement) { + var input = this._element.querySelector(Selector.INPUT); + + if (input) { + if (input.type === 'radio') { + if (input.checked && this._element.classList.contains(ClassName.ACTIVE)) { + triggerChangeEvent = false; + } else { + var activeElement = rootElement.querySelector(Selector.ACTIVE); + + if (activeElement) { + $$$1(activeElement).removeClass(ClassName.ACTIVE); + } + } + } + + if (triggerChangeEvent) { + if (input.hasAttribute('disabled') || rootElement.hasAttribute('disabled') || input.classList.contains('disabled') || rootElement.classList.contains('disabled')) { + return; + } + + input.checked = !this._element.classList.contains(ClassName.ACTIVE); + $$$1(input).trigger('change'); + } + + input.focus(); + addAriaPressed = false; + } + } + + if (addAriaPressed) { + this._element.setAttribute('aria-pressed', !this._element.classList.contains(ClassName.ACTIVE)); + } + + if (triggerChangeEvent) { + $$$1(this._element).toggleClass(ClassName.ACTIVE); + } + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + this._element = null; + }; // Static + + + Button._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + if (!data) { + data = new Button(this); + $$$1(this).data(DATA_KEY, data); + } + + if (config === 'toggle') { + data[config](); + } + }); + }; + + _createClass(Button, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }]); + + return Button; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE_CARROT, function (event) { + event.preventDefault(); + var button = event.target; + + if (!$$$1(button).hasClass(ClassName.BUTTON)) { + button = $$$1(button).closest(Selector.BUTTON); + } + + Button._jQueryInterface.call($$$1(button), 'toggle'); + }).on(Event.FOCUS_BLUR_DATA_API, Selector.DATA_TOGGLE_CARROT, function (event) { + var button = $$$1(event.target).closest(Selector.BUTTON)[0]; + $$$1(button).toggleClass(ClassName.FOCUS, /^focus(in)?$/.test(event.type)); + }); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Button._jQueryInterface; + $$$1.fn[NAME].Constructor = Button; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Button._jQueryInterface; + }; + + return Button; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): carousel.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Carousel = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'carousel'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.carousel'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var ARROW_LEFT_KEYCODE = 37; // KeyboardEvent.which value for left arrow key + + var ARROW_RIGHT_KEYCODE = 39; // KeyboardEvent.which value for right arrow key + + var TOUCHEVENT_COMPAT_WAIT = 500; // Time for mouse compat events to fire after touch + + var Default = { + interval: 5000, + keyboard: true, + slide: false, + pause: 'hover', + wrap: true + }; + var DefaultType = { + interval: '(number|boolean)', + keyboard: 'boolean', + slide: '(boolean|string)', + pause: '(string|boolean)', + wrap: 'boolean' + }; + var Direction = { + NEXT: 'next', + PREV: 'prev', + LEFT: 'left', + RIGHT: 'right' + }; + var Event = { + SLIDE: "slide" + EVENT_KEY, + SLID: "slid" + EVENT_KEY, + KEYDOWN: "keydown" + EVENT_KEY, + MOUSEENTER: "mouseenter" + EVENT_KEY, + MOUSELEAVE: "mouseleave" + EVENT_KEY, + TOUCHEND: "touchend" + EVENT_KEY, + LOAD_DATA_API: "load" + EVENT_KEY + DATA_API_KEY, + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + CAROUSEL: 'carousel', + ACTIVE: 'active', + SLIDE: 'slide', + RIGHT: 'carousel-item-right', + LEFT: 'carousel-item-left', + NEXT: 'carousel-item-next', + PREV: 'carousel-item-prev', + ITEM: 'carousel-item' + }; + var Selector = { + ACTIVE: '.active', + ACTIVE_ITEM: '.active.carousel-item', + ITEM: '.carousel-item', + NEXT_PREV: '.carousel-item-next, .carousel-item-prev', + INDICATORS: '.carousel-indicators', + DATA_SLIDE: '[data-slide], [data-slide-to]', + DATA_RIDE: '[data-ride="carousel"]' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Carousel = + /*#__PURE__*/ + function () { + function Carousel(element, config) { + this._items = null; + this._interval = null; + this._activeElement = null; + this._isPaused = false; + this._isSliding = false; + this.touchTimeout = null; + this._config = this._getConfig(config); + this._element = $$$1(element)[0]; + this._indicatorsElement = this._element.querySelector(Selector.INDICATORS); + + this._addEventListeners(); + } // Getters + + + var _proto = Carousel.prototype; + + // Public + _proto.next = function next() { + if (!this._isSliding) { + this._slide(Direction.NEXT); + } + }; + + _proto.nextWhenVisible = function nextWhenVisible() { + // Don't call next when the page isn't visible + // or the carousel or its parent isn't visible + if (!document.hidden && $$$1(this._element).is(':visible') && $$$1(this._element).css('visibility') !== 'hidden') { + this.next(); + } + }; + + _proto.prev = function prev() { + if (!this._isSliding) { + this._slide(Direction.PREV); + } + }; + + _proto.pause = function pause(event) { + if (!event) { + this._isPaused = true; + } + + if (this._element.querySelector(Selector.NEXT_PREV)) { + Util.triggerTransitionEnd(this._element); + this.cycle(true); + } + + clearInterval(this._interval); + this._interval = null; + }; + + _proto.cycle = function cycle(event) { + if (!event) { + this._isPaused = false; + } + + if (this._interval) { + clearInterval(this._interval); + this._interval = null; + } + + if (this._config.interval && !this._isPaused) { + this._interval = setInterval((document.visibilityState ? this.nextWhenVisible : this.next).bind(this), this._config.interval); + } + }; + + _proto.to = function to(index) { + var _this = this; + + this._activeElement = this._element.querySelector(Selector.ACTIVE_ITEM); + + var activeIndex = this._getItemIndex(this._activeElement); + + if (index > this._items.length - 1 || index < 0) { + return; + } + + if (this._isSliding) { + $$$1(this._element).one(Event.SLID, function () { + return _this.to(index); + }); + return; + } + + if (activeIndex === index) { + this.pause(); + this.cycle(); + return; + } + + var direction = index > activeIndex ? Direction.NEXT : Direction.PREV; + + this._slide(direction, this._items[index]); + }; + + _proto.dispose = function dispose() { + $$$1(this._element).off(EVENT_KEY); + $$$1.removeData(this._element, DATA_KEY); + this._items = null; + this._config = null; + this._element = null; + this._interval = null; + this._isPaused = null; + this._isSliding = null; + this._activeElement = null; + this._indicatorsElement = null; + }; // Private + + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, Default, config); + Util.typeCheckConfig(NAME, config, DefaultType); + return config; + }; + + _proto._addEventListeners = function _addEventListeners() { + var _this2 = this; + + if (this._config.keyboard) { + $$$1(this._element).on(Event.KEYDOWN, function (event) { + return _this2._keydown(event); + }); + } + + if (this._config.pause === 'hover') { + $$$1(this._element).on(Event.MOUSEENTER, function (event) { + return _this2.pause(event); + }).on(Event.MOUSELEAVE, function (event) { + return _this2.cycle(event); + }); + + if ('ontouchstart' in document.documentElement) { + // If it's a touch-enabled device, mouseenter/leave are fired as + // part of the mouse compatibility events on first tap - the carousel + // would stop cycling until user tapped out of it; + // here, we listen for touchend, explicitly pause the carousel + // (as if it's the second time we tap on it, mouseenter compat event + // is NOT fired) and after a timeout (to allow for mouse compatibility + // events to fire) we explicitly restart cycling + $$$1(this._element).on(Event.TOUCHEND, function () { + _this2.pause(); + + if (_this2.touchTimeout) { + clearTimeout(_this2.touchTimeout); + } + + _this2.touchTimeout = setTimeout(function (event) { + return _this2.cycle(event); + }, TOUCHEVENT_COMPAT_WAIT + _this2._config.interval); + }); + } + } + }; + + _proto._keydown = function _keydown(event) { + if (/input|textarea/i.test(event.target.tagName)) { + return; + } + + switch (event.which) { + case ARROW_LEFT_KEYCODE: + event.preventDefault(); + this.prev(); + break; + + case ARROW_RIGHT_KEYCODE: + event.preventDefault(); + this.next(); + break; + + default: + } + }; + + _proto._getItemIndex = function _getItemIndex(element) { + this._items = element && element.parentNode ? [].slice.call(element.parentNode.querySelectorAll(Selector.ITEM)) : []; + return this._items.indexOf(element); + }; + + _proto._getItemByDirection = function _getItemByDirection(direction, activeElement) { + var isNextDirection = direction === Direction.NEXT; + var isPrevDirection = direction === Direction.PREV; + + var activeIndex = this._getItemIndex(activeElement); + + var lastItemIndex = this._items.length - 1; + var isGoingToWrap = isPrevDirection && activeIndex === 0 || isNextDirection && activeIndex === lastItemIndex; + + if (isGoingToWrap && !this._config.wrap) { + return activeElement; + } + + var delta = direction === Direction.PREV ? -1 : 1; + var itemIndex = (activeIndex + delta) % this._items.length; + return itemIndex === -1 ? this._items[this._items.length - 1] : this._items[itemIndex]; + }; + + _proto._triggerSlideEvent = function _triggerSlideEvent(relatedTarget, eventDirectionName) { + var targetIndex = this._getItemIndex(relatedTarget); + + var fromIndex = this._getItemIndex(this._element.querySelector(Selector.ACTIVE_ITEM)); + + var slideEvent = $$$1.Event(Event.SLIDE, { + relatedTarget: relatedTarget, + direction: eventDirectionName, + from: fromIndex, + to: targetIndex + }); + $$$1(this._element).trigger(slideEvent); + return slideEvent; + }; + + _proto._setActiveIndicatorElement = function _setActiveIndicatorElement(element) { + if (this._indicatorsElement) { + var indicators = [].slice.call(this._indicatorsElement.querySelectorAll(Selector.ACTIVE)); + $$$1(indicators).removeClass(ClassName.ACTIVE); + + var nextIndicator = this._indicatorsElement.children[this._getItemIndex(element)]; + + if (nextIndicator) { + $$$1(nextIndicator).addClass(ClassName.ACTIVE); + } + } + }; + + _proto._slide = function _slide(direction, element) { + var _this3 = this; + + var activeElement = this._element.querySelector(Selector.ACTIVE_ITEM); + + var activeElementIndex = this._getItemIndex(activeElement); + + var nextElement = element || activeElement && this._getItemByDirection(direction, activeElement); + + var nextElementIndex = this._getItemIndex(nextElement); + + var isCycling = Boolean(this._interval); + var directionalClassName; + var orderClassName; + var eventDirectionName; + + if (direction === Direction.NEXT) { + directionalClassName = ClassName.LEFT; + orderClassName = ClassName.NEXT; + eventDirectionName = Direction.LEFT; + } else { + directionalClassName = ClassName.RIGHT; + orderClassName = ClassName.PREV; + eventDirectionName = Direction.RIGHT; + } + + if (nextElement && $$$1(nextElement).hasClass(ClassName.ACTIVE)) { + this._isSliding = false; + return; + } + + var slideEvent = this._triggerSlideEvent(nextElement, eventDirectionName); + + if (slideEvent.isDefaultPrevented()) { + return; + } + + if (!activeElement || !nextElement) { + // Some weirdness is happening, so we bail + return; + } + + this._isSliding = true; + + if (isCycling) { + this.pause(); + } + + this._setActiveIndicatorElement(nextElement); + + var slidEvent = $$$1.Event(Event.SLID, { + relatedTarget: nextElement, + direction: eventDirectionName, + from: activeElementIndex, + to: nextElementIndex + }); + + if ($$$1(this._element).hasClass(ClassName.SLIDE)) { + $$$1(nextElement).addClass(orderClassName); + Util.reflow(nextElement); + $$$1(activeElement).addClass(directionalClassName); + $$$1(nextElement).addClass(directionalClassName); + var transitionDuration = Util.getTransitionDurationFromElement(activeElement); + $$$1(activeElement).one(Util.TRANSITION_END, function () { + $$$1(nextElement).removeClass(directionalClassName + " " + orderClassName).addClass(ClassName.ACTIVE); + $$$1(activeElement).removeClass(ClassName.ACTIVE + " " + orderClassName + " " + directionalClassName); + _this3._isSliding = false; + setTimeout(function () { + return $$$1(_this3._element).trigger(slidEvent); + }, 0); + }).emulateTransitionEnd(transitionDuration); + } else { + $$$1(activeElement).removeClass(ClassName.ACTIVE); + $$$1(nextElement).addClass(ClassName.ACTIVE); + this._isSliding = false; + $$$1(this._element).trigger(slidEvent); + } + + if (isCycling) { + this.cycle(); + } + }; // Static + + + Carousel._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + var _config = _objectSpread({}, Default, $$$1(this).data()); + + if (typeof config === 'object') { + _config = _objectSpread({}, _config, config); + } + + var action = typeof config === 'string' ? config : _config.slide; + + if (!data) { + data = new Carousel(this, _config); + $$$1(this).data(DATA_KEY, data); + } + + if (typeof config === 'number') { + data.to(config); + } else if (typeof action === 'string') { + if (typeof data[action] === 'undefined') { + throw new TypeError("No method named \"" + action + "\""); + } + + data[action](); + } else if (_config.interval) { + data.pause(); + data.cycle(); + } + }); + }; + + Carousel._dataApiClickHandler = function _dataApiClickHandler(event) { + var selector = Util.getSelectorFromElement(this); + + if (!selector) { + return; + } + + var target = $$$1(selector)[0]; + + if (!target || !$$$1(target).hasClass(ClassName.CAROUSEL)) { + return; + } + + var config = _objectSpread({}, $$$1(target).data(), $$$1(this).data()); + + var slideIndex = this.getAttribute('data-slide-to'); + + if (slideIndex) { + config.interval = false; + } + + Carousel._jQueryInterface.call($$$1(target), config); + + if (slideIndex) { + $$$1(target).data(DATA_KEY).to(slideIndex); + } + + event.preventDefault(); + }; + + _createClass(Carousel, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }]); + + return Carousel; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.CLICK_DATA_API, Selector.DATA_SLIDE, Carousel._dataApiClickHandler); + $$$1(window).on(Event.LOAD_DATA_API, function () { + var carousels = [].slice.call(document.querySelectorAll(Selector.DATA_RIDE)); + + for (var i = 0, len = carousels.length; i < len; i++) { + var $carousel = $$$1(carousels[i]); + + Carousel._jQueryInterface.call($carousel, $carousel.data()); + } + }); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Carousel._jQueryInterface; + $$$1.fn[NAME].Constructor = Carousel; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Carousel._jQueryInterface; + }; + + return Carousel; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): collapse.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Collapse = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'collapse'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.collapse'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var Default = { + toggle: true, + parent: '' + }; + var DefaultType = { + toggle: 'boolean', + parent: '(string|element)' + }; + var Event = { + SHOW: "show" + EVENT_KEY, + SHOWN: "shown" + EVENT_KEY, + HIDE: "hide" + EVENT_KEY, + HIDDEN: "hidden" + EVENT_KEY, + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + SHOW: 'show', + COLLAPSE: 'collapse', + COLLAPSING: 'collapsing', + COLLAPSED: 'collapsed' + }; + var Dimension = { + WIDTH: 'width', + HEIGHT: 'height' + }; + var Selector = { + ACTIVES: '.show, .collapsing', + DATA_TOGGLE: '[data-toggle="collapse"]' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Collapse = + /*#__PURE__*/ + function () { + function Collapse(element, config) { + this._isTransitioning = false; + this._element = element; + this._config = this._getConfig(config); + this._triggerArray = $$$1.makeArray(document.querySelectorAll("[data-toggle=\"collapse\"][href=\"#" + element.id + "\"]," + ("[data-toggle=\"collapse\"][data-target=\"#" + element.id + "\"]"))); + var toggleList = [].slice.call(document.querySelectorAll(Selector.DATA_TOGGLE)); + + for (var i = 0, len = toggleList.length; i < len; i++) { + var elem = toggleList[i]; + var selector = Util.getSelectorFromElement(elem); + var filterElement = [].slice.call(document.querySelectorAll(selector)).filter(function (foundElem) { + return foundElem === element; + }); + + if (selector !== null && filterElement.length > 0) { + this._selector = selector; + + this._triggerArray.push(elem); + } + } + + this._parent = this._config.parent ? this._getParent() : null; + + if (!this._config.parent) { + this._addAriaAndCollapsedClass(this._element, this._triggerArray); + } + + if (this._config.toggle) { + this.toggle(); + } + } // Getters + + + var _proto = Collapse.prototype; + + // Public + _proto.toggle = function toggle() { + if ($$$1(this._element).hasClass(ClassName.SHOW)) { + this.hide(); + } else { + this.show(); + } + }; + + _proto.show = function show() { + var _this = this; + + if (this._isTransitioning || $$$1(this._element).hasClass(ClassName.SHOW)) { + return; + } + + var actives; + var activesData; + + if (this._parent) { + actives = [].slice.call(this._parent.querySelectorAll(Selector.ACTIVES)).filter(function (elem) { + return elem.getAttribute('data-parent') === _this._config.parent; + }); + + if (actives.length === 0) { + actives = null; + } + } + + if (actives) { + activesData = $$$1(actives).not(this._selector).data(DATA_KEY); + + if (activesData && activesData._isTransitioning) { + return; + } + } + + var startEvent = $$$1.Event(Event.SHOW); + $$$1(this._element).trigger(startEvent); + + if (startEvent.isDefaultPrevented()) { + return; + } + + if (actives) { + Collapse._jQueryInterface.call($$$1(actives).not(this._selector), 'hide'); + + if (!activesData) { + $$$1(actives).data(DATA_KEY, null); + } + } + + var dimension = this._getDimension(); + + $$$1(this._element).removeClass(ClassName.COLLAPSE).addClass(ClassName.COLLAPSING); + this._element.style[dimension] = 0; + + if (this._triggerArray.length) { + $$$1(this._triggerArray).removeClass(ClassName.COLLAPSED).attr('aria-expanded', true); + } + + this.setTransitioning(true); + + var complete = function complete() { + $$$1(_this._element).removeClass(ClassName.COLLAPSING).addClass(ClassName.COLLAPSE).addClass(ClassName.SHOW); + _this._element.style[dimension] = ''; + + _this.setTransitioning(false); + + $$$1(_this._element).trigger(Event.SHOWN); + }; + + var capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1); + var scrollSize = "scroll" + capitalizedDimension; + var transitionDuration = Util.getTransitionDurationFromElement(this._element); + $$$1(this._element).one(Util.TRANSITION_END, complete).emulateTransitionEnd(transitionDuration); + this._element.style[dimension] = this._element[scrollSize] + "px"; + }; + + _proto.hide = function hide() { + var _this2 = this; + + if (this._isTransitioning || !$$$1(this._element).hasClass(ClassName.SHOW)) { + return; + } + + var startEvent = $$$1.Event(Event.HIDE); + $$$1(this._element).trigger(startEvent); + + if (startEvent.isDefaultPrevented()) { + return; + } + + var dimension = this._getDimension(); + + this._element.style[dimension] = this._element.getBoundingClientRect()[dimension] + "px"; + Util.reflow(this._element); + $$$1(this._element).addClass(ClassName.COLLAPSING).removeClass(ClassName.COLLAPSE).removeClass(ClassName.SHOW); + var triggerArrayLength = this._triggerArray.length; + + if (triggerArrayLength > 0) { + for (var i = 0; i < triggerArrayLength; i++) { + var trigger = this._triggerArray[i]; + var selector = Util.getSelectorFromElement(trigger); + + if (selector !== null) { + var $elem = $$$1([].slice.call(document.querySelectorAll(selector))); + + if (!$elem.hasClass(ClassName.SHOW)) { + $$$1(trigger).addClass(ClassName.COLLAPSED).attr('aria-expanded', false); + } + } + } + } + + this.setTransitioning(true); + + var complete = function complete() { + _this2.setTransitioning(false); + + $$$1(_this2._element).removeClass(ClassName.COLLAPSING).addClass(ClassName.COLLAPSE).trigger(Event.HIDDEN); + }; + + this._element.style[dimension] = ''; + var transitionDuration = Util.getTransitionDurationFromElement(this._element); + $$$1(this._element).one(Util.TRANSITION_END, complete).emulateTransitionEnd(transitionDuration); + }; + + _proto.setTransitioning = function setTransitioning(isTransitioning) { + this._isTransitioning = isTransitioning; + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + this._config = null; + this._parent = null; + this._element = null; + this._triggerArray = null; + this._isTransitioning = null; + }; // Private + + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, Default, config); + config.toggle = Boolean(config.toggle); // Coerce string values + + Util.typeCheckConfig(NAME, config, DefaultType); + return config; + }; + + _proto._getDimension = function _getDimension() { + var hasWidth = $$$1(this._element).hasClass(Dimension.WIDTH); + return hasWidth ? Dimension.WIDTH : Dimension.HEIGHT; + }; + + _proto._getParent = function _getParent() { + var _this3 = this; + + var parent = null; + + if (Util.isElement(this._config.parent)) { + parent = this._config.parent; // It's a jQuery object + + if (typeof this._config.parent.jquery !== 'undefined') { + parent = this._config.parent[0]; + } + } else { + parent = document.querySelector(this._config.parent); + } + + var selector = "[data-toggle=\"collapse\"][data-parent=\"" + this._config.parent + "\"]"; + var children = [].slice.call(parent.querySelectorAll(selector)); + $$$1(children).each(function (i, element) { + _this3._addAriaAndCollapsedClass(Collapse._getTargetFromElement(element), [element]); + }); + return parent; + }; + + _proto._addAriaAndCollapsedClass = function _addAriaAndCollapsedClass(element, triggerArray) { + if (element) { + var isOpen = $$$1(element).hasClass(ClassName.SHOW); + + if (triggerArray.length) { + $$$1(triggerArray).toggleClass(ClassName.COLLAPSED, !isOpen).attr('aria-expanded', isOpen); + } + } + }; // Static + + + Collapse._getTargetFromElement = function _getTargetFromElement(element) { + var selector = Util.getSelectorFromElement(element); + return selector ? document.querySelector(selector) : null; + }; + + Collapse._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var $this = $$$1(this); + var data = $this.data(DATA_KEY); + + var _config = _objectSpread({}, Default, $this.data(), typeof config === 'object' && config ? config : {}); + + if (!data && _config.toggle && /show|hide/.test(config)) { + _config.toggle = false; + } + + if (!data) { + data = new Collapse(this, _config); + $this.data(DATA_KEY, data); + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError("No method named \"" + config + "\""); + } + + data[config](); + } + }); + }; + + _createClass(Collapse, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }]); + + return Collapse; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) { + // preventDefault only for elements (which change the URL) not inside the collapsible element + if (event.currentTarget.tagName === 'A') { + event.preventDefault(); + } + + var $trigger = $$$1(this); + var selector = Util.getSelectorFromElement(this); + var selectors = [].slice.call(document.querySelectorAll(selector)); + $$$1(selectors).each(function () { + var $target = $$$1(this); + var data = $target.data(DATA_KEY); + var config = data ? 'toggle' : $trigger.data(); + + Collapse._jQueryInterface.call($target, config); + }); + }); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Collapse._jQueryInterface; + $$$1.fn[NAME].Constructor = Collapse; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Collapse._jQueryInterface; + }; + + return Collapse; + }($); + + /**! + * @fileOverview Kickass library to create and place poppers near their reference elements. + * @version 1.14.3 + * @license + * Copyright (c) 2016 Federico Zivolo and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + var isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; + + var longerTimeoutBrowsers = ['Edge', 'Trident', 'Firefox']; + var timeoutDuration = 0; + for (var i = 0; i < longerTimeoutBrowsers.length; i += 1) { + if (isBrowser && navigator.userAgent.indexOf(longerTimeoutBrowsers[i]) >= 0) { + timeoutDuration = 1; + break; + } + } + + function microtaskDebounce(fn) { + var called = false; + return function () { + if (called) { + return; + } + called = true; + window.Promise.resolve().then(function () { + called = false; + fn(); + }); + }; + } + + function taskDebounce(fn) { + var scheduled = false; + return function () { + if (!scheduled) { + scheduled = true; + setTimeout(function () { + scheduled = false; + fn(); + }, timeoutDuration); + } + }; + } + + var supportsMicroTasks = isBrowser && window.Promise; + + /** + * Create a debounced version of a method, that's asynchronously deferred + * but called in the minimum time possible. + * + * @method + * @memberof Popper.Utils + * @argument {Function} fn + * @returns {Function} + */ + var debounce = supportsMicroTasks ? microtaskDebounce : taskDebounce; + + /** + * Check if the given variable is a function + * @method + * @memberof Popper.Utils + * @argument {Any} functionToCheck - variable to check + * @returns {Boolean} answer to: is a function? + */ + function isFunction(functionToCheck) { + var getType = {}; + return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]'; + } + + /** + * Get CSS computed property of the given element + * @method + * @memberof Popper.Utils + * @argument {Eement} element + * @argument {String} property + */ + function getStyleComputedProperty(element, property) { + if (element.nodeType !== 1) { + return []; + } + // NOTE: 1 DOM access here + var css = getComputedStyle(element, null); + return property ? css[property] : css; + } + + /** + * Returns the parentNode or the host of the element + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @returns {Element} parent + */ + function getParentNode(element) { + if (element.nodeName === 'HTML') { + return element; + } + return element.parentNode || element.host; + } + + /** + * Returns the scrolling parent of the given element + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @returns {Element} scroll parent + */ + function getScrollParent(element) { + // Return body, `getScroll` will take care to get the correct `scrollTop` from it + if (!element) { + return document.body; + } + + switch (element.nodeName) { + case 'HTML': + case 'BODY': + return element.ownerDocument.body; + case '#document': + return element.body; + } + + // Firefox want us to check `-x` and `-y` variations as well + + var _getStyleComputedProp = getStyleComputedProperty(element), + overflow = _getStyleComputedProp.overflow, + overflowX = _getStyleComputedProp.overflowX, + overflowY = _getStyleComputedProp.overflowY; + + if (/(auto|scroll|overlay)/.test(overflow + overflowY + overflowX)) { + return element; + } + + return getScrollParent(getParentNode(element)); + } + + var isIE11 = isBrowser && !!(window.MSInputMethodContext && document.documentMode); + var isIE10 = isBrowser && /MSIE 10/.test(navigator.userAgent); + + /** + * Determines if the browser is Internet Explorer + * @method + * @memberof Popper.Utils + * @param {Number} version to check + * @returns {Boolean} isIE + */ + function isIE(version) { + if (version === 11) { + return isIE11; + } + if (version === 10) { + return isIE10; + } + return isIE11 || isIE10; + } + + /** + * Returns the offset parent of the given element + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @returns {Element} offset parent + */ + function getOffsetParent(element) { + if (!element) { + return document.documentElement; + } + + var noOffsetParent = isIE(10) ? document.body : null; + + // NOTE: 1 DOM access here + var offsetParent = element.offsetParent; + // Skip hidden elements which don't have an offsetParent + while (offsetParent === noOffsetParent && element.nextElementSibling) { + offsetParent = (element = element.nextElementSibling).offsetParent; + } + + var nodeName = offsetParent && offsetParent.nodeName; + + if (!nodeName || nodeName === 'BODY' || nodeName === 'HTML') { + return element ? element.ownerDocument.documentElement : document.documentElement; + } + + // .offsetParent will return the closest TD or TABLE in case + // no offsetParent is present, I hate this job... + if (['TD', 'TABLE'].indexOf(offsetParent.nodeName) !== -1 && getStyleComputedProperty(offsetParent, 'position') === 'static') { + return getOffsetParent(offsetParent); + } + + return offsetParent; + } + + function isOffsetContainer(element) { + var nodeName = element.nodeName; + + if (nodeName === 'BODY') { + return false; + } + return nodeName === 'HTML' || getOffsetParent(element.firstElementChild) === element; + } + + /** + * Finds the root node (document, shadowDOM root) of the given element + * @method + * @memberof Popper.Utils + * @argument {Element} node + * @returns {Element} root node + */ + function getRoot(node) { + if (node.parentNode !== null) { + return getRoot(node.parentNode); + } + + return node; + } + + /** + * Finds the offset parent common to the two provided nodes + * @method + * @memberof Popper.Utils + * @argument {Element} element1 + * @argument {Element} element2 + * @returns {Element} common offset parent + */ + function findCommonOffsetParent(element1, element2) { + // This check is needed to avoid errors in case one of the elements isn't defined for any reason + if (!element1 || !element1.nodeType || !element2 || !element2.nodeType) { + return document.documentElement; + } + + // Here we make sure to give as "start" the element that comes first in the DOM + var order = element1.compareDocumentPosition(element2) & Node.DOCUMENT_POSITION_FOLLOWING; + var start = order ? element1 : element2; + var end = order ? element2 : element1; + + // Get common ancestor container + var range = document.createRange(); + range.setStart(start, 0); + range.setEnd(end, 0); + var commonAncestorContainer = range.commonAncestorContainer; + + // Both nodes are inside #document + + if (element1 !== commonAncestorContainer && element2 !== commonAncestorContainer || start.contains(end)) { + if (isOffsetContainer(commonAncestorContainer)) { + return commonAncestorContainer; + } + + return getOffsetParent(commonAncestorContainer); + } + + // one of the nodes is inside shadowDOM, find which one + var element1root = getRoot(element1); + if (element1root.host) { + return findCommonOffsetParent(element1root.host, element2); + } else { + return findCommonOffsetParent(element1, getRoot(element2).host); + } + } + + /** + * Gets the scroll value of the given element in the given side (top and left) + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @argument {String} side `top` or `left` + * @returns {number} amount of scrolled pixels + */ + function getScroll(element) { + var side = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'top'; + + var upperSide = side === 'top' ? 'scrollTop' : 'scrollLeft'; + var nodeName = element.nodeName; + + if (nodeName === 'BODY' || nodeName === 'HTML') { + var html = element.ownerDocument.documentElement; + var scrollingElement = element.ownerDocument.scrollingElement || html; + return scrollingElement[upperSide]; + } + + return element[upperSide]; + } + + /* + * Sum or subtract the element scroll values (left and top) from a given rect object + * @method + * @memberof Popper.Utils + * @param {Object} rect - Rect object you want to change + * @param {HTMLElement} element - The element from the function reads the scroll values + * @param {Boolean} subtract - set to true if you want to subtract the scroll values + * @return {Object} rect - The modifier rect object + */ + function includeScroll(rect, element) { + var subtract = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + + var scrollTop = getScroll(element, 'top'); + var scrollLeft = getScroll(element, 'left'); + var modifier = subtract ? -1 : 1; + rect.top += scrollTop * modifier; + rect.bottom += scrollTop * modifier; + rect.left += scrollLeft * modifier; + rect.right += scrollLeft * modifier; + return rect; + } + + /* + * Helper to detect borders of a given element + * @method + * @memberof Popper.Utils + * @param {CSSStyleDeclaration} styles + * Result of `getStyleComputedProperty` on the given element + * @param {String} axis - `x` or `y` + * @return {number} borders - The borders size of the given axis + */ + + function getBordersSize(styles, axis) { + var sideA = axis === 'x' ? 'Left' : 'Top'; + var sideB = sideA === 'Left' ? 'Right' : 'Bottom'; + + return parseFloat(styles['border' + sideA + 'Width'], 10) + parseFloat(styles['border' + sideB + 'Width'], 10); + } + + function getSize(axis, body, html, computedStyle) { + return Math.max(body['offset' + axis], body['scroll' + axis], html['client' + axis], html['offset' + axis], html['scroll' + axis], isIE(10) ? html['offset' + axis] + computedStyle['margin' + (axis === 'Height' ? 'Top' : 'Left')] + computedStyle['margin' + (axis === 'Height' ? 'Bottom' : 'Right')] : 0); + } + + function getWindowSizes() { + var body = document.body; + var html = document.documentElement; + var computedStyle = isIE(10) && getComputedStyle(html); + + return { + height: getSize('Height', body, html, computedStyle), + width: getSize('Width', body, html, computedStyle) + }; + } + + var classCallCheck = function (instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + }; + + var createClass = function () { + function defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + return function (Constructor, protoProps, staticProps) { + if (protoProps) defineProperties(Constructor.prototype, protoProps); + if (staticProps) defineProperties(Constructor, staticProps); + return Constructor; + }; + }(); + + + + + + var defineProperty = function (obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + + return obj; + }; + + var _extends = Object.assign || function (target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + + for (var key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = source[key]; + } + } + } + + return target; + }; + + /** + * Given element offsets, generate an output similar to getBoundingClientRect + * @method + * @memberof Popper.Utils + * @argument {Object} offsets + * @returns {Object} ClientRect like output + */ + function getClientRect(offsets) { + return _extends({}, offsets, { + right: offsets.left + offsets.width, + bottom: offsets.top + offsets.height + }); + } + + /** + * Get bounding client rect of given element + * @method + * @memberof Popper.Utils + * @param {HTMLElement} element + * @return {Object} client rect + */ + function getBoundingClientRect(element) { + var rect = {}; + + // IE10 10 FIX: Please, don't ask, the element isn't + // considered in DOM in some circumstances... + // This isn't reproducible in IE10 compatibility mode of IE11 + try { + if (isIE(10)) { + rect = element.getBoundingClientRect(); + var scrollTop = getScroll(element, 'top'); + var scrollLeft = getScroll(element, 'left'); + rect.top += scrollTop; + rect.left += scrollLeft; + rect.bottom += scrollTop; + rect.right += scrollLeft; + } else { + rect = element.getBoundingClientRect(); + } + } catch (e) {} + + var result = { + left: rect.left, + top: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top + }; + + // subtract scrollbar size from sizes + var sizes = element.nodeName === 'HTML' ? getWindowSizes() : {}; + var width = sizes.width || element.clientWidth || result.right - result.left; + var height = sizes.height || element.clientHeight || result.bottom - result.top; + + var horizScrollbar = element.offsetWidth - width; + var vertScrollbar = element.offsetHeight - height; + + // if an hypothetical scrollbar is detected, we must be sure it's not a `border` + // we make this check conditional for performance reasons + if (horizScrollbar || vertScrollbar) { + var styles = getStyleComputedProperty(element); + horizScrollbar -= getBordersSize(styles, 'x'); + vertScrollbar -= getBordersSize(styles, 'y'); + + result.width -= horizScrollbar; + result.height -= vertScrollbar; + } + + return getClientRect(result); + } + + function getOffsetRectRelativeToArbitraryNode(children, parent) { + var fixedPosition = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + + var isIE10 = isIE(10); + var isHTML = parent.nodeName === 'HTML'; + var childrenRect = getBoundingClientRect(children); + var parentRect = getBoundingClientRect(parent); + var scrollParent = getScrollParent(children); + + var styles = getStyleComputedProperty(parent); + var borderTopWidth = parseFloat(styles.borderTopWidth, 10); + var borderLeftWidth = parseFloat(styles.borderLeftWidth, 10); + + // In cases where the parent is fixed, we must ignore negative scroll in offset calc + if (fixedPosition && parent.nodeName === 'HTML') { + parentRect.top = Math.max(parentRect.top, 0); + parentRect.left = Math.max(parentRect.left, 0); + } + var offsets = getClientRect({ + top: childrenRect.top - parentRect.top - borderTopWidth, + left: childrenRect.left - parentRect.left - borderLeftWidth, + width: childrenRect.width, + height: childrenRect.height + }); + offsets.marginTop = 0; + offsets.marginLeft = 0; + + // Subtract margins of documentElement in case it's being used as parent + // we do this only on HTML because it's the only element that behaves + // differently when margins are applied to it. The margins are included in + // the box of the documentElement, in the other cases not. + if (!isIE10 && isHTML) { + var marginTop = parseFloat(styles.marginTop, 10); + var marginLeft = parseFloat(styles.marginLeft, 10); + + offsets.top -= borderTopWidth - marginTop; + offsets.bottom -= borderTopWidth - marginTop; + offsets.left -= borderLeftWidth - marginLeft; + offsets.right -= borderLeftWidth - marginLeft; + + // Attach marginTop and marginLeft because in some circumstances we may need them + offsets.marginTop = marginTop; + offsets.marginLeft = marginLeft; + } + + if (isIE10 && !fixedPosition ? parent.contains(scrollParent) : parent === scrollParent && scrollParent.nodeName !== 'BODY') { + offsets = includeScroll(offsets, parent); + } + + return offsets; + } + + function getViewportOffsetRectRelativeToArtbitraryNode(element) { + var excludeScroll = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + + var html = element.ownerDocument.documentElement; + var relativeOffset = getOffsetRectRelativeToArbitraryNode(element, html); + var width = Math.max(html.clientWidth, window.innerWidth || 0); + var height = Math.max(html.clientHeight, window.innerHeight || 0); + + var scrollTop = !excludeScroll ? getScroll(html) : 0; + var scrollLeft = !excludeScroll ? getScroll(html, 'left') : 0; + + var offset = { + top: scrollTop - relativeOffset.top + relativeOffset.marginTop, + left: scrollLeft - relativeOffset.left + relativeOffset.marginLeft, + width: width, + height: height + }; + + return getClientRect(offset); + } + + /** + * Check if the given element is fixed or is inside a fixed parent + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @argument {Element} customContainer + * @returns {Boolean} answer to "isFixed?" + */ + function isFixed(element) { + var nodeName = element.nodeName; + if (nodeName === 'BODY' || nodeName === 'HTML') { + return false; + } + if (getStyleComputedProperty(element, 'position') === 'fixed') { + return true; + } + return isFixed(getParentNode(element)); + } + + /** + * Finds the first parent of an element that has a transformed property defined + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @returns {Element} first transformed parent or documentElement + */ + + function getFixedPositionOffsetParent(element) { + // This check is needed to avoid errors in case one of the elements isn't defined for any reason + if (!element || !element.parentElement || isIE()) { + return document.documentElement; + } + var el = element.parentElement; + while (el && getStyleComputedProperty(el, 'transform') === 'none') { + el = el.parentElement; + } + return el || document.documentElement; + } + + /** + * Computed the boundaries limits and return them + * @method + * @memberof Popper.Utils + * @param {HTMLElement} popper + * @param {HTMLElement} reference + * @param {number} padding + * @param {HTMLElement} boundariesElement - Element used to define the boundaries + * @param {Boolean} fixedPosition - Is in fixed position mode + * @returns {Object} Coordinates of the boundaries + */ + function getBoundaries(popper, reference, padding, boundariesElement) { + var fixedPosition = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false; + + // NOTE: 1 DOM access here + + var boundaries = { top: 0, left: 0 }; + var offsetParent = fixedPosition ? getFixedPositionOffsetParent(popper) : findCommonOffsetParent(popper, reference); + + // Handle viewport case + if (boundariesElement === 'viewport') { + boundaries = getViewportOffsetRectRelativeToArtbitraryNode(offsetParent, fixedPosition); + } else { + // Handle other cases based on DOM element used as boundaries + var boundariesNode = void 0; + if (boundariesElement === 'scrollParent') { + boundariesNode = getScrollParent(getParentNode(reference)); + if (boundariesNode.nodeName === 'BODY') { + boundariesNode = popper.ownerDocument.documentElement; + } + } else if (boundariesElement === 'window') { + boundariesNode = popper.ownerDocument.documentElement; + } else { + boundariesNode = boundariesElement; + } + + var offsets = getOffsetRectRelativeToArbitraryNode(boundariesNode, offsetParent, fixedPosition); + + // In case of HTML, we need a different computation + if (boundariesNode.nodeName === 'HTML' && !isFixed(offsetParent)) { + var _getWindowSizes = getWindowSizes(), + height = _getWindowSizes.height, + width = _getWindowSizes.width; + + boundaries.top += offsets.top - offsets.marginTop; + boundaries.bottom = height + offsets.top; + boundaries.left += offsets.left - offsets.marginLeft; + boundaries.right = width + offsets.left; + } else { + // for all the other DOM elements, this one is good + boundaries = offsets; + } + } + + // Add paddings + boundaries.left += padding; + boundaries.top += padding; + boundaries.right -= padding; + boundaries.bottom -= padding; + + return boundaries; + } + + function getArea(_ref) { + var width = _ref.width, + height = _ref.height; + + return width * height; + } + + /** + * Utility used to transform the `auto` placement to the placement with more + * available space. + * @method + * @memberof Popper.Utils + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function computeAutoPlacement(placement, refRect, popper, reference, boundariesElement) { + var padding = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 0; + + if (placement.indexOf('auto') === -1) { + return placement; + } + + var boundaries = getBoundaries(popper, reference, padding, boundariesElement); + + var rects = { + top: { + width: boundaries.width, + height: refRect.top - boundaries.top + }, + right: { + width: boundaries.right - refRect.right, + height: boundaries.height + }, + bottom: { + width: boundaries.width, + height: boundaries.bottom - refRect.bottom + }, + left: { + width: refRect.left - boundaries.left, + height: boundaries.height + } + }; + + var sortedAreas = Object.keys(rects).map(function (key) { + return _extends({ + key: key + }, rects[key], { + area: getArea(rects[key]) + }); + }).sort(function (a, b) { + return b.area - a.area; + }); + + var filteredAreas = sortedAreas.filter(function (_ref2) { + var width = _ref2.width, + height = _ref2.height; + return width >= popper.clientWidth && height >= popper.clientHeight; + }); + + var computedPlacement = filteredAreas.length > 0 ? filteredAreas[0].key : sortedAreas[0].key; + + var variation = placement.split('-')[1]; + + return computedPlacement + (variation ? '-' + variation : ''); + } + + /** + * Get offsets to the reference element + * @method + * @memberof Popper.Utils + * @param {Object} state + * @param {Element} popper - the popper element + * @param {Element} reference - the reference element (the popper will be relative to this) + * @param {Element} fixedPosition - is in fixed position mode + * @returns {Object} An object containing the offsets which will be applied to the popper + */ + function getReferenceOffsets(state, popper, reference) { + var fixedPosition = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null; + + var commonOffsetParent = fixedPosition ? getFixedPositionOffsetParent(popper) : findCommonOffsetParent(popper, reference); + return getOffsetRectRelativeToArbitraryNode(reference, commonOffsetParent, fixedPosition); + } + + /** + * Get the outer sizes of the given element (offset size + margins) + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @returns {Object} object containing width and height properties + */ + function getOuterSizes(element) { + var styles = getComputedStyle(element); + var x = parseFloat(styles.marginTop) + parseFloat(styles.marginBottom); + var y = parseFloat(styles.marginLeft) + parseFloat(styles.marginRight); + var result = { + width: element.offsetWidth + y, + height: element.offsetHeight + x + }; + return result; + } + + /** + * Get the opposite placement of the given one + * @method + * @memberof Popper.Utils + * @argument {String} placement + * @returns {String} flipped placement + */ + function getOppositePlacement(placement) { + var hash = { left: 'right', right: 'left', bottom: 'top', top: 'bottom' }; + return placement.replace(/left|right|bottom|top/g, function (matched) { + return hash[matched]; + }); + } + + /** + * Get offsets to the popper + * @method + * @memberof Popper.Utils + * @param {Object} position - CSS position the Popper will get applied + * @param {HTMLElement} popper - the popper element + * @param {Object} referenceOffsets - the reference offsets (the popper will be relative to this) + * @param {String} placement - one of the valid placement options + * @returns {Object} popperOffsets - An object containing the offsets which will be applied to the popper + */ + function getPopperOffsets(popper, referenceOffsets, placement) { + placement = placement.split('-')[0]; + + // Get popper node sizes + var popperRect = getOuterSizes(popper); + + // Add position, width and height to our offsets object + var popperOffsets = { + width: popperRect.width, + height: popperRect.height + }; + + // depending by the popper placement we have to compute its offsets slightly differently + var isHoriz = ['right', 'left'].indexOf(placement) !== -1; + var mainSide = isHoriz ? 'top' : 'left'; + var secondarySide = isHoriz ? 'left' : 'top'; + var measurement = isHoriz ? 'height' : 'width'; + var secondaryMeasurement = !isHoriz ? 'height' : 'width'; + + popperOffsets[mainSide] = referenceOffsets[mainSide] + referenceOffsets[measurement] / 2 - popperRect[measurement] / 2; + if (placement === secondarySide) { + popperOffsets[secondarySide] = referenceOffsets[secondarySide] - popperRect[secondaryMeasurement]; + } else { + popperOffsets[secondarySide] = referenceOffsets[getOppositePlacement(secondarySide)]; + } + + return popperOffsets; + } + + /** + * Mimics the `find` method of Array + * @method + * @memberof Popper.Utils + * @argument {Array} arr + * @argument prop + * @argument value + * @returns index or -1 + */ + function find(arr, check) { + // use native find if supported + if (Array.prototype.find) { + return arr.find(check); + } + + // use `filter` to obtain the same behavior of `find` + return arr.filter(check)[0]; + } + + /** + * Return the index of the matching object + * @method + * @memberof Popper.Utils + * @argument {Array} arr + * @argument prop + * @argument value + * @returns index or -1 + */ + function findIndex(arr, prop, value) { + // use native findIndex if supported + if (Array.prototype.findIndex) { + return arr.findIndex(function (cur) { + return cur[prop] === value; + }); + } + + // use `find` + `indexOf` if `findIndex` isn't supported + var match = find(arr, function (obj) { + return obj[prop] === value; + }); + return arr.indexOf(match); + } + + /** + * Loop trough the list of modifiers and run them in order, + * each of them will then edit the data object. + * @method + * @memberof Popper.Utils + * @param {dataObject} data + * @param {Array} modifiers + * @param {String} ends - Optional modifier name used as stopper + * @returns {dataObject} + */ + function runModifiers(modifiers, data, ends) { + var modifiersToRun = ends === undefined ? modifiers : modifiers.slice(0, findIndex(modifiers, 'name', ends)); + + modifiersToRun.forEach(function (modifier) { + if (modifier['function']) { + // eslint-disable-line dot-notation + console.warn('`modifier.function` is deprecated, use `modifier.fn`!'); + } + var fn = modifier['function'] || modifier.fn; // eslint-disable-line dot-notation + if (modifier.enabled && isFunction(fn)) { + // Add properties to offsets to make them a complete clientRect object + // we do this before each modifier to make sure the previous one doesn't + // mess with these values + data.offsets.popper = getClientRect(data.offsets.popper); + data.offsets.reference = getClientRect(data.offsets.reference); + + data = fn(data, modifier); + } + }); + + return data; + } + + /** + * Updates the position of the popper, computing the new offsets and applying + * the new style.
+ * Prefer `scheduleUpdate` over `update` because of performance reasons. + * @method + * @memberof Popper + */ + function update() { + // if popper is destroyed, don't perform any further update + if (this.state.isDestroyed) { + return; + } + + var data = { + instance: this, + styles: {}, + arrowStyles: {}, + attributes: {}, + flipped: false, + offsets: {} + }; + + // compute reference element offsets + data.offsets.reference = getReferenceOffsets(this.state, this.popper, this.reference, this.options.positionFixed); + + // compute auto placement, store placement inside the data object, + // modifiers will be able to edit `placement` if needed + // and refer to originalPlacement to know the original value + data.placement = computeAutoPlacement(this.options.placement, data.offsets.reference, this.popper, this.reference, this.options.modifiers.flip.boundariesElement, this.options.modifiers.flip.padding); + + // store the computed placement inside `originalPlacement` + data.originalPlacement = data.placement; + + data.positionFixed = this.options.positionFixed; + + // compute the popper offsets + data.offsets.popper = getPopperOffsets(this.popper, data.offsets.reference, data.placement); + + data.offsets.popper.position = this.options.positionFixed ? 'fixed' : 'absolute'; + + // run the modifiers + data = runModifiers(this.modifiers, data); + + // the first `update` will call `onCreate` callback + // the other ones will call `onUpdate` callback + if (!this.state.isCreated) { + this.state.isCreated = true; + this.options.onCreate(data); + } else { + this.options.onUpdate(data); + } + } + + /** + * Helper used to know if the given modifier is enabled. + * @method + * @memberof Popper.Utils + * @returns {Boolean} + */ + function isModifierEnabled(modifiers, modifierName) { + return modifiers.some(function (_ref) { + var name = _ref.name, + enabled = _ref.enabled; + return enabled && name === modifierName; + }); + } + + /** + * Get the prefixed supported property name + * @method + * @memberof Popper.Utils + * @argument {String} property (camelCase) + * @returns {String} prefixed property (camelCase or PascalCase, depending on the vendor prefix) + */ + function getSupportedPropertyName(property) { + var prefixes = [false, 'ms', 'Webkit', 'Moz', 'O']; + var upperProp = property.charAt(0).toUpperCase() + property.slice(1); + + for (var i = 0; i < prefixes.length; i++) { + var prefix = prefixes[i]; + var toCheck = prefix ? '' + prefix + upperProp : property; + if (typeof document.body.style[toCheck] !== 'undefined') { + return toCheck; + } + } + return null; + } + + /** + * Destroy the popper + * @method + * @memberof Popper + */ + function destroy() { + this.state.isDestroyed = true; + + // touch DOM only if `applyStyle` modifier is enabled + if (isModifierEnabled(this.modifiers, 'applyStyle')) { + this.popper.removeAttribute('x-placement'); + this.popper.style.position = ''; + this.popper.style.top = ''; + this.popper.style.left = ''; + this.popper.style.right = ''; + this.popper.style.bottom = ''; + this.popper.style.willChange = ''; + this.popper.style[getSupportedPropertyName('transform')] = ''; + } + + this.disableEventListeners(); + + // remove the popper if user explicity asked for the deletion on destroy + // do not use `remove` because IE11 doesn't support it + if (this.options.removeOnDestroy) { + this.popper.parentNode.removeChild(this.popper); + } + return this; + } + + /** + * Get the window associated with the element + * @argument {Element} element + * @returns {Window} + */ + function getWindow(element) { + var ownerDocument = element.ownerDocument; + return ownerDocument ? ownerDocument.defaultView : window; + } + + function attachToScrollParents(scrollParent, event, callback, scrollParents) { + var isBody = scrollParent.nodeName === 'BODY'; + var target = isBody ? scrollParent.ownerDocument.defaultView : scrollParent; + target.addEventListener(event, callback, { passive: true }); + + if (!isBody) { + attachToScrollParents(getScrollParent(target.parentNode), event, callback, scrollParents); + } + scrollParents.push(target); + } + + /** + * Setup needed event listeners used to update the popper position + * @method + * @memberof Popper.Utils + * @private + */ + function setupEventListeners(reference, options, state, updateBound) { + // Resize event listener on window + state.updateBound = updateBound; + getWindow(reference).addEventListener('resize', state.updateBound, { passive: true }); + + // Scroll event listener on scroll parents + var scrollElement = getScrollParent(reference); + attachToScrollParents(scrollElement, 'scroll', state.updateBound, state.scrollParents); + state.scrollElement = scrollElement; + state.eventsEnabled = true; + + return state; + } + + /** + * It will add resize/scroll events and start recalculating + * position of the popper element when they are triggered. + * @method + * @memberof Popper + */ + function enableEventListeners() { + if (!this.state.eventsEnabled) { + this.state = setupEventListeners(this.reference, this.options, this.state, this.scheduleUpdate); + } + } + + /** + * Remove event listeners used to update the popper position + * @method + * @memberof Popper.Utils + * @private + */ + function removeEventListeners(reference, state) { + // Remove resize event listener on window + getWindow(reference).removeEventListener('resize', state.updateBound); + + // Remove scroll event listener on scroll parents + state.scrollParents.forEach(function (target) { + target.removeEventListener('scroll', state.updateBound); + }); + + // Reset state + state.updateBound = null; + state.scrollParents = []; + state.scrollElement = null; + state.eventsEnabled = false; + return state; + } + + /** + * It will remove resize/scroll events and won't recalculate popper position + * when they are triggered. It also won't trigger onUpdate callback anymore, + * unless you call `update` method manually. + * @method + * @memberof Popper + */ + function disableEventListeners() { + if (this.state.eventsEnabled) { + cancelAnimationFrame(this.scheduleUpdate); + this.state = removeEventListeners(this.reference, this.state); + } + } + + /** + * Tells if a given input is a number + * @method + * @memberof Popper.Utils + * @param {*} input to check + * @return {Boolean} + */ + function isNumeric(n) { + return n !== '' && !isNaN(parseFloat(n)) && isFinite(n); + } + + /** + * Set the style to the given popper + * @method + * @memberof Popper.Utils + * @argument {Element} element - Element to apply the style to + * @argument {Object} styles + * Object with a list of properties and values which will be applied to the element + */ + function setStyles(element, styles) { + Object.keys(styles).forEach(function (prop) { + var unit = ''; + // add unit if the value is numeric and is one of the following + if (['width', 'height', 'top', 'right', 'bottom', 'left'].indexOf(prop) !== -1 && isNumeric(styles[prop])) { + unit = 'px'; + } + element.style[prop] = styles[prop] + unit; + }); + } + + /** + * Set the attributes to the given popper + * @method + * @memberof Popper.Utils + * @argument {Element} element - Element to apply the attributes to + * @argument {Object} styles + * Object with a list of properties and values which will be applied to the element + */ + function setAttributes(element, attributes) { + Object.keys(attributes).forEach(function (prop) { + var value = attributes[prop]; + if (value !== false) { + element.setAttribute(prop, attributes[prop]); + } else { + element.removeAttribute(prop); + } + }); + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by `update` method + * @argument {Object} data.styles - List of style properties - values to apply to popper element + * @argument {Object} data.attributes - List of attribute properties - values to apply to popper element + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The same data object + */ + function applyStyle(data) { + // any property present in `data.styles` will be applied to the popper, + // in this way we can make the 3rd party modifiers add custom styles to it + // Be aware, modifiers could override the properties defined in the previous + // lines of this modifier! + setStyles(data.instance.popper, data.styles); + + // any property present in `data.attributes` will be applied to the popper, + // they will be set as HTML attributes of the element + setAttributes(data.instance.popper, data.attributes); + + // if arrowElement is defined and arrowStyles has some properties + if (data.arrowElement && Object.keys(data.arrowStyles).length) { + setStyles(data.arrowElement, data.arrowStyles); + } + + return data; + } + + /** + * Set the x-placement attribute before everything else because it could be used + * to add margins to the popper margins needs to be calculated to get the + * correct popper offsets. + * @method + * @memberof Popper.modifiers + * @param {HTMLElement} reference - The reference element used to position the popper + * @param {HTMLElement} popper - The HTML element used as popper + * @param {Object} options - Popper.js options + */ + function applyStyleOnLoad(reference, popper, options, modifierOptions, state) { + // compute reference element offsets + var referenceOffsets = getReferenceOffsets(state, popper, reference, options.positionFixed); + + // compute auto placement, store placement inside the data object, + // modifiers will be able to edit `placement` if needed + // and refer to originalPlacement to know the original value + var placement = computeAutoPlacement(options.placement, referenceOffsets, popper, reference, options.modifiers.flip.boundariesElement, options.modifiers.flip.padding); + + popper.setAttribute('x-placement', placement); + + // Apply `position` to popper before anything else because + // without the position applied we can't guarantee correct computations + setStyles(popper, { position: options.positionFixed ? 'fixed' : 'absolute' }); + + return options; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by `update` method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function computeStyle(data, options) { + var x = options.x, + y = options.y; + var popper = data.offsets.popper; + + // Remove this legacy support in Popper.js v2 + + var legacyGpuAccelerationOption = find(data.instance.modifiers, function (modifier) { + return modifier.name === 'applyStyle'; + }).gpuAcceleration; + if (legacyGpuAccelerationOption !== undefined) { + console.warn('WARNING: `gpuAcceleration` option moved to `computeStyle` modifier and will not be supported in future versions of Popper.js!'); + } + var gpuAcceleration = legacyGpuAccelerationOption !== undefined ? legacyGpuAccelerationOption : options.gpuAcceleration; + + var offsetParent = getOffsetParent(data.instance.popper); + var offsetParentRect = getBoundingClientRect(offsetParent); + + // Styles + var styles = { + position: popper.position + }; + + // Avoid blurry text by using full pixel integers. + // For pixel-perfect positioning, top/bottom prefers rounded + // values, while left/right prefers floored values. + var offsets = { + left: Math.floor(popper.left), + top: Math.round(popper.top), + bottom: Math.round(popper.bottom), + right: Math.floor(popper.right) + }; + + var sideA = x === 'bottom' ? 'top' : 'bottom'; + var sideB = y === 'right' ? 'left' : 'right'; + + // if gpuAcceleration is set to `true` and transform is supported, + // we use `translate3d` to apply the position to the popper we + // automatically use the supported prefixed version if needed + var prefixedProperty = getSupportedPropertyName('transform'); + + // now, let's make a step back and look at this code closely (wtf?) + // If the content of the popper grows once it's been positioned, it + // may happen that the popper gets misplaced because of the new content + // overflowing its reference element + // To avoid this problem, we provide two options (x and y), which allow + // the consumer to define the offset origin. + // If we position a popper on top of a reference element, we can set + // `x` to `top` to make the popper grow towards its top instead of + // its bottom. + var left = void 0, + top = void 0; + if (sideA === 'bottom') { + top = -offsetParentRect.height + offsets.bottom; + } else { + top = offsets.top; + } + if (sideB === 'right') { + left = -offsetParentRect.width + offsets.right; + } else { + left = offsets.left; + } + if (gpuAcceleration && prefixedProperty) { + styles[prefixedProperty] = 'translate3d(' + left + 'px, ' + top + 'px, 0)'; + styles[sideA] = 0; + styles[sideB] = 0; + styles.willChange = 'transform'; + } else { + // othwerise, we use the standard `top`, `left`, `bottom` and `right` properties + var invertTop = sideA === 'bottom' ? -1 : 1; + var invertLeft = sideB === 'right' ? -1 : 1; + styles[sideA] = top * invertTop; + styles[sideB] = left * invertLeft; + styles.willChange = sideA + ', ' + sideB; + } + + // Attributes + var attributes = { + 'x-placement': data.placement + }; + + // Update `data` attributes, styles and arrowStyles + data.attributes = _extends({}, attributes, data.attributes); + data.styles = _extends({}, styles, data.styles); + data.arrowStyles = _extends({}, data.offsets.arrow, data.arrowStyles); + + return data; + } + + /** + * Helper used to know if the given modifier depends from another one.
+ * It checks if the needed modifier is listed and enabled. + * @method + * @memberof Popper.Utils + * @param {Array} modifiers - list of modifiers + * @param {String} requestingName - name of requesting modifier + * @param {String} requestedName - name of requested modifier + * @returns {Boolean} + */ + function isModifierRequired(modifiers, requestingName, requestedName) { + var requesting = find(modifiers, function (_ref) { + var name = _ref.name; + return name === requestingName; + }); + + var isRequired = !!requesting && modifiers.some(function (modifier) { + return modifier.name === requestedName && modifier.enabled && modifier.order < requesting.order; + }); + + if (!isRequired) { + var _requesting = '`' + requestingName + '`'; + var requested = '`' + requestedName + '`'; + console.warn(requested + ' modifier is required by ' + _requesting + ' modifier in order to work, be sure to include it before ' + _requesting + '!'); + } + return isRequired; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function arrow(data, options) { + var _data$offsets$arrow; + + // arrow depends on keepTogether in order to work + if (!isModifierRequired(data.instance.modifiers, 'arrow', 'keepTogether')) { + return data; + } + + var arrowElement = options.element; + + // if arrowElement is a string, suppose it's a CSS selector + if (typeof arrowElement === 'string') { + arrowElement = data.instance.popper.querySelector(arrowElement); + + // if arrowElement is not found, don't run the modifier + if (!arrowElement) { + return data; + } + } else { + // if the arrowElement isn't a query selector we must check that the + // provided DOM node is child of its popper node + if (!data.instance.popper.contains(arrowElement)) { + console.warn('WARNING: `arrow.element` must be child of its popper element!'); + return data; + } + } + + var placement = data.placement.split('-')[0]; + var _data$offsets = data.offsets, + popper = _data$offsets.popper, + reference = _data$offsets.reference; + + var isVertical = ['left', 'right'].indexOf(placement) !== -1; + + var len = isVertical ? 'height' : 'width'; + var sideCapitalized = isVertical ? 'Top' : 'Left'; + var side = sideCapitalized.toLowerCase(); + var altSide = isVertical ? 'left' : 'top'; + var opSide = isVertical ? 'bottom' : 'right'; + var arrowElementSize = getOuterSizes(arrowElement)[len]; + + // + // extends keepTogether behavior making sure the popper and its + // reference have enough pixels in conjuction + // + + // top/left side + if (reference[opSide] - arrowElementSize < popper[side]) { + data.offsets.popper[side] -= popper[side] - (reference[opSide] - arrowElementSize); + } + // bottom/right side + if (reference[side] + arrowElementSize > popper[opSide]) { + data.offsets.popper[side] += reference[side] + arrowElementSize - popper[opSide]; + } + data.offsets.popper = getClientRect(data.offsets.popper); + + // compute center of the popper + var center = reference[side] + reference[len] / 2 - arrowElementSize / 2; + + // Compute the sideValue using the updated popper offsets + // take popper margin in account because we don't have this info available + var css = getStyleComputedProperty(data.instance.popper); + var popperMarginSide = parseFloat(css['margin' + sideCapitalized], 10); + var popperBorderSide = parseFloat(css['border' + sideCapitalized + 'Width'], 10); + var sideValue = center - data.offsets.popper[side] - popperMarginSide - popperBorderSide; + + // prevent arrowElement from being placed not contiguously to its popper + sideValue = Math.max(Math.min(popper[len] - arrowElementSize, sideValue), 0); + + data.arrowElement = arrowElement; + data.offsets.arrow = (_data$offsets$arrow = {}, defineProperty(_data$offsets$arrow, side, Math.round(sideValue)), defineProperty(_data$offsets$arrow, altSide, ''), _data$offsets$arrow); + + return data; + } + + /** + * Get the opposite placement variation of the given one + * @method + * @memberof Popper.Utils + * @argument {String} placement variation + * @returns {String} flipped placement variation + */ + function getOppositeVariation(variation) { + if (variation === 'end') { + return 'start'; + } else if (variation === 'start') { + return 'end'; + } + return variation; + } + + /** + * List of accepted placements to use as values of the `placement` option.
+ * Valid placements are: + * - `auto` + * - `top` + * - `right` + * - `bottom` + * - `left` + * + * Each placement can have a variation from this list: + * - `-start` + * - `-end` + * + * Variations are interpreted easily if you think of them as the left to right + * written languages. Horizontally (`top` and `bottom`), `start` is left and `end` + * is right.
+ * Vertically (`left` and `right`), `start` is top and `end` is bottom. + * + * Some valid examples are: + * - `top-end` (on top of reference, right aligned) + * - `right-start` (on right of reference, top aligned) + * - `bottom` (on bottom, centered) + * - `auto-right` (on the side with more space available, alignment depends by placement) + * + * @static + * @type {Array} + * @enum {String} + * @readonly + * @method placements + * @memberof Popper + */ + var placements = ['auto-start', 'auto', 'auto-end', 'top-start', 'top', 'top-end', 'right-start', 'right', 'right-end', 'bottom-end', 'bottom', 'bottom-start', 'left-end', 'left', 'left-start']; + + // Get rid of `auto` `auto-start` and `auto-end` + var validPlacements = placements.slice(3); + + /** + * Given an initial placement, returns all the subsequent placements + * clockwise (or counter-clockwise). + * + * @method + * @memberof Popper.Utils + * @argument {String} placement - A valid placement (it accepts variations) + * @argument {Boolean} counter - Set to true to walk the placements counterclockwise + * @returns {Array} placements including their variations + */ + function clockwise(placement) { + var counter = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + + var index = validPlacements.indexOf(placement); + var arr = validPlacements.slice(index + 1).concat(validPlacements.slice(0, index)); + return counter ? arr.reverse() : arr; + } + + var BEHAVIORS = { + FLIP: 'flip', + CLOCKWISE: 'clockwise', + COUNTERCLOCKWISE: 'counterclockwise' + }; + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function flip(data, options) { + // if `inner` modifier is enabled, we can't use the `flip` modifier + if (isModifierEnabled(data.instance.modifiers, 'inner')) { + return data; + } + + if (data.flipped && data.placement === data.originalPlacement) { + // seems like flip is trying to loop, probably there's not enough space on any of the flippable sides + return data; + } + + var boundaries = getBoundaries(data.instance.popper, data.instance.reference, options.padding, options.boundariesElement, data.positionFixed); + + var placement = data.placement.split('-')[0]; + var placementOpposite = getOppositePlacement(placement); + var variation = data.placement.split('-')[1] || ''; + + var flipOrder = []; + + switch (options.behavior) { + case BEHAVIORS.FLIP: + flipOrder = [placement, placementOpposite]; + break; + case BEHAVIORS.CLOCKWISE: + flipOrder = clockwise(placement); + break; + case BEHAVIORS.COUNTERCLOCKWISE: + flipOrder = clockwise(placement, true); + break; + default: + flipOrder = options.behavior; + } + + flipOrder.forEach(function (step, index) { + if (placement !== step || flipOrder.length === index + 1) { + return data; + } + + placement = data.placement.split('-')[0]; + placementOpposite = getOppositePlacement(placement); + + var popperOffsets = data.offsets.popper; + var refOffsets = data.offsets.reference; + + // using floor because the reference offsets may contain decimals we are not going to consider here + var floor = Math.floor; + var overlapsRef = placement === 'left' && floor(popperOffsets.right) > floor(refOffsets.left) || placement === 'right' && floor(popperOffsets.left) < floor(refOffsets.right) || placement === 'top' && floor(popperOffsets.bottom) > floor(refOffsets.top) || placement === 'bottom' && floor(popperOffsets.top) < floor(refOffsets.bottom); + + var overflowsLeft = floor(popperOffsets.left) < floor(boundaries.left); + var overflowsRight = floor(popperOffsets.right) > floor(boundaries.right); + var overflowsTop = floor(popperOffsets.top) < floor(boundaries.top); + var overflowsBottom = floor(popperOffsets.bottom) > floor(boundaries.bottom); + + var overflowsBoundaries = placement === 'left' && overflowsLeft || placement === 'right' && overflowsRight || placement === 'top' && overflowsTop || placement === 'bottom' && overflowsBottom; + + // flip the variation if required + var isVertical = ['top', 'bottom'].indexOf(placement) !== -1; + var flippedVariation = !!options.flipVariations && (isVertical && variation === 'start' && overflowsLeft || isVertical && variation === 'end' && overflowsRight || !isVertical && variation === 'start' && overflowsTop || !isVertical && variation === 'end' && overflowsBottom); + + if (overlapsRef || overflowsBoundaries || flippedVariation) { + // this boolean to detect any flip loop + data.flipped = true; + + if (overlapsRef || overflowsBoundaries) { + placement = flipOrder[index + 1]; + } + + if (flippedVariation) { + variation = getOppositeVariation(variation); + } + + data.placement = placement + (variation ? '-' + variation : ''); + + // this object contains `position`, we want to preserve it along with + // any additional property we may add in the future + data.offsets.popper = _extends({}, data.offsets.popper, getPopperOffsets(data.instance.popper, data.offsets.reference, data.placement)); + + data = runModifiers(data.instance.modifiers, data, 'flip'); + } + }); + return data; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function keepTogether(data) { + var _data$offsets = data.offsets, + popper = _data$offsets.popper, + reference = _data$offsets.reference; + + var placement = data.placement.split('-')[0]; + var floor = Math.floor; + var isVertical = ['top', 'bottom'].indexOf(placement) !== -1; + var side = isVertical ? 'right' : 'bottom'; + var opSide = isVertical ? 'left' : 'top'; + var measurement = isVertical ? 'width' : 'height'; + + if (popper[side] < floor(reference[opSide])) { + data.offsets.popper[opSide] = floor(reference[opSide]) - popper[measurement]; + } + if (popper[opSide] > floor(reference[side])) { + data.offsets.popper[opSide] = floor(reference[side]); + } + + return data; + } + + /** + * Converts a string containing value + unit into a px value number + * @function + * @memberof {modifiers~offset} + * @private + * @argument {String} str - Value + unit string + * @argument {String} measurement - `height` or `width` + * @argument {Object} popperOffsets + * @argument {Object} referenceOffsets + * @returns {Number|String} + * Value in pixels, or original string if no values were extracted + */ + function toValue(str, measurement, popperOffsets, referenceOffsets) { + // separate value from unit + var split = str.match(/((?:\-|\+)?\d*\.?\d*)(.*)/); + var value = +split[1]; + var unit = split[2]; + + // If it's not a number it's an operator, I guess + if (!value) { + return str; + } + + if (unit.indexOf('%') === 0) { + var element = void 0; + switch (unit) { + case '%p': + element = popperOffsets; + break; + case '%': + case '%r': + default: + element = referenceOffsets; + } + + var rect = getClientRect(element); + return rect[measurement] / 100 * value; + } else if (unit === 'vh' || unit === 'vw') { + // if is a vh or vw, we calculate the size based on the viewport + var size = void 0; + if (unit === 'vh') { + size = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); + } else { + size = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); + } + return size / 100 * value; + } else { + // if is an explicit pixel unit, we get rid of the unit and keep the value + // if is an implicit unit, it's px, and we return just the value + return value; + } + } + + /** + * Parse an `offset` string to extrapolate `x` and `y` numeric offsets. + * @function + * @memberof {modifiers~offset} + * @private + * @argument {String} offset + * @argument {Object} popperOffsets + * @argument {Object} referenceOffsets + * @argument {String} basePlacement + * @returns {Array} a two cells array with x and y offsets in numbers + */ + function parseOffset(offset, popperOffsets, referenceOffsets, basePlacement) { + var offsets = [0, 0]; + + // Use height if placement is left or right and index is 0 otherwise use width + // in this way the first offset will use an axis and the second one + // will use the other one + var useHeight = ['right', 'left'].indexOf(basePlacement) !== -1; + + // Split the offset string to obtain a list of values and operands + // The regex addresses values with the plus or minus sign in front (+10, -20, etc) + var fragments = offset.split(/(\+|\-)/).map(function (frag) { + return frag.trim(); + }); + + // Detect if the offset string contains a pair of values or a single one + // they could be separated by comma or space + var divider = fragments.indexOf(find(fragments, function (frag) { + return frag.search(/,|\s/) !== -1; + })); + + if (fragments[divider] && fragments[divider].indexOf(',') === -1) { + console.warn('Offsets separated by white space(s) are deprecated, use a comma (,) instead.'); + } + + // If divider is found, we divide the list of values and operands to divide + // them by ofset X and Y. + var splitRegex = /\s*,\s*|\s+/; + var ops = divider !== -1 ? [fragments.slice(0, divider).concat([fragments[divider].split(splitRegex)[0]]), [fragments[divider].split(splitRegex)[1]].concat(fragments.slice(divider + 1))] : [fragments]; + + // Convert the values with units to absolute pixels to allow our computations + ops = ops.map(function (op, index) { + // Most of the units rely on the orientation of the popper + var measurement = (index === 1 ? !useHeight : useHeight) ? 'height' : 'width'; + var mergeWithPrevious = false; + return op + // This aggregates any `+` or `-` sign that aren't considered operators + // e.g.: 10 + +5 => [10, +, +5] + .reduce(function (a, b) { + if (a[a.length - 1] === '' && ['+', '-'].indexOf(b) !== -1) { + a[a.length - 1] = b; + mergeWithPrevious = true; + return a; + } else if (mergeWithPrevious) { + a[a.length - 1] += b; + mergeWithPrevious = false; + return a; + } else { + return a.concat(b); + } + }, []) + // Here we convert the string values into number values (in px) + .map(function (str) { + return toValue(str, measurement, popperOffsets, referenceOffsets); + }); + }); + + // Loop trough the offsets arrays and execute the operations + ops.forEach(function (op, index) { + op.forEach(function (frag, index2) { + if (isNumeric(frag)) { + offsets[index] += frag * (op[index2 - 1] === '-' ? -1 : 1); + } + }); + }); + return offsets; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @argument {Number|String} options.offset=0 + * The offset value as described in the modifier description + * @returns {Object} The data object, properly modified + */ + function offset(data, _ref) { + var offset = _ref.offset; + var placement = data.placement, + _data$offsets = data.offsets, + popper = _data$offsets.popper, + reference = _data$offsets.reference; + + var basePlacement = placement.split('-')[0]; + + var offsets = void 0; + if (isNumeric(+offset)) { + offsets = [+offset, 0]; + } else { + offsets = parseOffset(offset, popper, reference, basePlacement); + } + + if (basePlacement === 'left') { + popper.top += offsets[0]; + popper.left -= offsets[1]; + } else if (basePlacement === 'right') { + popper.top += offsets[0]; + popper.left += offsets[1]; + } else if (basePlacement === 'top') { + popper.left += offsets[0]; + popper.top -= offsets[1]; + } else if (basePlacement === 'bottom') { + popper.left += offsets[0]; + popper.top += offsets[1]; + } + + data.popper = popper; + return data; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by `update` method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function preventOverflow(data, options) { + var boundariesElement = options.boundariesElement || getOffsetParent(data.instance.popper); + + // If offsetParent is the reference element, we really want to + // go one step up and use the next offsetParent as reference to + // avoid to make this modifier completely useless and look like broken + if (data.instance.reference === boundariesElement) { + boundariesElement = getOffsetParent(boundariesElement); + } + + // NOTE: DOM access here + // resets the popper's position so that the document size can be calculated excluding + // the size of the popper element itself + var transformProp = getSupportedPropertyName('transform'); + var popperStyles = data.instance.popper.style; // assignment to help minification + var top = popperStyles.top, + left = popperStyles.left, + transform = popperStyles[transformProp]; + + popperStyles.top = ''; + popperStyles.left = ''; + popperStyles[transformProp] = ''; + + var boundaries = getBoundaries(data.instance.popper, data.instance.reference, options.padding, boundariesElement, data.positionFixed); + + // NOTE: DOM access here + // restores the original style properties after the offsets have been computed + popperStyles.top = top; + popperStyles.left = left; + popperStyles[transformProp] = transform; + + options.boundaries = boundaries; + + var order = options.priority; + var popper = data.offsets.popper; + + var check = { + primary: function primary(placement) { + var value = popper[placement]; + if (popper[placement] < boundaries[placement] && !options.escapeWithReference) { + value = Math.max(popper[placement], boundaries[placement]); + } + return defineProperty({}, placement, value); + }, + secondary: function secondary(placement) { + var mainSide = placement === 'right' ? 'left' : 'top'; + var value = popper[mainSide]; + if (popper[placement] > boundaries[placement] && !options.escapeWithReference) { + value = Math.min(popper[mainSide], boundaries[placement] - (placement === 'right' ? popper.width : popper.height)); + } + return defineProperty({}, mainSide, value); + } + }; + + order.forEach(function (placement) { + var side = ['left', 'top'].indexOf(placement) !== -1 ? 'primary' : 'secondary'; + popper = _extends({}, popper, check[side](placement)); + }); + + data.offsets.popper = popper; + + return data; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by `update` method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function shift(data) { + var placement = data.placement; + var basePlacement = placement.split('-')[0]; + var shiftvariation = placement.split('-')[1]; + + // if shift shiftvariation is specified, run the modifier + if (shiftvariation) { + var _data$offsets = data.offsets, + reference = _data$offsets.reference, + popper = _data$offsets.popper; + + var isVertical = ['bottom', 'top'].indexOf(basePlacement) !== -1; + var side = isVertical ? 'left' : 'top'; + var measurement = isVertical ? 'width' : 'height'; + + var shiftOffsets = { + start: defineProperty({}, side, reference[side]), + end: defineProperty({}, side, reference[side] + reference[measurement] - popper[measurement]) + }; + + data.offsets.popper = _extends({}, popper, shiftOffsets[shiftvariation]); + } + + return data; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function hide(data) { + if (!isModifierRequired(data.instance.modifiers, 'hide', 'preventOverflow')) { + return data; + } + + var refRect = data.offsets.reference; + var bound = find(data.instance.modifiers, function (modifier) { + return modifier.name === 'preventOverflow'; + }).boundaries; + + if (refRect.bottom < bound.top || refRect.left > bound.right || refRect.top > bound.bottom || refRect.right < bound.left) { + // Avoid unnecessary DOM access if visibility hasn't changed + if (data.hide === true) { + return data; + } + + data.hide = true; + data.attributes['x-out-of-boundaries'] = ''; + } else { + // Avoid unnecessary DOM access if visibility hasn't changed + if (data.hide === false) { + return data; + } + + data.hide = false; + data.attributes['x-out-of-boundaries'] = false; + } + + return data; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by `update` method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function inner(data) { + var placement = data.placement; + var basePlacement = placement.split('-')[0]; + var _data$offsets = data.offsets, + popper = _data$offsets.popper, + reference = _data$offsets.reference; + + var isHoriz = ['left', 'right'].indexOf(basePlacement) !== -1; + + var subtractLength = ['top', 'left'].indexOf(basePlacement) === -1; + + popper[isHoriz ? 'left' : 'top'] = reference[basePlacement] - (subtractLength ? popper[isHoriz ? 'width' : 'height'] : 0); + + data.placement = getOppositePlacement(placement); + data.offsets.popper = getClientRect(popper); + + return data; + } + + /** + * Modifier function, each modifier can have a function of this type assigned + * to its `fn` property.
+ * These functions will be called on each update, this means that you must + * make sure they are performant enough to avoid performance bottlenecks. + * + * @function ModifierFn + * @argument {dataObject} data - The data object generated by `update` method + * @argument {Object} options - Modifiers configuration and options + * @returns {dataObject} The data object, properly modified + */ + + /** + * Modifiers are plugins used to alter the behavior of your poppers.
+ * Popper.js uses a set of 9 modifiers to provide all the basic functionalities + * needed by the library. + * + * Usually you don't want to override the `order`, `fn` and `onLoad` props. + * All the other properties are configurations that could be tweaked. + * @namespace modifiers + */ + var modifiers = { + /** + * Modifier used to shift the popper on the start or end of its reference + * element.
+ * It will read the variation of the `placement` property.
+ * It can be one either `-end` or `-start`. + * @memberof modifiers + * @inner + */ + shift: { + /** @prop {number} order=100 - Index used to define the order of execution */ + order: 100, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: shift + }, + + /** + * The `offset` modifier can shift your popper on both its axis. + * + * It accepts the following units: + * - `px` or unitless, interpreted as pixels + * - `%` or `%r`, percentage relative to the length of the reference element + * - `%p`, percentage relative to the length of the popper element + * - `vw`, CSS viewport width unit + * - `vh`, CSS viewport height unit + * + * For length is intended the main axis relative to the placement of the popper.
+ * This means that if the placement is `top` or `bottom`, the length will be the + * `width`. In case of `left` or `right`, it will be the height. + * + * You can provide a single value (as `Number` or `String`), or a pair of values + * as `String` divided by a comma or one (or more) white spaces.
+ * The latter is a deprecated method because it leads to confusion and will be + * removed in v2.
+ * Additionally, it accepts additions and subtractions between different units. + * Note that multiplications and divisions aren't supported. + * + * Valid examples are: + * ``` + * 10 + * '10%' + * '10, 10' + * '10%, 10' + * '10 + 10%' + * '10 - 5vh + 3%' + * '-10px + 5vh, 5px - 6%' + * ``` + * > **NB**: If you desire to apply offsets to your poppers in a way that may make them overlap + * > with their reference element, unfortunately, you will have to disable the `flip` modifier. + * > More on this [reading this issue](https://github.com/FezVrasta/popper.js/issues/373) + * + * @memberof modifiers + * @inner + */ + offset: { + /** @prop {number} order=200 - Index used to define the order of execution */ + order: 200, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: offset, + /** @prop {Number|String} offset=0 + * The offset value as described in the modifier description + */ + offset: 0 + }, + + /** + * Modifier used to prevent the popper from being positioned outside the boundary. + * + * An scenario exists where the reference itself is not within the boundaries.
+ * We can say it has "escaped the boundaries" — or just "escaped".
+ * In this case we need to decide whether the popper should either: + * + * - detach from the reference and remain "trapped" in the boundaries, or + * - if it should ignore the boundary and "escape with its reference" + * + * When `escapeWithReference` is set to`true` and reference is completely + * outside its boundaries, the popper will overflow (or completely leave) + * the boundaries in order to remain attached to the edge of the reference. + * + * @memberof modifiers + * @inner + */ + preventOverflow: { + /** @prop {number} order=300 - Index used to define the order of execution */ + order: 300, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: preventOverflow, + /** + * @prop {Array} [priority=['left','right','top','bottom']] + * Popper will try to prevent overflow following these priorities by default, + * then, it could overflow on the left and on top of the `boundariesElement` + */ + priority: ['left', 'right', 'top', 'bottom'], + /** + * @prop {number} padding=5 + * Amount of pixel used to define a minimum distance between the boundaries + * and the popper this makes sure the popper has always a little padding + * between the edges of its container + */ + padding: 5, + /** + * @prop {String|HTMLElement} boundariesElement='scrollParent' + * Boundaries used by the modifier, can be `scrollParent`, `window`, + * `viewport` or any DOM element. + */ + boundariesElement: 'scrollParent' + }, + + /** + * Modifier used to make sure the reference and its popper stay near eachothers + * without leaving any gap between the two. Expecially useful when the arrow is + * enabled and you want to assure it to point to its reference element. + * It cares only about the first axis, you can still have poppers with margin + * between the popper and its reference element. + * @memberof modifiers + * @inner + */ + keepTogether: { + /** @prop {number} order=400 - Index used to define the order of execution */ + order: 400, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: keepTogether + }, + + /** + * This modifier is used to move the `arrowElement` of the popper to make + * sure it is positioned between the reference element and its popper element. + * It will read the outer size of the `arrowElement` node to detect how many + * pixels of conjuction are needed. + * + * It has no effect if no `arrowElement` is provided. + * @memberof modifiers + * @inner + */ + arrow: { + /** @prop {number} order=500 - Index used to define the order of execution */ + order: 500, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: arrow, + /** @prop {String|HTMLElement} element='[x-arrow]' - Selector or node used as arrow */ + element: '[x-arrow]' + }, + + /** + * Modifier used to flip the popper's placement when it starts to overlap its + * reference element. + * + * Requires the `preventOverflow` modifier before it in order to work. + * + * **NOTE:** this modifier will interrupt the current update cycle and will + * restart it if it detects the need to flip the placement. + * @memberof modifiers + * @inner + */ + flip: { + /** @prop {number} order=600 - Index used to define the order of execution */ + order: 600, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: flip, + /** + * @prop {String|Array} behavior='flip' + * The behavior used to change the popper's placement. It can be one of + * `flip`, `clockwise`, `counterclockwise` or an array with a list of valid + * placements (with optional variations). + */ + behavior: 'flip', + /** + * @prop {number} padding=5 + * The popper will flip if it hits the edges of the `boundariesElement` + */ + padding: 5, + /** + * @prop {String|HTMLElement} boundariesElement='viewport' + * The element which will define the boundaries of the popper position, + * the popper will never be placed outside of the defined boundaries + * (except if keepTogether is enabled) + */ + boundariesElement: 'viewport' + }, + + /** + * Modifier used to make the popper flow toward the inner of the reference element. + * By default, when this modifier is disabled, the popper will be placed outside + * the reference element. + * @memberof modifiers + * @inner + */ + inner: { + /** @prop {number} order=700 - Index used to define the order of execution */ + order: 700, + /** @prop {Boolean} enabled=false - Whether the modifier is enabled or not */ + enabled: false, + /** @prop {ModifierFn} */ + fn: inner + }, + + /** + * Modifier used to hide the popper when its reference element is outside of the + * popper boundaries. It will set a `x-out-of-boundaries` attribute which can + * be used to hide with a CSS selector the popper when its reference is + * out of boundaries. + * + * Requires the `preventOverflow` modifier before it in order to work. + * @memberof modifiers + * @inner + */ + hide: { + /** @prop {number} order=800 - Index used to define the order of execution */ + order: 800, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: hide + }, + + /** + * Computes the style that will be applied to the popper element to gets + * properly positioned. + * + * Note that this modifier will not touch the DOM, it just prepares the styles + * so that `applyStyle` modifier can apply it. This separation is useful + * in case you need to replace `applyStyle` with a custom implementation. + * + * This modifier has `850` as `order` value to maintain backward compatibility + * with previous versions of Popper.js. Expect the modifiers ordering method + * to change in future major versions of the library. + * + * @memberof modifiers + * @inner + */ + computeStyle: { + /** @prop {number} order=850 - Index used to define the order of execution */ + order: 850, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: computeStyle, + /** + * @prop {Boolean} gpuAcceleration=true + * If true, it uses the CSS 3d transformation to position the popper. + * Otherwise, it will use the `top` and `left` properties. + */ + gpuAcceleration: true, + /** + * @prop {string} [x='bottom'] + * Where to anchor the X axis (`bottom` or `top`). AKA X offset origin. + * Change this if your popper should grow in a direction different from `bottom` + */ + x: 'bottom', + /** + * @prop {string} [x='left'] + * Where to anchor the Y axis (`left` or `right`). AKA Y offset origin. + * Change this if your popper should grow in a direction different from `right` + */ + y: 'right' + }, + + /** + * Applies the computed styles to the popper element. + * + * All the DOM manipulations are limited to this modifier. This is useful in case + * you want to integrate Popper.js inside a framework or view library and you + * want to delegate all the DOM manipulations to it. + * + * Note that if you disable this modifier, you must make sure the popper element + * has its position set to `absolute` before Popper.js can do its work! + * + * Just disable this modifier and define you own to achieve the desired effect. + * + * @memberof modifiers + * @inner + */ + applyStyle: { + /** @prop {number} order=900 - Index used to define the order of execution */ + order: 900, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: applyStyle, + /** @prop {Function} */ + onLoad: applyStyleOnLoad, + /** + * @deprecated since version 1.10.0, the property moved to `computeStyle` modifier + * @prop {Boolean} gpuAcceleration=true + * If true, it uses the CSS 3d transformation to position the popper. + * Otherwise, it will use the `top` and `left` properties. + */ + gpuAcceleration: undefined + } + }; + + /** + * The `dataObject` is an object containing all the informations used by Popper.js + * this object get passed to modifiers and to the `onCreate` and `onUpdate` callbacks. + * @name dataObject + * @property {Object} data.instance The Popper.js instance + * @property {String} data.placement Placement applied to popper + * @property {String} data.originalPlacement Placement originally defined on init + * @property {Boolean} data.flipped True if popper has been flipped by flip modifier + * @property {Boolean} data.hide True if the reference element is out of boundaries, useful to know when to hide the popper. + * @property {HTMLElement} data.arrowElement Node used as arrow by arrow modifier + * @property {Object} data.styles Any CSS property defined here will be applied to the popper, it expects the JavaScript nomenclature (eg. `marginBottom`) + * @property {Object} data.arrowStyles Any CSS property defined here will be applied to the popper arrow, it expects the JavaScript nomenclature (eg. `marginBottom`) + * @property {Object} data.boundaries Offsets of the popper boundaries + * @property {Object} data.offsets The measurements of popper, reference and arrow elements. + * @property {Object} data.offsets.popper `top`, `left`, `width`, `height` values + * @property {Object} data.offsets.reference `top`, `left`, `width`, `height` values + * @property {Object} data.offsets.arrow] `top` and `left` offsets, only one of them will be different from 0 + */ + + /** + * Default options provided to Popper.js constructor.
+ * These can be overriden using the `options` argument of Popper.js.
+ * To override an option, simply pass as 3rd argument an object with the same + * structure of this object, example: + * ``` + * new Popper(ref, pop, { + * modifiers: { + * preventOverflow: { enabled: false } + * } + * }) + * ``` + * @type {Object} + * @static + * @memberof Popper + */ + var Defaults = { + /** + * Popper's placement + * @prop {Popper.placements} placement='bottom' + */ + placement: 'bottom', + + /** + * Set this to true if you want popper to position it self in 'fixed' mode + * @prop {Boolean} positionFixed=false + */ + positionFixed: false, + + /** + * Whether events (resize, scroll) are initially enabled + * @prop {Boolean} eventsEnabled=true + */ + eventsEnabled: true, + + /** + * Set to true if you want to automatically remove the popper when + * you call the `destroy` method. + * @prop {Boolean} removeOnDestroy=false + */ + removeOnDestroy: false, + + /** + * Callback called when the popper is created.
+ * By default, is set to no-op.
+ * Access Popper.js instance with `data.instance`. + * @prop {onCreate} + */ + onCreate: function onCreate() {}, + + /** + * Callback called when the popper is updated, this callback is not called + * on the initialization/creation of the popper, but only on subsequent + * updates.
+ * By default, is set to no-op.
+ * Access Popper.js instance with `data.instance`. + * @prop {onUpdate} + */ + onUpdate: function onUpdate() {}, + + /** + * List of modifiers used to modify the offsets before they are applied to the popper. + * They provide most of the functionalities of Popper.js + * @prop {modifiers} + */ + modifiers: modifiers + }; + + /** + * @callback onCreate + * @param {dataObject} data + */ + + /** + * @callback onUpdate + * @param {dataObject} data + */ + + // Utils + // Methods + var Popper = function () { + /** + * Create a new Popper.js instance + * @class Popper + * @param {HTMLElement|referenceObject} reference - The reference element used to position the popper + * @param {HTMLElement} popper - The HTML element used as popper. + * @param {Object} options - Your custom options to override the ones defined in [Defaults](#defaults) + * @return {Object} instance - The generated Popper.js instance + */ + function Popper(reference, popper) { + var _this = this; + + var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + classCallCheck(this, Popper); + + this.scheduleUpdate = function () { + return requestAnimationFrame(_this.update); + }; + + // make update() debounced, so that it only runs at most once-per-tick + this.update = debounce(this.update.bind(this)); + + // with {} we create a new object with the options inside it + this.options = _extends({}, Popper.Defaults, options); + + // init state + this.state = { + isDestroyed: false, + isCreated: false, + scrollParents: [] + }; + + // get reference and popper elements (allow jQuery wrappers) + this.reference = reference && reference.jquery ? reference[0] : reference; + this.popper = popper && popper.jquery ? popper[0] : popper; + + // Deep merge modifiers options + this.options.modifiers = {}; + Object.keys(_extends({}, Popper.Defaults.modifiers, options.modifiers)).forEach(function (name) { + _this.options.modifiers[name] = _extends({}, Popper.Defaults.modifiers[name] || {}, options.modifiers ? options.modifiers[name] : {}); + }); + + // Refactoring modifiers' list (Object => Array) + this.modifiers = Object.keys(this.options.modifiers).map(function (name) { + return _extends({ + name: name + }, _this.options.modifiers[name]); + }) + // sort the modifiers by order + .sort(function (a, b) { + return a.order - b.order; + }); + + // modifiers have the ability to execute arbitrary code when Popper.js get inited + // such code is executed in the same order of its modifier + // they could add new properties to their options configuration + // BE AWARE: don't add options to `options.modifiers.name` but to `modifierOptions`! + this.modifiers.forEach(function (modifierOptions) { + if (modifierOptions.enabled && isFunction(modifierOptions.onLoad)) { + modifierOptions.onLoad(_this.reference, _this.popper, _this.options, modifierOptions, _this.state); + } + }); + + // fire the first update to position the popper in the right place + this.update(); + + var eventsEnabled = this.options.eventsEnabled; + if (eventsEnabled) { + // setup event listeners, they will take care of update the position in specific situations + this.enableEventListeners(); + } + + this.state.eventsEnabled = eventsEnabled; + } + + // We can't use class properties because they don't get listed in the + // class prototype and break stuff like Sinon stubs + + + createClass(Popper, [{ + key: 'update', + value: function update$$1() { + return update.call(this); + } + }, { + key: 'destroy', + value: function destroy$$1() { + return destroy.call(this); + } + }, { + key: 'enableEventListeners', + value: function enableEventListeners$$1() { + return enableEventListeners.call(this); + } + }, { + key: 'disableEventListeners', + value: function disableEventListeners$$1() { + return disableEventListeners.call(this); + } + + /** + * Schedule an update, it will run on the next UI update available + * @method scheduleUpdate + * @memberof Popper + */ + + + /** + * Collection of utilities useful when writing custom modifiers. + * Starting from version 1.7, this method is available only if you + * include `popper-utils.js` before `popper.js`. + * + * **DEPRECATION**: This way to access PopperUtils is deprecated + * and will be removed in v2! Use the PopperUtils module directly instead. + * Due to the high instability of the methods contained in Utils, we can't + * guarantee them to follow semver. Use them at your own risk! + * @static + * @private + * @type {Object} + * @deprecated since version 1.8 + * @member Utils + * @memberof Popper + */ + + }]); + return Popper; + }(); + + /** + * The `referenceObject` is an object that provides an interface compatible with Popper.js + * and lets you use it as replacement of a real DOM node.
+ * You can use this method to position a popper relatively to a set of coordinates + * in case you don't have a DOM node to use as reference. + * + * ``` + * new Popper(referenceObject, popperNode); + * ``` + * + * NB: This feature isn't supported in Internet Explorer 10 + * @name referenceObject + * @property {Function} data.getBoundingClientRect + * A function that returns a set of coordinates compatible with the native `getBoundingClientRect` method. + * @property {number} data.clientWidth + * An ES6 getter that will return the width of the virtual reference element. + * @property {number} data.clientHeight + * An ES6 getter that will return the height of the virtual reference element. + */ + + + Popper.Utils = (typeof window !== 'undefined' ? window : global).PopperUtils; + Popper.placements = placements; + Popper.Defaults = Defaults; + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): dropdown.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Dropdown = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'dropdown'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.dropdown'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var ESCAPE_KEYCODE = 27; // KeyboardEvent.which value for Escape (Esc) key + + var SPACE_KEYCODE = 32; // KeyboardEvent.which value for space key + + var TAB_KEYCODE = 9; // KeyboardEvent.which value for tab key + + var ARROW_UP_KEYCODE = 38; // KeyboardEvent.which value for up arrow key + + var ARROW_DOWN_KEYCODE = 40; // KeyboardEvent.which value for down arrow key + + var RIGHT_MOUSE_BUTTON_WHICH = 3; // MouseEvent.which value for the right button (assuming a right-handed mouse) + + var REGEXP_KEYDOWN = new RegExp(ARROW_UP_KEYCODE + "|" + ARROW_DOWN_KEYCODE + "|" + ESCAPE_KEYCODE); + var Event = { + HIDE: "hide" + EVENT_KEY, + HIDDEN: "hidden" + EVENT_KEY, + SHOW: "show" + EVENT_KEY, + SHOWN: "shown" + EVENT_KEY, + CLICK: "click" + EVENT_KEY, + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY, + KEYDOWN_DATA_API: "keydown" + EVENT_KEY + DATA_API_KEY, + KEYUP_DATA_API: "keyup" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + DISABLED: 'disabled', + SHOW: 'show', + DROPUP: 'dropup', + DROPRIGHT: 'dropright', + DROPLEFT: 'dropleft', + MENURIGHT: 'dropdown-menu-right', + MENULEFT: 'dropdown-menu-left', + POSITION_STATIC: 'position-static' + }; + var Selector = { + DATA_TOGGLE: '[data-toggle="dropdown"]', + FORM_CHILD: '.dropdown form', + MENU: '.dropdown-menu', + NAVBAR_NAV: '.navbar-nav', + VISIBLE_ITEMS: '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)' + }; + var AttachmentMap = { + TOP: 'top-start', + TOPEND: 'top-end', + BOTTOM: 'bottom-start', + BOTTOMEND: 'bottom-end', + RIGHT: 'right-start', + RIGHTEND: 'right-end', + LEFT: 'left-start', + LEFTEND: 'left-end' + }; + var Default = { + offset: 0, + flip: true, + boundary: 'scrollParent', + reference: 'toggle', + display: 'dynamic' + }; + var DefaultType = { + offset: '(number|string|function)', + flip: 'boolean', + boundary: '(string|element)', + reference: '(string|element)', + display: 'string' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Dropdown = + /*#__PURE__*/ + function () { + function Dropdown(element, config) { + this._element = element; + this._popper = null; + this._config = this._getConfig(config); + this._menu = this._getMenuElement(); + this._inNavbar = this._detectNavbar(); + + this._addEventListeners(); + } // Getters + + + var _proto = Dropdown.prototype; + + // Public + _proto.toggle = function toggle() { + if (this._element.disabled || $$$1(this._element).hasClass(ClassName.DISABLED)) { + return; + } + + var parent = Dropdown._getParentFromElement(this._element); + + var isActive = $$$1(this._menu).hasClass(ClassName.SHOW); + + Dropdown._clearMenus(); + + if (isActive) { + return; + } + + var relatedTarget = { + relatedTarget: this._element + }; + var showEvent = $$$1.Event(Event.SHOW, relatedTarget); + $$$1(parent).trigger(showEvent); + + if (showEvent.isDefaultPrevented()) { + return; + } // Disable totally Popper.js for Dropdown in Navbar + + + if (!this._inNavbar) { + /** + * Check for Popper dependency + * Popper - https://popper.js.org + */ + if (typeof Popper === 'undefined') { + throw new TypeError('Bootstrap dropdown require Popper.js (https://popper.js.org)'); + } + + var referenceElement = this._element; + + if (this._config.reference === 'parent') { + referenceElement = parent; + } else if (Util.isElement(this._config.reference)) { + referenceElement = this._config.reference; // Check if it's jQuery element + + if (typeof this._config.reference.jquery !== 'undefined') { + referenceElement = this._config.reference[0]; + } + } // If boundary is not `scrollParent`, then set position to `static` + // to allow the menu to "escape" the scroll parent's boundaries + // https://github.com/twbs/bootstrap/issues/24251 + + + if (this._config.boundary !== 'scrollParent') { + $$$1(parent).addClass(ClassName.POSITION_STATIC); + } + + this._popper = new Popper(referenceElement, this._menu, this._getPopperConfig()); + } // If this is a touch-enabled device we add extra + // empty mouseover listeners to the body's immediate children; + // only needed because of broken event delegation on iOS + // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html + + + if ('ontouchstart' in document.documentElement && $$$1(parent).closest(Selector.NAVBAR_NAV).length === 0) { + $$$1(document.body).children().on('mouseover', null, $$$1.noop); + } + + this._element.focus(); + + this._element.setAttribute('aria-expanded', true); + + $$$1(this._menu).toggleClass(ClassName.SHOW); + $$$1(parent).toggleClass(ClassName.SHOW).trigger($$$1.Event(Event.SHOWN, relatedTarget)); + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + $$$1(this._element).off(EVENT_KEY); + this._element = null; + this._menu = null; + + if (this._popper !== null) { + this._popper.destroy(); + + this._popper = null; + } + }; + + _proto.update = function update() { + this._inNavbar = this._detectNavbar(); + + if (this._popper !== null) { + this._popper.scheduleUpdate(); + } + }; // Private + + + _proto._addEventListeners = function _addEventListeners() { + var _this = this; + + $$$1(this._element).on(Event.CLICK, function (event) { + event.preventDefault(); + event.stopPropagation(); + + _this.toggle(); + }); + }; + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, this.constructor.Default, $$$1(this._element).data(), config); + Util.typeCheckConfig(NAME, config, this.constructor.DefaultType); + return config; + }; + + _proto._getMenuElement = function _getMenuElement() { + if (!this._menu) { + var parent = Dropdown._getParentFromElement(this._element); + + if (parent) { + this._menu = parent.querySelector(Selector.MENU); + } + } + + return this._menu; + }; + + _proto._getPlacement = function _getPlacement() { + var $parentDropdown = $$$1(this._element.parentNode); + var placement = AttachmentMap.BOTTOM; // Handle dropup + + if ($parentDropdown.hasClass(ClassName.DROPUP)) { + placement = AttachmentMap.TOP; + + if ($$$1(this._menu).hasClass(ClassName.MENURIGHT)) { + placement = AttachmentMap.TOPEND; + } + } else if ($parentDropdown.hasClass(ClassName.DROPRIGHT)) { + placement = AttachmentMap.RIGHT; + } else if ($parentDropdown.hasClass(ClassName.DROPLEFT)) { + placement = AttachmentMap.LEFT; + } else if ($$$1(this._menu).hasClass(ClassName.MENURIGHT)) { + placement = AttachmentMap.BOTTOMEND; + } + + return placement; + }; + + _proto._detectNavbar = function _detectNavbar() { + return $$$1(this._element).closest('.navbar').length > 0; + }; + + _proto._getPopperConfig = function _getPopperConfig() { + var _this2 = this; + + var offsetConf = {}; + + if (typeof this._config.offset === 'function') { + offsetConf.fn = function (data) { + data.offsets = _objectSpread({}, data.offsets, _this2._config.offset(data.offsets) || {}); + return data; + }; + } else { + offsetConf.offset = this._config.offset; + } + + var popperConfig = { + placement: this._getPlacement(), + modifiers: { + offset: offsetConf, + flip: { + enabled: this._config.flip + }, + preventOverflow: { + boundariesElement: this._config.boundary + } + } // Disable Popper.js if we have a static display + + }; + + if (this._config.display === 'static') { + popperConfig.modifiers.applyStyle = { + enabled: false + }; + } + + return popperConfig; + }; // Static + + + Dropdown._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + var _config = typeof config === 'object' ? config : null; + + if (!data) { + data = new Dropdown(this, _config); + $$$1(this).data(DATA_KEY, data); + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError("No method named \"" + config + "\""); + } + + data[config](); + } + }); + }; + + Dropdown._clearMenus = function _clearMenus(event) { + if (event && (event.which === RIGHT_MOUSE_BUTTON_WHICH || event.type === 'keyup' && event.which !== TAB_KEYCODE)) { + return; + } + + var toggles = [].slice.call(document.querySelectorAll(Selector.DATA_TOGGLE)); + + for (var i = 0, len = toggles.length; i < len; i++) { + var parent = Dropdown._getParentFromElement(toggles[i]); + + var context = $$$1(toggles[i]).data(DATA_KEY); + var relatedTarget = { + relatedTarget: toggles[i] + }; + + if (event && event.type === 'click') { + relatedTarget.clickEvent = event; + } + + if (!context) { + continue; + } + + var dropdownMenu = context._menu; + + if (!$$$1(parent).hasClass(ClassName.SHOW)) { + continue; + } + + if (event && (event.type === 'click' && /input|textarea/i.test(event.target.tagName) || event.type === 'keyup' && event.which === TAB_KEYCODE) && $$$1.contains(parent, event.target)) { + continue; + } + + var hideEvent = $$$1.Event(Event.HIDE, relatedTarget); + $$$1(parent).trigger(hideEvent); + + if (hideEvent.isDefaultPrevented()) { + continue; + } // If this is a touch-enabled device we remove the extra + // empty mouseover listeners we added for iOS support + + + if ('ontouchstart' in document.documentElement) { + $$$1(document.body).children().off('mouseover', null, $$$1.noop); + } + + toggles[i].setAttribute('aria-expanded', 'false'); + $$$1(dropdownMenu).removeClass(ClassName.SHOW); + $$$1(parent).removeClass(ClassName.SHOW).trigger($$$1.Event(Event.HIDDEN, relatedTarget)); + } + }; + + Dropdown._getParentFromElement = function _getParentFromElement(element) { + var parent; + var selector = Util.getSelectorFromElement(element); + + if (selector) { + parent = document.querySelector(selector); + } + + return parent || element.parentNode; + }; // eslint-disable-next-line complexity + + + Dropdown._dataApiKeydownHandler = function _dataApiKeydownHandler(event) { + // If not input/textarea: + // - And not a key in REGEXP_KEYDOWN => not a dropdown command + // If input/textarea: + // - If space key => not a dropdown command + // - If key is other than escape + // - If key is not up or down => not a dropdown command + // - If trigger inside the menu => not a dropdown command + if (/input|textarea/i.test(event.target.tagName) ? event.which === SPACE_KEYCODE || event.which !== ESCAPE_KEYCODE && (event.which !== ARROW_DOWN_KEYCODE && event.which !== ARROW_UP_KEYCODE || $$$1(event.target).closest(Selector.MENU).length) : !REGEXP_KEYDOWN.test(event.which)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (this.disabled || $$$1(this).hasClass(ClassName.DISABLED)) { + return; + } + + var parent = Dropdown._getParentFromElement(this); + + var isActive = $$$1(parent).hasClass(ClassName.SHOW); + + if (!isActive && (event.which !== ESCAPE_KEYCODE || event.which !== SPACE_KEYCODE) || isActive && (event.which === ESCAPE_KEYCODE || event.which === SPACE_KEYCODE)) { + if (event.which === ESCAPE_KEYCODE) { + var toggle = parent.querySelector(Selector.DATA_TOGGLE); + $$$1(toggle).trigger('focus'); + } + + $$$1(this).trigger('click'); + return; + } + + var items = [].slice.call(parent.querySelectorAll(Selector.VISIBLE_ITEMS)); + + if (items.length === 0) { + return; + } + + var index = items.indexOf(event.target); + + if (event.which === ARROW_UP_KEYCODE && index > 0) { + // Up + index--; + } + + if (event.which === ARROW_DOWN_KEYCODE && index < items.length - 1) { + // Down + index++; + } + + if (index < 0) { + index = 0; + } + + items[index].focus(); + }; + + _createClass(Dropdown, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }, { + key: "DefaultType", + get: function get() { + return DefaultType; + } + }]); + + return Dropdown; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.KEYDOWN_DATA_API, Selector.DATA_TOGGLE, Dropdown._dataApiKeydownHandler).on(Event.KEYDOWN_DATA_API, Selector.MENU, Dropdown._dataApiKeydownHandler).on(Event.CLICK_DATA_API + " " + Event.KEYUP_DATA_API, Dropdown._clearMenus).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) { + event.preventDefault(); + event.stopPropagation(); + + Dropdown._jQueryInterface.call($$$1(this), 'toggle'); + }).on(Event.CLICK_DATA_API, Selector.FORM_CHILD, function (e) { + e.stopPropagation(); + }); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Dropdown._jQueryInterface; + $$$1.fn[NAME].Constructor = Dropdown; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Dropdown._jQueryInterface; + }; + + return Dropdown; + }($, Popper); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): modal.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Modal = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'modal'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.modal'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var ESCAPE_KEYCODE = 27; // KeyboardEvent.which value for Escape (Esc) key + + var Default = { + backdrop: true, + keyboard: true, + focus: true, + show: true + }; + var DefaultType = { + backdrop: '(boolean|string)', + keyboard: 'boolean', + focus: 'boolean', + show: 'boolean' + }; + var Event = { + HIDE: "hide" + EVENT_KEY, + HIDDEN: "hidden" + EVENT_KEY, + SHOW: "show" + EVENT_KEY, + SHOWN: "shown" + EVENT_KEY, + FOCUSIN: "focusin" + EVENT_KEY, + RESIZE: "resize" + EVENT_KEY, + CLICK_DISMISS: "click.dismiss" + EVENT_KEY, + KEYDOWN_DISMISS: "keydown.dismiss" + EVENT_KEY, + MOUSEUP_DISMISS: "mouseup.dismiss" + EVENT_KEY, + MOUSEDOWN_DISMISS: "mousedown.dismiss" + EVENT_KEY, + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + SCROLLBAR_MEASURER: 'modal-scrollbar-measure', + BACKDROP: 'modal-backdrop', + OPEN: 'modal-open', + FADE: 'fade', + SHOW: 'show' + }; + var Selector = { + DIALOG: '.modal-dialog', + DATA_TOGGLE: '[data-toggle="modal"]', + DATA_DISMISS: '[data-dismiss="modal"]', + FIXED_CONTENT: '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top', + STICKY_CONTENT: '.sticky-top' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Modal = + /*#__PURE__*/ + function () { + function Modal(element, config) { + this._config = this._getConfig(config); + this._element = element; + this._dialog = element.querySelector(Selector.DIALOG); + this._backdrop = null; + this._isShown = false; + this._isBodyOverflowing = false; + this._ignoreBackdropClick = false; + this._scrollbarWidth = 0; + } // Getters + + + var _proto = Modal.prototype; + + // Public + _proto.toggle = function toggle(relatedTarget) { + return this._isShown ? this.hide() : this.show(relatedTarget); + }; + + _proto.show = function show(relatedTarget) { + var _this = this; + + if (this._isTransitioning || this._isShown) { + return; + } + + if ($$$1(this._element).hasClass(ClassName.FADE)) { + this._isTransitioning = true; + } + + var showEvent = $$$1.Event(Event.SHOW, { + relatedTarget: relatedTarget + }); + $$$1(this._element).trigger(showEvent); + + if (this._isShown || showEvent.isDefaultPrevented()) { + return; + } + + this._isShown = true; + + this._checkScrollbar(); + + this._setScrollbar(); + + this._adjustDialog(); + + $$$1(document.body).addClass(ClassName.OPEN); + + this._setEscapeEvent(); + + this._setResizeEvent(); + + $$$1(this._element).on(Event.CLICK_DISMISS, Selector.DATA_DISMISS, function (event) { + return _this.hide(event); + }); + $$$1(this._dialog).on(Event.MOUSEDOWN_DISMISS, function () { + $$$1(_this._element).one(Event.MOUSEUP_DISMISS, function (event) { + if ($$$1(event.target).is(_this._element)) { + _this._ignoreBackdropClick = true; + } + }); + }); + + this._showBackdrop(function () { + return _this._showElement(relatedTarget); + }); + }; + + _proto.hide = function hide(event) { + var _this2 = this; + + if (event) { + event.preventDefault(); + } + + if (this._isTransitioning || !this._isShown) { + return; + } + + var hideEvent = $$$1.Event(Event.HIDE); + $$$1(this._element).trigger(hideEvent); + + if (!this._isShown || hideEvent.isDefaultPrevented()) { + return; + } + + this._isShown = false; + var transition = $$$1(this._element).hasClass(ClassName.FADE); + + if (transition) { + this._isTransitioning = true; + } + + this._setEscapeEvent(); + + this._setResizeEvent(); + + $$$1(document).off(Event.FOCUSIN); + $$$1(this._element).removeClass(ClassName.SHOW); + $$$1(this._element).off(Event.CLICK_DISMISS); + $$$1(this._dialog).off(Event.MOUSEDOWN_DISMISS); + + if (transition) { + var transitionDuration = Util.getTransitionDurationFromElement(this._element); + $$$1(this._element).one(Util.TRANSITION_END, function (event) { + return _this2._hideModal(event); + }).emulateTransitionEnd(transitionDuration); + } else { + this._hideModal(); + } + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + $$$1(window, document, this._element, this._backdrop).off(EVENT_KEY); + this._config = null; + this._element = null; + this._dialog = null; + this._backdrop = null; + this._isShown = null; + this._isBodyOverflowing = null; + this._ignoreBackdropClick = null; + this._scrollbarWidth = null; + }; + + _proto.handleUpdate = function handleUpdate() { + this._adjustDialog(); + }; // Private + + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, Default, config); + Util.typeCheckConfig(NAME, config, DefaultType); + return config; + }; + + _proto._showElement = function _showElement(relatedTarget) { + var _this3 = this; + + var transition = $$$1(this._element).hasClass(ClassName.FADE); + + if (!this._element.parentNode || this._element.parentNode.nodeType !== Node.ELEMENT_NODE) { + // Don't move modal's DOM position + document.body.appendChild(this._element); + } + + this._element.style.display = 'block'; + + this._element.removeAttribute('aria-hidden'); + + this._element.scrollTop = 0; + + if (transition) { + Util.reflow(this._element); + } + + $$$1(this._element).addClass(ClassName.SHOW); + + if (this._config.focus) { + this._enforceFocus(); + } + + var shownEvent = $$$1.Event(Event.SHOWN, { + relatedTarget: relatedTarget + }); + + var transitionComplete = function transitionComplete() { + if (_this3._config.focus) { + _this3._element.focus(); + } + + _this3._isTransitioning = false; + $$$1(_this3._element).trigger(shownEvent); + }; + + if (transition) { + var transitionDuration = Util.getTransitionDurationFromElement(this._element); + $$$1(this._dialog).one(Util.TRANSITION_END, transitionComplete).emulateTransitionEnd(transitionDuration); + } else { + transitionComplete(); + } + }; + + _proto._enforceFocus = function _enforceFocus() { + var _this4 = this; + + $$$1(document).off(Event.FOCUSIN) // Guard against infinite focus loop + .on(Event.FOCUSIN, function (event) { + if (document !== event.target && _this4._element !== event.target && $$$1(_this4._element).has(event.target).length === 0) { + _this4._element.focus(); + } + }); + }; + + _proto._setEscapeEvent = function _setEscapeEvent() { + var _this5 = this; + + if (this._isShown && this._config.keyboard) { + $$$1(this._element).on(Event.KEYDOWN_DISMISS, function (event) { + if (event.which === ESCAPE_KEYCODE) { + event.preventDefault(); + + _this5.hide(); + } + }); + } else if (!this._isShown) { + $$$1(this._element).off(Event.KEYDOWN_DISMISS); + } + }; + + _proto._setResizeEvent = function _setResizeEvent() { + var _this6 = this; + + if (this._isShown) { + $$$1(window).on(Event.RESIZE, function (event) { + return _this6.handleUpdate(event); + }); + } else { + $$$1(window).off(Event.RESIZE); + } + }; + + _proto._hideModal = function _hideModal() { + var _this7 = this; + + this._element.style.display = 'none'; + + this._element.setAttribute('aria-hidden', true); + + this._isTransitioning = false; + + this._showBackdrop(function () { + $$$1(document.body).removeClass(ClassName.OPEN); + + _this7._resetAdjustments(); + + _this7._resetScrollbar(); + + $$$1(_this7._element).trigger(Event.HIDDEN); + }); + }; + + _proto._removeBackdrop = function _removeBackdrop() { + if (this._backdrop) { + $$$1(this._backdrop).remove(); + this._backdrop = null; + } + }; + + _proto._showBackdrop = function _showBackdrop(callback) { + var _this8 = this; + + var animate = $$$1(this._element).hasClass(ClassName.FADE) ? ClassName.FADE : ''; + + if (this._isShown && this._config.backdrop) { + this._backdrop = document.createElement('div'); + this._backdrop.className = ClassName.BACKDROP; + + if (animate) { + this._backdrop.classList.add(animate); + } + + $$$1(this._backdrop).appendTo(document.body); + $$$1(this._element).on(Event.CLICK_DISMISS, function (event) { + if (_this8._ignoreBackdropClick) { + _this8._ignoreBackdropClick = false; + return; + } + + if (event.target !== event.currentTarget) { + return; + } + + if (_this8._config.backdrop === 'static') { + _this8._element.focus(); + } else { + _this8.hide(); + } + }); + + if (animate) { + Util.reflow(this._backdrop); + } + + $$$1(this._backdrop).addClass(ClassName.SHOW); + + if (!callback) { + return; + } + + if (!animate) { + callback(); + return; + } + + var backdropTransitionDuration = Util.getTransitionDurationFromElement(this._backdrop); + $$$1(this._backdrop).one(Util.TRANSITION_END, callback).emulateTransitionEnd(backdropTransitionDuration); + } else if (!this._isShown && this._backdrop) { + $$$1(this._backdrop).removeClass(ClassName.SHOW); + + var callbackRemove = function callbackRemove() { + _this8._removeBackdrop(); + + if (callback) { + callback(); + } + }; + + if ($$$1(this._element).hasClass(ClassName.FADE)) { + var _backdropTransitionDuration = Util.getTransitionDurationFromElement(this._backdrop); + + $$$1(this._backdrop).one(Util.TRANSITION_END, callbackRemove).emulateTransitionEnd(_backdropTransitionDuration); + } else { + callbackRemove(); + } + } else if (callback) { + callback(); + } + }; // ---------------------------------------------------------------------- + // the following methods are used to handle overflowing modals + // todo (fat): these should probably be refactored out of modal.js + // ---------------------------------------------------------------------- + + + _proto._adjustDialog = function _adjustDialog() { + var isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight; + + if (!this._isBodyOverflowing && isModalOverflowing) { + this._element.style.paddingLeft = this._scrollbarWidth + "px"; + } + + if (this._isBodyOverflowing && !isModalOverflowing) { + this._element.style.paddingRight = this._scrollbarWidth + "px"; + } + }; + + _proto._resetAdjustments = function _resetAdjustments() { + this._element.style.paddingLeft = ''; + this._element.style.paddingRight = ''; + }; + + _proto._checkScrollbar = function _checkScrollbar() { + var rect = document.body.getBoundingClientRect(); + this._isBodyOverflowing = rect.left + rect.right < window.innerWidth; + this._scrollbarWidth = this._getScrollbarWidth(); + }; + + _proto._setScrollbar = function _setScrollbar() { + var _this9 = this; + + if (this._isBodyOverflowing) { + // Note: DOMNode.style.paddingRight returns the actual value or '' if not set + // while $(DOMNode).css('padding-right') returns the calculated value or 0 if not set + var fixedContent = [].slice.call(document.querySelectorAll(Selector.FIXED_CONTENT)); + var stickyContent = [].slice.call(document.querySelectorAll(Selector.STICKY_CONTENT)); // Adjust fixed content padding + + $$$1(fixedContent).each(function (index, element) { + var actualPadding = element.style.paddingRight; + var calculatedPadding = $$$1(element).css('padding-right'); + $$$1(element).data('padding-right', actualPadding).css('padding-right', parseFloat(calculatedPadding) + _this9._scrollbarWidth + "px"); + }); // Adjust sticky content margin + + $$$1(stickyContent).each(function (index, element) { + var actualMargin = element.style.marginRight; + var calculatedMargin = $$$1(element).css('margin-right'); + $$$1(element).data('margin-right', actualMargin).css('margin-right', parseFloat(calculatedMargin) - _this9._scrollbarWidth + "px"); + }); // Adjust body padding + + var actualPadding = document.body.style.paddingRight; + var calculatedPadding = $$$1(document.body).css('padding-right'); + $$$1(document.body).data('padding-right', actualPadding).css('padding-right', parseFloat(calculatedPadding) + this._scrollbarWidth + "px"); + } + }; + + _proto._resetScrollbar = function _resetScrollbar() { + // Restore fixed content padding + var fixedContent = [].slice.call(document.querySelectorAll(Selector.FIXED_CONTENT)); + $$$1(fixedContent).each(function (index, element) { + var padding = $$$1(element).data('padding-right'); + $$$1(element).removeData('padding-right'); + element.style.paddingRight = padding ? padding : ''; + }); // Restore sticky content + + var elements = [].slice.call(document.querySelectorAll("" + Selector.STICKY_CONTENT)); + $$$1(elements).each(function (index, element) { + var margin = $$$1(element).data('margin-right'); + + if (typeof margin !== 'undefined') { + $$$1(element).css('margin-right', margin).removeData('margin-right'); + } + }); // Restore body padding + + var padding = $$$1(document.body).data('padding-right'); + $$$1(document.body).removeData('padding-right'); + document.body.style.paddingRight = padding ? padding : ''; + }; + + _proto._getScrollbarWidth = function _getScrollbarWidth() { + // thx d.walsh + var scrollDiv = document.createElement('div'); + scrollDiv.className = ClassName.SCROLLBAR_MEASURER; + document.body.appendChild(scrollDiv); + var scrollbarWidth = scrollDiv.getBoundingClientRect().width - scrollDiv.clientWidth; + document.body.removeChild(scrollDiv); + return scrollbarWidth; + }; // Static + + + Modal._jQueryInterface = function _jQueryInterface(config, relatedTarget) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + var _config = _objectSpread({}, Default, $$$1(this).data(), typeof config === 'object' && config ? config : {}); + + if (!data) { + data = new Modal(this, _config); + $$$1(this).data(DATA_KEY, data); + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError("No method named \"" + config + "\""); + } + + data[config](relatedTarget); + } else if (_config.show) { + data.show(relatedTarget); + } + }); + }; + + _createClass(Modal, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }]); + + return Modal; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) { + var _this10 = this; + + var target; + var selector = Util.getSelectorFromElement(this); + + if (selector) { + target = document.querySelector(selector); + } + + var config = $$$1(target).data(DATA_KEY) ? 'toggle' : _objectSpread({}, $$$1(target).data(), $$$1(this).data()); + + if (this.tagName === 'A' || this.tagName === 'AREA') { + event.preventDefault(); + } + + var $target = $$$1(target).one(Event.SHOW, function (showEvent) { + if (showEvent.isDefaultPrevented()) { + // Only register focus restorer if modal will actually get shown + return; + } + + $target.one(Event.HIDDEN, function () { + if ($$$1(_this10).is(':visible')) { + _this10.focus(); + } + }); + }); + + Modal._jQueryInterface.call($$$1(target), config, this); + }); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Modal._jQueryInterface; + $$$1.fn[NAME].Constructor = Modal; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Modal._jQueryInterface; + }; + + return Modal; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): tooltip.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Tooltip = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'tooltip'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.tooltip'; + var EVENT_KEY = "." + DATA_KEY; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var CLASS_PREFIX = 'bs-tooltip'; + var BSCLS_PREFIX_REGEX = new RegExp("(^|\\s)" + CLASS_PREFIX + "\\S+", 'g'); + var DefaultType = { + animation: 'boolean', + template: 'string', + title: '(string|element|function)', + trigger: 'string', + delay: '(number|object)', + html: 'boolean', + selector: '(string|boolean)', + placement: '(string|function)', + offset: '(number|string)', + container: '(string|element|boolean)', + fallbackPlacement: '(string|array)', + boundary: '(string|element)' + }; + var AttachmentMap = { + AUTO: 'auto', + TOP: 'top', + RIGHT: 'right', + BOTTOM: 'bottom', + LEFT: 'left' + }; + var Default = { + animation: true, + template: '', + trigger: 'hover focus', + title: '', + delay: 0, + html: false, + selector: false, + placement: 'top', + offset: 0, + container: false, + fallbackPlacement: 'flip', + boundary: 'scrollParent' + }; + var HoverState = { + SHOW: 'show', + OUT: 'out' + }; + var Event = { + HIDE: "hide" + EVENT_KEY, + HIDDEN: "hidden" + EVENT_KEY, + SHOW: "show" + EVENT_KEY, + SHOWN: "shown" + EVENT_KEY, + INSERTED: "inserted" + EVENT_KEY, + CLICK: "click" + EVENT_KEY, + FOCUSIN: "focusin" + EVENT_KEY, + FOCUSOUT: "focusout" + EVENT_KEY, + MOUSEENTER: "mouseenter" + EVENT_KEY, + MOUSELEAVE: "mouseleave" + EVENT_KEY + }; + var ClassName = { + FADE: 'fade', + SHOW: 'show' + }; + var Selector = { + TOOLTIP: '.tooltip', + TOOLTIP_INNER: '.tooltip-inner', + ARROW: '.arrow' + }; + var Trigger = { + HOVER: 'hover', + FOCUS: 'focus', + CLICK: 'click', + MANUAL: 'manual' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Tooltip = + /*#__PURE__*/ + function () { + function Tooltip(element, config) { + /** + * Check for Popper dependency + * Popper - https://popper.js.org + */ + if (typeof Popper === 'undefined') { + throw new TypeError('Bootstrap tooltips require Popper.js (https://popper.js.org)'); + } // private + + + this._isEnabled = true; + this._timeout = 0; + this._hoverState = ''; + this._activeTrigger = {}; + this._popper = null; // Protected + + this.element = element; + this.config = this._getConfig(config); + this.tip = null; + + this._setListeners(); + } // Getters + + + var _proto = Tooltip.prototype; + + // Public + _proto.enable = function enable() { + this._isEnabled = true; + }; + + _proto.disable = function disable() { + this._isEnabled = false; + }; + + _proto.toggleEnabled = function toggleEnabled() { + this._isEnabled = !this._isEnabled; + }; + + _proto.toggle = function toggle(event) { + if (!this._isEnabled) { + return; + } + + if (event) { + var dataKey = this.constructor.DATA_KEY; + var context = $$$1(event.currentTarget).data(dataKey); + + if (!context) { + context = new this.constructor(event.currentTarget, this._getDelegateConfig()); + $$$1(event.currentTarget).data(dataKey, context); + } + + context._activeTrigger.click = !context._activeTrigger.click; + + if (context._isWithActiveTrigger()) { + context._enter(null, context); + } else { + context._leave(null, context); + } + } else { + if ($$$1(this.getTipElement()).hasClass(ClassName.SHOW)) { + this._leave(null, this); + + return; + } + + this._enter(null, this); + } + }; + + _proto.dispose = function dispose() { + clearTimeout(this._timeout); + $$$1.removeData(this.element, this.constructor.DATA_KEY); + $$$1(this.element).off(this.constructor.EVENT_KEY); + $$$1(this.element).closest('.modal').off('hide.bs.modal'); + + if (this.tip) { + $$$1(this.tip).remove(); + } + + this._isEnabled = null; + this._timeout = null; + this._hoverState = null; + this._activeTrigger = null; + + if (this._popper !== null) { + this._popper.destroy(); + } + + this._popper = null; + this.element = null; + this.config = null; + this.tip = null; + }; + + _proto.show = function show() { + var _this = this; + + if ($$$1(this.element).css('display') === 'none') { + throw new Error('Please use show on visible elements'); + } + + var showEvent = $$$1.Event(this.constructor.Event.SHOW); + + if (this.isWithContent() && this._isEnabled) { + $$$1(this.element).trigger(showEvent); + var isInTheDom = $$$1.contains(this.element.ownerDocument.documentElement, this.element); + + if (showEvent.isDefaultPrevented() || !isInTheDom) { + return; + } + + var tip = this.getTipElement(); + var tipId = Util.getUID(this.constructor.NAME); + tip.setAttribute('id', tipId); + this.element.setAttribute('aria-describedby', tipId); + this.setContent(); + + if (this.config.animation) { + $$$1(tip).addClass(ClassName.FADE); + } + + var placement = typeof this.config.placement === 'function' ? this.config.placement.call(this, tip, this.element) : this.config.placement; + + var attachment = this._getAttachment(placement); + + this.addAttachmentClass(attachment); + var container = this.config.container === false ? document.body : $$$1(document).find(this.config.container); + $$$1(tip).data(this.constructor.DATA_KEY, this); + + if (!$$$1.contains(this.element.ownerDocument.documentElement, this.tip)) { + $$$1(tip).appendTo(container); + } + + $$$1(this.element).trigger(this.constructor.Event.INSERTED); + this._popper = new Popper(this.element, tip, { + placement: attachment, + modifiers: { + offset: { + offset: this.config.offset + }, + flip: { + behavior: this.config.fallbackPlacement + }, + arrow: { + element: Selector.ARROW + }, + preventOverflow: { + boundariesElement: this.config.boundary + } + }, + onCreate: function onCreate(data) { + if (data.originalPlacement !== data.placement) { + _this._handlePopperPlacementChange(data); + } + }, + onUpdate: function onUpdate(data) { + _this._handlePopperPlacementChange(data); + } + }); + $$$1(tip).addClass(ClassName.SHOW); // If this is a touch-enabled device we add extra + // empty mouseover listeners to the body's immediate children; + // only needed because of broken event delegation on iOS + // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html + + if ('ontouchstart' in document.documentElement) { + $$$1(document.body).children().on('mouseover', null, $$$1.noop); + } + + var complete = function complete() { + if (_this.config.animation) { + _this._fixTransition(); + } + + var prevHoverState = _this._hoverState; + _this._hoverState = null; + $$$1(_this.element).trigger(_this.constructor.Event.SHOWN); + + if (prevHoverState === HoverState.OUT) { + _this._leave(null, _this); + } + }; + + if ($$$1(this.tip).hasClass(ClassName.FADE)) { + var transitionDuration = Util.getTransitionDurationFromElement(this.tip); + $$$1(this.tip).one(Util.TRANSITION_END, complete).emulateTransitionEnd(transitionDuration); + } else { + complete(); + } + } + }; + + _proto.hide = function hide(callback) { + var _this2 = this; + + var tip = this.getTipElement(); + var hideEvent = $$$1.Event(this.constructor.Event.HIDE); + + var complete = function complete() { + if (_this2._hoverState !== HoverState.SHOW && tip.parentNode) { + tip.parentNode.removeChild(tip); + } + + _this2._cleanTipClass(); + + _this2.element.removeAttribute('aria-describedby'); + + $$$1(_this2.element).trigger(_this2.constructor.Event.HIDDEN); + + if (_this2._popper !== null) { + _this2._popper.destroy(); + } + + if (callback) { + callback(); + } + }; + + $$$1(this.element).trigger(hideEvent); + + if (hideEvent.isDefaultPrevented()) { + return; + } + + $$$1(tip).removeClass(ClassName.SHOW); // If this is a touch-enabled device we remove the extra + // empty mouseover listeners we added for iOS support + + if ('ontouchstart' in document.documentElement) { + $$$1(document.body).children().off('mouseover', null, $$$1.noop); + } + + this._activeTrigger[Trigger.CLICK] = false; + this._activeTrigger[Trigger.FOCUS] = false; + this._activeTrigger[Trigger.HOVER] = false; + + if ($$$1(this.tip).hasClass(ClassName.FADE)) { + var transitionDuration = Util.getTransitionDurationFromElement(tip); + $$$1(tip).one(Util.TRANSITION_END, complete).emulateTransitionEnd(transitionDuration); + } else { + complete(); + } + + this._hoverState = ''; + }; + + _proto.update = function update() { + if (this._popper !== null) { + this._popper.scheduleUpdate(); + } + }; // Protected + + + _proto.isWithContent = function isWithContent() { + return Boolean(this.getTitle()); + }; + + _proto.addAttachmentClass = function addAttachmentClass(attachment) { + $$$1(this.getTipElement()).addClass(CLASS_PREFIX + "-" + attachment); + }; + + _proto.getTipElement = function getTipElement() { + this.tip = this.tip || $$$1(this.config.template)[0]; + return this.tip; + }; + + _proto.setContent = function setContent() { + var tip = this.getTipElement(); + this.setElementContent($$$1(tip.querySelectorAll(Selector.TOOLTIP_INNER)), this.getTitle()); + $$$1(tip).removeClass(ClassName.FADE + " " + ClassName.SHOW); + }; + + _proto.setElementContent = function setElementContent($element, content) { + var html = this.config.html; + + if (typeof content === 'object' && (content.nodeType || content.jquery)) { + // Content is a DOM node or a jQuery + if (html) { + if (!$$$1(content).parent().is($element)) { + $element.empty().append(content); + } + } else { + $element.text($$$1(content).text()); + } + } else { + $element[html ? 'html' : 'text'](content); + } + }; + + _proto.getTitle = function getTitle() { + var title = this.element.getAttribute('data-original-title'); + + if (!title) { + title = typeof this.config.title === 'function' ? this.config.title.call(this.element) : this.config.title; + } + + return title; + }; // Private + + + _proto._getAttachment = function _getAttachment(placement) { + return AttachmentMap[placement.toUpperCase()]; + }; + + _proto._setListeners = function _setListeners() { + var _this3 = this; + + var triggers = this.config.trigger.split(' '); + triggers.forEach(function (trigger) { + if (trigger === 'click') { + $$$1(_this3.element).on(_this3.constructor.Event.CLICK, _this3.config.selector, function (event) { + return _this3.toggle(event); + }); + } else if (trigger !== Trigger.MANUAL) { + var eventIn = trigger === Trigger.HOVER ? _this3.constructor.Event.MOUSEENTER : _this3.constructor.Event.FOCUSIN; + var eventOut = trigger === Trigger.HOVER ? _this3.constructor.Event.MOUSELEAVE : _this3.constructor.Event.FOCUSOUT; + $$$1(_this3.element).on(eventIn, _this3.config.selector, function (event) { + return _this3._enter(event); + }).on(eventOut, _this3.config.selector, function (event) { + return _this3._leave(event); + }); + } + + $$$1(_this3.element).closest('.modal').on('hide.bs.modal', function () { + return _this3.hide(); + }); + }); + + if (this.config.selector) { + this.config = _objectSpread({}, this.config, { + trigger: 'manual', + selector: '' + }); + } else { + this._fixTitle(); + } + }; + + _proto._fixTitle = function _fixTitle() { + var titleType = typeof this.element.getAttribute('data-original-title'); + + if (this.element.getAttribute('title') || titleType !== 'string') { + this.element.setAttribute('data-original-title', this.element.getAttribute('title') || ''); + this.element.setAttribute('title', ''); + } + }; + + _proto._enter = function _enter(event, context) { + var dataKey = this.constructor.DATA_KEY; + context = context || $$$1(event.currentTarget).data(dataKey); + + if (!context) { + context = new this.constructor(event.currentTarget, this._getDelegateConfig()); + $$$1(event.currentTarget).data(dataKey, context); + } + + if (event) { + context._activeTrigger[event.type === 'focusin' ? Trigger.FOCUS : Trigger.HOVER] = true; + } + + if ($$$1(context.getTipElement()).hasClass(ClassName.SHOW) || context._hoverState === HoverState.SHOW) { + context._hoverState = HoverState.SHOW; + return; + } + + clearTimeout(context._timeout); + context._hoverState = HoverState.SHOW; + + if (!context.config.delay || !context.config.delay.show) { + context.show(); + return; + } + + context._timeout = setTimeout(function () { + if (context._hoverState === HoverState.SHOW) { + context.show(); + } + }, context.config.delay.show); + }; + + _proto._leave = function _leave(event, context) { + var dataKey = this.constructor.DATA_KEY; + context = context || $$$1(event.currentTarget).data(dataKey); + + if (!context) { + context = new this.constructor(event.currentTarget, this._getDelegateConfig()); + $$$1(event.currentTarget).data(dataKey, context); + } + + if (event) { + context._activeTrigger[event.type === 'focusout' ? Trigger.FOCUS : Trigger.HOVER] = false; + } + + if (context._isWithActiveTrigger()) { + return; + } + + clearTimeout(context._timeout); + context._hoverState = HoverState.OUT; + + if (!context.config.delay || !context.config.delay.hide) { + context.hide(); + return; + } + + context._timeout = setTimeout(function () { + if (context._hoverState === HoverState.OUT) { + context.hide(); + } + }, context.config.delay.hide); + }; + + _proto._isWithActiveTrigger = function _isWithActiveTrigger() { + for (var trigger in this._activeTrigger) { + if (this._activeTrigger[trigger]) { + return true; + } + } + + return false; + }; + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, this.constructor.Default, $$$1(this.element).data(), typeof config === 'object' && config ? config : {}); + + if (typeof config.delay === 'number') { + config.delay = { + show: config.delay, + hide: config.delay + }; + } + + if (typeof config.title === 'number') { + config.title = config.title.toString(); + } + + if (typeof config.content === 'number') { + config.content = config.content.toString(); + } + + Util.typeCheckConfig(NAME, config, this.constructor.DefaultType); + return config; + }; + + _proto._getDelegateConfig = function _getDelegateConfig() { + var config = {}; + + if (this.config) { + for (var key in this.config) { + if (this.constructor.Default[key] !== this.config[key]) { + config[key] = this.config[key]; + } + } + } + + return config; + }; + + _proto._cleanTipClass = function _cleanTipClass() { + var $tip = $$$1(this.getTipElement()); + var tabClass = $tip.attr('class').match(BSCLS_PREFIX_REGEX); + + if (tabClass !== null && tabClass.length) { + $tip.removeClass(tabClass.join('')); + } + }; + + _proto._handlePopperPlacementChange = function _handlePopperPlacementChange(popperData) { + var popperInstance = popperData.instance; + this.tip = popperInstance.popper; + + this._cleanTipClass(); + + this.addAttachmentClass(this._getAttachment(popperData.placement)); + }; + + _proto._fixTransition = function _fixTransition() { + var tip = this.getTipElement(); + var initConfigAnimation = this.config.animation; + + if (tip.getAttribute('x-placement') !== null) { + return; + } + + $$$1(tip).removeClass(ClassName.FADE); + this.config.animation = false; + this.hide(); + this.show(); + this.config.animation = initConfigAnimation; + }; // Static + + + Tooltip._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + var _config = typeof config === 'object' && config; + + if (!data && /dispose|hide/.test(config)) { + return; + } + + if (!data) { + data = new Tooltip(this, _config); + $$$1(this).data(DATA_KEY, data); + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError("No method named \"" + config + "\""); + } + + data[config](); + } + }); + }; + + _createClass(Tooltip, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }, { + key: "NAME", + get: function get() { + return NAME; + } + }, { + key: "DATA_KEY", + get: function get() { + return DATA_KEY; + } + }, { + key: "Event", + get: function get() { + return Event; + } + }, { + key: "EVENT_KEY", + get: function get() { + return EVENT_KEY; + } + }, { + key: "DefaultType", + get: function get() { + return DefaultType; + } + }]); + + return Tooltip; + }(); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + + $$$1.fn[NAME] = Tooltip._jQueryInterface; + $$$1.fn[NAME].Constructor = Tooltip; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Tooltip._jQueryInterface; + }; + + return Tooltip; + }($, Popper); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): popover.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Popover = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'popover'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.popover'; + var EVENT_KEY = "." + DATA_KEY; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var CLASS_PREFIX = 'bs-popover'; + var BSCLS_PREFIX_REGEX = new RegExp("(^|\\s)" + CLASS_PREFIX + "\\S+", 'g'); + + var Default = _objectSpread({}, Tooltip.Default, { + placement: 'right', + trigger: 'click', + content: '', + template: '' + }); + + var DefaultType = _objectSpread({}, Tooltip.DefaultType, { + content: '(string|element|function)' + }); + + var ClassName = { + FADE: 'fade', + SHOW: 'show' + }; + var Selector = { + TITLE: '.popover-header', + CONTENT: '.popover-body' + }; + var Event = { + HIDE: "hide" + EVENT_KEY, + HIDDEN: "hidden" + EVENT_KEY, + SHOW: "show" + EVENT_KEY, + SHOWN: "shown" + EVENT_KEY, + INSERTED: "inserted" + EVENT_KEY, + CLICK: "click" + EVENT_KEY, + FOCUSIN: "focusin" + EVENT_KEY, + FOCUSOUT: "focusout" + EVENT_KEY, + MOUSEENTER: "mouseenter" + EVENT_KEY, + MOUSELEAVE: "mouseleave" + EVENT_KEY + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Popover = + /*#__PURE__*/ + function (_Tooltip) { + _inheritsLoose(Popover, _Tooltip); + + function Popover() { + return _Tooltip.apply(this, arguments) || this; + } + + var _proto = Popover.prototype; + + // Overrides + _proto.isWithContent = function isWithContent() { + return this.getTitle() || this._getContent(); + }; + + _proto.addAttachmentClass = function addAttachmentClass(attachment) { + $$$1(this.getTipElement()).addClass(CLASS_PREFIX + "-" + attachment); + }; + + _proto.getTipElement = function getTipElement() { + this.tip = this.tip || $$$1(this.config.template)[0]; + return this.tip; + }; + + _proto.setContent = function setContent() { + var $tip = $$$1(this.getTipElement()); // We use append for html objects to maintain js events + + this.setElementContent($tip.find(Selector.TITLE), this.getTitle()); + + var content = this._getContent(); + + if (typeof content === 'function') { + content = content.call(this.element); + } + + this.setElementContent($tip.find(Selector.CONTENT), content); + $tip.removeClass(ClassName.FADE + " " + ClassName.SHOW); + }; // Private + + + _proto._getContent = function _getContent() { + return this.element.getAttribute('data-content') || this.config.content; + }; + + _proto._cleanTipClass = function _cleanTipClass() { + var $tip = $$$1(this.getTipElement()); + var tabClass = $tip.attr('class').match(BSCLS_PREFIX_REGEX); + + if (tabClass !== null && tabClass.length > 0) { + $tip.removeClass(tabClass.join('')); + } + }; // Static + + + Popover._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + var _config = typeof config === 'object' ? config : null; + + if (!data && /destroy|hide/.test(config)) { + return; + } + + if (!data) { + data = new Popover(this, _config); + $$$1(this).data(DATA_KEY, data); + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError("No method named \"" + config + "\""); + } + + data[config](); + } + }); + }; + + _createClass(Popover, null, [{ + key: "VERSION", + // Getters + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }, { + key: "NAME", + get: function get() { + return NAME; + } + }, { + key: "DATA_KEY", + get: function get() { + return DATA_KEY; + } + }, { + key: "Event", + get: function get() { + return Event; + } + }, { + key: "EVENT_KEY", + get: function get() { + return EVENT_KEY; + } + }, { + key: "DefaultType", + get: function get() { + return DefaultType; + } + }]); + + return Popover; + }(Tooltip); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + + $$$1.fn[NAME] = Popover._jQueryInterface; + $$$1.fn[NAME].Constructor = Popover; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Popover._jQueryInterface; + }; + + return Popover; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): scrollspy.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var ScrollSpy = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'scrollspy'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.scrollspy'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var Default = { + offset: 10, + method: 'auto', + target: '' + }; + var DefaultType = { + offset: 'number', + method: 'string', + target: '(string|element)' + }; + var Event = { + ACTIVATE: "activate" + EVENT_KEY, + SCROLL: "scroll" + EVENT_KEY, + LOAD_DATA_API: "load" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + DROPDOWN_ITEM: 'dropdown-item', + DROPDOWN_MENU: 'dropdown-menu', + ACTIVE: 'active' + }; + var Selector = { + DATA_SPY: '[data-spy="scroll"]', + ACTIVE: '.active', + NAV_LIST_GROUP: '.nav, .list-group', + NAV_LINKS: '.nav-link', + NAV_ITEMS: '.nav-item', + LIST_ITEMS: '.list-group-item', + DROPDOWN: '.dropdown', + DROPDOWN_ITEMS: '.dropdown-item', + DROPDOWN_TOGGLE: '.dropdown-toggle' + }; + var OffsetMethod = { + OFFSET: 'offset', + POSITION: 'position' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var ScrollSpy = + /*#__PURE__*/ + function () { + function ScrollSpy(element, config) { + var _this = this; + + this._element = element; + this._scrollElement = element.tagName === 'BODY' ? window : element; + this._config = this._getConfig(config); + this._selector = this._config.target + " " + Selector.NAV_LINKS + "," + (this._config.target + " " + Selector.LIST_ITEMS + ",") + (this._config.target + " " + Selector.DROPDOWN_ITEMS); + this._offsets = []; + this._targets = []; + this._activeTarget = null; + this._scrollHeight = 0; + $$$1(this._scrollElement).on(Event.SCROLL, function (event) { + return _this._process(event); + }); + this.refresh(); + + this._process(); + } // Getters + + + var _proto = ScrollSpy.prototype; + + // Public + _proto.refresh = function refresh() { + var _this2 = this; + + var autoMethod = this._scrollElement === this._scrollElement.window ? OffsetMethod.OFFSET : OffsetMethod.POSITION; + var offsetMethod = this._config.method === 'auto' ? autoMethod : this._config.method; + var offsetBase = offsetMethod === OffsetMethod.POSITION ? this._getScrollTop() : 0; + this._offsets = []; + this._targets = []; + this._scrollHeight = this._getScrollHeight(); + var targets = [].slice.call(document.querySelectorAll(this._selector)); + targets.map(function (element) { + var target; + var targetSelector = Util.getSelectorFromElement(element); + + if (targetSelector) { + target = document.querySelector(targetSelector); + } + + if (target) { + var targetBCR = target.getBoundingClientRect(); + + if (targetBCR.width || targetBCR.height) { + // TODO (fat): remove sketch reliance on jQuery position/offset + return [$$$1(target)[offsetMethod]().top + offsetBase, targetSelector]; + } + } + + return null; + }).filter(function (item) { + return item; + }).sort(function (a, b) { + return a[0] - b[0]; + }).forEach(function (item) { + _this2._offsets.push(item[0]); + + _this2._targets.push(item[1]); + }); + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + $$$1(this._scrollElement).off(EVENT_KEY); + this._element = null; + this._scrollElement = null; + this._config = null; + this._selector = null; + this._offsets = null; + this._targets = null; + this._activeTarget = null; + this._scrollHeight = null; + }; // Private + + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, Default, typeof config === 'object' && config ? config : {}); + + if (typeof config.target !== 'string') { + var id = $$$1(config.target).attr('id'); + + if (!id) { + id = Util.getUID(NAME); + $$$1(config.target).attr('id', id); + } + + config.target = "#" + id; + } + + Util.typeCheckConfig(NAME, config, DefaultType); + return config; + }; + + _proto._getScrollTop = function _getScrollTop() { + return this._scrollElement === window ? this._scrollElement.pageYOffset : this._scrollElement.scrollTop; + }; + + _proto._getScrollHeight = function _getScrollHeight() { + return this._scrollElement.scrollHeight || Math.max(document.body.scrollHeight, document.documentElement.scrollHeight); + }; + + _proto._getOffsetHeight = function _getOffsetHeight() { + return this._scrollElement === window ? window.innerHeight : this._scrollElement.getBoundingClientRect().height; + }; + + _proto._process = function _process() { + var scrollTop = this._getScrollTop() + this._config.offset; + + var scrollHeight = this._getScrollHeight(); + + var maxScroll = this._config.offset + scrollHeight - this._getOffsetHeight(); + + if (this._scrollHeight !== scrollHeight) { + this.refresh(); + } + + if (scrollTop >= maxScroll) { + var target = this._targets[this._targets.length - 1]; + + if (this._activeTarget !== target) { + this._activate(target); + } + + return; + } + + if (this._activeTarget && scrollTop < this._offsets[0] && this._offsets[0] > 0) { + this._activeTarget = null; + + this._clear(); + + return; + } + + var offsetLength = this._offsets.length; + + for (var i = offsetLength; i--;) { + var isActiveTarget = this._activeTarget !== this._targets[i] && scrollTop >= this._offsets[i] && (typeof this._offsets[i + 1] === 'undefined' || scrollTop < this._offsets[i + 1]); + + if (isActiveTarget) { + this._activate(this._targets[i]); + } + } + }; + + _proto._activate = function _activate(target) { + this._activeTarget = target; + + this._clear(); + + var queries = this._selector.split(','); // eslint-disable-next-line arrow-body-style + + + queries = queries.map(function (selector) { + return selector + "[data-target=\"" + target + "\"]," + (selector + "[href=\"" + target + "\"]"); + }); + var $link = $$$1([].slice.call(document.querySelectorAll(queries.join(',')))); + + if ($link.hasClass(ClassName.DROPDOWN_ITEM)) { + $link.closest(Selector.DROPDOWN).find(Selector.DROPDOWN_TOGGLE).addClass(ClassName.ACTIVE); + $link.addClass(ClassName.ACTIVE); + } else { + // Set triggered link as active + $link.addClass(ClassName.ACTIVE); // Set triggered links parents as active + // With both