diff --git a/.docker/README.md b/.docker/README.md index 66b76b0f2d..5e18956d90 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -4,7 +4,7 @@ This directory is a space for mounting directories to docker containers, allowin ### 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. +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 `pnpm 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. diff --git a/.eslintrc.js b/.eslintrc.js index a9539d7f86..ac430c7806 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,4 @@ -const esLintConfig = require('kolibri-tools/.eslintrc'); +const esLintConfig = require('kolibri-format/.eslintrc'); esLintConfig.globals = { $: false, @@ -8,7 +8,13 @@ esLintConfig.globals = { MathJax: false, jest: false, }; -esLintConfig.settings['import/resolver']['webpack'] = { config: 'webpack.config.js'}; +esLintConfig.settings['import/resolver']['webpack'] = { config: require.resolve('./webpack.config.js')}; + +// Remove once Vuetify is gone-- Vuetify uses too many unacceptable class names +esLintConfig.rules['kolibri/vue-component-class-name-casing'] = 0; + +// Dumb +esLintConfig.rules['vue/no-v-text-v-html-on-component'] = 0; // Vuetify's helper attributes use hyphens and they would // not be recognized if auto-formatted to camel case diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..7c8dcb41d3 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# Run this command to always ignore formatting commits in `git blame` +# git config blame.ignoreRevsFile .git-blame-ignore-revs + +# Linting updates and fixes +a52e08e5c2031cecb97a03fbed49997756ebe01b diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 24349f8d83..57a66d4c51 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,7 +12,7 @@ updates: time: "00:00" # Maintain dependencies for Javascript - - package-ecosystem: "npm" + - package-ecosystem: "pnpm" directory: "/" schedule: interval: "weekly" diff --git a/.github/workflows/deploytest.yml b/.github/workflows/deploytest.yml index 71b3b9296c..a6cac9754c 100644 --- a/.github/workflows/deploytest.yml +++ b/.github/workflows/deploytest.yml @@ -20,7 +20,7 @@ jobs: uses: fkirc/skip-duplicate-actions@master with: github_token: ${{ github.token }} - paths: '["**.py", "requirements.txt", ".github/workflows/deploytest.yml", "**.vue", "**.js", "yarn.lock", "package.json"]' + paths: '["**.py", "requirements.txt", ".github/workflows/deploytest.yml", "**.vue", "**.js", "pnpm-lock.yaml", "package.json"]' build_assets: name: Build frontend assets needs: pre_job @@ -28,23 +28,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Use pnpm + uses: pnpm/action-setup@v4 - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '16.x' - - name: Cache Node.js modules - uses: actions/cache@v4 - with: - path: '**/node_modules' - key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.OS }}-node- + node-version: '18.x' + cache: 'pnpm' - name: Install dependencies run: | - yarn --frozen-lockfile - npm rebuild node-sass + pnpm install --frozen-lockfile + pnpm rebuild node-sass - name: Build frontend - run: yarn run build + run: pnpm run build make_messages: name: Build all message files needs: pre_job @@ -68,21 +64,17 @@ jobs: python -m pip install --upgrade pip pip install pip-tools pip-sync requirements.txt + - name: Use pnpm + uses: pnpm/action-setup@v4 - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '16.x' - - name: Cache Node.js modules - uses: actions/cache@v4 - with: - path: '**/node_modules' - key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.OS }}-node- + node-version: '18.x' + cache: 'pnpm' - name: Install node dependencies run: | - yarn --frozen-lockfile - npm rebuild node-sass + pnpm install --frozen-lockfile + pnpm rebuild node-sass - name: Install gettext run: | sudo apt-get update -y diff --git a/.github/workflows/frontendlint.yml b/.github/workflows/frontendlint.yml index 371706b251..7a6df55e63 100644 --- a/.github/workflows/frontendlint.yml +++ b/.github/workflows/frontendlint.yml @@ -20,7 +20,7 @@ jobs: uses: fkirc/skip-duplicate-actions@master with: github_token: ${{ github.token }} - paths: '["**.vue", "**.js", "yarn.lock", ".github/workflows/frontendlint.yml"]' + paths: '["**.vue", "**.js", "pnpm-lock.yaml", ".github/workflows/frontendlint.yml"]' test: name: Frontend linting needs: pre_job @@ -28,23 +28,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Use pnpm + uses: pnpm/action-setup@v4 - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '16.x' - - name: Cache Node.js modules - uses: actions/cache@v4 - with: - path: '**/node_modules' - key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.OS }}-node- + node-version: '18.x' + cache: 'pnpm' - name: Install dependencies run: | - yarn --frozen-lockfile - npm rebuild node-sass + pnpm install --frozen-lockfile + pnpm rebuild node-sass - name: Run tests - run: yarn run lint-frontend:format + run: pnpm run lint-frontend:format - name: Run pre-commit-ci-lite uses: pre-commit-ci/lite-action@v1.1.0 if: always() diff --git a/.github/workflows/frontendtest.yml b/.github/workflows/frontendtest.yml index e83ac316d8..36366b5651 100644 --- a/.github/workflows/frontendtest.yml +++ b/.github/workflows/frontendtest.yml @@ -20,7 +20,7 @@ jobs: uses: fkirc/skip-duplicate-actions@master with: github_token: ${{ github.token }} - paths: '["**.vue", "**.js", "yarn.lock"]' + paths: '["**.vue", "**.js", "pnpm-lock.yaml"]' test: name: Frontend tests needs: pre_job @@ -28,20 +28,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Use pnpm + uses: pnpm/action-setup@v4 - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '16.x' - - name: Cache Node.js modules - uses: actions/cache@v4 - with: - path: '**/node_modules' - key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.OS }}-node- + node-version: '18.x' + cache: 'pnpm' - name: Install dependencies run: | - yarn --frozen-lockfile - npm rebuild node-sass + pnpm install --frozen-lockfile + pnpm rebuild node-sass - name: Run tests - run: yarn run test + run: pnpm run test diff --git a/.htmlhintrc.js b/.htmlhintrc.js deleted file mode 100644 index 9c906cb345..0000000000 --- a/.htmlhintrc.js +++ /dev/null @@ -1,5 +0,0 @@ -const htmlHintConfig = require('kolibri-tools/.htmlhintrc'); -htmlHintConfig['id-class-value'] = false; -htmlHintConfig['--vue-component-conventions'] = false; -htmlHintConfig['id-class-value'] = false; -module.exports = htmlHintConfig; diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b3085d3aa6..e0bad917b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,6 +22,6 @@ repos: - id: frontend-lint name: Linting of JS, Vue, SCSS and CSS files description: This hook handles all frontend linting for Kolibri Studio - entry: yarn run lint-frontend:format + entry: pnpm run lint-frontend:format language: system files: \.(js|vue|scss|css)$ diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 120000 index f425df857f..0000000000 --- a/.prettierrc.js +++ /dev/null @@ -1 +0,0 @@ -./node_modules/kolibri-tools/.prettierrc.js \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000000..aa0587e6af --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1 @@ +module.exports = require('kolibri-format/.prettierrc'); diff --git a/.stylelintrc.js b/.stylelintrc.js index 88bbd1b144..9b7b1077f7 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,6 +1,6 @@ module.exports = { extends: [ - 'kolibri-tools/.stylelintrc', + 'kolibri-format/.stylelintrc', ], rules: { /* @@ -9,10 +9,10 @@ module.exports = { */ 'selector-max-id': null, // This would require a major refactor 'csstree/validator': null, // this triggers issues with unknown at rules too. - 'selector-pseudo-element-no-unknown': [ + 'selector-pseudo-element-no-unknown': [ true, { - // In Vue 2.6 and later, `::v-deep` is used for deep selectors. + // In Vue 2.6 and later, `::v-deep` is used for deep selectors. // This rule allows `::v-deep` to prevent linting errors. ignorePseudoElements: ['v-deep'], } diff --git a/Makefile b/Makefile index 619fcee41e..d8e53cd189 100644 --- a/Makefile +++ b/Makefile @@ -66,7 +66,7 @@ reconcile: ############################################################### i18n-extract-frontend: # generate frontend messages - yarn makemessages + pnpm makemessages i18n-extract-backend: # generate backend messages @@ -75,7 +75,7 @@ i18n-extract-backend: i18n-extract: i18n-extract-frontend i18n-extract-backend i18n-transfer-context: - yarn transfercontext + pnpm transfercontext i18n-django-compilemessages: # Change working directory to contentcuration/ such that compilemessages @@ -94,9 +94,9 @@ i18n-pretranslate-approve-all: 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} - yarn exec kolibri-tools i18n-code-gen -- --output-dir ./contentcuration/contentcuration/frontend/shared/i18n + pnpm 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 + pnpm exec kolibri-tools i18n-create-message-files -- --namespace contentcuration --searchPath ./contentcuration/contentcuration/frontend i18n-download: i18n-download-translations diff --git a/contentcuration/contentcuration/frontend/RecommendedResourceCard/components/RecommendedResourceCard.vue b/contentcuration/contentcuration/frontend/RecommendedResourceCard/components/RecommendedResourceCard.vue index 8408c4bfbc..679cc138da 100644 --- a/contentcuration/contentcuration/frontend/RecommendedResourceCard/components/RecommendedResourceCard.vue +++ b/contentcuration/contentcuration/frontend/RecommendedResourceCard/components/RecommendedResourceCard.vue @@ -91,7 +91,7 @@ }; }, isSelected() { - return function(node) { + return function (node) { return Boolean(find(this.selected, { id: node.id })); }; }, @@ -127,6 +127,7 @@ diff --git a/contentcuration/contentcuration/frontend/accounts/components/MessageLayout.vue b/contentcuration/contentcuration/frontend/accounts/components/MessageLayout.vue index 869ccf3a88..096f3a1205 100644 --- a/contentcuration/contentcuration/frontend/accounts/components/MessageLayout.vue +++ b/contentcuration/contentcuration/frontend/accounts/components/MessageLayout.vue @@ -1,15 +1,19 @@ + + \ 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 de49330d84..907f26d265 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ForgotPassword.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ForgotPassword.vue @@ -4,9 +4,21 @@ :header="$tr('forgotPasswordTitle')" :text="$tr('forgotPasswordPrompt')" > - - - + + + + \ No newline at end of file + diff --git a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue index ebd60495b8..f7149673d5 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue @@ -4,8 +4,17 @@ :header="$tr('resetPasswordTitle')" :text="$tr('resetPasswordPrompt')" > - - + + + + + diff --git a/contentcuration/contentcuration/frontend/administration/components/__tests__/clipboardChip.spec.js b/contentcuration/contentcuration/frontend/administration/components/__tests__/clipboardChip.spec.js index a696e950c4..143c861d46 100644 --- a/contentcuration/contentcuration/frontend/administration/components/__tests__/clipboardChip.spec.js +++ b/contentcuration/contentcuration/frontend/administration/components/__tests__/clipboardChip.spec.js @@ -1,8 +1,10 @@ import { mount } from '@vue/test-utils'; import ClipboardChip from '../ClipboardChip.vue'; +import { factory } from '../../store'; function makeWrapper() { return mount(ClipboardChip, { + store: factory(), propsData: { value: 'testtoken', }, @@ -11,19 +13,20 @@ function makeWrapper() { describe('clipboardChip', () => { let wrapper; + beforeEach(() => { navigator.clipboard = { - writeText: jest.fn(), + writeText: jest.fn().mockImplementation(() => Promise.resolve()), }; wrapper = makeWrapper(); }); + afterEach(() => { delete navigator.clipboard; }); - it('should fire a copy operation on button click', () => { - const copyToClipboard = jest.fn(); - wrapper.setMethods({ copyToClipboard }); - wrapper.find('[data-test="copy"]').trigger('click'); - expect(copyToClipboard).toHaveBeenCalled(); + + it('should fire a copy operation on button click', async () => { + await wrapper.findComponent({ ref: 'copyButton' }).trigger('click'); + expect(navigator.clipboard.writeText).toHaveBeenCalled(); }); }); diff --git a/contentcuration/contentcuration/frontend/administration/mixins.js b/contentcuration/contentcuration/frontend/administration/mixins.js index c27e933ed5..22199e0bfb 100644 --- a/contentcuration/contentcuration/frontend/administration/mixins.js +++ b/contentcuration/contentcuration/frontend/administration/mixins.js @@ -51,7 +51,7 @@ export function generateFilterMixin(filterMap) { result[key] = this.$route.query[key]; return result; }, - {} + {}, ); // Set the router with the params from the filterMap and current route @@ -88,7 +88,7 @@ export function generateFilterMixin(filterMap) { result[key] = value; } }, - {} + {}, ); this.$router.push({ query }).catch(error => { if (error && error.name != 'NavigationDuplicated') { @@ -96,7 +96,7 @@ export function generateFilterMixin(filterMap) { } }); }, - clearSearch: function() { + clearSearch: function () { this.keywords = ''; }, updateKeywords() { @@ -140,7 +140,7 @@ export const tableMixin = { }, (value, key) => { return value !== null && key !== 'rowsPerPage' && key !== 'totalItems'; - } + }, ); this.$router diff --git a/contentcuration/contentcuration/frontend/administration/pages/AdministrationIndex.vue b/contentcuration/contentcuration/frontend/administration/pages/AdministrationIndex.vue index 5650631c1a..09f7ac3455 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/AdministrationIndex.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/AdministrationIndex.vue @@ -2,7 +2,10 @@ - - - + @@ -225,5 +226,4 @@ - + diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue index d2b166bf8b..470a807b9e 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue @@ -2,23 +2,47 @@ - + - + This channel has been deleted @@ -31,8 +55,11 @@ @deleted="dialog = false" /> - -
+ - + @@ -50,13 +80,14 @@ + + diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue index cc0a438725..1fb7d08010 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue @@ -4,8 +4,16 @@

