diff --git a/.docker/README.md b/.docker/README.md new file mode 100644 index 0000000000..66b76b0f2d --- /dev/null +++ b/.docker/README.md @@ -0,0 +1,17 @@ +## What is this directory? +This directory is a space for mounting directories to docker containers, allowing the mounts to be specified in committed code, but the contents of the mounts to remain ignored by git. + +### postgres +The `postgres` directory is mounted to `/docker-entrypoint-initdb.d`. Any `.sh` or `.sql` files will be executed when the container is first started with a new data volume. You may read more regarding this functionality on the [Docker Hub page](https://hub.docker.com/_/postgres), under _Initialization scripts_. + +When running docker services through the Makefile commands, it specifies a docker-compose project name that depends on the name of the current git branch. This causes the volumes to change when the branch changes, which is helpful when switching between many branches that might have incompatible database schema changes. The downside is that whenever you start a new branch, you'll have to re-initialize the database again, like with `yarn run devsetup`. Creating a SQL dump from an existing, initialized database and placing it in this directory will allow you to skip this step. + +To create a SQL dump of your preferred database data useful for local testing, run `make .docker/postgres/init.sql` while the docker postgres container is running. + +> Note: you will likely need to run `make migrate` to ensure your database schema is up-to-date when using this technique. + +#### pgpass +Stores the postgres authentication for the docker service for scripting access without manually providing a password, created by `make .docker/pgpass` + +### minio +The `minio` directory is mounted to `/data`, since it isn't necessarily useful to have this data isolated based off the current git branch. diff --git a/.editorconfig b/.editorconfig index 1f49431c53..8db7923734 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,8 @@ root = true +[*] +max_line_length = 100 + [*.js] indent_size = 2 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 128ae4fe41..663fdde54c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,9 +8,18 @@ updates: directory: "/" schedule: interval: "daily" + time: "00:00" # Maintain dependencies for Javascript - package-ecosystem: "npm" directory: "/" schedule: interval: "daily" + time: "00:00" + + # Maintain dependencies for Github Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "00:00" diff --git a/.github/workflows/deploytest.yml b/.github/workflows/deploytest.yml index 11768ed489..d63794aaeb 100644 --- a/.github/workflows/deploytest.yml +++ b/.github/workflows/deploytest.yml @@ -27,13 +27,13 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Use Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: '16.x' - name: Cache Node.js modules - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: '**/node_modules' key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }} @@ -51,13 +51,13 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.9 - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: 3.9 - name: pip cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.cache/pip key: ${{ runner.os }}-pyprod-${{ hashFiles('requirements.txt') }} @@ -69,11 +69,11 @@ jobs: pip install pip-tools pip-sync requirements.txt - name: Use Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: '16.x' - name: Cache Node.js modules - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: '**/node_modules' key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }} diff --git a/.github/workflows/frontendlint.yml b/.github/workflows/frontendlint.yml index 6de4d701bd..d0a3e648f2 100644 --- a/.github/workflows/frontendlint.yml +++ b/.github/workflows/frontendlint.yml @@ -27,13 +27,13 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Use Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: '16.x' - name: Cache Node.js modules - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: '**/node_modules' key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }} diff --git a/.github/workflows/frontendtest.yml b/.github/workflows/frontendtest.yml index c9ed46672c..7489c2748a 100644 --- a/.github/workflows/frontendtest.yml +++ b/.github/workflows/frontendtest.yml @@ -27,13 +27,13 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Use Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: '16.x' - name: Cache Node.js modules - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: '**/node_modules' key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }} diff --git a/.github/workflows/pythontest.yml b/.github/workflows/pythontest.yml index 217399ea8e..f015f7bc1e 100644 --- a/.github/workflows/pythontest.yml +++ b/.github/workflows/pythontest.yml @@ -61,7 +61,7 @@ jobs: # Maps port 6379 on service container to the host - 6379:6379 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up minio run: | docker run -d -p 9000:9000 --name minio \ @@ -71,11 +71,11 @@ jobs: -v /tmp/minio_config:/root/.minio \ minio/minio server /data - name: Set up Python 3.9 - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: 3.9 - name: pip cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.cache/pip key: ${{ runner.os }}-pytest-${{ hashFiles('requirements.txt', 'requirements-dev.txt') }} diff --git a/.gitignore b/.gitignore index b5e0261f09..8d869357f8 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ var/ # IntelliJ IDE, except project config .idea/* !.idea/studio.iml +# ignore future updates to run configuration +.run/devserver.run.xml # PyInstaller # Usually these files are written by a python script from a template @@ -95,8 +97,11 @@ contentcuration/csvs/ # Ignore the TAGS file generated by some editors TAGS -# Ignore Vagrant-created files -/.vagrant/ +# Services +.vagrant/ +.docker/minio/* +.docker/postgres/* +.docker/pgpass # Ignore test files /contentcuration/contentcuration/proxy_settings.py diff --git a/.run/devserver.run.xml b/.run/devserver.run.xml new file mode 100644 index 0000000000..1c94ee6402 --- /dev/null +++ b/.run/devserver.run.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/Makefile b/Makefile index 29fe984285..b7575fe74e 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,11 @@ +# standalone install method +DOCKER_COMPOSE = docker-compose + +# support new plugin installation for docker-compose +ifeq (, $(shell which docker-compose)) +DOCKER_COMPOSE = docker compose +endif + ############################################################### # PRODUCTION COMMANDS ######################################### ############################################################### @@ -20,6 +28,18 @@ migrate: python contentcuration/manage.py migrate || true python contentcuration/manage.py loadconstants +# This is a special command that is we'll reuse to run data migrations outside of the normal +# django migration process. This is useful for long running migrations which we don't want to block +# the CD build. Do not delete! +# Procedure: +# 1) Add a new management command for the migration +# 2) Call it here +# 3) Perform the release +# 4) Remove the management command from this `deploy-migrate` recipe +# 5) Repeat! +deploy-migrate: + python contentcuration/manage.py export_channels_to_kolibri_public + contentnodegc: python contentcuration/manage.py garbage_collect @@ -31,7 +51,11 @@ learningactivities: set-tsvectors: python contentcuration/manage.py set_channel_tsvectors - python contentcuration/manage.py set_contentnode_tsvectors + python contentcuration/manage.py set_contentnode_tsvectors --published + +reconcile: + python contentcuration/manage.py reconcile_publishing_status + python contentcuration/manage.py reconcile_change_tasks ############################################################### # END PRODUCTION COMMANDS ##################################### @@ -53,10 +77,10 @@ i18n-extract: i18n-extract-frontend i18n-extract-backend i18n-transfer-context: yarn transfercontext -#i18n-django-compilemessages: - # Change working directory to kolibri/ such that compilemessages +i18n-django-compilemessages: + # Change working directory to contentcuration/ such that compilemessages # finds only the .po files nested there. - #cd kolibri && PYTHONPATH="..:$$PYTHONPATH" python -m kolibri manage compilemessages + cd contentcuration && python manage.py compilemessages i18n-upload: i18n-extract python node_modules/kolibri-tools/lib/i18n/crowdin.py upload-sources ${branch} @@ -67,27 +91,15 @@ i18n-pretranslate: i18n-pretranslate-approve-all: python node_modules/kolibri-tools/lib/i18n/crowdin.py pretranslate ${branch} --approve-all -i18n-convert: - python node_modules/kolibri-tools/lib/i18n/crowdin.py convert-files - i18n-download-translations: python node_modules/kolibri-tools/lib/i18n/crowdin.py rebuild-translations ${branch} python node_modules/kolibri-tools/lib/i18n/crowdin.py download-translations ${branch} - node node_modules/kolibri-tools/lib/i18n/intl_code_gen.js - python node_modules/kolibri-tools/lib/i18n/crowdin.py convert-files - # TODO: is this necessary? # Manual hack to add es language by copying es_ES to es - # cp -r contentcuration/locale/es_ES contentcuration/locale/es + yarn exec kolibri-tools i18n-code-gen -- --output-dir ./contentcuration/contentcuration/frontend/shared/i18n + $(MAKE) i18n-django-compilemessages + yarn exec kolibri-tools i18n-create-message-files -- --namespace contentcuration --searchPath ./contentcuration/contentcuration/frontend i18n-download: i18n-download-translations -i18n-update: - echo "WARNING: i18n-update has been renamed to i18n-download" - $(MAKE) i18n-download - echo "WARNING: i18n-update has been renamed to i18n-download" - -i18n-stats: - python node_modules/kolibri-tools/lib/i18n/crowdin.py translation-stats ${branch} - i18n-download-glossary: python node_modules/kolibri-tools/lib/i18n/crowdin.py download-glossary @@ -126,9 +138,9 @@ hascaptions: export COMPOSE_PROJECT_NAME=studio_$(shell git rev-parse --abbrev-ref HEAD) -purge-postgres: - -PGPASSWORD=kolibri dropdb -U learningequality "kolibri-studio" --port 5432 -h localhost - PGPASSWORD=kolibri createdb -U learningequality "kolibri-studio" --port 5432 -h localhost +purge-postgres: .docker/pgpass + -PGPASSFILE=.docker/pgpass dropdb -U learningequality "kolibri-studio" --port 5432 -h localhost + PGPASSFILE=.docker/pgpass createdb -U learningequality "kolibri-studio" --port 5432 -h localhost destroy-and-recreate-database: purge-postgres setup @@ -138,39 +150,56 @@ devceleryworkers: run-services: $(MAKE) -j 2 dcservicesup devceleryworkers +.docker/minio: + mkdir -p $@ + +.docker/postgres: + mkdir -p $@ + +.docker/pgpass: + echo "localhost:5432:kolibri-studio:learningequality:kolibri" > $@ + chmod 600 $@ + +.docker/postgres/init.sql: .docker/pgpass + # assumes postgres is running in a docker container + PGPASSFILE=.docker/pgpass pg_dump --host localhost --port 5432 --username learningequality --dbname "kolibri-studio" --exclude-table-data=contentcuration_change --file $@ + dcbuild: # build all studio docker image and all dependent services using docker-compose - docker-compose build + $(DOCKER_COMPOSE) build -dcup: +dcup: .docker/minio .docker/postgres # run all services except for cloudprober - docker-compose up studio-app celery-worker + $(DOCKER_COMPOSE) up studio-app celery-worker -dcup-cloudprober: +dcup-cloudprober: .docker/minio .docker/postgres # run all services including cloudprober - docker-compose up + $(DOCKER_COMPOSE) up dcdown: - # run make deverver in foreground with all dependent services using docker-compose - docker-compose down + # run make deverver in foreground with all dependent services using $(DOCKER_COMPOSE) + $(DOCKER_COMPOSE) down dcclean: # stop all containers and delete volumes - docker-compose down -v + $(DOCKER_COMPOSE) down -v docker image prune -f dcshell: # bash shell inside the (running!) studio-app container - docker-compose exec studio-app /usr/bin/fish + $(DOCKER_COMPOSE) exec studio-app /usr/bin/fish + +dcpsql: .docker/pgpass + PGPASSFILE=.docker/pgpass psql --host localhost --port 5432 --username learningequality --dbname "kolibri-studio" -dctest: +dctest: .docker/minio .docker/postgres # run backend tests inside docker, in new instances - docker-compose run studio-app make test + $(DOCKER_COMPOSE) run studio-app make test -dcservicesup: +dcservicesup: .docker/minio .docker/postgres # launch all studio's dependent services using docker-compose - docker-compose -f docker-compose.yml -f docker-compose.alt.yml up minio postgres redis + $(DOCKER_COMPOSE) -f docker-compose.yml -f docker-compose.alt.yml up minio postgres redis dcservicesdown: # stop services that were started using dcservicesup - docker-compose -f docker-compose.yml -f docker-compose.alt.yml down + $(DOCKER_COMPOSE) -f docker-compose.yml -f docker-compose.alt.yml down diff --git a/README.md b/README.md index 821aba3b88..aa0a9ad8d5 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,10 @@ export LDFLAGS="-L/opt/homebrew/opt/openssl/lib" ``` ### Install frontend dependencies -Install the version of node.js supported by Studio, and install `yarn`: +Install the version of node.js supported by Studio, and install `yarn` version 1.x: ```bash volta install node@16 -volta install yarn +volta install yarn@1 ``` After installing `yarn`, you may now install frontend dependencies: ```bash diff --git a/contentcuration/contentcuration/api.py b/contentcuration/contentcuration/api.py index 33c9692cbc..b297ffaba6 100644 --- a/contentcuration/contentcuration/api.py +++ b/contentcuration/contentcuration/api.py @@ -10,9 +10,6 @@ from django.core.files.storage import default_storage import contentcuration.models as models -from contentcuration.utils.garbage_collect import get_deleted_chefs_root -from contentcuration.viewsets.sync.constants import CHANNEL -from contentcuration.viewsets.sync.utils import generate_update_event def write_file_to_storage(fobj, check_valid=False, name=None): @@ -68,33 +65,3 @@ def get_hash(fobj): md5.update(chunk) fobj.seek(0) return md5.hexdigest() - - -def activate_channel(channel, user): - user.check_channel_space(channel) - - if channel.previous_tree and channel.previous_tree != channel.main_tree: - # IMPORTANT: Do not remove this block, MPTT updating the deleted chefs block could hang the server - with models.ContentNode.objects.disable_mptt_updates(): - garbage_node = get_deleted_chefs_root() - channel.previous_tree.parent = garbage_node - channel.previous_tree.title = "Previous tree for channel {}".format(channel.pk) - channel.previous_tree.save() - - channel.previous_tree = channel.main_tree - channel.main_tree = channel.staging_tree - channel.staging_tree = None - channel.save() - - user.staged_files.all().delete() - user.set_space_used() - - models.Change.create_change(generate_update_event( - channel.id, - CHANNEL, - { - "root_id": channel.main_tree.id, - "staging_root_id": None - }, - channel_id=channel.id, - ), applied=True, created_by_id=user.id) diff --git a/contentcuration/contentcuration/constants/channel_history.py b/contentcuration/contentcuration/constants/channel_history.py index 28de05e035..790b4dfd51 100644 --- a/contentcuration/contentcuration/constants/channel_history.py +++ b/contentcuration/contentcuration/constants/channel_history.py @@ -1,13 +1,11 @@ -from django.utils.translation import ugettext_lazy as _ - CREATION = "creation" PUBLICATION = "publication" DELETION = "deletion" RECOVERY = "recovery" choices = ( - (CREATION, _("Creation")), - (PUBLICATION, _("Publication")), - (DELETION, _("Deletion")), - (RECOVERY, _("Deletion recovery")), + (CREATION, "Creation"), + (PUBLICATION, "Publication"), + (DELETION, "Deletion"), + (RECOVERY, "Deletion recovery"), ) diff --git a/contentcuration/contentcuration/constants/user_history.py b/contentcuration/contentcuration/constants/user_history.py new file mode 100644 index 0000000000..9adc9b56c6 --- /dev/null +++ b/contentcuration/contentcuration/constants/user_history.py @@ -0,0 +1,9 @@ +DELETION = "soft-deletion" +RECOVERY = "soft-recovery" +RELATED_DATA_HARD_DELETION = "related-data-hard-deletion" + +choices = ( + (DELETION, "User soft deletion"), + (RECOVERY, "User soft deletion recovery"), + (RELATED_DATA_HARD_DELETION, "User related data hard deletion"), +) diff --git a/contentcuration/contentcuration/db/models/manager.py b/contentcuration/contentcuration/db/models/manager.py index 72e15186a7..4d833caf8f 100644 --- a/contentcuration/contentcuration/db/models/manager.py +++ b/contentcuration/contentcuration/db/models/manager.py @@ -47,14 +47,33 @@ def log_lock_time_spent(timespent): logging.debug("Spent {} seconds inside an mptt lock".format(timespent)) -def execute_queryset_without_results(queryset): - query = queryset.query - compiler = query.get_compiler(queryset.db) - sql, params = compiler.as_sql() - if not sql: - return - cursor = compiler.connection.cursor() - cursor.execute(sql, params) +# Fields that are allowed to be overridden on copies coming from a source that the user +# does not have edit rights to. +ALLOWED_OVERRIDES = { + "node_id", + "title", + "description", + "aggregator", + "provider", + "language_id", + "grade_levels", + "resource_types", + "learning_activities", + "accessibility_labels", + "categories", + "learner_needs", + "role", + "extra_fields", + "suggested_duration", +} + +EDIT_ALLOWED_OVERRIDES = ALLOWED_OVERRIDES.union({ + "license_id", + "license_description", + "extra_fields", + "copyright_holder", + "author", +}) class CustomContentNodeTreeManager(TreeManager.from_queryset(CustomTreeQuerySet)): @@ -272,7 +291,10 @@ def _clone_node( copy.update(self.get_source_attributes(source)) if isinstance(mods, dict): - copy.update(mods) + allowed_keys = EDIT_ALLOWED_OVERRIDES if can_edit_source_channel else ALLOWED_OVERRIDES + for key, value in mods.items(): + if key in copy and key in allowed_keys: + copy[key] = value # There might be some legacy nodes that don't have these, so ensure they are added if ( diff --git a/contentcuration/contentcuration/debug/middleware.py b/contentcuration/contentcuration/debug/middleware.py deleted file mode 100644 index 803a94f89b..0000000000 --- a/contentcuration/contentcuration/debug/middleware.py +++ /dev/null @@ -1,47 +0,0 @@ -import threading -import time - -import debug_panel.urls -from debug_panel.cache import cache -from debug_panel.middleware import DebugPanelMiddleware -from django.urls import reverse - - -class CustomDebugPanelMiddleware(DebugPanelMiddleware): - """ - Custom version to fix SQL escaping: - https://github.com/recamshak/django-debug-panel/issues/17#issuecomment-366268893 - """ - - def process_response(self, request, response): - """ - Store the DebugToolbarMiddleware rendered toolbar into a cache store. - The data stored in the cache are then reachable from an URL that is appened - to the HTTP response header under the 'X-debug-data-url' key. - """ - toolbar = self.__class__.debug_toolbars.get( - threading.current_thread().ident, None - ) - - response = super(DebugPanelMiddleware, self).process_response(request, response) - - if toolbar: - # for django-debug-toolbar >= 1.4 - for panel in reversed(toolbar.enabled_panels): - if ( - hasattr(panel, "generate_stats") and not panel.get_stats() - ): # PATCH HERE - panel.generate_stats(request, response) - - cache_key = "%f" % time.time() - cache.set(cache_key, toolbar.render_toolbar()) - - response["X-debug-data-url"] = request.build_absolute_uri( - reverse( - "debug_data", - urlconf=debug_panel.urls, - kwargs={"cache_key": cache_key}, - ) - ) - - return response diff --git a/contentcuration/contentcuration/debug_panel_settings.py b/contentcuration/contentcuration/debug_panel_settings.py index 79f9ddac6e..c097acbbc6 100644 --- a/contentcuration/contentcuration/debug_panel_settings.py +++ b/contentcuration/contentcuration/debug_panel_settings.py @@ -1,8 +1,13 @@ from .dev_settings import * # noqa -# These endpoints will throw an error on the django debug panel +# These endpoints will throw an error on the django debug panel. EXCLUDED_DEBUG_URLS = [ "/content/storage", + + # Disabling sync API because as soon as the sync API gets polled + # the current request data gets overwritten. + # Can be removed after websockets deployment. + "/api/sync", ] DEBUG_PANEL_ACTIVE = True @@ -14,10 +19,10 @@ def custom_show_toolbar(request): ) # noqa F405 -# if debug_panel exists, add it to our INSTALLED_APPS +# if debug_panel exists, add it to our INSTALLED_APPS. INSTALLED_APPS += ("debug_panel", "debug_toolbar", "pympler") # noqa F405 MIDDLEWARE += ( # noqa F405 - "contentcuration.debug.middleware.CustomDebugPanelMiddleware", + "debug_toolbar.middleware.DebugToolbarMiddleware", ) DEBUG_TOOLBAR_CONFIG = { "SHOW_TOOLBAR_CALLBACK": custom_show_toolbar, diff --git a/contentcuration/contentcuration/forms.py b/contentcuration/contentcuration/forms.py index 973916431e..d9dc781f61 100644 --- a/contentcuration/contentcuration/forms.py +++ b/contentcuration/contentcuration/forms.py @@ -7,6 +7,7 @@ from django.contrib.auth.forms import UserChangeForm from django.contrib.auth.forms import UserCreationForm from django.core import signing +from django.db.models import Q from django.template.loader import render_to_string from contentcuration.models import User @@ -45,7 +46,7 @@ class RegistrationForm(UserCreationForm, ExtraFormMixin): def clean_email(self): email = self.cleaned_data['email'].strip().lower() - if User.objects.filter(email__iexact=email, is_active=True).exists(): + if User.objects.filter(Q(is_active=True) | Q(deleted=True), email__iexact=email).exists(): raise UserWarning return email diff --git a/contentcuration/contentcuration/frontend/accounts/components/MessageLayout.vue b/contentcuration/contentcuration/frontend/accounts/components/MessageLayout.vue index 0a89c4ba5d..869ccf3a88 100644 --- a/contentcuration/contentcuration/frontend/accounts/components/MessageLayout.vue +++ b/contentcuration/contentcuration/frontend/accounts/components/MessageLayout.vue @@ -13,7 +13,10 @@

- +

diff --git a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue index 361ffce01d..b211cd5634 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue @@ -15,7 +15,7 @@

{{ $tr('createAnAccountTitle') }}

- + {{ registrationFailed ? $tr('registrationFailed') : $tr('errorsMessage') }} @@ -131,42 +131,45 @@ /> - - + +
+ + - - - + + | + + + +
+ +
+
+ {{ $tr('contactMessage') }} +
+
-

- {{ $tr('contactMessage') }} -

- - {{ $tr('finishButton') }} - +
@@ -238,12 +241,19 @@ passwordConfirmRules() { return [value => (this.form.password1 === value ? true : this.$tr('passwordMatchMessage'))]; }, - tosRules() { + tosAndPolicyRules() { return [value => (value ? true : this.$tr('ToSRequiredMessage'))]; }, - policyRules() { - return [value => (value ? true : this.$tr('privacyPolicyRequiredMessage'))]; + acceptedAgreement: { + get() { + return this.form.accepted_tos && this.form.accepted_policy; + }, + set(accepted) { + this.form.accepted_tos = accepted; + this.form.accepted_policy = accepted; + }, }, + usageOptions() { return [ { @@ -350,7 +360,7 @@ }, clean() { return data => { - let cleanedData = { ...data, policies: {} }; + const cleanedData = { ...data, policies: {} }; Object.keys(cleanedData).forEach(key => { // Trim text fields if (key === 'source') { @@ -413,10 +423,9 @@ showOtherField(id) { return id === uses.OTHER && this.form.uses.includes(id); }, - submit() { if (this.$refs.form.validate()) { - let cleanedData = this.clean(this.form); + const cleanedData = this.clean(this.form); return this.register(cleanedData) .then(() => { this.$router.push({ name: 'ActivationSent' }); @@ -439,6 +448,7 @@ return Promise.resolve(); }, }, + $trs: { backToLoginButton: 'Sign in', createAnAccountTitle: 'Create an account', @@ -447,7 +457,6 @@ registrationFailed: 'There was an error registering your account. Please try again', registrationFailedOffline: 'You seem to be offline. Please connect to the internet to create an account.', - // Basic information strings basicInformationHeader: 'Basic information', firstNameLabel: 'First name', @@ -492,15 +501,13 @@ otherSourcePlaceholder: 'Please describe', // Privacy policy + terms of service - viewToSLink: 'View terms of service', - ToSCheck: 'I have read and agree to the terms of service', - ToSRequiredMessage: 'Please accept our terms of service', + viewToSLink: 'View Terms of Service', + ToSRequiredMessage: 'Please accept our terms of service and policy', - viewPrivacyPolicyLink: 'View privacy policy', - privacyPolicyCheck: 'I have read and agree to the privacy policy', - privacyPolicyRequiredMessage: 'Please accept our privacy policy', + viewPrivacyPolicyLink: 'View Privacy Policy', contactMessage: 'Questions or concerns? Please email us at content@learningequality.org', finishButton: 'Finish', + agreement: 'I have read and agree to terms of service and the privacy policy', }, }; @@ -521,6 +528,11 @@ } } + .policy-checkbox /deep/ .v-messages { + min-height: 0; + margin-left: 40px; + } + iframe { width: 100%; min-height: 400px; @@ -529,4 +541,23 @@ border: 0; } + .span-spacing { + display: flex; + margin-left: 40px; + } + + .span-spacing span { + margin-left: 2px; + font-size: 16px; + } + + .span-spacing-email { + margin-left: 3px; + font-size: 16px; + } + + .align-items { + display: block; + } + diff --git a/contentcuration/contentcuration/frontend/accounts/pages/Main.vue b/contentcuration/contentcuration/frontend/accounts/pages/Main.vue index c15a96fca1..49833f60e2 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/Main.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/Main.vue @@ -6,12 +6,12 @@ justify-center class="main pt-5" > -

- -

- + {{ $tr('kolibriStudio') }} - - + + - - - + + +

- +

- - {{ $tr('signInButton') }} - - - {{ $tr('createAccountButton') }} - + +

- +

@@ -90,7 +123,6 @@ import PolicyModals from 'shared/views/policies/PolicyModals'; import { policies } from 'shared/constants'; import LanguageSwitcherList from 'shared/languageSwitcher/LanguageSwitcherList'; - import OfflineText from 'shared/views/OfflineText'; export default { name: 'Main', @@ -100,7 +132,6 @@ LanguageSwitcherList, PasswordField, PolicyModals, - OfflineText, }, data() { return { @@ -132,7 +163,7 @@ submit() { if (this.$refs.form.validate()) { this.busy = true; - let credentials = { + const credentials = { username: this.username, password: this.password, }; @@ -180,6 +211,7 @@ .main { overflow: auto; + /* stylelint-disable-next-line custom-property-pattern */ background-color: var(--v-backgroundColor-base); } @@ -191,10 +223,8 @@ content: '•'; } - .corner { - position: absolute; - top: 1em; - left: 1em; + .w-100 { + width: 100%; } diff --git a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js index dc9f24df06..f2a201b560 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js +++ b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js @@ -34,7 +34,7 @@ const defaultData = { const register = jest.fn(); function makeWrapper(formData) { - let wrapper = mount(Create, { + const wrapper = mount(Create, { router, computed: { getPolicyAcceptedData() { @@ -62,7 +62,6 @@ function makeWrapper(formData) { }); return wrapper; } - function makeFailedPromise(statusCode) { return () => { return new Promise((resolve, reject) => { @@ -81,13 +80,13 @@ describe('create', () => { }); it('should trigger submit method when form is submitted', () => { const submit = jest.fn(); - let wrapper = makeWrapper(); + const wrapper = makeWrapper(); wrapper.setMethods({ submit }); wrapper.find({ ref: 'form' }).trigger('submit'); expect(submit).toHaveBeenCalled(); }); it('should call register with form data', () => { - let wrapper = makeWrapper(); + const wrapper = makeWrapper(); wrapper.find({ ref: 'form' }).trigger('submit'); expect(register.mock.calls[0][0]).toEqual({ ...defaultData, @@ -98,12 +97,12 @@ describe('create', () => { }); it('should automatically fill the email if provided in the query param', () => { router.push({ name: 'Create', query: { email: 'newtest@test.com' } }); - let wrapper = mount(Create, { router, stubs: ['PolicyModals'], mocks: connectionStateMocks }); + const wrapper = mount(Create, { router, stubs: ['PolicyModals'], mocks: connectionStateMocks }); expect(wrapper.vm.form.email).toBe('newtest@test.com'); }); describe('validation', () => { it('should call register if form is valid', () => { - let wrapper = makeWrapper(); + const wrapper = makeWrapper(); wrapper.vm.submit(); expect(register).toHaveBeenCalled(); }); @@ -122,26 +121,26 @@ describe('create', () => { }; Object.keys(form).forEach(field => { - let wrapper = makeWrapper({ [field]: form[field] }); + const wrapper = makeWrapper({ [field]: form[field] }); wrapper.vm.submit(); expect(register).not.toHaveBeenCalled(); }); }); it('should fail if password1 and password2 do not match', () => { - let wrapper = makeWrapper({ password1: 'some other password' }); + const wrapper = makeWrapper({ password1: 'some other password' }); wrapper.vm.submit(); expect(register).not.toHaveBeenCalled(); }); it('should fail if uses field is set to fields that require more input that is not provided', () => { [uses.STORING, uses.OTHER].forEach(use => { - let wrapper = makeWrapper({ uses: [use] }); + const wrapper = makeWrapper({ uses: [use] }); wrapper.vm.submit(); expect(register).not.toHaveBeenCalled(); }); }); it('should fail if source field is set to an option that requires more input that is not provided', () => { [sources.ORGANIZATION, sources.CONFERENCE, sources.OTHER].forEach(source => { - let wrapper = makeWrapper({ source }); + const wrapper = makeWrapper({ source }); wrapper.vm.submit(); expect(register).not.toHaveBeenCalled(); }); diff --git a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/main.spec.js b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/main.spec.js index dc57ccf210..2525a83d57 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/main.spec.js +++ b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/main.spec.js @@ -5,7 +5,7 @@ import Main from '../Main'; const login = jest.fn(); function makeWrapper() { - let wrapper = mount(Main, { + const wrapper = mount(Main, { router, stubs: ['GlobalSnackbar', 'PolicyModals'], mocks: { diff --git a/contentcuration/contentcuration/frontend/accounts/pages/accountDeleted/AccountDeleted.vue b/contentcuration/contentcuration/frontend/accounts/pages/accountDeleted/AccountDeleted.vue index f881737065..032cfc506a 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/accountDeleted/AccountDeleted.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/accountDeleted/AccountDeleted.vue @@ -5,7 +5,7 @@ > @@ -24,7 +24,7 @@ }, $trs: { accountDeletedTitle: 'Account successfully deleted', - continueToSignIn: 'Continue to sign-in page', + backToLogin: 'Continue to sign-in page', }, }; diff --git a/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/AccountCreated.vue b/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/AccountCreated.vue index b55bbdf418..14e106c232 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/AccountCreated.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/AccountCreated.vue @@ -5,7 +5,7 @@ > @@ -24,7 +24,7 @@ }, $trs: { accountCreatedTitle: 'Account successfully created', - continueToSignIn: 'Continue to sign-in', + backToLogin: 'Continue to sign-in page', }, }; diff --git a/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/AccountNotActivated.vue b/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/AccountNotActivated.vue index a0db43bca2..ccbec003d1 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/AccountNotActivated.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/AccountNotActivated.vue @@ -4,9 +4,12 @@ :header="$tr('title')" :text="$tr('text')" > - - {{ $tr('requestNewLink') }} - + diff --git a/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/ActivationExpired.vue b/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/ActivationExpired.vue index cdf2db7a43..9dd1dad18c 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/ActivationExpired.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/ActivationExpired.vue @@ -4,9 +4,12 @@ :header="$tr('activationExpiredTitle')" :text="$tr('activationExpiredText')" > - - {{ $tr('requestNewLink') }} - + diff --git a/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/RequestNewActivationLink.vue b/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/RequestNewActivationLink.vue index df889b3ecb..afb06cdaca 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/RequestNewActivationLink.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/activateAccount/RequestNewActivationLink.vue @@ -11,9 +11,12 @@ > - - {{ $tr('submitButton') }} - + @@ -64,3 +67,11 @@ }; + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ForgotPassword.vue b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ForgotPassword.vue index 13910a9bad..7349cf97ff 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ForgotPassword.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ForgotPassword.vue @@ -7,9 +7,12 @@ - - {{ $tr('submitButton') }} - + @@ -65,3 +68,11 @@ }; + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetLinkExpired.vue b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetLinkExpired.vue index 4f0c8fc057..0249fd9fda 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetLinkExpired.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetLinkExpired.vue @@ -4,9 +4,12 @@ :header="$tr('resetExpiredTitle')" :text="$tr('resetExpiredText')" > - - {{ $tr('requestNewLink') }} - + diff --git a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue index b3866f5ccd..2fd3ceddd3 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue @@ -16,9 +16,12 @@ :label="$tr('passwordConfirmLabel')" :additionalRules="passwordConfirmRules" /> - - {{ $tr('submitButton') }} - + @@ -55,7 +58,7 @@ submit() { this.error = false; if (this.$refs.form.validate()) { - let payload = { + const payload = { ...this.$route.query, new_password1: this.new_password1, new_password2: this.new_password2, @@ -84,3 +87,11 @@ }; + + diff --git a/contentcuration/contentcuration/frontend/accounts/vuex/index.js b/contentcuration/contentcuration/frontend/accounts/vuex/index.js index bc95d1ed29..f64df2778a 100644 --- a/contentcuration/contentcuration/frontend/accounts/vuex/index.js +++ b/contentcuration/contentcuration/frontend/accounts/vuex/index.js @@ -17,7 +17,7 @@ export default { return client.post(window.Urls.auth_password_reset(), { email }); }, setPassword(context, { uidb64, token, new_password1, new_password2 }) { - let data = { + const data = { new_password1, new_password2, }; diff --git a/contentcuration/contentcuration/frontend/administration/mixins.js b/contentcuration/contentcuration/frontend/administration/mixins.js index 529c75ffae..262dbf3cc9 100644 --- a/contentcuration/contentcuration/frontend/administration/mixins.js +++ b/contentcuration/contentcuration/frontend/administration/mixins.js @@ -24,7 +24,7 @@ export function generateFilterMixin(filterMap) { return this.$route.query.keywords; }, set(value) { - let params = { ...this.$route.query, page: 1 }; + const params = { ...this.$route.query, page: 1 }; if (value) { params.keywords = value; } else { @@ -37,7 +37,7 @@ export function generateFilterMixin(filterMap) { get() { // Return filter where all param conditions are met const filterKeys = intersection(Object.keys(this.$route.query), paramKeys); - let key = findKey(filterMap, value => { + const key = findKey(filterMap, value => { return filterKeys.every(field => { return value.params[field] === _getBooleanVal(this.$route.query[field]); }); @@ -115,7 +115,7 @@ export const tableMixin = { computed: { pagination: { get() { - let params = { + const params = { rowsPerPage: Number(this.$route.query.page_size) || 25, page: Number(this.$route.query.page) || 1, }; diff --git a/contentcuration/contentcuration/frontend/administration/pages/AdministrationIndex.vue b/contentcuration/contentcuration/frontend/administration/pages/AdministrationIndex.vue index 7ed794230f..10f2b67efb 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/AdministrationIndex.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/AdministrationIndex.vue @@ -128,6 +128,7 @@ .v-icon:not(.v-icon--is-component) { font-size: 16pt !important; + /* stylelint-disable-next-line custom-property-pattern */ color: var(--v-darkGrey-darken1) !important; opacity: 1 !important; transform: none !important; @@ -159,6 +160,7 @@ } tr:hover td { + /* stylelint-disable-next-line custom-property-pattern */ background-color: var(--v-greyBackground-base) !important; } diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue index d71841492f..e243a8fd98 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue @@ -146,14 +146,11 @@ channel() { return this.getChannel(this.channelId); }, - name() { - return this.channel.name; - }, searchChannelEditorsLink() { return { name: RouteNames.USERS, query: { - keywords: `${this.name} ${this.channel.id}`, + keywords: `${this.channel.id}`, }, }; }, diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelItem.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelItem.vue index 3e975714af..d87e1d4d3f 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelItem.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelItem.vue @@ -176,8 +176,8 @@ import ClipboardChip from '../../components/ClipboardChip'; import { RouteNames } from '../../constants'; import ChannelActionsDropdown from './ChannelActionsDropdown'; - import Checkbox from 'shared/views/form/Checkbox'; import { fileSizeMixin } from 'shared/mixins'; + import Checkbox from 'shared/views/form/Checkbox'; export default { name: 'ChannelItem', @@ -232,7 +232,7 @@ return { name: RouteNames.USERS, query: { - keywords: `${this.channel.name} ${this.channelId}`, + keywords: `${this.channelId}`, }, }; }, diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue index 76bfc4d4e2..b4185d8a85 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue @@ -100,8 +100,8 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/progress/ProgressModal.vue b/contentcuration/contentcuration/frontend/channelEdit/views/progress/ProgressModal.vue index f5ce76de6b..a124e0cc6e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/progress/ProgressModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/progress/ProgressModal.vue @@ -1,14 +1,21 @@ @@ -84,11 +107,13 @@ import { mapActions, mapState } from 'vuex'; import KolibriLogo from './KolibriLogo'; + import LanguageSwitcherModal from 'shared/languageSwitcher/LanguageSwitcherModal'; export default { name: 'MainNavigationDrawer', components: { KolibriLogo, + LanguageSwitcherModal, }, props: { value: { @@ -96,10 +121,22 @@ default: false, }, }, + data() { + return { + showLanguageModal: false, + }; + }, computed: { ...mapState({ user: state => state.session.currentUser, }), + handleclickTab() { + if (this.value) { + return 0; + } else { + return -1; + } + }, drawer: { get() { return this.value; @@ -131,6 +168,7 @@ channelsLink: 'Channels', administrationLink: 'Administration', settingsLink: 'Settings', + changeLanguage: 'Change language', helpLink: 'Help and support', logoutLink: 'Sign out', copyright: '© {year} Learning Equality', diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/MarkdownEditor.vue b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/MarkdownEditor.vue index f8b779ca96..c8f671838f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/MarkdownEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/MarkdownEditor.vue @@ -487,9 +487,9 @@ // element has `contenteditable="false"` (which is necessary on FireFox for // a different reason). As a result, Squire.js gets stuck. The trick here is // to fix its selection so it knows what to delete. - let fixedStartContainer = + const fixedStartContainer = selection.startContainer.childNodes[selection.startOffset - 1]; - let fixedEndContainer = selection.endContainer.childNodes[selection.endOffset - 1]; + const fixedEndContainer = selection.endContainer.childNodes[selection.endOffset - 1]; if (fixedStartContainer && fixedEndContainer) { selection.setStart(fixedStartContainer, 0); selection.setEnd(fixedEndContainer, 1); @@ -522,7 +522,7 @@ } // if any part of a custom node is in the selection, include the whole thing if (isCustomNode(selection.startContainer)) { - let previousSibling = selection.startContainer.previousSibling; + const previousSibling = selection.startContainer.previousSibling; selection.setStart(previousSibling, previousSibling.length - 1); squire.setSelection(selection); } @@ -711,14 +711,14 @@ formulaEl.setAttribute('is', 'markdown-formula-field'); formulaEl.setAttribute('editing', true); formulaEl.innerHTML = formula; - let formulaHTML = formulaEl.outerHTML; + const formulaHTML = formulaEl.outerHTML; const activeMathFieldEl = this.findActiveMathField(); if (activeMathFieldEl !== null) { // setting `outerHTML` is the preferred way to reset a custom node activeMathFieldEl.outerHTML = formulaHTML; } else { - let squire = this.editor.getSquire(); + const squire = this.editor.getSquire(); squire.insertHTML(formulaHTML); } }, @@ -737,10 +737,10 @@ /* IMAGE MENU */ handleEditImage(event) { - let { editorField, editEvent, image } = event.detail; + const { editorField, editEvent, image } = event.detail; this.activeImageField = editorField; - let editorEl = this.$el; - let position = getExtensionMenuPosition({ + const editorEl = this.$el; + const position = getExtensionMenuPosition({ editorEl, targetX: editEvent.clientX, targetY: editEvent.clientY, diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownViewer/MarkdownViewer.vue b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownViewer/MarkdownViewer.vue index cd1b1c49bc..2769e88cd5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownViewer/MarkdownViewer.vue +++ b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownViewer/MarkdownViewer.vue @@ -68,7 +68,7 @@ methods: { initStaticMathFields() { const mathFieldEls = this.$el.getElementsByClassName(CLASS_MATH_FIELD); - for (let mathFieldEl of mathFieldEls) { + for (const mathFieldEl of mathFieldEls) { this.mathQuill.StaticMath(mathFieldEl); } }, diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/formulas/markdownFormulaField.spec.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/formulas/markdownFormulaField.spec.js index 806b1d3e8d..0c8a16bd49 100644 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/formulas/markdownFormulaField.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/formulas/markdownFormulaField.spec.js @@ -27,9 +27,9 @@ describe('MarkdownFormulaField custom element', () => { it('renders some MathQuill markup in a shadowRoot', async done => { await window.customElements.whenDefined('markdown-formula-field'); - let shadowRoot = formulaEl.shadowRoot; + const shadowRoot = formulaEl.shadowRoot; expect(shadowRoot).toBeTruthy(); - let varEls = shadowRoot.querySelectorAll('var'); + const varEls = shadowRoot.querySelectorAll('var'); expect(varEls[0].innerHTML).toBe('x'); expect(varEls[1].innerHTML).toBe('y'); done(); diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/formulas/formula-html-to-md.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/formulas/formula-html-to-md.js index 1d170c9d0a..1fb6abd25b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/formulas/formula-html-to-md.js +++ b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/formulas/formula-html-to-md.js @@ -31,10 +31,10 @@ export default html => { const mathFieldsEls = doc.querySelectorAll('span[is="markdown-formula-field"]'); for (const mathFieldEl of mathFieldsEls) { - let formula = mathFieldEl.innerHTML; + const formula = mathFieldEl.innerHTML; mathFieldEl.replaceWith('$$' + formula.trim() + '$$'); } - let newHtml = doc.body.innerHTML; + const newHtml = doc.body.innerHTML; return newHtml; }; diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/image-upload/style.less b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/image-upload/style.less index 78e77f5820..11392b3ec7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/image-upload/style.less +++ b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/image-upload/style.less @@ -8,6 +8,7 @@ img { max-width: 100%; height: auto; + /* stylelint-disable-next-line custom-property-pattern */ border: 1px solid var(--v-greyBorder-base); object-fit: cover; } @@ -48,6 +49,7 @@ img { top: 4px; right: 4px; user-select: none; + /* stylelint-disable-next-line custom-property-pattern */ background-color: var(--v-backgroundColor--base); opacity: 0; diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/registerCustomMarkdownField.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/registerCustomMarkdownField.js index da581955be..3074a8d543 100644 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/registerCustomMarkdownField.js +++ b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/plugins/registerCustomMarkdownField.js @@ -73,8 +73,8 @@ export default VueComponent => { mutations.forEach(mutation => { const checkIfElementNode = x => x.nodeType === document.ELEMENT_NODE; const checkIfTextNode = x => x.nodeType === document.TEXT_NODE; - let elementNodesAdded = [...mutation.addedNodes].filter(checkIfElementNode); - let textNodesRemoved = [...mutation.removedNodes].filter(checkIfTextNode); + const elementNodesAdded = [...mutation.addedNodes].filter(checkIfElementNode); + const textNodesRemoved = [...mutation.removedNodes].filter(checkIfTextNode); // Prevent TUI.editor from adding unwanted DOM elements to the custom element // This is necessary so that style modifiers don't wrap markdown in or tags. diff --git a/contentcuration/contentcuration/frontend/shared/views/ResizableNavigationDrawer.vue b/contentcuration/contentcuration/frontend/shared/views/ResizableNavigationDrawer.vue index c8fc608b05..a0c817f2dd 100644 --- a/contentcuration/contentcuration/frontend/shared/views/ResizableNavigationDrawer.vue +++ b/contentcuration/contentcuration/frontend/shared/views/ResizableNavigationDrawer.vue @@ -113,7 +113,7 @@ this.throttledUpdateWidth(e.clientX); }, updateWidth(clientX) { - let offset = this.isRight ? window.innerWidth - clientX : clientX; + const offset = this.isRight ? window.innerWidth - clientX : clientX; this.width = localStorage[this.localStorageName] = Math.min( Math.max(this.minWidth, offset), this.maxWidth diff --git a/contentcuration/contentcuration/frontend/shared/views/ToolBar.vue b/contentcuration/contentcuration/frontend/shared/views/ToolBar.vue index fe5cbc3b35..e1f20efce7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/ToolBar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/ToolBar.vue @@ -2,7 +2,9 @@ - + diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/copyToken.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/copyToken.spec.js index 0137778e95..46620f4904 100644 --- a/contentcuration/contentcuration/frontend/shared/views/__tests__/copyToken.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/copyToken.spec.js @@ -15,7 +15,7 @@ describe('copyToken', () => { wrapper = makeWrapper(); }); it('text should be populated on load', () => { - let token = wrapper.find({ name: 'v-text-field' }); + const token = wrapper.find({ name: 'v-text-field' }); expect(token.props().value).toEqual('testt-oken'); expect(wrapper.vm.copyStatus === 'IDLE'); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/messageDialog.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/messageDialog.spec.js index 763d61ebd1..e86a1b2c1b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/__tests__/messageDialog.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/messageDialog.spec.js @@ -13,11 +13,11 @@ function makeWrapper(options) { describe('messageDialog', () => { it('header and text should render properly from props', () => { - let wrapper = makeWrapper(); + const wrapper = makeWrapper(); expect(wrapper.find('[data-test="text"]').text()).toEqual('text'); }); it('slots should render content correctly', () => { - let wrapper = makeWrapper({ + const wrapper = makeWrapper({ slots: { default: ['new text'], buttons: ['buttons'], @@ -27,7 +27,7 @@ describe('messageDialog', () => { expect(wrapper.find('[data-test="buttons"]').text()).toContain('buttons'); }); it('close should emit an input event to close the modal', () => { - let wrapper = makeWrapper(); + const wrapper = makeWrapper(); wrapper.vm.close(); expect(wrapper.emitted('input')[0][0]).toBe(false); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/resizableNavigationDrawer.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/resizableNavigationDrawer.spec.js index b1782953a0..04a5e6a67e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/__tests__/resizableNavigationDrawer.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/resizableNavigationDrawer.spec.js @@ -7,7 +7,7 @@ function makeWrapper(props = {}) { describe('resizableNavigationDrawer', () => { it('slot should render content', () => { - let wrapper = makeWrapper({ + const wrapper = makeWrapper({ slots: { default: 'test content', }, diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/toggleText.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/toggleText.spec.js index dd156a35f2..7ad98767b3 100644 --- a/contentcuration/contentcuration/frontend/shared/views/__tests__/toggleText.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/toggleText.spec.js @@ -18,9 +18,9 @@ describe('toggleText', () => { expect(splitWrapper.vm.overflowText).toBeFalsy(); }); it('clicking the toggle button should collapse/expand text', () => { - let splitWrapper = makeWrapper(5); - let toggler = splitWrapper.find('.toggler'); - let overflow = splitWrapper.find('[data-test="overflow"]'); + const splitWrapper = makeWrapper(5); + const toggler = splitWrapper.find('.toggler'); + const overflow = splitWrapper.find('[data-test="overflow"]'); expect(overflow.isVisible()).toBe(false); toggler.trigger('click'); expect(overflow.isVisible()).toBe(true); diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue index f73779ba50..a67c060203 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue @@ -5,7 +5,7 @@ color="black" > diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelModal.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelModal.vue index b76c6782b2..50f66d3679 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelModal.vue @@ -72,7 +72,7 @@ v-model="contentDefaults" /> - + {{ isNew ? $tr('createButton') : $tr('saveChangesButton' ) }} @@ -109,8 +109,8 @@ import Vue from 'vue'; import { mapActions, mapGetters, mapMutations, mapState } from 'vuex'; - import ChannelSharing from './ChannelSharing'; import ChannelThumbnail from './ChannelThumbnail'; + import ChannelSharing from './ChannelSharing'; import { NEW_OBJECT, ErrorTypes } from 'shared/constants'; import MessageDialog from 'shared/views/MessageDialog'; import LanguageDropdown from 'shared/views/LanguageDropdown'; @@ -153,6 +153,7 @@ showUnsavedDialog: false, diffTracker: {}, dialog: true, + isDisable: false, }; }, computed: { @@ -287,21 +288,25 @@ ...mapActions('channel', ['updateChannel', 'loadChannel', 'commitChannel']), ...mapMutations('channel', ['REMOVE_CHANNEL']), saveChannel() { + this.isDisable = true; if (this.$refs.detailsform.validate()) { this.changed = false; if (this.isNew) { return this.commitChannel({ id: this.channelId, ...this.diffTracker }).then(() => { // TODO: Make sure channel gets created before navigating to channel window.location = window.Urls.channel(this.channelId); + this.isDisable = false; }); } else { return this.updateChannel({ id: this.channelId, ...this.diffTracker }).then(() => { this.$store.dispatch('showSnackbarSimple', this.$tr('changesSaved')); this.header = this.channel.name; + this.isDisable = false; }); } } else if (this.$refs.detailsform.$el.scrollIntoView) { this.$refs.detailsform.$el.scrollIntoView({ behavior: 'smooth' }); + this.isDisable = false; } }, updateTitleForPage() { @@ -321,7 +326,7 @@ this.dialog = value; }, setChannel(data) { - for (let key in data) { + for (const key in data) { Vue.set(this.diffTracker, key, data[key]); } this.changed = true; diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharingTable.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharingTable.vue index 2fbecc4b78..5c9353af24 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharingTable.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharingTable.vue @@ -172,7 +172,7 @@ user: state => state.session.currentUser, }), users() { - let users = this.getChannelUsers(this.channelId, this.mode); + const users = this.getChannelUsers(this.channelId, this.mode); // Make sure current user is at the top of the list if (users.find(u => u.id === this.user.id)) { @@ -215,7 +215,7 @@ 'removeViewer', ]), getUserText(user) { - let nameParams = { + const nameParams = { first_name: user.first_name, last_name: user.last_name, }; diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelThumbnail.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelThumbnail.vue index a6a0b448ca..4a9a59f395 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelThumbnail.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelThumbnail.vue @@ -331,6 +331,7 @@ .thumbnail { padding: 28% 0; + /* stylelint-disable-next-line custom-property-pattern */ border-color: var(--v-greyBorder-base) !important; border-style: solid; border-width: 1px; diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelModal.spec.js b/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelModal.spec.js index 40c66e9949..c23e85d48c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelModal.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelModal.spec.js @@ -18,7 +18,7 @@ const router = new VueRouter({ const store = storeFactory(); const channelId = '11111111111111111111111111111111'; -let tab = 'share'; +const tab = 'share'; function makeWrapper() { router @@ -62,7 +62,7 @@ describe('channelModal', () => { wrapper = makeWrapper(); }); it('clicking close should call cancelChanges', () => { - let cancelChanges = jest.fn(); + const cancelChanges = jest.fn(); wrapper.setMethods({ cancelChanges }); wrapper.find('[data-test="close"]').trigger('click'); expect(cancelChanges).toHaveBeenCalled(); diff --git a/contentcuration/contentcuration/frontend/shared/views/details/Details.vue b/contentcuration/contentcuration/frontend/shared/views/details/Details.vue index 4beb67f2d4..ce5397a624 100644 --- a/contentcuration/contentcuration/frontend/shared/views/details/Details.vue +++ b/contentcuration/contentcuration/frontend/shared/views/details/Details.vue @@ -83,6 +83,42 @@ + + + + + +