{{ `${$formatNumber(count)} ${count === 1 ? 'channel' : 'channels'}` }}

- - + + - + - + @@ -210,7 +234,11 @@ }, methods: { ...mapActions('channelAdmin', ['loadChannels', 'getAdminChannelListDetails']), - /* @public - used in generated filterMixin */ + /** + * @public + * @param params + * @return {*} + */ fetch(params) { return this.loadChannels(params); }, diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelActionsDropdown.spec.js b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelActionsDropdown.spec.js index cf63c1bb23..01113d2371 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelActionsDropdown.spec.js +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelActionsDropdown.spec.js @@ -6,7 +6,6 @@ import ChannelActionsDropdown from '../ChannelActionsDropdown'; const store = factory(); const channelId = '11111111111111111111111111111111'; -const updateChannel = jest.fn().mockReturnValue(Promise.resolve()); const channel = { id: channelId, name: 'Channel Test', @@ -21,7 +20,24 @@ const channel = { }; function makeWrapper(channelProps = {}) { - return mount(ChannelActionsDropdown, { + const mocks = { + restore() { + for (const key of Object.keys(this)) { + if (key === 'restore') continue; + this[key].mockRestore(); + } + }, + }; + mocks.downloadPDF = jest.spyOn(ChannelActionsDropdown.methods, 'downloadPDF').mockResolvedValue(); + mocks.downloadCSV = jest.spyOn(ChannelActionsDropdown.methods, 'downloadCSV').mockResolvedValue(); + mocks.deleteChannel = jest + .spyOn(ChannelActionsDropdown.methods, 'deleteChannel') + .mockResolvedValue(); + mocks.updateChannel = jest + .spyOn(ChannelActionsDropdown.methods, 'updateChannel') + .mockResolvedValue(); + + const wrapper = mount(ChannelActionsDropdown, { router, store, propsData: { channelId }, @@ -33,83 +49,95 @@ function makeWrapper(channelProps = {}) { }; }, }, - methods: { updateChannel }, }); + + return [wrapper, mocks]; } describe('channelActionsDropdown', () => { - let wrapper; - beforeEach(() => { - updateChannel.mockClear(); + let wrapper, mocks; + + afterEach(() => { + if (mocks) { + mocks.restore(); + } }); describe('deleted channel actions', () => { beforeEach(() => { - wrapper = makeWrapper({ deleted: true }); + [wrapper, mocks] = makeWrapper({ deleted: true }); }); - it('restore channel should open restore confirmation', () => { - wrapper.find('[data-test="restore"]').trigger('click'); + + it('restore channel should open restore confirmation', async () => { + await wrapper.findComponent('[data-test="restore"]').trigger('click'); expect(wrapper.vm.restoreDialog).toBe(true); }); + it('confirm restore channel should call updateChannel with deleted = false', () => { - wrapper.find('[data-test="confirm-restore"]').vm.$emit('confirm'); - expect(updateChannel).toHaveBeenCalledWith({ id: channelId, deleted: false }); + wrapper.findComponent('[data-test="confirm-restore"]').vm.$emit('confirm'); + expect(mocks.updateChannel).toHaveBeenCalledWith({ id: channelId, deleted: false }); }); - it('delete channel should open delete confirmation', () => { - wrapper.find('[data-test="delete"]').trigger('click'); + + it('delete channel should open delete confirmation', async () => { + await wrapper.findComponent('[data-test="delete"]').trigger('click'); expect(wrapper.vm.deleteDialog).toBe(true); }); + it('confirm delete channel should call deleteChannel', () => { - const deleteChannel = jest.fn().mockReturnValue(Promise.resolve()); - wrapper.setMethods({ deleteChannel }); - wrapper.find('[data-test="confirm-delete"]').vm.$emit('confirm'); - expect(deleteChannel).toHaveBeenCalledWith(channelId); + wrapper.findComponent('[data-test="confirm-delete"]').vm.$emit('confirm'); + expect(mocks.deleteChannel).toHaveBeenCalledWith(channelId); }); }); + describe('live channel actions', () => { beforeEach(() => { - wrapper = makeWrapper({ public: false, deleted: false }); + [wrapper, mocks] = makeWrapper({ public: false, deleted: false }); }); - it('download PDF button should call downloadPDF', () => { - const downloadPDF = jest.fn(); - wrapper.setMethods({ downloadPDF }); - wrapper.find('[data-test="pdf"]').trigger('click'); - expect(downloadPDF).toHaveBeenCalled(); + + it('download PDF button should call downloadPDF', async () => { + await wrapper.findComponent('[data-test="pdf"]').trigger('click'); + expect(mocks.downloadPDF).toHaveBeenCalled(); }); - it('download CSV button should call downloadCSV', () => { - const downloadCSV = jest.fn(); - wrapper.setMethods({ downloadCSV }); - wrapper.find('[data-test="csv"]').trigger('click'); - expect(downloadCSV).toHaveBeenCalled(); + + it('download CSV button should call downloadCSV', async () => { + await wrapper.findComponent('[data-test="csv"]').trigger('click'); + expect(mocks.downloadCSV).toHaveBeenCalled(); }); - it('make public button should open make public confirmation', () => { - wrapper.find('[data-test="public"]').trigger('click'); + + it('make public button should open make public confirmation', async () => { + await wrapper.findComponent('[data-test="public"]').trigger('click'); expect(wrapper.vm.makePublicDialog).toBe(true); }); + it('confirm make public should call updateChannel with isPublic = true', () => { - wrapper.find('[data-test="confirm-public"]').vm.$emit('confirm'); - expect(updateChannel).toHaveBeenCalledWith({ id: channelId, isPublic: true }); + wrapper.findComponent('[data-test="confirm-public"]').vm.$emit('confirm'); + expect(mocks.updateChannel).toHaveBeenCalledWith({ id: channelId, isPublic: true }); }); - it('soft delete button should open soft delete confirmation', () => { - wrapper.find('[data-test="softdelete"]').trigger('click'); + + it('soft delete button should open soft delete confirmation', async () => { + await wrapper.findComponent('[data-test="softdelete"]').trigger('click'); expect(wrapper.vm.softDeleteDialog).toBe(true); }); + it('confirm soft delete button should call updateChannel with deleted = true', () => { - wrapper.find('[data-test="confirm-softdelete"]').vm.$emit('confirm'); - expect(updateChannel).toHaveBeenCalledWith({ id: channelId, deleted: true }); + wrapper.findComponent('[data-test="confirm-softdelete"]').vm.$emit('confirm'); + expect(mocks.updateChannel).toHaveBeenCalledWith({ id: channelId, deleted: true }); }); }); + describe('public channel actions', () => { beforeEach(() => { - wrapper = makeWrapper(); + [wrapper, mocks] = makeWrapper(); }); - it('make private button should open make private confirmation', () => { - wrapper.find('[data-test="private"]').trigger('click'); + + it('make private button should open make private confirmation', async () => { + await wrapper.findComponent('[data-test="private"]').trigger('click'); expect(wrapper.vm.makePrivateDialog).toBe(true); }); + it('confirm make private should call updateChannel with isPublic = false', () => { - wrapper.find('[data-test="confirm-private"]').vm.$emit('confirm'); - expect(updateChannel).toHaveBeenCalledWith({ id: channelId, isPublic: false }); + wrapper.findComponent('[data-test="confirm-private"]').vm.$emit('confirm'); + expect(mocks.updateChannel).toHaveBeenCalledWith({ id: channelId, isPublic: false }); }); }); }); diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelDetails.spec.js b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelDetails.spec.js index 08e2099eed..aea277c0af 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelDetails.spec.js +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelDetails.spec.js @@ -32,44 +32,47 @@ function makeWrapper() { describe('channelDetails', () => { let wrapper; + beforeEach(() => { wrapper = makeWrapper(); }); + it('clicking close should close the modal', () => { wrapper.vm.dialog = false; expect(wrapper.vm.$route.name).toBe(RouteNames.CHANNELS); }); + describe('load', () => { - it('should automatically close if loadChannel does not find a channel', () => { - wrapper.setMethods({ - loadChannel: jest.fn().mockReturnValue(Promise.resolve()), - loadChannelDetails: jest.fn().mockReturnValue(Promise.resolve()), - }); - return wrapper.vm.load().then(() => { - expect(wrapper.vm.$route.name).toBe(RouteNames.CHANNELS); - }); + it('should automatically close if loadChannel does not find a channel', async () => { + const loadChannel = jest.spyOn(wrapper.vm, 'loadChannel'); + loadChannel.mockReturnValue(Promise.resolve()); + const loadChannelDetails = jest.spyOn(wrapper.vm, 'loadChannelDetails'); + loadChannelDetails.mockReturnValue(Promise.resolve()); + await wrapper.vm.load(); + expect(wrapper.vm.$route.name).toBe(RouteNames.CHANNELS); }); - it('load should call loadChannel and loadChannelDetails', () => { - const loadChannel = jest.fn().mockReturnValue(Promise.resolve({ id: channelId })); - const loadChannelDetails = jest.fn().mockReturnValue(Promise.resolve()); - wrapper.setMethods({ loadChannel, loadChannelDetails }); - return wrapper.vm.load().then(() => { - expect(loadChannel).toHaveBeenCalled(); - expect(loadChannelDetails).toHaveBeenCalled(); - }); + + it('load should call loadChannel and loadChannelDetails', async () => { + const loadChannel = jest.spyOn(wrapper.vm, 'loadChannel'); + loadChannel.mockReturnValue(Promise.resolve({ id: channelId })); + const loadChannelDetails = jest.spyOn(wrapper.vm, 'loadChannelDetails'); + loadChannelDetails.mockReturnValue(Promise.resolve()); + await wrapper.vm.load(); + expect(loadChannel).toHaveBeenCalled(); + expect(loadChannelDetails).toHaveBeenCalled(); }); }); - it('clicking info tab should navigate to info tab', () => { + + it('clicking info tab should navigate to info tab', async () => { wrapper.vm.tab = 'share'; - wrapper.find('[data-test="info-tab"] a').trigger('click'); - wrapper.vm.$nextTick(() => { - expect(wrapper.vm.tab).toBe('info'); - }); + await wrapper.findComponent('[data-test="info-tab"] a').trigger('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.tab).toBe('info'); }); - it('clicking share tab should navigate to share tab', () => { - wrapper.find('[data-test="share-tab"] a').trigger('click'); - wrapper.vm.$nextTick(() => { - expect(wrapper.vm.tab).toBe('share'); - }); + + it('clicking share tab should navigate to share tab', async () => { + await wrapper.find('[data-test="share-tab"] a').trigger('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.tab).toBe('share'); }); }); diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelItem.spec.js b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelItem.spec.js index acd7dc8fb5..9272eb90fd 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelItem.spec.js +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelItem.spec.js @@ -42,36 +42,39 @@ function makeWrapper() { describe('channelItem', () => { let wrapper; + beforeEach(() => { wrapper = makeWrapper(); }); + it('selecting the channel should emit list with channel id', () => { wrapper.vm.selected = true; expect(wrapper.emitted('input')[0][0]).toEqual([channelId]); }); - it('deselecting the channel should emit list without channel id', () => { - wrapper.setProps({ value: [channelId] }); + + it('deselecting the channel should emit list without channel id', async () => { + await wrapper.setProps({ value: [channelId] }); wrapper.vm.selected = false; expect(wrapper.emitted('input')[0][0]).toEqual([]); }); - it('saveDemoServerUrl should call updateChannel with new demo_server_url', () => { - const updateChannel = jest.fn().mockReturnValue(Promise.resolve()); - wrapper.setMethods({ updateChannel }); - return wrapper.vm.saveDemoServerUrl().then(() => { - expect(updateChannel).toHaveBeenCalledWith({ - id: channelId, - demo_server_url: channel.demo_server_url, - }); + + it('saveDemoServerUrl should call updateChannel with new demo_server_url', async () => { + const updateChannel = jest.spyOn(wrapper.vm, 'updateChannel'); + updateChannel.mockReturnValue(Promise.resolve()); + await wrapper.vm.saveDemoServerUrl(); + expect(updateChannel).toHaveBeenCalledWith({ + id: channelId, + demo_server_url: channel.demo_server_url, }); }); - it('saveSourceUrl should call updateChannel with new source_url', () => { - const updateChannel = jest.fn().mockReturnValue(Promise.resolve()); - wrapper.setMethods({ updateChannel }); - return wrapper.vm.saveSourceUrl().then(() => { - expect(updateChannel).toHaveBeenCalledWith({ - id: channelId, - source_url: channel.source_url, - }); + + it('saveSourceUrl should call updateChannel with new source_url', async () => { + const updateChannel = jest.spyOn(wrapper.vm, 'updateChannel'); + updateChannel.mockReturnValue(Promise.resolve()); + await wrapper.vm.saveSourceUrl(); + expect(updateChannel).toHaveBeenCalledWith({ + id: channelId, + source_url: channel.source_url, }); }); }); diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelTable.spec.js b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelTable.spec.js index 1ced780018..c300285443 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelTable.spec.js +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelTable.spec.js @@ -6,14 +6,19 @@ import ChannelTable from '../ChannelTable'; const store = factory(); -const loadChannels = jest.fn().mockReturnValue(Promise.resolve()); const channelList = ['test', 'channel', 'table']; +let loadItems; + function makeWrapper() { + loadItems = jest.spyOn(ChannelTable.mixins[0].methods, '_loadItems'); + loadItems.mockImplementation(() => Promise.resolve()); + router.replace({ name: RouteNames.CHANNELS }); + return mount(ChannelTable, { router, store, - sync: false, + attachTo: document.body, computed: { count() { return 10; @@ -22,9 +27,6 @@ function makeWrapper() { return channelList; }, }, - methods: { - loadChannels, - }, stubs: { ChannelItem: true, }, @@ -33,9 +35,13 @@ function makeWrapper() { describe('channelTable', () => { let wrapper; + beforeEach(() => { wrapper = makeWrapper(); }); + afterEach(() => { + loadItems.mockRestore(); + }); describe('filters', () => { it('changing filter should set query params', () => { wrapper.vm.filter = 'public'; @@ -55,18 +61,17 @@ describe('channelTable', () => { wrapper.vm.selectAll = true; expect(wrapper.vm.selected).toEqual(channelList); }); - it('removing selectAll should set selected to empty list', () => { + it('removing selectAll should set selected to empty list', async () => { wrapper.vm.selected = channelList; wrapper.vm.selectAll = false; - wrapper.vm.$nextTick(() => { - expect(wrapper.vm.selected).toEqual([]); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.selected).toEqual([]); }); it('selectedCount should match the selected length', () => { wrapper.vm.selected = ['test']; expect(wrapper.vm.selectedCount).toBe(1); }); - it('selected should clear on query changes', () => { + it('selected should clear on query changes', async () => { wrapper.vm.selected = ['test']; router.push({ ...wrapper.vm.$route, @@ -74,9 +79,8 @@ describe('channelTable', () => { param: 'test', }, }); - wrapper.vm.$nextTick(() => { - expect(wrapper.vm.selected).toEqual([]); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.selected).toEqual([]); }); }); describe('bulk actions', () => { @@ -84,30 +88,27 @@ describe('channelTable', () => { expect(wrapper.find('[data-test="csv"]').exists()).toBe(false); expect(wrapper.find('[data-test="pdf"]').exists()).toBe(false); }); - it('should be visible if items are selected', () => { + it('should be visible if items are selected', async () => { wrapper.vm.selected = channelList; - wrapper.vm.$nextTick(() => { - expect(wrapper.find('[data-test="csv"]').exists()).toBe(true); - expect(wrapper.find('[data-test="pdf"]').exists()).toBe(true); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.find('[data-test="csv"]').exists()).toBe(true); + expect(wrapper.find('[data-test="pdf"]').exists()).toBe(true); }); - it('download PDF should call downloadPDF', () => { - const downloadPDF = jest.fn(); - wrapper.setMethods({ downloadPDF }); + it('download PDF should call downloadPDF', async () => { + const downloadPDF = jest.spyOn(wrapper.vm, 'downloadPDF'); + downloadPDF.mockImplementation(() => Promise.resolve()); wrapper.vm.selected = channelList; - wrapper.vm.$nextTick(() => { - wrapper.find('[data-test="pdf"] .v-btn').trigger('click'); - expect(downloadPDF).toHaveBeenCalled(); - }); + await wrapper.vm.$nextTick(); + wrapper.findComponent('[data-test="pdf"]').trigger('click'); + expect(downloadPDF).toHaveBeenCalled(); }); - it('download CSV should call downloadCSV', () => { - const downloadCSV = jest.fn(); - wrapper.setMethods({ downloadCSV }); + it('download CSV should call downloadCSV', async () => { + const downloadCSV = jest.spyOn(wrapper.vm, 'downloadCSV'); + downloadCSV.mockImplementation(() => Promise.resolve()); wrapper.vm.selected = channelList; - wrapper.vm.$nextTick(() => { - wrapper.find('[data-test="csv"] .v-btn').trigger('click'); - expect(downloadCSV).toHaveBeenCalled(); - }); + await wrapper.vm.$nextTick(); + wrapper.findComponent('[data-test="csv"]').trigger('click'); + expect(downloadCSV).toHaveBeenCalled(); }); }); }); diff --git a/contentcuration/contentcuration/frontend/administration/pages/Users/EmailUsersDialog.vue b/contentcuration/contentcuration/frontend/administration/pages/Users/EmailUsersDialog.vue index 242baecb8d..a77085c5d1 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Users/EmailUsersDialog.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Users/EmailUsersDialog.vue @@ -1,14 +1,28 @@