diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8f81bf7041..fda197ed70 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,20 +4,22 @@ version: 2 updates: # Maintain dependencies for Python - - package-ecosystem: "pip" + - package-ecosystem: "uv" directory: "/" schedule: - interval: "weekly" - day: "wednesday" + interval: "monthly" time: "00:00" + cooldown: + default-days: 7 # Maintain dependencies for Javascript - package-ecosystem: "npm" directory: "/" schedule: - interval: "weekly" - day: "wednesday" + interval: "monthly" time: "00:00" + cooldown: + default-days: 7 groups: babel: patterns: @@ -33,9 +35,10 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" - day: "wednesday" + interval: "monthly" time: "00:00" + cooldown: + default-days: 7 groups: github: patterns: diff --git a/.github/workflows/call-contributor-pr-reply.yml b/.github/workflows/call-contributor-pr-reply.yml new file mode 100644 index 0000000000..e8316e041b --- /dev/null +++ b/.github/workflows/call-contributor-pr-reply.yml @@ -0,0 +1,12 @@ +name: Send reply on a new contributor pull request +on: + pull_request_target: + types: [opened] +jobs: + call-workflow: + name: Call shared workflow + uses: learningequality/.github/.github/workflows/contributor-pr-reply.yml@main + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} diff --git a/.github/workflows/call-pull-request-target.yml b/.github/workflows/call-pull-request-target.yml new file mode 100644 index 0000000000..737c149000 --- /dev/null +++ b/.github/workflows/call-pull-request-target.yml @@ -0,0 +1,11 @@ +name: Handle pull request events +on: + pull_request_target: + types: [review_requested, labeled] +jobs: + call-workflow: + name: Call shared workflow + uses: learningequality/.github/.github/workflows/pull-request-target.yml@main + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/update-pr-spreadsheet.yml b/.github/workflows/call-update-pr-spreadsheet.yml similarity index 60% rename from .github/workflows/update-pr-spreadsheet.yml rename to .github/workflows/call-update-pr-spreadsheet.yml index 8411239cd0..35464d6b02 100644 --- a/.github/workflows/update-pr-spreadsheet.yml +++ b/.github/workflows/call-update-pr-spreadsheet.yml @@ -1,12 +1,15 @@ name: Update community pull requests spreadsheet on: pull_request_target: - types: [assigned,unassigned,opened,closed,reopened] + types: [assigned, unassigned, opened, closed, reopened, edited, review_requested, review_request_removed] jobs: - call-update-spreadsheet: + call-workflow: + name: Call shared workflow uses: learningequality/.github/.github/workflows/update-pr-spreadsheet.yml@main secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} CONTRIBUTIONS_SPREADSHEET_ID: ${{ secrets.CONTRIBUTIONS_SPREADSHEET_ID }} CONTRIBUTIONS_SHEET_NAME: ${{ secrets.CONTRIBUTIONS_SHEET_NAME }} GH_UPLOADER_GCP_SA_CREDENTIALS: ${{ secrets.GH_UPLOADER_GCP_SA_CREDENTIALS }} diff --git a/.github/workflows/containerbuild.yml b/.github/workflows/containerbuild.yml index 0056d99cb4..3893888937 100644 --- a/.github/workflows/containerbuild.yml +++ b/.github/workflows/containerbuild.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout codebase - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -88,7 +88,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout codebase - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/deploytest.yml b/.github/workflows/deploytest.yml index cb4e4a2346..6cc81d3501 100644 --- a/.github/workflows/deploytest.yml +++ b/.github/workflows/deploytest.yml @@ -27,11 +27,11 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Use pnpm uses: pnpm/action-setup@v4 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' cache: 'pnpm' @@ -47,29 +47,21 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: python-version: '3.10' - - name: pip cache - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pyprod-${{ hashFiles('requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pyprod- - - name: Install pip-tools and python dependencies + activate-environment: "true" + enable-cache: "true" + - name: Install python dependencies with uv run: | - # Pin pip to 25.2 to avoid incompatibility with pip-tools and 25.3 - # see https://github.com/jazzband/pip-tools/issues/2252 - python -m pip install pip==25.2 - pip install pip-tools - pip-sync requirements.txt + # Use uv to install dependencies directly from requirements files + uv pip sync requirements.txt - name: Use pnpm uses: pnpm/action-setup@v4 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' cache: 'pnpm' diff --git a/.github/workflows/frontendtest.yml b/.github/workflows/frontendtest.yml index f886ac8c6c..f98fd612ad 100644 --- a/.github/workflows/frontendtest.yml +++ b/.github/workflows/frontendtest.yml @@ -27,11 +27,11 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Use pnpm uses: pnpm/action-setup@v4 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' cache: 'pnpm' diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 1bb5d71a03..fc5315bea3 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -31,14 +31,16 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: python-version: '3.10' + ignore-nothing-to-cache: 'true' - name: Use pnpm uses: pnpm/action-setup@v4 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' cache: 'pnpm' diff --git a/.github/workflows/pythontest.yml b/.github/workflows/pythontest.yml index 5467f8a47c..e77c613e69 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@v4 + - uses: actions/checkout@v6 - name: Set up minio run: | docker run -d -p 9000:9000 --name minio \ @@ -69,24 +69,16 @@ jobs: -e "MINIO_ROOT_PASSWORD=development" \ -e "MINIO_DEFAULT_BUCKETS=content:public" \ bitnamilegacy/minio:2024.5.28 - - name: Set up Python 3.10 - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: python-version: '3.10' - - name: pip cache - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pytest-${{ hashFiles('requirements.txt', 'requirements-dev.txt') }} - restore-keys: | - ${{ runner.os }}-pytest- - - name: Install pip-tools and python dependencies + activate-environment: "true" + enable-cache: "true" + - name: Install python dependencies with uv run: | - # Pin pip to 25.2 to avoid incompatibility with pip-tools and 25.3 - # see https://github.com/jazzband/pip-tools/issues/2252 - python -m pip install pip==25.2 - pip install pip-tools - pip-sync requirements.txt requirements-dev.txt + # Use uv to install dependencies directly from requirements files + uv pip sync requirements.txt requirements-dev.txt - name: Test pytest run: | sh -c './contentcuration/manage.py makemigrations --check' diff --git a/.gitignore b/.gitignore index 9f9debd85c..e59d442289 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ var/ # Ignore editor / IDE related data .vscode/ +.claude/ # IntelliJ IDE, except project config .idea/ @@ -129,6 +130,3 @@ storybook-static/ # i18n /contentcuration/locale/**/LC_MESSAGES/*.csv - -# pyenv -.python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/contentcuration/contentcuration/constants/community_library_submission.py b/contentcuration/contentcuration/constants/community_library_submission.py new file mode 100644 index 0000000000..4e02bc5f6e --- /dev/null +++ b/contentcuration/contentcuration/constants/community_library_submission.py @@ -0,0 +1,27 @@ +STATUS_PENDING = "PENDING" +STATUS_APPROVED = "APPROVED" +STATUS_REJECTED = "REJECTED" +STATUS_SUPERSEDED = "SUPERSEDED" +STATUS_LIVE = "LIVE" + +status_choices = ( + (STATUS_PENDING, "Pending"), + (STATUS_APPROVED, "Approved"), + (STATUS_REJECTED, "Rejected"), + (STATUS_SUPERSEDED, "Superseded"), + (STATUS_LIVE, "Live"), +) + +REASON_INVALID_LICENSING = "INVALID_LICENSING" +REASON_TECHNICAL_QUALITY_ASSURANCE = "TECHNICAL_QUALITY_ASSURANCE" +REASON_INVALID_METADATA = "INVALID_METADATA" +REASON_PORTABILITY_ISSUES = "PORTABILITY_ISSUES" +REASON_OTHER = "OTHER" + +resolution_reason_choices = ( + (REASON_INVALID_LICENSING, "Invalid Licensing"), + (REASON_TECHNICAL_QUALITY_ASSURANCE, "Technical Quality Assurance"), + (REASON_INVALID_METADATA, "Invalid Metadata"), + (REASON_PORTABILITY_ISSUES, "Portability Issues"), + (REASON_OTHER, "Other"), +) diff --git a/contentcuration/contentcuration/dev_urls.py b/contentcuration/contentcuration/dev_urls.py index 77cbddfbac..003cac87a3 100644 --- a/contentcuration/contentcuration/dev_urls.py +++ b/contentcuration/contentcuration/dev_urls.py @@ -8,7 +8,6 @@ from django.urls import include from django.urls import path from django.urls import re_path -from django.views.generic import TemplateView from drf_yasg import openapi from drf_yasg.views import get_schema_view from rest_framework import permissions @@ -76,10 +75,3 @@ def file_server(request, storage_path=None): re_path(r"^api-auth/", include("rest_framework.urls", namespace="rest_framework")), re_path(r"^content/(?P.+)$", file_server), ] - -urlpatterns += [ - re_path( - r"^editor-dev(?:/.*)?$", - TemplateView.as_view(template_name="contentcuration/editor_dev.html"), - ), -] diff --git a/contentcuration/contentcuration/frontend/accounts/components/StudioMessageLayout.vue b/contentcuration/contentcuration/frontend/accounts/components/StudioMessageLayout.vue new file mode 100644 index 0000000000..8be9c6a54d --- /dev/null +++ b/contentcuration/contentcuration/frontend/accounts/components/StudioMessageLayout.vue @@ -0,0 +1,112 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/accounts/components/__tests__/StudioMessageLayout.spec.js b/contentcuration/contentcuration/frontend/accounts/components/__tests__/StudioMessageLayout.spec.js new file mode 100644 index 0000000000..9d1b29aec3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/accounts/components/__tests__/StudioMessageLayout.spec.js @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import StudioMessageLayout from '../StudioMessageLayout.vue'; + +const createRouter = () => { + return new VueRouter({ + mode: 'abstract', + routes: [{ path: '/', name: 'Main' }], + }); +}; + +const renderComponent = (props = {}, slots = {}) => { + return render(StudioMessageLayout, { + props: { + header: 'Default Header', + ...props, + }, + slots, + routes: createRouter(), + }); +}; + +describe('StudioMessageLayout', () => { + it('renders with required header prop', () => { + renderComponent({ header: 'Test Message' }); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Test Message'); + }); + + it('renders with optional text prop', () => { + renderComponent({ + header: 'Header', + text: 'This is additional information', + }); + + expect(screen.getByText('This is additional information')).toBeInTheDocument(); + }); + + it('renders default slot content', () => { + renderComponent({ header: 'Header' }, { default: 'Custom slot content here' }); + + expect(screen.getByText('Custom slot content here')).toBeInTheDocument(); + }); + + it('renders named back slot override', () => { + renderComponent({ header: 'Header' }, { back: 'Custom back content' }); + + expect(screen.getByText('Custom back content')).toBeInTheDocument(); + expect(screen.queryByText('Continue to sign-in page')).not.toBeInTheDocument(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/accounts/pages/AccountsMain.vue b/contentcuration/contentcuration/frontend/accounts/pages/AccountsMain.vue index 9473b8da23..a5860cbbbe 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/AccountsMain.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/AccountsMain.vue @@ -53,9 +53,10 @@ :label="$tr('passwordLabel')" />

-

-

@@ -87,21 +89,25 @@

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

+ -

+

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

+ -

{{ $tr('usageLabel') }}*

+

{{ $tr('usageLabel') }}*

-

{{ $tr('locationLabel') }}*

+

{{ $tr('locationLabel') }}*

-

{{ $tr('sourceLabel') }}*

+

{{ $tr('sourceLabel') }}*

@@ -337,6 +337,7 @@ @@ -362,6 +362,7 @@ @@ -1016,6 +1016,12 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/modals/ResubmitToCommunityLibraryModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/modals/ResubmitToCommunityLibraryModal.vue new file mode 100644 index 0000000000..28b74958d7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/modals/ResubmitToCommunityLibraryModal.vue @@ -0,0 +1,71 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue index be9d555a19..7ffceed67e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue @@ -31,13 +31,12 @@ - - {{ $tr('addTopic') }} - + /> - - {{ $tr('cancel') }} - - - {{ $tr('moveHere') }} - + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue index ba47272bcd..7949d1f4c8 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue @@ -42,54 +42,47 @@ {{ $tr('publishMessageLabel') }}

- - - - - - - - - - +
+ + +
- - - - - - -
+
+ + +
@@ -276,3 +269,23 @@ }; + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/ChannelVersionHistory.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/ChannelVersionHistory.vue new file mode 100644 index 0000000000..0ee92065a4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/ChannelVersionHistory.vue @@ -0,0 +1,314 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/PublishSidePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/PublishSidePanel.vue new file mode 100644 index 0000000000..6a591798c4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/PublishSidePanel.vue @@ -0,0 +1,530 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue new file mode 100644 index 0000000000..e47f52c4e9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue @@ -0,0 +1,209 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/LoadingText.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/LoadingText.vue new file mode 100644 index 0000000000..6138ec7a0c --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/LoadingText.vue @@ -0,0 +1,67 @@ + + + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js new file mode 100644 index 0000000000..558ce38c94 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js @@ -0,0 +1,622 @@ +import { computed, ref } from 'vue'; +import { mount } from '@vue/test-utils'; +import { factory } from '../../../../store'; + +import SubmitToCommunityLibrarySidePanel from '../'; +import Box from '../Box.vue'; + +import { useVersionDetail } from '../composables/useVersionDetail'; +import { useLatestCommunityLibrarySubmission } from 'shared/composables/useLatestCommunityLibrarySubmission'; +import CommunityLibraryStatusChip from 'shared/views/communityLibrary/CommunityLibraryStatusChip.vue'; +import { Categories, CommunityLibraryStatus } from 'shared/constants'; +import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; +import { CommunityLibrarySubmission } from 'shared/data/resources'; +import CountryField from 'shared/views/form/CountryField.vue'; + +jest.mock('../composables/useVersionDetail'); +jest.mock('shared/composables/useLatestCommunityLibrarySubmission'); +jest.mock('shared/data/resources', () => ({ + CommunityLibrarySubmission: { + create: jest.fn(() => Promise.resolve()), + }, + Channel: { + fetchModel: jest.fn(), + getCatalogChannel: jest.fn(() => Promise.resolve()), + }, + AuditedSpecialPermissionsLicense: { + fetchCollection: jest.fn(() => Promise.resolve([])), + }, +})); + +const store = factory(); + +const { nonePrimaryInfo$, flaggedPrimaryInfo$, approvedPrimaryInfo$, submittedPrimaryInfo$ } = + communityChannelsStrings; + +async function makeWrapper({ channel, publishedData, latestSubmission }) { + const isLoading = ref(true); + const isFinished = ref(false); + const fetchPublishedData = jest.fn(() => Promise.resolve()); + const fetchLatestSubmission = jest.fn(() => Promise.resolve()); + + store.state.currentChannel.currentChannelId = channel.id; + store.commit('channel/ADD_CHANNEL', channel); + + useVersionDetail.mockReturnValue({ + isLoading, + isFinished, + data: computed(() => ({ id: 'abcdabcdabcdabcdabcdabcdabcdabcd', ...publishedData })), + fetchData: fetchPublishedData, + }); + + useLatestCommunityLibrarySubmission.mockReturnValue({ + isLoading, + isFinished, + data: computed(() => latestSubmission), + fetchData: fetchLatestSubmission, + }); + + const wrapper = mount(SubmitToCommunityLibrarySidePanel, { + store, + propsData: { + channel, + }, + }); + + // To simmulate that first the data is loading and then it finishes loading + // and correctly trigger watchers depending on that + await wrapper.vm.$nextTick(); + + isLoading.value = false; + isFinished.value = true; + + await wrapper.vm.$nextTick(); + + return wrapper; +} + +const publishedNonPublicChannel = { + id: 'published-non-public-channel', + version: 2, + name: 'Published Non-Public Channel', + published: true, + public: false, +}; + +const publicChannel = { + id: 'public-channel', + version: 2, + name: 'Public Channel', + published: true, + public: true, +}; + +const nonPublishedChannel = { + id: 'non-published-channel', + version: 0, + name: 'Non-public Channel', + published: false, + public: false, +}; + +const publishedData = { + version: 2, + included_languages: ['en', null], + included_licenses: [1], + included_categories: [Categories.SCHOOL], +}; + +const submittedLatestSubmission = { channel_version: 2, status: CommunityLibraryStatus.PENDING }; + +describe('SubmitToCommunityLibrarySidePanel', () => { + beforeEach(() => { + store.state.currentChannel.currentChannelId = null; + store.state.channel.channelsMap = {}; + }); + describe('correct warnings are shown', () => { + it('when channel is published, not public and not submitted', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const warningBoxes = wrapper + .findAllComponents(Box) + .filter(box => box.props('kind') === 'warning'); + expect(warningBoxes.length).toBe(0); + }); + + it('when channel is public', async () => { + const wrapper = await makeWrapper({ + channel: publicChannel, + publishedData, + latestSubmission: null, + }); + + const warningBoxes = wrapper + .findAllComponents(Box) + .filter(box => box.props('kind') === 'warning'); + expect(warningBoxes.length).toBe(1); + const warningBox = warningBoxes.wrappers[0]; + expect(warningBox.attributes('data-test')).toBe('public-warning'); + }); + + it('when channel is not published', async () => { + const wrapper = await makeWrapper({ + channel: nonPublishedChannel, + publishedData: null, + latestSubmission: null, + }); + + const warningBoxes = wrapper + .findAllComponents(Box) + .filter(box => box.props('kind') === 'warning'); + expect(warningBoxes.length).toBe(1); + const warningBox = warningBoxes.wrappers[0]; + expect(warningBox.attributes('data-test')).toBe('not-published-warning'); + }); + + it('when current version of channel is already submitted', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: submittedLatestSubmission, + }); + + const warningBoxes = wrapper + .findAllComponents(Box) + .filter(box => box.props('kind') === 'warning'); + expect(warningBoxes.length).toBe(1); + const warningBox = warningBoxes.wrappers[0]; + expect(warningBox.attributes('data-test')).toBe('already-submitted-warning'); + }); + }); + + describe('correct info is shown in the info box', () => { + it('when this is the first submission', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const infoSection = wrapper.find('.info-section'); + expect(infoSection.exists()).toBe(true); + expect(infoSection.text()).toContain(nonePrimaryInfo$()); + }); + + it('when the previous submission was rejected', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: { channel_version: 1, status: CommunityLibraryStatus.REJECTED }, + }); + + const infoSection = wrapper.find('.info-section'); + expect(infoSection.exists()).toBe(true); + expect(infoSection.text()).toContain(flaggedPrimaryInfo$()); + }); + + it('when the previous submission was approved', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: { channel_version: 1, status: CommunityLibraryStatus.APPROVED }, + }); + + const infoSection = wrapper.find('.info-section'); + expect(infoSection.exists()).toBe(true); + expect(infoSection.text()).toContain(approvedPrimaryInfo$()); + }); + + it('when the previous submission is pending', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: { channel_version: 1, status: CommunityLibraryStatus.PENDING }, + }); + + const infoSection = wrapper.find('.info-section'); + expect(infoSection.exists()).toBe(true); + expect(infoSection.text()).toContain(submittedPrimaryInfo$()); + }); + }); + + describe('show more button', () => { + it('is displayed when this is the first submission', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const moreDetailsButton = wrapper.find('[data-test="more-details-button"]'); + expect(moreDetailsButton.exists()).toBe(true); + }); + + it('is not displayed when there are previous submissions', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: { channel_version: 1, status: CommunityLibraryStatus.APPROVED }, + }); + + const moreDetailsButton = wrapper.find('[data-test="more-details-button"]'); + expect(moreDetailsButton.exists()).toBe(false); + }); + + it('when clicked, shows additional info', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const infoText = wrapper.find('.info-text'); + expect(infoText.text()).not.toContain('The Kolibri Community Library features channels'); + + const moreDetailsButton = wrapper.find('[data-test="more-details-button"]'); + await moreDetailsButton.trigger('click'); + + expect(infoText.text()).toContain('The Kolibri Community Library features channels'); + + const lessDetailsButton = wrapper.find('[data-test="less-details-button"]'); + expect(lessDetailsButton.exists()).toBe(true); + }); + }); + + describe('publishing state', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('disables form and shows loader when channel is publishing', async () => { + const channel = { ...publishedNonPublicChannel, publishing: true }; + const wrapper = await makeWrapper({ channel, publishedData, latestSubmission: null }); + + // Loader/message container should exist + expect(wrapper.find('.publishing-loader').exists()).toBe(true); + + const descriptionTextbox = wrapper.findComponent('.description-textbox'); + expect(descriptionTextbox.exists()).toBe(false); + const submitButton = wrapper.find('[data-test="submit-button"]'); + expect(submitButton.exists()).toBe(false); + }); + + it('enables form after publishing flips to false (poll-driven)', async () => { + const { Channel } = require('shared/data/resources'); + Channel.fetchModel.mockResolvedValue({ + id: publishedNonPublicChannel.id, + publishing: false, + version: 3, + }); + + const channel = { ...publishedNonPublicChannel, publishing: true }; + const publishedDataWithVersion3 = { + version: 3, + included_languages: ['en', null], + included_licenses: [1], + included_categories: [Categories.SCHOOL], + }; + const wrapper = await makeWrapper({ + channel, + publishedData: publishedDataWithVersion3, + latestSubmission: null, + }); + + const updatedChannel = { ...channel, publishing: false, version: 3 }; + await wrapper.setProps({ channel: updatedChannel }); + store.commit('channel/ADD_CHANNEL', updatedChannel); + + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + const descriptionTextbox = wrapper.findComponent('.description-textbox'); + expect(descriptionTextbox.props('disabled')).toBe(false); + + await descriptionTextbox.vm.$emit('input', 'Some description'); + await wrapper.vm.$nextTick(); + const submitButton = wrapper.find('[data-test="submit-button"]'); + expect(submitButton.attributes('disabled')).toBeUndefined(); + }); + }); + + describe('show less button', () => { + it('is displayed when additional info is shown', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const moreDetails = wrapper.find('[data-test="more-details"]'); + expect(moreDetails.exists()).toBe(false); + + let lessDetailsButton = wrapper.find('[data-test="less-details-button"]'); + expect(lessDetailsButton.exists()).toBe(false); + + const moreDetailsButton = wrapper.find('[data-test="more-details-button"]'); + await moreDetailsButton.trigger('click'); + + lessDetailsButton = wrapper.find('[data-test="less-details-button"]'); + expect(lessDetailsButton.exists()).toBe(true); + }); + + it('when clicked, hides additional info', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + let moreDetails = wrapper.find('[data-test="more-details"]'); + expect(moreDetails.exists()).toBe(false); + + const moreDetailsButton = wrapper.find('[data-test="more-details-button"]'); + await moreDetailsButton.trigger('click'); + + let lessDetailsButton = wrapper.find('[data-test="less-details-button"]'); + expect(lessDetailsButton.exists()).toBe(true); + await lessDetailsButton.trigger('click'); + + moreDetails = wrapper.find('[data-test="more-details"]'); + expect(moreDetails.exists()).toBe(false); + + lessDetailsButton = wrapper.find('[data-test="less-details-button"]'); + expect(lessDetailsButton.exists()).toBe(false); + }); + }); + + describe('submission status chip', () => { + it('is not displayed when there are no submissions', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const statusChip = wrapper.findAllComponents(CommunityLibraryStatusChip); + expect(statusChip.exists()).toBe(false); + }); + + function testStatusChip(submissionStatus, chipStatus) { + it(`is displayed correctly when status is ${submissionStatus}`, async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: { channel_version: 1, status: submissionStatus }, + }); + + const statusChip = wrapper.findComponent(CommunityLibraryStatusChip); + expect(statusChip.attributes('status')).toBe(chipStatus); + }); + } + + testStatusChip(CommunityLibraryStatus.APPROVED, CommunityLibraryStatus.APPROVED); + testStatusChip(CommunityLibraryStatus.LIVE, CommunityLibraryStatus.APPROVED); + testStatusChip(CommunityLibraryStatus.REJECTED, CommunityLibraryStatus.REJECTED); + testStatusChip(CommunityLibraryStatus.PENDING, CommunityLibraryStatus.PENDING); + testStatusChip(CommunityLibraryStatus.SUPERSEDED, CommunityLibraryStatus.PENDING); + }); + + it('is editable when channel is published, not public and not submitted', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const descriptionTextbox = wrapper.findComponent('.description-textbox'); + expect(descriptionTextbox.props('disabled')).toBe(false); + }); + + describe('is not editable', () => { + it('when channel is public', async () => { + const wrapper = await makeWrapper({ + channel: publicChannel, + publishedData, + latestSubmission: null, + }); + + const descriptionTextbox = wrapper.findComponent('.description-textbox'); + expect(descriptionTextbox.props('disabled')).toBe(true); + }); + + it('when channel is not published', async () => { + const wrapper = await makeWrapper({ + channel: nonPublishedChannel, + publishedData: null, + latestSubmission: null, + }); + + const descriptionTextbox = wrapper.findComponent('.description-textbox'); + expect(descriptionTextbox.props('disabled')).toBe(true); + }); + + it('when current version of channel is already submitted', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: submittedLatestSubmission, + }); + + const descriptionTextbox = wrapper.findComponent('.description-textbox'); + expect(descriptionTextbox.props('disabled')).toBe(true); + }); + }); + + describe('submit button', () => { + describe('is disabled', () => { + it('when channel is public', async () => { + const wrapper = await makeWrapper({ + channel: publicChannel, + publishedData, + latestSubmission: null, + }); + + const submitButton = wrapper.find('[data-test="submit-button"]'); + expect(submitButton.props('disabled')).toBe(true); + }); + + it('when channel is not published', async () => { + const wrapper = await makeWrapper({ + channel: nonPublishedChannel, + publishedData: null, + latestSubmission: null, + }); + + const submitButton = wrapper.find('[data-test="submit-button"]'); + expect(submitButton.props('disabled')).toBe(true); + }); + + it('when current version of channel is already submitted', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: submittedLatestSubmission, + }); + + const submitButton = wrapper.find('[data-test="submit-button"]'); + expect(submitButton.props('disabled')).toBe(true); + }); + + it('when no description is provided', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const submitButton = wrapper.find('[data-test="submit-button"]'); + expect(submitButton.props('disabled')).toBe(true); + }); + }); + + it('is enabled when channel is published, not public, not submitted and description is provided', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const descriptionTextbox = wrapper.findComponent('.description-textbox'); + await descriptionTextbox.vm.$emit('input', 'Some description'); + + const submitButton = wrapper.find('[data-test="submit-button"]'); + expect(submitButton.props('disabled')).toBe(false); + }); + }); + + it('cancel button emits close event', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const cancelButton = wrapper.find('[data-test="cancel-button"]'); + await cancelButton.trigger('click'); + + expect(wrapper.emitted('close')).toBeTruthy(); + }); + + describe('when submit button is clicked', () => { + beforeEach(() => { + CommunityLibrarySubmission.create.mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('the panel closes', async () => { + jest.useFakeTimers(); + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const descriptionTextbox = wrapper.findComponent('.description-textbox'); + await descriptionTextbox.vm.$emit('input', 'Some description'); + + const submitButton = wrapper.find('[data-test="submit-button"]'); + await submitButton.trigger('click'); + + expect(wrapper.emitted('close')).toBeTruthy(); + jest.useRealTimers(); + }); + + it('a submission snackbar is shown', async () => { + jest.useFakeTimers(); + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const descriptionTextbox = wrapper.findComponent('.description-textbox'); + await descriptionTextbox.vm.$emit('input', 'Some description'); + + const submitButton = wrapper.find('[data-test="submit-button"]'); + await submitButton.trigger('click'); + await wrapper.vm.$nextTick(); + + expect(store.getters['snackbarIsVisible']).toBe(true); + expect(CommunityLibrarySubmission.create).not.toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('the submission is created after a timeout', async () => { + jest.useFakeTimers(); + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: null, + }); + + const descriptionTextbox = wrapper.findComponent('.description-textbox'); + await descriptionTextbox.vm.$emit('input', 'Some description'); + + const countryField = wrapper.findComponent(CountryField); + await countryField.vm.$emit('input', ['Czech Republic']); + await wrapper.vm.$nextTick(); + + const submitButton = wrapper.find('[data-test="submit-button"]'); + await submitButton.trigger('click'); + await wrapper.vm.$nextTick(); + + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + + expect(CommunityLibrarySubmission.create).toHaveBeenCalledWith({ + description: 'Some description', + channel: publishedNonPublicChannel.id, + countries: ['CZ'], + categories: { [Categories.SCHOOL]: true }, + }); + jest.useRealTimers(); + }); + }); + + describe('when a previous submission exists', () => { + it('the previously selected countries are pre-filled', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: { + channel_version: 1, + status: CommunityLibraryStatus.REJECTED, + countries: ['CZ'], + }, + }); + + const countryField = wrapper.findComponent(CountryField); + expect(countryField.props('value')).toEqual(['Czech Republic']); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLatestCommunityLibrarySubmission.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLatestCommunityLibrarySubmission.js new file mode 100644 index 0000000000..5cc223bcfc --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLatestCommunityLibrarySubmission.js @@ -0,0 +1,19 @@ +import { computed, ref } from 'vue'; + +const MOCK_DEFAULTS = { + isLoading: ref(true), + isFinished: ref(false), + data: computed(() => null), + fetchData: jest.fn(() => Promise.resolve()), +}; + +export function useLatestCommunityLibrarySubmissionMock(overrides = {}) { + return { + ...MOCK_DEFAULTS, + ...overrides, + }; +} + +export const useLatestCommunityLibrarySubmission = jest.fn(() => + useLatestCommunityLibrarySubmissionMock(), +); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/usePublishedData.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/usePublishedData.js new file mode 100644 index 0000000000..8759458f73 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/usePublishedData.js @@ -0,0 +1,17 @@ +import { computed, ref } from 'vue'; + +const MOCK_DEFAULTS = { + isLoading: ref(true), + isFinished: ref(false), + data: computed(() => null), + fetchData: jest.fn(() => Promise.resolve()), +}; + +export function usePublishedDataMock(overrides = {}) { + return { + ...MOCK_DEFAULTS, + ...overrides, + }; +} + +export const usePublishedData = jest.fn(() => usePublishedDataMock()); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useVersionDetail.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useVersionDetail.js new file mode 100644 index 0000000000..5d70248310 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useVersionDetail.js @@ -0,0 +1,6 @@ +import { useFetch } from 'shared/composables/useFetch'; +import { Channel } from 'shared/data/resources'; + +export function useVersionDetail(channelId) { + return useFetch({ asyncFetchFunc: () => Channel.getVersionDetail(channelId) }); +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue new file mode 100644 index 0000000000..de0c086a1e --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -0,0 +1,720 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/CompatibleLicensesNotice.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/CompatibleLicensesNotice.vue new file mode 100644 index 0000000000..179977fac4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/CompatibleLicensesNotice.vue @@ -0,0 +1,58 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/InvalidLicensesNotice.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/InvalidLicensesNotice.vue new file mode 100644 index 0000000000..b1ba5f3707 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/InvalidLicensesNotice.vue @@ -0,0 +1,54 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/__tests__/ChannelVersionHistory.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/__tests__/ChannelVersionHistory.spec.js new file mode 100644 index 0000000000..1758c58e12 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/__tests__/ChannelVersionHistory.spec.js @@ -0,0 +1,344 @@ +import { render, fireEvent, waitFor, screen } from '@testing-library/vue'; +import { createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import { ref } from 'vue'; +import KThemePlugin from 'kolibri-design-system/lib/KThemePlugin'; +import ChannelVersionHistory from '../ChannelVersionHistory.vue'; +import { useChannelVersionHistory } from 'shared/composables/useChannelVersionHistory'; +import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +localVue.use(KThemePlugin); + +jest.mock('shared/composables/useChannelVersionHistory'); + +const createVersion = (id, version, version_notes = null) => ({ + id, + version, + version_notes, + date_published: '2026-03-06T12:00:00Z', +}); + +describe('ChannelVersionHistory', () => { + let mockComposable; + + beforeEach(() => { + jest.clearAllMocks(); + + mockComposable = { + versions: ref([]), + isLoading: ref(false), + isLoadingMore: ref(false), + error: ref(null), + hasMore: ref(false), + fetchVersions: jest.fn(), + fetchMore: jest.fn(), + reset: jest.fn(), + }; + + useChannelVersionHistory.mockReturnValue(mockComposable); + }); + + const renderComponent = (props = {}) => { + const router = new VueRouter(); + return render(ChannelVersionHistory, { + localVue, + router, + propsData: { + channelId: 'test-channel-id', + ...props, + }, + }); + }; + + describe('initial state', () => { + it('renders "See all versions" button when collapsed', () => { + renderComponent(); + expect(screen.getByText(communityChannelsStrings.seeAllVersions$())).toBeInTheDocument(); + }); + + it('does not show versions list when collapsed', () => { + mockComposable.versions.value = [createVersion(1, 1, 'Test')]; + renderComponent(); + + expect( + screen.queryByText(communityChannelsStrings.versionLabel$({ version: 1 })), + ).not.toBeInTheDocument(); + }); + }); + + describe('expanding', () => { + it('fetches versions when expanded for the first time', async () => { + renderComponent(); + + const expandButton = screen.getByText(communityChannelsStrings.seeAllVersions$()); + await fireEvent.click(expandButton); + + expect(mockComposable.fetchVersions).toHaveBeenCalledWith('test-channel-id'); + }); + + it('does not fetch again if versions already loaded', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + renderComponent(); + + const expandButton = screen.getByText(communityChannelsStrings.seeAllVersions$()); + await fireEvent.click(expandButton); + + expect(mockComposable.fetchVersions).not.toHaveBeenCalled(); + }); + + it('shows loading indicator when fetching initial versions', async () => { + mockComposable.isLoading.value = true; + renderComponent(); + + const expandButton = screen.getByText(communityChannelsStrings.seeAllVersions$()); + await fireEvent.click(expandButton); + + await waitFor(() => { + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + }); + + it('shows error message when fetch fails and no versions loaded', async () => { + renderComponent(); + + const expandButton = screen.getByText(communityChannelsStrings.seeAllVersions$()); + await fireEvent.click(expandButton); + + // Simulate error after expansion + mockComposable.error.value = new Error('Network error'); + + await waitFor(() => { + expect( + screen.getByText(communityChannelsStrings.errorLoadingVersions$()), + ).toBeInTheDocument(); + }); + }); + }); + + describe('displaying versions', () => { + it('shows empty state when no versions available', async () => { + mockComposable.versions.value = []; + mockComposable.isLoading.value = false; + mockComposable.error.value = null; + + renderComponent(); + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + + await waitFor(() => { + expect( + screen.getByText(communityChannelsStrings.noVersionsAvailable$()), + ).toBeInTheDocument(); + }); + }); + + it('renders version list when versions are available', async () => { + const versions = [ + createVersion(1, 3, 'Version 3 notes'), + createVersion(2, 2, 'Version 2 notes'), + createVersion(3, 1, null), + ]; + mockComposable.versions.value = versions; + + renderComponent(); + + const expandButton = screen.getByText(communityChannelsStrings.seeAllVersions$()); + await fireEvent.click(expandButton); + + await waitFor(() => { + expect( + screen.getByText(communityChannelsStrings.versionLabel$({ version: 3 })), + ).toBeInTheDocument(); + expect( + screen.getByText(communityChannelsStrings.versionLabel$({ version: 2 })), + ).toBeInTheDocument(); + expect( + screen.getByText(communityChannelsStrings.versionLabel$({ version: 1 })), + ).toBeInTheDocument(); + }); + }); + + it('renders version descriptions when available', async () => { + mockComposable.versions.value = [createVersion(1, 2, 'Added new features')]; + + renderComponent(); + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + + await waitFor(() => { + expect(screen.getByText('Added new features')).toBeInTheDocument(); + expect( + screen.getByText(communityChannelsStrings.versionDescriptionLabel$()), + ).toBeInTheDocument(); + }); + }); + + it('shows "Hide versions" button when expanded', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + renderComponent(); + + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + + await waitFor(() => { + expect(screen.getByText(communityChannelsStrings.hideVersions$())).toBeInTheDocument(); + }); + }); + }); + + describe('pagination', () => { + it('shows "Show more" button when hasMore is true', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + mockComposable.hasMore.value = true; + + renderComponent(); + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + + await waitFor(() => { + expect(screen.getByText(communityChannelsStrings.showMore$())).toBeInTheDocument(); + }); + }); + + it('does not show "Show more" button when hasMore is false', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + mockComposable.hasMore.value = false; + + renderComponent(); + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + + await waitFor(() => { + expect(screen.queryByText(communityChannelsStrings.showMore$())).not.toBeInTheDocument(); + }); + }); + + it('calls fetchMore when "Show more" is clicked', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + mockComposable.hasMore.value = true; + + renderComponent(); + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + + const showMoreButton = await screen.findByText(communityChannelsStrings.showMore$()); + await fireEvent.click(showMoreButton); + + expect(mockComposable.fetchMore).toHaveBeenCalled(); + }); + + it('disables "Show more" button when loading more', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + mockComposable.hasMore.value = true; + mockComposable.isLoadingMore.value = true; + + renderComponent(); + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + + await waitFor(() => { + const showMoreButton = screen.getByText(communityChannelsStrings.showMore$()).closest('a'); + expect(showMoreButton).toHaveAttribute('disabled'); + }); + }); + + it('shows inline loader when loading more', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + mockComposable.hasMore.value = true; + mockComposable.isLoadingMore.value = true; + + renderComponent(); + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + + await waitFor(() => { + const loaders = screen.getAllByRole('progressbar'); + expect(loaders.length).toBeGreaterThan(0); + }); + }); + + it('shows error message when fetchMore fails but versions exist', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + mockComposable.hasMore.value = true; + mockComposable.error.value = new Error('Failed to fetch more'); + + renderComponent(); + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + + await waitFor(() => { + const errorMessages = screen.getAllByText(communityChannelsStrings.errorLoadingVersions$()); + expect(errorMessages.length).toBe(1); + }); + }); + + it('shows retry button when fetchMore fails', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + mockComposable.hasMore.value = true; + mockComposable.error.value = new Error('Failed to fetch more'); + + renderComponent(); + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + + await waitFor(() => { + expect(screen.getByText(communityChannelsStrings.retry$())).toBeInTheDocument(); + }); + }); + + it('calls fetchMore when retry button is clicked', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + mockComposable.hasMore.value = true; + mockComposable.error.value = new Error('Failed to fetch more'); + + renderComponent(); + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + + const retryButton = await screen.findByText(communityChannelsStrings.retry$()); + await fireEvent.click(retryButton); + + expect(mockComposable.fetchMore).toHaveBeenCalled(); + }); + }); + + describe('collapsing', () => { + it('hides version list when "Hide versions" is clicked', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + + renderComponent(); + + // Expand + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + expect( + screen.getByText(communityChannelsStrings.versionLabel$({ version: 1 })), + ).toBeInTheDocument(); + + // Collapse + const hideVersionsButton = screen.getByText(communityChannelsStrings.hideVersions$()); + await fireEvent.click(hideVersionsButton); + + await waitFor(() => { + expect( + screen.queryByText(communityChannelsStrings.versionLabel$({ version: 1 })), + ).not.toBeInTheDocument(); + expect(screen.getByText(communityChannelsStrings.seeAllVersions$())).toBeInTheDocument(); + }); + }); + }); + + describe('channel ID changes', () => { + it('calls reset when channelId prop changes', async () => { + mockComposable.versions.value = [createVersion(1, 1)]; + + const wrapper = renderComponent({ channelId: 'channel-1' }); + + // Expand + await fireEvent.click(screen.getByText(communityChannelsStrings.seeAllVersions$())); + await wrapper.updateProps({ channelId: 'channel-2' }); + + await waitFor(() => { + expect(mockComposable.reset).toHaveBeenCalled(); + }); + }); + + it('does not reset when channelId remains the same', async () => { + const wrapper = renderComponent({ channelId: 'channel-1' }); + + await wrapper.updateProps({ channelId: 'channel-1' }); + + expect(mockComposable.reset).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/__tests__/PublishSidePanel.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/__tests__/PublishSidePanel.spec.js new file mode 100644 index 0000000000..ee191c6930 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/__tests__/PublishSidePanel.spec.js @@ -0,0 +1,400 @@ +import { render, fireEvent, waitFor, screen } from '@testing-library/vue'; +import { createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import VueRouter from 'vue-router'; +import KThemePlugin from 'kolibri-design-system/lib/KThemePlugin'; +import { factory } from '../../../store'; +import PublishSidePanel from '../PublishSidePanel.vue'; +import { Channel, CommunityLibrarySubmission } from 'shared/data/resources'; +import { forceServerSync } from 'shared/data/serverSync'; +import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; + +const localVue = createLocalVue(); +localVue.use(Vuex); +localVue.use(VueRouter); +localVue.use(KThemePlugin); + +jest.mock('shared/data/resources', () => ({ + Channel: jest.fn(), + CommunityLibrarySubmission: jest.fn(), +})); + +jest.mock('shared/data/serverSync', () => ({ + forceServerSync: jest.fn(), +})); + +jest.mock('shared/logging', () => ({ + error: jest.fn(), +})); + +let store; +const renderComponent = (props = {}) => { + const currentChannel = { + id: 'channel-id', + version: 1, + language: 'en', + public: false, + ricecooker_version: null, + root_id: 'root-id', + ...props.currentChannel, + }; + const rootNode = { + id: 'root-id', + error_count: props.errorCount || 0, + }; + + // Set up vuex store state + window.CHANNEL_EDIT_GLOBAL.channel_id = currentChannel.id; + + store = factory(); + + store.commit('channel/ADD_CHANNEL', currentChannel); + store.commit('contentNode/ADD_CONTENTNODE', rootNode); + store.commit('SET_UNSAVED_CHANGES', props.areAllChangesSaved === false); + store.commit('UPDATE_SESSION', { is_admin: true }); + + const router = new VueRouter(); + + return render(PublishSidePanel, { + localVue, + store, + router, + }); +}; + +describe('PublishSidePanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default mocks + Channel.publish = jest.fn(); + Channel.publishDraft = jest.fn(); + Channel.update = jest.fn(); + Channel.languageExistsInResources = jest.fn(() => true); + Channel.languagesInResources = jest.fn(() => []); + CommunityLibrarySubmission.fetchCollection = jest.fn(); + forceServerSync.mockResolvedValue({}); + }); + + it('renders correctly in default LIVE mode', async () => { + renderComponent(); + + // Headers and default texts + expect(screen.getByText(communityChannelsStrings.publishChannel$())).toBeVisible(); + expect(screen.getByText(communityChannelsStrings.modeLive$())).toBeVisible(); + + expect( + screen.getByText(communityChannelsStrings.publishingInfo$({ version: 2 })), + ).toBeVisible(); + + // Default button text + expect(screen.getByText(communityChannelsStrings.publishAction$())).toBeVisible(); + + // Live mode selected by default + const liveRadio = screen.getByRole('radio', { name: /Live/ }); + expect(liveRadio).toBeChecked(); + }); + + it('validates version notes in LIVE mode', async () => { + renderComponent(); + + const publishBtn = screen.getByText(communityChannelsStrings.publishAction$()); + + const notesInput = screen.getByLabelText(communityChannelsStrings.versionDescriptionLabel$()); + + // Initially button disabled because notes empty + expect(publishBtn).toBeDisabled(); + + // Touch field to trigger validation visible + await fireEvent.blur(notesInput); + expect(screen.getByText('Version notes are required')).toBeVisible(); + + // Type notes + await fireEvent.update(notesInput, 'My version notes'); + await fireEvent.blur(notesInput); + + // Validation error should disappear + await waitFor(() => + expect(screen.queryByText('Version notes are required')).not.toBeInTheDocument(), + ); + await waitFor(() => expect(publishBtn).toBeEnabled()); + }); + + describe('Language dropdown', () => { + it('shows language dropdown and validates language when conditions met (Live Mode)', async () => { + // Condition: resources have different languages, or not set + Channel.languageExistsInResources.mockResolvedValue(false); + Channel.languagesInResources.mockResolvedValue(['de']); + + renderComponent({ + currentChannel: { language: 'en' }, // Channel is en, but resource is de + }); + + // Wait for onMounted actions + await waitFor(() => expect(Channel.languageExistsInResources).toHaveBeenCalled()); + await waitFor(() => expect(Channel.languagesInResources).toHaveBeenCalled()); + + // Check dropdown visibility via label (found as text since KSelect uses div) + expect(screen.getByText(communityChannelsStrings.languageLabel$())).toBeVisible(); + + // Load 'Deutsch' text presence in the DOM (it might be hidden in dropdown) + await waitFor(() => expect(screen.getAllByText(/Deutsch/i).length).toBeGreaterThan(0)); + + // Check validation of publish button + const publishBtn = screen.getByText(communityChannelsStrings.publishAction$()); + expect(publishBtn).toBeDisabled(); + + // Select Deutsch + // Use getAllByText and take first or iterate? Just click one. + await fireEvent.click(screen.getAllByText(/Deutsch/i)[0]); + + // Add notes logic (required for Live) + const notesInput = screen.getByLabelText(communityChannelsStrings.versionDescriptionLabel$()); + await fireEvent.update(notesInput, 'Notes'); + + await waitFor(() => expect(publishBtn).toBeEnabled()); + }); + + it('shows language dropdown if first time publishing a private channel, even if channel language exists in resources', async () => { + Channel.languageExistsInResources.mockResolvedValue(true); + renderComponent({ + currentChannel: { version: 0, language: 'en' }, + }); + await waitFor(() => expect(Channel.languageExistsInResources).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByText(communityChannelsStrings.languageLabel$())).toBeVisible(), + ); + }); + + it('shows language dropdown if first time publishing a ricecooker channel, even if channel language exists in resources', async () => { + Channel.languageExistsInResources.mockResolvedValue(true); + Channel.languagesInResources.mockResolvedValue(['de']); + + renderComponent({ + currentChannel: { ricecooker_version: 'v1', version: 0, language: 'en' }, + }); + await waitFor(() => expect(Channel.languageExistsInResources).toHaveBeenCalled()); + + waitFor(() => + expect(screen.getByText(communityChannelsStrings.languageLabel$())).toBeVisible(), + ); + }); + + it('does not show language dropdown if not first time publishing and channel language exists in resources', async () => { + Channel.languageExistsInResources.mockResolvedValue(true); + renderComponent({ + currentChannel: { version: 1, language: 'en' }, + }); + await waitFor(() => expect(Channel.languageExistsInResources).toHaveBeenCalled()); + expect(screen.queryByText(communityChannelsStrings.languageLabel$())).not.toBeInTheDocument(); + }); + + it('shows only channel language as only option if first time publishing and channel language exists in resources', async () => { + Channel.languageExistsInResources.mockResolvedValue(true); + renderComponent({ + currentChannel: { version: 0, language: 'en' }, + }); + await waitFor(() => expect(Channel.languageExistsInResources).toHaveBeenCalled()); + + await waitFor(() => + expect(screen.getByText(communityChannelsStrings.languageLabel$())).toBeVisible(), + ); + // Only English should be present + // To be greater than 0 because KSelect duplicates this value twice + await waitFor(() => expect(screen.getAllByText(/English/i).length).toBeGreaterThan(0)); + expect(screen.queryByText(/Deutsch/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Italiano/i)).not.toBeInTheDocument(); + }); + + it('shows all resources languages as language options if channel language does not exist in resources', async () => { + Channel.languageExistsInResources.mockResolvedValue(false); + Channel.languagesInResources.mockResolvedValue(['de', 'it']); + + renderComponent({ currentChannel: { language: 'en', version: 1 } }); + await waitFor(() => expect(Channel.languagesInResources).toHaveBeenCalled()); + expect(screen.getByText(communityChannelsStrings.languageLabel$())).toBeVisible(); + + await waitFor(() => expect(screen.getAllByText(/Deutsch/i).length).toBeGreaterThan(0)); + expect(screen.getAllByText(/Italiano/i).length).toBeGreaterThan(0); + expect(screen.queryByText(/English/i)).not.toBeInTheDocument(); + }); + + it('does not show current channel language as option if channel language does not exist in resources', async () => { + Channel.languageExistsInResources.mockResolvedValue(false); + Channel.languagesInResources.mockResolvedValue(['de']); + + renderComponent({ currentChannel: { language: 'en' } }); + await waitFor(() => expect(Channel.languagesInResources).toHaveBeenCalled()); + await waitFor(() => expect(screen.getAllByText(/Deutsch/i).length).toBeGreaterThan(0)); + expect(screen.queryByText(/English/i)).not.toBeInTheDocument(); + }); + + it('shows only channel language as only option if no resources languages exist and channel language does not exist in resources', async () => { + Channel.languageExistsInResources.mockResolvedValue(false); + Channel.languagesInResources.mockResolvedValue([]); + + renderComponent({ currentChannel: { language: 'en' } }); + await waitFor(() => expect(Channel.languagesInResources).toHaveBeenCalled()); + + expect(screen.getByText(communityChannelsStrings.languageLabel$())).toBeVisible(); + await waitFor(() => expect(screen.getAllByText(/English/i).length).toBeGreaterThan(0)); + expect(screen.queryByText(/Deutsch/i)).not.toBeInTheDocument(); + }); + }); + + it('does not validate in DRAFT mode', async () => { + renderComponent(); + + // Switch to Draft + const draftRadio = screen.getByRole('radio', { name: /Draft/i }); + await fireEvent.click(draftRadio); + + const saveDraftBtn = screen.getByText(communityChannelsStrings.saveDraft$()); + + // Should be enabled even without notes + expect(saveDraftBtn).toBeEnabled(); + }); + + it('shows warning if incomplete resources exist', async () => { + renderComponent({ errorCount: 5 }); + + // Warning text: "5 incomplete resources" + expect( + screen.getByText(communityChannelsStrings.incompleteResourcesWarning$({ count: 5 })), + ).toBeVisible(); + }); + + it('submits DRAFT properly', async () => { + renderComponent(); + + // Switch to Draft + const draftRadio = screen.getByRole('radio', { name: /Draft/i }); + await fireEvent.click(draftRadio); + + // Submit + const saveBtn = screen.getByText(communityChannelsStrings.saveDraft$()); + await fireEvent.click(saveBtn); + + expect(Channel.publishDraft).toHaveBeenCalled(); + }); + + it('calls forceServerSync when changes are not saved', async () => { + renderComponent({ areAllChangesSaved: false }); + + // Draft mode to submit quickly + const draftRadio = screen.getByRole('radio', { name: /Draft/i }); + await fireEvent.click(draftRadio); + + const saveBtn = screen.getByText(communityChannelsStrings.saveDraft$()); + await fireEvent.click(saveBtn); + + expect(forceServerSync).toHaveBeenCalled(); + }); + + it('submits LIVE properly', async () => { + renderComponent(); + + // Fill notes + const notesInput = screen.getByLabelText(communityChannelsStrings.versionDescriptionLabel$()); + await fireEvent.update(notesInput, 'Ready to publish'); + + // Submit + const publishBtn = screen.getByText(communityChannelsStrings.publishAction$()); + await fireEvent.click(publishBtn); + + expect(Channel.publish).toHaveBeenCalledWith('channel-id', 'Ready to publish'); + }); + + it('emits close on successful submission', async () => { + const { emitted } = renderComponent(); + + const notesInput = screen.getByLabelText(communityChannelsStrings.versionDescriptionLabel$()); + await fireEvent.update(notesInput, 'Ready to publish'); + + const publishBtn = screen.getByText(communityChannelsStrings.publishAction$()); + await fireEvent.click(publishBtn); + + await waitFor(() => expect(emitted().close).toBeTruthy()); + }); + + it('handles community library submission logic (Resubmit Modal)', async () => { + CommunityLibrarySubmission.fetchCollection.mockResolvedValue({ + results: [{ channel_version: 5 }], + }); + + const { emitted } = renderComponent(); + + const notesInput = screen.getByLabelText(communityChannelsStrings.versionDescriptionLabel$()); + await fireEvent.update(notesInput, 'Notes'); + + const publishBtn = screen.getByText(communityChannelsStrings.publishAction$()); + await fireEvent.click(publishBtn); + + await waitFor(() => expect(CommunityLibrarySubmission.fetchCollection).toHaveBeenCalled()); + expect(emitted().showResubmitCommunityLibraryModal).toBeTruthy(); + expect(emitted().showResubmitCommunityLibraryModal[0][0]).toEqual({ + channel: expect.objectContaining({ id: 'channel-id' }), + latestSubmissionVersion: 5, + }); + }); + + it('updates channel language if changed during submit', async () => { + Channel.languageExistsInResources.mockResolvedValue(false); + Channel.languagesInResources.mockResolvedValue(['de']); + + renderComponent({ + currentChannel: { language: 'en' }, + }); + + await waitFor(() => expect(Channel.languagesInResources).toHaveBeenCalled()); + + await waitFor(() => expect(screen.getAllByText(/Deutsch/i).length).toBeGreaterThan(0)); + + // Select Deutsch + await fireEvent.click(screen.getAllByText(/Deutsch/i)[0]); + + const notesInput = screen.getByLabelText(communityChannelsStrings.versionDescriptionLabel$()); + await fireEvent.update(notesInput, 'Notes'); + + const publishBtn = screen.getByText(communityChannelsStrings.publishAction$()); + await fireEvent.click(publishBtn); + + // Check that current channel in the store was updated with new language + await waitFor(() => { + const updatedChannel = store.getters['currentChannel/currentChannel']; + expect(updatedChannel.language).toBe('de'); + }); + await waitFor(() => { + expect(Channel.publish).toHaveBeenCalled(); + }); + }); + + it('handles error during submit', async () => { + Channel.publish.mockRejectedValue({ response: { status: 500 } }); + + renderComponent(); + + const notesInput = screen.getByLabelText(communityChannelsStrings.versionDescriptionLabel$()); + await fireEvent.update(notesInput, 'Notes'); + + const publishBtn = screen.getByText(communityChannelsStrings.publishAction$()); + await fireEvent.click(publishBtn); + + // Wait for fullPageError to be set in the store, it is in store.state.errors.fullPageError + await waitFor(() => { + const fullPageError = store.state.errors.fullPageError; + expect(fullPageError).toBeTruthy(); + }); + }); + + it('closes panel when cancel is clicked', async () => { + const { emitted } = renderComponent(); + const cancelBtn = screen.getByText(communityChannelsStrings.cancelAction$()); + await fireEvent.click(cancelBtn); + expect(emitted().close).toBeTruthy(); + }); + + it('renders ChannelVersionHistory component', () => { + renderComponent(); + expect(screen.getByText(communityChannelsStrings.seeAllVersions$())).toBeInTheDocument(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue b/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue index 6939d232fc..0fe3914fdb 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue @@ -552,7 +552,7 @@ ...mapActions('currentChannel', [ 'loadCurrentChannelStagingDiff', 'deployCurrentChannel', - 'publishDraftChannel', + 'publishStagingChannel', 'reloadCurrentChannelStagingDiff', ]), ...mapActions('currentChannel', { loadCurrentChannel: 'loadChannel' }), @@ -646,7 +646,7 @@ this.displayPublishDraftDialog = false; this.isPublishingDraft = true; - this.publishDraftChannel() + this.publishStagingChannel() .then(publishDraftchange => Channel.waitForPublishingDraft(publishDraftchange)) .then(() => { this.isPublishingDraft = false; diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 54e2530f0f..0d734e558f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -2,7 +2,8 @@ import translator from './translator'; import { RouteNames } from './constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; -import { metadataStrings, constantStrings } from 'shared/mixins'; +import { metadataStrings } from 'shared/strings/metadataStrings'; +import { constantStrings } from 'shared/mixins'; import { ContentModalities, AssessmentItemTypes, diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/ChannelEditAppError.vue b/contentcuration/contentcuration/frontend/channelEdit/views/ChannelEditAppError.vue index a7644b6d54..eaf1896b8a 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/ChannelEditAppError.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/ChannelEditAppError.vue @@ -13,12 +13,7 @@ - - @@ -39,7 +34,6 @@ import MainNavigationDrawer from 'shared/views/MainNavigationDrawer'; import ToolBar from 'shared/views/ToolBar'; import ChannelNotFoundError from 'shared/views/errors/ChannelNotFoundError'; - import ChannelDeletedError from 'shared/views/errors/ChannelDeletedError'; import GenericError from 'shared/views/errors/GenericError'; // NOTE: 404 Error Page for the topic level is contained inside of TreeViewBase @@ -47,7 +41,6 @@ name: 'ChannelEditAppError', components: { ChannelNotFoundError, - ChannelDeletedError, GenericError, MainNavigationDrawer, ToolBar, diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/SavedSearchesModal.vue b/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/SavedSearchesModal.vue index 8d494e8899..50d1377594 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/SavedSearchesModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/SavedSearchesModal.vue @@ -13,72 +13,65 @@ />

{{ $tr('noSavedSearches') }}

- - - +
    +
  • +
    +
    + + {{ search.name }} + +
    + +
    + +
    + +
    +
  • +
- - - +

{{ $tr('deleteConfirmation') }}

+
@@ -87,16 +80,10 @@ - diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogFilterBar.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogFilterBar.vue index 832d75bce4..f9881596c7 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogFilterBar.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogFilterBar.vue @@ -7,15 +7,17 @@ :key="`catalog-filter-${index}`" close class="ma-1" + :data-test="`filter-chip-${index}`" @input="filter.onclose" > {{ filter.text }} - @@ -140,4 +142,8 @@ } } + .clear-link { + margin: 0 8px; + } + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogFilters.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogFilters.vue index e6e83f74a2..7b6cb7d0e7 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogFilters.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogFilters.vue @@ -1,128 +1,28 @@ @@ -130,135 +30,67 @@ diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogList.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogList.vue index eacc77d598..42db4a9615 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogList.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogList.vue @@ -1,132 +1,169 @@ @@ -140,13 +177,15 @@ import isEqual from 'lodash/isEqual'; import sortBy from 'lodash/sortBy'; import union from 'lodash/union'; + import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import { RouteNames } from '../../constants'; import CatalogFilters from './CatalogFilters'; - import ChannelItem from './ChannelItem'; - import LoadingText from 'shared/views/LoadingText'; + import CatalogFilterBar from './CatalogFilterBar'; + import StudioChannelCard from './StudioChannelCard'; + import ChannelStar from './ChannelStar'; + import ChannelTokenModal from 'shared/views/channel/ChannelTokenModal'; import Pagination from 'shared/views/Pagination'; import BottomBar from 'shared/views/BottomBar'; - import Checkbox from 'shared/views/form/Checkbox'; import ToolBar from 'shared/views/ToolBar'; import OfflineText from 'shared/views/OfflineText'; import { constantsTranslationMixin } from 'shared/mixins'; @@ -155,29 +194,43 @@ export default { name: 'CatalogList', components: { - ChannelItem, - LoadingText, + StudioChannelCard, + ChannelStar, + ChannelTokenModal, CatalogFilters, + CatalogFilterBar, Pagination, BottomBar, - Checkbox, ToolBar, OfflineText, }, mixins: [channelExportMixin, constantsTranslationMixin], + setup() { + const { windowIsSmall, windowBreakpoint } = useKResponsiveWindow(); + + return { + windowIsSmall, + windowBreakpoint, + }; + }, data() { return { loading: true, loadError: false, selecting: false, + tokenChannelId: null, /** * jayoshih: router guard makes it difficult to track * differences between previous query params and new * query params, so just track it manually */ - previousQuery: this.$route.query, - + /** + * MisRob: Add 'page: 1' as default to prevent it from being + * added later and causing redundant $router watcher call when + * page initially loading (fixes loading state showing twice) + */ + previousQuery: { page: 1, ...this.$route.query }, /** * jayoshih: using excluded logic here instead of selected * to account for selections across pages (some channels @@ -188,10 +241,29 @@ }, computed: { ...mapGetters('channel', ['getChannels']), + ...mapGetters(['loggedIn']), ...mapState('channelList', ['page']), ...mapState({ offline: state => !state.connection.online, }), + skeletonsConfig() { + return [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + count: 2, + orientation: 'vertical', + thumbnailDisplay: 'small', + thumbnailAlign: 'left', + thumbnailAspectRatio: '16:9', + minHeight: '380px', + }, + { + breakpoints: [3, 4, 5, 6, 7], + orientation: 'horizontal', + minHeight: '230px', + }, + ]; + }, selectAll: { get() { return this.selected.length === this.channels.length; @@ -214,9 +286,6 @@ debouncedSearch() { return debounce(this.loadCatalog, 1000); }, - detailsRouteName() { - return RouteNames.CATALOG_DETAILS; - }, channels() { // Sort again by the same ordering used on the backend - name. // Have to do this because of how we are getting the object data via getChannels. @@ -225,6 +294,13 @@ selectedCount() { return this.page.count - this.excluded.length; }, + isIndeterminate() { + return this.selected.length > 0 && this.selected.length < this.channels.length; + }, + tokenChannel() { + if (!this.tokenChannelId) return null; + return this.channels.find(c => c.id === this.tokenChannelId) || null; + }, }, watch: { $route(to) { @@ -243,11 +319,61 @@ this.previousQuery = { ...to.query }; }, }, - mounted() { + created() { this.loadCatalog(); }, methods: { ...mapActions('channelList', ['searchCatalog']), + onTokenModalInput(val) { + if (!val) this.tokenChannelId = null; + }, + getDropdownItems(channel) { + const items = []; + if (channel.source_url) { + items.push({ label: this.$tr('goToWebsite'), icon: 'openNewTab', value: 'source-url' }); + } + if (channel.demo_server_url) { + items.push({ label: this.$tr('viewContent'), icon: 'openNewTab', value: 'demo-url' }); + } + return items; + }, + handleDropdownSelect(option, channel) { + if (option.value === 'source-url') { + window.open(channel.source_url, '_blank'); + } else if (option.value === 'demo-url') { + window.open(channel.demo_server_url, '_blank'); + } + }, + getChannelDetailsRoute(channel) { + return { + name: RouteNames.CATALOG_DETAILS, + query: { + ...this.$route.query, + last: this.$route.name, + }, + params: { + channelId: channel.id, + }, + }; + }, + onCardClick(channel) { + if (this.loggedIn) { + window.location.assign(window.Urls.channel(channel.id)); + } else { + this.$router.push(this.getChannelDetailsRoute(channel)); + } + }, + isChannelSelected(channel) { + return this.selected.includes(channel.id); + }, + handleSelectionToggle(channelId) { + const currentlySelected = this.selected; + if (currentlySelected.includes(channelId)) { + this.selected = currentlySelected.filter(id => id !== channelId); + } else { + this.selected = [...currentlySelected, channelId]; + } + }, loadCatalog() { this.loading = true; const params = { @@ -286,8 +412,16 @@ this.setSelection(false); return this.downloadChannelsPDF(params); }, + selectDownloadOption(option) { + if (option.value === 'pdf') { + this.downloadPDF(); + } else if (option.value === 'csv') { + this.downloadCSV(); + } + }, }, $trs: { + title: 'Content library', resultsText: '{count, plural,\n =1 {# result found}\n other {# results found}}', selectChannels: 'Download a summary of selected channels', cancelButton: 'Cancel', @@ -298,6 +432,10 @@ channelSelectionCount: '{count, plural,\n =1 {# channel selected}\n other {# channels selected}}', selectAll: 'Select all', + copyToken: 'Copy channel token', + moreOptions: 'More options', + goToWebsite: 'Go to source website', + viewContent: 'View channel on Kolibri', }, }; @@ -306,9 +444,54 @@ diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelInvitation.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelInvitation.vue index 8fef4ecee4..1e074ab885 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelInvitation.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelInvitation.vue @@ -1,75 +1,53 @@ - - - - - - - {{ $tr('invitations', { count: invitationList.length }) }} - - - - - - import { mapActions, mapGetters, mapState } from 'vuex'; - import { - RouteNames, - ChannelInvitationMapping, - ListTypeToRouteMapping, - RouteToListTypeMapping, - } from '../constants'; + import { RouteNames, ChannelInvitationMapping, ListTypeToRouteMapping } from '../constants'; import ChannelListAppError from './ChannelListAppError'; - import ChannelInvitation from './Channel/ChannelInvitation'; import { ChannelListTypes } from 'shared/constants'; import { constantsTranslationMixin, routerMixin } from 'shared/mixins'; import GlobalSnackbar from 'shared/views/GlobalSnackbar'; import AppBar from 'shared/views/AppBar'; - import OfflineText from 'shared/views/OfflineText'; + import StudioOfflineAlert from 'shared/views/StudioOfflineAlert.vue'; import PolicyModals from 'shared/views/policies/PolicyModals'; const CATALOG_PAGES = [ @@ -157,11 +123,10 @@ name: 'ChannelListIndex', components: { AppBar, - ChannelInvitation, ChannelListAppError, GlobalSnackbar, PolicyModals, - OfflineText, + StudioOfflineAlert, }, mixins: [constantsTranslationMixin, routerMixin], computed: { @@ -182,9 +147,6 @@ isCatalogPage() { return this.$route.name === RouteNames.CATALOG_ITEMS; }, - currentListType() { - return RouteToListTypeMapping[this.$route.name]; - }, toolbarHeight() { return this.loggedIn && !this.isFAQPage ? 112 : 64; }, @@ -194,14 +156,6 @@ lists() { return Object.values(ChannelListTypes).filter(l => l !== 'public'); }, - invitationList() { - const invitations = this.invitations; - return ( - invitations.filter( - i => ChannelInvitationMapping[i.share_mode] === this.currentListType, - ) || [] - ); - }, invitationsByListCounts() { const inviteMap = {}; Object.values(ChannelListTypes).forEach(type => { @@ -217,9 +171,6 @@ catalogLink() { return { name: RouteNames.CATALOG_ITEMS }; }, - isChannelList() { - return this.lists.includes(this.currentListType); - }, homeLink() { return this.libraryMode ? window.Urls.base() : window.Urls.channels(); }, @@ -232,8 +183,10 @@ }, watch: { $route(route) { - if (this.loggedIn && route.name === RouteNames.CHANNELS_EDITABLE) { - this.loadInvitationList(); + if (route.name === RouteNames.CHANNELS_EDITABLE) { + this.loggedIn + ? this.loadInvitationList() + : this.$router.replace({ name: RouteNames.CATALOG_ITEMS }); } if (this.fullPageError) { this.$store.dispatch('errors/clearError'); @@ -248,9 +201,7 @@ if (this.loggedIn) { this.loadInvitationList(); } else if (!CATALOG_PAGES.includes(this.$route.name)) { - this.$router.push({ - name: RouteNames.CATALOG_ITEMS, - }); + this.$router.replace({ name: RouteNames.CATALOG_ITEMS }); } }, mounted() { @@ -292,7 +243,6 @@ $trs: { channelSets: 'Collections', catalog: 'Content Library', - invitations: 'You have {count, plural,\n =1 {# invitation}\n other {# invitations}}', libraryTitle: 'Kolibri Content Library Catalog', frequentlyAskedQuestions: 'Frequently asked questions', }, @@ -337,4 +287,8 @@ overflow: auto; } + .h-100 { + height: 100%; + } + diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSelectionList.vue b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSelectionList.vue index 97f36f8e1d..78d274943d 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSelectionList.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSelectionList.vue @@ -37,13 +37,13 @@
diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetItem.vue b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetItem.vue deleted file mode 100644 index d0761a23e4..0000000000 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetItem.vue +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetList.vue b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetList.vue deleted file mode 100644 index 2cf6b0fdef..0000000000 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetList.vue +++ /dev/null @@ -1,173 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetModal.vue b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetModal.vue index 53a1a5d363..750600ad82 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetModal.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetModal.vue @@ -38,17 +38,17 @@ lg10 xl8 > - - + - +

{{ $tr('tokenPrompt') }}

@@ -72,35 +72,30 @@

- - - +
- - {{ $tr('selectChannelsHeader') }} - + /> - - {{ $tr('removeText') }} - + />
@@ -155,50 +150,39 @@ - - - + {{ $tr('unsavedChangesText') }} + @@ -209,30 +193,40 @@ import { set } from 'vue'; import { mapGetters, mapActions } from 'vuex'; + import useKShow from 'kolibri-design-system/lib/composables/useKShow'; import difference from 'lodash/difference'; import { RouteNames } from '../../constants'; import ChannelItem from './ChannelItem'; import ChannelSelectionList from './ChannelSelectionList'; import { ChannelListTypes, ErrorTypes } from 'shared/constants'; - import { constantsTranslationMixin, routerMixin } from 'shared/mixins'; + import { generateFormMixin, constantsTranslationMixin, routerMixin } from 'shared/mixins'; import CopyToken from 'shared/views/CopyToken'; - import MessageDialog from 'shared/views/MessageDialog'; import FullscreenModal from 'shared/views/FullscreenModal'; + import StudioLargeLoader from 'shared/views/StudioLargeLoader'; import Tabs from 'shared/views/Tabs'; - import LoadingText from 'shared/views/LoadingText'; + + const formMixin = generateFormMixin({ + name: { + required: true, + validator: v => v && v.trim().length > 0, + }, + }); export default { name: 'ChannelSetModal', components: { CopyToken, ChannelSelectionList, - MessageDialog, ChannelItem, FullscreenModal, Tabs, - LoadingText, + StudioLargeLoader, + }, + mixins: [formMixin, constantsTranslationMixin, routerMixin], + setup() { + const { show } = useKShow(); + return { show }; }, - mixins: [constantsTranslationMixin, routerMixin], props: { channelSetId: { type: String, @@ -256,9 +250,6 @@ isNew() { return this.$route.path === '/collections/new'; }, - nameRules() { - return [name => (name && name.trim().length ? true : this.$tr('titleRequiredText'))]; - }, name: { get() { return Object.prototype.hasOwnProperty.call(this.diffTracker, 'name') @@ -385,44 +376,48 @@ this.saving = true; this.showUnsavedDialog = false; - if (this.$refs.channelsetform.validate()) { - let promise; + const formData = this.clean(); + if (!this.validate(formData)) { + this.saving = false; + return; + } - if (this.isNew) { - const channelSetData = { ...this.diffTracker }; - promise = this.commitChannelSet(channelSetData) - .then(newCollection => { - if (!newCollection || !newCollection.id) { - this.saving = false; - return; - } + let promise; - const newCollectionId = newCollection.id; + if (this.isNew) { + const channelSetData = { ...this.diffTracker, ...formData }; + promise = this.commitChannelSet(channelSetData) + .then(newCollection => { + if (!newCollection || !newCollection.id) { + this.saving = false; + return; + } - this.$router.replace({ - name: 'CHANNEL_SET_DETAILS', - params: { channelSetId: newCollectionId }, - }); + const newCollectionId = newCollection.id; - return newCollection; - }) - .catch(() => { - this.saving = false; + this.$router.replace({ + name: 'CHANNEL_SET_DETAILS', + params: { channelSetId: newCollectionId }, }); - } else { - promise = this.saveChannels().then(() => { - return this.updateChannelSet({ id: this.channelSetId, ...this.diffTracker }); - }); - } - promise - .then(() => { - this.close(); + return newCollection; }) - .finally(() => { + .catch(() => { this.saving = false; }); + } else { + promise = this.saveChannels().then(() => { + return this.updateChannelSet({ id: this.channelSetId, ...this.diffTracker }); + }); } + + promise + .then(() => { + this.close(); + }) + .finally(() => { + this.saving = false; + }); }, cancelChanges() { @@ -514,4 +509,14 @@ - + diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/StudioCollectionsTable.vue b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/StudioCollectionsTable.vue new file mode 100644 index 0000000000..fc9767b2e8 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/StudioCollectionsTable.vue @@ -0,0 +1,455 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/StudioCollectionsTable.spec.js b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/StudioCollectionsTable.spec.js new file mode 100644 index 0000000000..d17629b2d6 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/StudioCollectionsTable.spec.js @@ -0,0 +1,222 @@ +import { render, screen, within, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { createLocalVue } from '@vue/test-utils'; +import Vuex, { Store } from 'vuex'; +import VueRouter from 'vue-router'; +import StudioCollectionsTable from '../StudioCollectionsTable.vue'; +import { RouteNames } from '../../../constants'; + +const localVue = createLocalVue(); +localVue.use(Vuex); +localVue.use(VueRouter); + +const mockChannelSets = [ + { + id: 'collection-1', + name: 'Test Collection 1', + secret_token: 'token-123', + channels: ['channel-1', 'channel-2'], + }, + { + id: 'collection-2', + name: 'Test Collection 2', + secret_token: null, + channels: ['channel-3'], + }, +]; + +const mockActions = { + loadChannelSetList: jest.fn(() => Promise.resolve()), + deleteChannelSet: jest.fn(() => Promise.resolve()), +}; + +const createMockStore = () => { + return new Store({ + modules: { + channelSet: { + namespaced: true, + state: {}, + getters: { + channelSets: () => mockChannelSets, + getChannelSet: () => id => mockChannelSets.find(cs => cs.id === id), + }, + actions: mockActions, + }, + }, + actions: { + showSnackbarSimple: jest.fn(), + }, + }); +}; + +const renderComponent = async (options = {}) => { + const store = options.store || createMockStore(); + const router = new VueRouter({ + routes: [ + { + name: RouteNames.CHANNEL_SETS, + path: '/collections', + component: StudioCollectionsTable, + }, + { + name: RouteNames.NEW_CHANNEL_SET, + path: '/collections/new', + component: { template: '
New Channel Set
' }, + }, + { + name: RouteNames.CHANNEL_SET_DETAILS, + path: '/collections/:channelSetId', + component: { template: '
Channel Set Details
' }, + }, + ], + }); + + router.push({ name: RouteNames.CHANNEL_SETS }); + + const result = render(StudioCollectionsTable, { + localVue, + store, + router, + ...options, + }); + await waitFor(() => { + expect(mockActions.loadChannelSetList).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + return { ...result, router }; +}; + +describe('StudioCollectionsTable', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display channel sets in table when data is loaded', async () => { + await renderComponent(); + + const table = screen.getByRole('grid'); + const rows = within(table).getAllByRole('row').slice(1); + + expect(rows).toHaveLength(2); + + const row1Cells = within(rows[0]).getAllByRole('gridcell'); + expect(row1Cells[0]).toHaveTextContent('Test Collection 1'); + expect(row1Cells[1]).toHaveTextContent('Token'); + expect(row1Cells[2]).toHaveTextContent('2'); + + const row2Cells = within(rows[1]).getAllByRole('gridcell'); + expect(row2Cells[0]).toHaveTextContent('Test Collection 2'); + expect(row2Cells[1]).toHaveTextContent('Saving'); + expect(row2Cells[2]).toHaveTextContent('1'); + }); + + it('should display empty message when no collections are present', async () => { + const emptyStore = new Store({ + modules: { + channelSet: { + namespaced: true, + state: {}, + getters: { + channelSets: () => [], + getChannelSet: () => () => null, + }, + actions: mockActions, + }, + }, + }); + + await renderComponent({ store: emptyStore }); + + expect( + screen.getByText( + 'You can package together multiple channels to create a collection. The entire collection can then be imported to Kolibri at once by using a collection token.', + ), + ).toBeInTheDocument(); + expect(screen.getByText('Learn more about collections')).toBeInTheDocument(); + }); + + it('should open info modal when "Learn about collections" link is clicked', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const infoLink = screen.getByText('Learn more about collections'); + await user.click(infoLink); + + expect(screen.getByRole('heading', { name: 'About collections' })).toBeInTheDocument(); + + const modal = screen.getByRole('dialog'); + expect( + within(modal).getByText( + 'A collection contains multiple Kolibri Studio channels that can be imported at one time to Kolibri with a single collection token.', + ), + ).toBeInTheDocument(); + }); + + it('should navigate to new channel set page when "New collection" button is clicked', async () => { + const user = userEvent.setup(); + const { router } = await renderComponent(); + + const newCollectionButton = screen.getByRole('button', { name: /new collection/i }); + await user.click(newCollectionButton); + + expect(router.currentRoute.name).toBe(RouteNames.NEW_CHANNEL_SET); + }); + + it('should navigate to edit page when edit option is selected', async () => { + const user = userEvent.setup(); + const { router } = await renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Test Collection 1')).toBeInTheDocument(); + }); + + const optionsButtons = screen.getAllByRole('button', { name: /options/i }); + await user.click(optionsButtons[0]); + + const editOption = screen.getByText('Edit collection'); + await user.click(editOption); + + expect(router.currentRoute.name).toBe(RouteNames.CHANNEL_SET_DETAILS); + expect(router.currentRoute.params.channelSetId).toBe('collection-1'); + }); + + it('should call delete action when delete is confirmed', async () => { + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Test Collection 1')).toBeInTheDocument(); + }); + + const optionsButtons = screen.getAllByRole('button', { name: /options/i }); + await user.click(optionsButtons[0]); + + const deleteOption = screen.getByText('Delete collection'); + await user.click(deleteOption); + + const deleteConfirmationModal = screen.getByRole('dialog'); + + expect( + within(deleteConfirmationModal).getByRole('heading', { + name: 'Delete collection', + }), + ).toBeInTheDocument(); + + const deleteButton = within(deleteConfirmationModal).getByRole('button', { + name: 'Delete collection', + }); + await user.click(deleteButton); + + expect(mockActions.deleteChannelSet).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + id: 'collection-1', + name: 'Test Collection 1', + }), + ); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSelectionList.spec.js b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSelectionList.spec.js index a7befd7c43..4ea4aa9aac 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSelectionList.spec.js +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSelectionList.spec.js @@ -1,9 +1,12 @@ -import { mount } from '@vue/test-utils'; +import { render, screen, within } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import VueRouter from 'vue-router'; import { Store } from 'vuex'; import ChannelSelectionList from '../ChannelSelectionList'; import { ChannelListTypes } from 'shared/constants'; const searchWord = 'search test'; + const editChannel = { id: 'editchannel', name: searchWord, @@ -14,7 +17,7 @@ const editChannel = { const editChannel2 = { id: 'editchannel2', - name: '', + name: 'Another Channel', description: '', edit: true, published: true, @@ -22,100 +25,154 @@ const editChannel2 = { const publicChannel = { id: 'publicchannel', + name: 'Public Channel', public: true, published: true, }; -const getters = { - channels: jest.fn(() => [editChannel, editChannel2, publicChannel]), - getChannel: jest.fn(() => () => editChannel), -}; - -const actions = { +const mockActions = { loadChannelList: jest.fn(() => Promise.resolve()), }; -const store = new Store({ - modules: { - channel: { - namespaced: true, - getters, - actions, +const makeStore = () => + new Store({ + modules: { + channel: { + namespaced: true, + state: {}, + getters: { + channels: () => [editChannel, editChannel2, publicChannel], + getChannel: () => id => [editChannel, editChannel2, publicChannel].find(c => c.id === id), + }, + actions: mockActions, + mutations: { + ADD_CHANNELS() {}, + }, + }, }, - }, -}); + }); -function makeWrapper() { - const loadChannelList = jest.spyOn(ChannelSelectionList.methods, 'loadChannelList'); - loadChannelList.mockImplementation(() => Promise.resolve()); +const renderComponent = (props = {}) => { + const router = new VueRouter({ + routes: [{ path: '/', name: 'Home' }], + }); - const wrapper = mount(ChannelSelectionList, { - propsData: { + return render(ChannelSelectionList, { + router, + store: makeStore(), + props: { listType: ChannelListTypes.EDITABLE, + value: [], // Default to empty + ...props, // Allow overriding props for specific tests }, - computed: { - channels() { - return [editChannel, editChannel2, publicChannel]; - }, - }, - store, }); +}; - return [wrapper, { loadChannelList }]; -} - -describe('channelSelectionList', () => { - let wrapper, mocks; - +describe('ChannelSelectionList', () => { beforeEach(() => { - [wrapper, mocks] = makeWrapper(); + jest.clearAllMocks(); }); - afterEach(() => { - mocks.loadChannelList.mockRestore(); - }); + it('renders a list of editable channels and hides non-editable ones', async () => { + await renderComponent(); + + // Specific wait avoids wrapping the whole block in waitFor + expect(await screen.findByLabelText('Search for a channel')).toBeInTheDocument(); - it('should show the correct channels based on listType', async () => { - await wrapper.setData({ loading: false }); - expect(wrapper.vm.listChannels.find(c => c.id === editChannel.id)).toBeTruthy(); - expect(wrapper.vm.listChannels.find(c => c.id === editChannel2.id)).toBeTruthy(); - expect(wrapper.vm.listChannels.find(c => c.id === publicChannel.id)).toBeFalsy(); + expect(screen.getByText(editChannel.name)).toBeInTheDocument(); + expect(screen.getByText(editChannel2.name)).toBeInTheDocument(); + expect(screen.queryByText(publicChannel.name)).not.toBeInTheDocument(); }); - it('should select channels when the channel has been checked', async () => { - await wrapper.setData({ loading: false }); - await wrapper.findComponent(`[data-test="checkbox-${editChannel.id}"]`).trigger('click'); + it('filters the channel list when the user types in the search box', async () => { + const user = userEvent.setup(); + await renderComponent(); + + // Wait for data load + expect(await screen.findByText(editChannel.name)).toBeInTheDocument(); + expect(screen.getByText(editChannel2.name)).toBeInTheDocument(); + + const searchInput = screen.getByLabelText('Search for a channel'); + await user.clear(searchInput); + await user.type(searchInput, editChannel.name); - expect(wrapper.emitted('input')[0][0]).toEqual([editChannel.id]); + // Verify filter happened + expect(await screen.findByText(editChannel.name)).toBeInTheDocument(); + expect(screen.queryByText(editChannel2.name)).not.toBeInTheDocument(); }); - it('should deselect channels when the channel has been unchecked', async () => { - await wrapper.setData({ loading: false }); - await wrapper.findComponent(`[data-test="checkbox-${editChannel.id}"]`).trigger('click'); // Check the channel - await wrapper.findComponent(`[data-test="checkbox-${editChannel.id}"]`).trigger('click'); // Uncheck the channel + it('selects a channel when the user clicks the checkbox', async () => { + const user = userEvent.setup(); + const { emitted } = await renderComponent(); - expect(wrapper.emitted('input')[0].length).toEqual(1); // Only one event should be emitted (corresponding to the initial check) - expect(wrapper.emitted('input')[0][0]).toEqual([editChannel.id]); // The initial check event should be emitted + await screen.findByText(editChannel.name); + + // Using getByTestId because the component doesn't expose unique + // accessible roles for individual channel checkboxes + const checkboxRow = screen.getByTestId(`checkbox-${editChannel.id}`); + + // Find the checkbox strictly within this row + const checkbox = within(checkboxRow).getByRole('checkbox'); + + await user.click(checkbox); + + expect(emitted()).toHaveProperty('input'); + expect(emitted().input).toHaveLength(1); + expect(emitted().input[0][0]).toEqual([editChannel.id]); }); - it('should filter channels based on the search text', async () => { - await wrapper.setData({ loading: false, search: searchWord }); - expect(wrapper.vm.listChannels.find(c => c.id === editChannel.id)).toBeTruthy(); - expect(wrapper.vm.listChannels.find(c => c.id === editChannel2.id)).toBeFalsy(); + it('deselects a channel when the user clicks the checkbox of an already selected channel', async () => { + const user = userEvent.setup(); + + // Initialize with the channel already selected + const { emitted } = await renderComponent({ value: [editChannel.id] }); + + await screen.findByText(editChannel.name); + + // Using getByTestId because the component doesn't expose unique + // accessible roles for individual channel checkboxes + const checkboxRow = screen.getByTestId(`checkbox-${editChannel.id}`); + const checkbox = within(checkboxRow).getByRole('checkbox'); + + // Click the checkbox to deselect + await user.click(checkbox); + + expect(emitted()).toHaveProperty('input'); + expect(emitted().input).toHaveLength(1); + expect(emitted().input[0][0]).toEqual([]); }); - it('should select channels when the channel card has been clicked', async () => { - await wrapper.setData({ loading: false }); - await wrapper.findComponent(`[data-test="channel-item-${editChannel.id}"]`).trigger('click'); - expect(wrapper.emitted('input')[0][0]).toEqual([editChannel.id]); + it('selects a channel when the user clicks the channel card', async () => { + const user = userEvent.setup(); + const { emitted } = await renderComponent(); + + await screen.findByText(editChannel.name); + + // Using getByTestId because the component doesn't expose accessible + // roles for channel cards + const card = screen.getByTestId(`channel-item-${editChannel.id}`); + await user.click(card); + + expect(emitted()).toHaveProperty('input'); + expect(emitted().input).toHaveLength(1); + expect(emitted().input[0][0]).toEqual([editChannel.id]); }); - it('should deselect channels when the channel card has been clicked', async () => { - await wrapper.setData({ loading: false }); - await wrapper.findComponent(`[data-test="channel-item-${editChannel.id}"]`).trigger('click'); // Check the channel - await wrapper.findComponent(`[data-test="channel-item-${editChannel.id}"]`).trigger('click'); // Uncheck the channel + it('deselects a channel when the user clicks a selected channel card', async () => { + const user = userEvent.setup(); + + // Initialize with the channel already selected + const { emitted } = await renderComponent({ value: [editChannel.id] }); + + await screen.findByText(editChannel.name); + + // Using getByTestId because the component doesn't expose accessible + // roles for channel cards + const card = screen.getByTestId(`channel-item-${editChannel.id}`); + await user.click(card); - expect(wrapper.emitted('input')[0].length).toEqual(1); // Only one event should be emitted (corresponding to the initial check) - expect(wrapper.emitted('input')[0][0]).toEqual([editChannel.id]); // The initial check event should be emitted + expect(emitted()).toHaveProperty('input'); + expect(emitted().input).toHaveLength(1); + expect(emitted().input[0][0]).toEqual([]); }); }); diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetItem.spec.js b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetItem.spec.js deleted file mode 100644 index bbb57080e3..0000000000 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetItem.spec.js +++ /dev/null @@ -1,46 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { factory } from '../../../store'; -import router from '../../../router'; -import { RouteNames } from '../../../constants'; -import ChannelSetItem from '../ChannelSetItem.vue'; - -const store = factory(); - -const channelSet = { - id: 'testing', - channels: [], - secret_token: '1234567890', -}; - -store.commit('channelSet/ADD_CHANNELSET', channelSet); - -function makeWrapper() { - const wrapper = mount(ChannelSetItem, { - router, - store, - sync: false, - propsData: { channelSetId: channelSet.id }, - }); - const deleteChannelSet = jest.spyOn(wrapper.vm, 'deleteChannelSet'); - deleteChannelSet.mockImplementation(() => Promise.resolve()); - return [wrapper, { deleteChannelSet }]; -} - -describe('channelSetItem', () => { - let wrapper, mocks; - - beforeEach(() => { - [wrapper, mocks] = makeWrapper(); - }); - - it('clicking the edit option should open the channel set edit modal', () => { - wrapper.find('[data-test="edit"]').trigger('click'); - expect(wrapper.vm.$route.name).toEqual(RouteNames.CHANNEL_SET_DETAILS); - }); - - it('clicking delete button in dialog should delete the channel set', () => { - wrapper.vm.deleteDialog = true; - wrapper.find('[data-test="delete"]').trigger('click'); - expect(mocks.deleteChannelSet).toHaveBeenCalled(); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetList.spec.js b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetList.spec.js deleted file mode 100644 index 61fb53847a..0000000000 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetList.spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { factory } from '../../../store'; -import router from '../../../router'; -import { RouteNames } from '../../../constants'; -import ChannelSetList from '../ChannelSetList.vue'; - -const store = factory(); - -function makeWrapper() { - router.push({ - name: RouteNames.CHANNEL_SETS, - }); - return mount(ChannelSetList, { store, router }); -} - -describe('channelSetList', () => { - let wrapper; - - beforeEach(async () => { - wrapper = makeWrapper(); - await wrapper.setData({ loading: false }); - }); - - it('should open a new channel set modal when new set button is clicked', async () => { - const push = jest.fn(); - wrapper.vm.$router.push = push; - await wrapper.find('[data-test="add-channelset"]').trigger('click'); - expect(push).toHaveBeenCalledWith({ - name: RouteNames.NEW_CHANNEL_SET, - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetModal.spec.js b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetModal.spec.js index 959b677c5e..a1dd15c4fa 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetModal.spec.js @@ -11,6 +11,13 @@ import ChannelSetModal from '../ChannelSetModal'; import channel from 'shared/vuex/channel'; import storeFactory from 'shared/vuex/baseStore'; +jest.mock('kolibri-design-system/lib/composables/useKShow', () => ({ + __esModule: true, + default: () => ({ + show: () => false, // skip loading state + }), +})); + const localVue = createLocalVue(); localVue.use(Vuex); localVue.use(VueRouter); @@ -217,18 +224,18 @@ describe('ChannelSetModal', () => { }); it('should prompt user if there are unsaved changes', async () => { - expect(getUnsavedDialog(wrapper).attributes('data-test-visible')).toBeFalsy(); + expect(getUnsavedDialog(wrapper).exists()).toBeFalsy(); - await getCollectionNameInput(wrapper).setValue('My collection'); + await wrapper.setData({ name: 'My collection' }); await getCloseButton(wrapper).trigger('click'); - expect(getUnsavedDialog(wrapper).attributes('data-test-visible')).toBeTruthy(); + expect(getUnsavedDialog(wrapper).exists()).toBeTruthy(); }); }); describe('clicking save button', () => { it("shouldn't update a channel set when a collection name is missing", async () => { - await getCollectionNameInput(wrapper).setValue(''); + await wrapper.setData({ name: '' }); await getSaveButton(wrapper).trigger('click'); await flushPromises(); @@ -236,7 +243,7 @@ describe('ChannelSetModal', () => { }); it("shouldn't update a channel set when a collection name is made of empty characters", async () => { - await getCollectionNameInput(wrapper).setValue(' '); + await wrapper.setData({ name: ' ' }); await getSaveButton(wrapper).trigger('click'); await flushPromises(); @@ -244,7 +251,7 @@ describe('ChannelSetModal', () => { }); it('should update a channel set when a collection name is valid', async () => { - await getCollectionNameInput(wrapper).setValue('My collection'); + await wrapper.setData({ name: 'My collection' }); await getSaveButton(wrapper).trigger('click'); await flushPromises(); diff --git a/contentcuration/contentcuration/frontend/editorDev/index.js b/contentcuration/contentcuration/frontend/editorDev/index.js deleted file mode 100644 index ef9a10c8db..0000000000 --- a/contentcuration/contentcuration/frontend/editorDev/index.js +++ /dev/null @@ -1,24 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import VueRouter from 'vue-router'; -import TipTapEditor from '../shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; -import startApp from 'shared/app'; -import storeFactory from 'shared/vuex/baseStore'; - -Vue.use(VueRouter); -Vue.use(Vuex); - -// Create a minimal store that has the required methods -const store = storeFactory(); - -startApp({ - store, // Provide the store - this is required Althought not needed - router: new VueRouter({ - routes: [ - { - path: '/', - component: TipTapEditor, - }, - ], - }), -}); diff --git a/contentcuration/contentcuration/frontend/editorDev/router.js b/contentcuration/contentcuration/frontend/editorDev/router.js deleted file mode 100644 index 1bb8ee6838..0000000000 --- a/contentcuration/contentcuration/frontend/editorDev/router.js +++ /dev/null @@ -1,10 +0,0 @@ -import Vue from 'vue'; -import Router from 'vue-router'; -import TipTapEditor from '../shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; - -Vue.use(Router); -export default new Router({ - mode: 'history', - base: '/editor-dev/', - routes: [{ path: '/', component: TipTapEditor }], -}); diff --git a/contentcuration/contentcuration/frontend/settings/pages/Account/index.vue b/contentcuration/contentcuration/frontend/settings/pages/Account/index.vue index ff66b59f0c..6f3be20ffa 100644 --- a/contentcuration/contentcuration/frontend/settings/pages/Account/index.vue +++ b/contentcuration/contentcuration/frontend/settings/pages/Account/index.vue @@ -75,7 +75,7 @@ rel="noopener noreferrer" />

- import { mapActions, mapState } from 'vuex'; + import StudioCopyToken from '../../../shared/views/StudioCopyToken'; import FullNameForm from './FullNameForm'; import ChangePasswordForm from './ChangePasswordForm'; import DeleteAccountForm from './DeleteAccountForm'; - import CopyToken from 'shared/views/CopyToken'; export default { name: 'Account', components: { ChangePasswordForm, - CopyToken, FullNameForm, DeleteAccountForm, + StudioCopyToken, }, data() { return { diff --git a/contentcuration/contentcuration/frontend/settings/pages/SettingsIndex.vue b/contentcuration/contentcuration/frontend/settings/pages/SettingsIndex.vue index add98d3e5b..dea18db8fb 100644 --- a/contentcuration/contentcuration/frontend/settings/pages/SettingsIndex.vue +++ b/contentcuration/contentcuration/frontend/settings/pages/SettingsIndex.vue @@ -18,20 +18,12 @@ - - - - - - - + + + @@ -48,10 +40,11 @@ import { routerMixin } from 'shared/mixins'; import StudioOfflineAlert from 'shared/views/StudioOfflineAlert'; import PolicyModals from 'shared/views/policies/PolicyModals'; + import StudioPage from 'shared/views/StudioPage'; export default { name: 'SettingsIndex', - components: { GlobalSnackbar, AppBar, StudioOfflineAlert, PolicyModals }, + components: { GlobalSnackbar, AppBar, StudioOfflineAlert, StudioPage, PolicyModals }, mixins: [routerMixin], computed: { ...mapState({ diff --git a/contentcuration/contentcuration/frontend/settings/pages/Storage/RequestForm.vue b/contentcuration/contentcuration/frontend/settings/pages/Storage/RequestForm.vue index 325b26bf0e..9d8cca72c7 100644 --- a/contentcuration/contentcuration/frontend/settings/pages/Storage/RequestForm.vue +++ b/contentcuration/contentcuration/frontend/settings/pages/Storage/RequestForm.vue @@ -4,12 +4,13 @@ ref="form" @submit.prevent="submit" > - + class="studio-banner" + > + {{ errorText() }} +

{{ $tr('natureOfYourContentLabel') }}

@@ -262,11 +263,12 @@ import sortBy from 'lodash/sortBy'; import { mapActions, mapState } from 'vuex'; + import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion'; import { generateFormMixin, constantsTranslationMixin } from 'shared/mixins'; import { LicensesList } from 'shared/leUtils/Licenses'; import CountryField from 'shared/views/form/CountryField'; import MultiSelect from 'shared/views/form/MultiSelect'; - import Banner from 'shared/views/Banner'; + import StudioBanner from 'shared/views/StudioBanner'; import InfoModal from 'shared/views/InfoModal'; const formMixin = generateFormMixin({ @@ -318,10 +320,14 @@ components: { CountryField, MultiSelect, - Banner, + StudioBanner, InfoModal, }, mixins: [constantsTranslationMixin, formMixin], + setup() { + const { sendPoliteMessage } = useKLiveRegion(); + return { sendPoliteMessage }; + }, computed: { ...mapState('settings', ['channels']), orgSelected() { @@ -394,6 +400,7 @@ if (this.$refs.form.scrollIntoView) { this.$refs.form.scrollIntoView({ behavior: 'smooth' }); } + this.sendPoliteMessage(this.errorText()); }, // eslint-disable-next-line kolibri/vue-no-unused-methods, vue/no-unused-properties @@ -516,14 +523,14 @@ font-size: 14px; } - /* fixes unintended margin caused by KDS styles */ - .license-link ::v-deep span { - margin-left: 0 !important; - } - .license-description { margin-bottom: 8px; line-height: 1.5; } + .studio-banner { + margin-top: 8px; + margin-bottom: 8px; + } + diff --git a/contentcuration/contentcuration/frontend/settings/pages/Storage/index.vue b/contentcuration/contentcuration/frontend/settings/pages/Storage/index.vue index b551814440..cc6ea9ad14 100644 --- a/contentcuration/contentcuration/frontend/settings/pages/Storage/index.vue +++ b/contentcuration/contentcuration/frontend/settings/pages/Storage/index.vue @@ -13,8 +13,13 @@ }}
+ + @@ -47,12 +52,6 @@ -
- -

@@ -111,15 +110,24 @@ - - - diff --git a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue index fc29cefd07..6313279e30 100644 --- a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue @@ -40,11 +40,13 @@ style="text-transform: none" v-on="on" > - + + + {{ user.first_name }} + + + + + + + + import { mapActions, mapState, mapGetters } from 'vuex'; + import WithNotificationIndicator from './WithNotificationIndicator.vue'; import Tabs from 'shared/views/Tabs'; import MainNavigationDrawer from 'shared/views/MainNavigationDrawer'; import LanguageSwitcherModal from 'shared/languageSwitcher/LanguageSwitcherModal'; + import { Modals } from 'shared/constants'; + import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; export default { name: 'AppBar', @@ -173,6 +189,13 @@ LanguageSwitcherModal, MainNavigationDrawer, Tabs, + WithNotificationIndicator, + }, + setup() { + const { notificationsLabel$ } = communityChannelsStrings; + return { + notificationsLabel$, + }; }, props: { title: { @@ -204,6 +227,15 @@ }, methods: { ...mapActions(['logout']), + showNotificationsModal() { + this.$router.push({ + query: { + ...this.$route.query, + modal: Modals.NOTIFICATIONS, + }, + }); + this.$analytics.trackClick('general', `Notifications`); + }, }, $trs: { title: 'Kolibri Studio', diff --git a/contentcuration/contentcuration/frontend/shared/views/BottomBar.vue b/contentcuration/contentcuration/frontend/shared/views/BottomBar.vue index 6cceeb228d..d0b6c51aa0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/BottomBar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/BottomBar.vue @@ -2,7 +2,7 @@
@@ -17,7 +17,16 @@ props: { appearanceOverrides: { type: Object, - default: () => {}, + default: () => ({}), + }, + }, + computed: { + bottomBarStyles() { + return { + backgroundColor: this.$themeTokens.surface, + borderTop: `1px solid ${this.$themeTokens.fineLine}`, + ...this.appearanceOverrides, + }; }, }, }; @@ -36,8 +45,6 @@ align-items: center; width: 100%; height: 64px; - background-color: #ffffff; - border-top: 1px solid #dddddd; } diff --git a/contentcuration/contentcuration/frontend/shared/views/HelpButton.vue b/contentcuration/contentcuration/frontend/shared/views/HelpButton.vue new file mode 100644 index 0000000000..e10c0b3c29 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/HelpButton.vue @@ -0,0 +1,61 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/HelpTooltip.vue b/contentcuration/contentcuration/frontend/shared/views/HelpTooltip.vue index b48a4a586c..9b58e61162 100644 --- a/contentcuration/contentcuration/frontend/shared/views/HelpTooltip.vue +++ b/contentcuration/contentcuration/frontend/shared/views/HelpTooltip.vue @@ -1,39 +1,67 @@ + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/InfoModal.vue b/contentcuration/contentcuration/frontend/shared/views/InfoModal.vue index 719cc8101f..d12a10ab9a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/InfoModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/InfoModal.vue @@ -1,9 +1,9 @@ @@ -142,12 +160,24 @@ - - - diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/FormulasMenu/symbols.json b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/FormulasMenu/symbols.json deleted file mode 100644 index ca58afa42e..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/FormulasMenu/symbols.json +++ /dev/null @@ -1,237 +0,0 @@ -[ - { - "title": "Formulas", - "symbols": [ - {"key": "^", "preview": "{a}^{b}", "title": "Superscript"}, - {"key": "_", "preview": "{a}_{b}", "title": "Subscript"}, - {"key": "\\frac", "preview": "\\frac{a}{b}", "title": "Fraction"}, - {"key": "\\binom", "preview": "(\\binom_b^a)", "title": "Binomial coefficient"}, - {"key": "\\sum", "preview": "\\sum_b^a", "title": "Sum"}, - {"key": "\\prod", "preview": "\\prod_b^a", "title": "Product"}, - {"key": "\\coprod", "preview": "\\coprod_b^a", "title": "Coproduct"}, - {"key": "\\int", "preview": "\\int_b^a", "title": "Integral"} - ] - }, - { - "title": "Lines", - "symbols": [ - {"key": "\\sqrt", "preview": "\\surd\\bar{abc}", "title": "Square root"}, - {"key": "\\bar", "preview": "\\bar{abc}", "title": "Bar"}, - {"key": "\\underline", "preview": "\\underline{abc}", "title": "Underline"}, - {"key": "\\overleftarrow", "preview": "\\overleftarrow{abc}", "title": "Left arrow"}, - {"key": "\\overrightarrow", "preview": "\\overrightarrow{abc}", "title": "Right arrow"}, - {"key": "\\vec", "preview": "\\vec{v}", "title": "Vector"} - ] - }, - { - "title": "Basic", - "symbols": [ - {"key": "+","preview": "+","title": "Addition"}, - {"key": "-","preview": "−","title": "Subtraction"}, - {"key": "\\times","preview": "×","title": "Multiplication"}, - {"key": "\\div","preview": "÷","title": "Division"}, - {"key": "\\cdot","preview": "·","title": "Dot"}, - {"key": "\\neg","preview": "¬","title": "Negation"}, - {"key": "\\pm","preview": "±","title": "Plus-minus"}, - {"key": "\\mp","preview": "∓","title": "Minus-plus"}, - {"key": "\\neq","preview": "≠","title": "Does not equal"}, - {"key": "\\approx","preview": "≈","title": "Approximately"}, - {"key": "\\propto","preview": "∝","title": "Proportional"}, - {"key": "\\doteq","preview": "≐","title": "Definition"}, - {"key": "\\gt","preview": ">","title": "Greater than"}, - {"key": "\\ge","preview": "≥","title": "Greater than or equal to"}, - {"key": "\\ngtr","preview": "≯","title": "Not greater than"}, - {"key": "\\gg","preview": "≫","title": "Significantly greater than"}, - {"key": "\\lt","preview": "<","title": "Less than"}, - {"key": "\\le","preview": "≤","title": "Less than or equal to"}, - {"key": "\\nless","preview": "≮","title": "Not less than"}, - {"key": "\\ll","preview": "≪","title": "Significantly less than"}, - {"key": "\\lceil","preview": "⌈","title": "Left ceiling"}, - {"key": "\\lfloor","preview": "⌊","title": "Left floor"}, - {"key": "\\rceil","preview": "⌉","title": "Right ceiling"}, - {"key": "\\rfloor","preview": "⌋","title": "Right floor"} - ] - }, - { - "title": "Advanced", - "symbols": [ - {"key": "\\bigotimes","preview": "⨂","title": "Tensor product"}, - {"key": "\\otimes","preview": "⊗","title": "Tensor product"}, - {"key": "\\oint","preview": "∮","title": "Contour integral"}, - {"key": "\\nabla","preview": "∇","title": "Nabla"}, - {"key": "\\ast","preview": "*","title": "Conjugate"}, - {"key": "\\dagger","preview": "†","title": "Conjugate transpose"}, - {"key": "\\partial","preview": "∂","title": "Partial"}, - {"key": "\\wedge","preview": "∧","title": "Wedge product"}, - {"key": "\\infty","preview": "∞","title": "Infinity"}, - {"key": "\\top","preview": "⊤","title": "Top element"}, - {"key": "\\prec","preview": "≺","title": "Reducible to"}, - {"key": "\\succ","preview": "≻","title": "Nondominated by"} - ] - }, - { - "title": "Logic", - "symbols": [ - {"key": "\\land","preview": "∧","title": "And"}, - {"key": "\\lor","preview": "∨","title": "Or"}, - {"key": "\\iff","preview": "⟺","title": "If and only if"}, - {"key": "\\models","preview": "⊨","title": "Entails"}, - {"key": "\\vdash","preview": "⊢","title": "Implies"}, - {"key": "\\vert","preview": "|","title": "Given that/Such that"}, - {"key": "\\exists","preview": "∃","title": "Exists"}, - {"key": "\\forall","preview": "∀","title": "For all"}, - {"key": "\\because","preview": "∵","title": "Because"}, - {"key": "\\therefore","preview": "∴","title": "Therefore"}, - {"key": "\\square","preview": "□","title": "QED"}, - {"key": "\\bigoplus","preview": "⨁","title": "Exclusive or"}, - {"key": "\\oplus","preview": "⊕","title": "Exclusive or"} - ] - }, - { - "title": "Geometry", - "symbols": [ - {"key": "\\circ","preview": "∘","title": "Degrees"}, - {"key": "\\angle","preview": "∠","title": "Angle"}, - {"key": "\\measuredangle","preview": "∡","title": "Measured angle"}, - {"key": "\\parallel","preview": "∥","title": "Parallel"}, - {"key": "\\perp","preview": "⊥","title": "Perpendicular"}, - {"key": "\\nparallel","preview": "∦","title": "Incomparable to"}, - {"key": "\\sim","preview": "∼","title": "Similar to"}, - {"key": "\\simeq","preview": "≃","title": "Similar or equal to"}, - {"key": "\\cong","preview": "≅","title": "Congruent to"}, - {"key": "\\equiv","preview": "≡","title": "Congruent to"} - ] - }, - { - "title": "Sets", - "symbols": [ - {"key": "\\dots","preview": "…","title": "Ellipsis"}, - {"key": "\\vdots","preview": "⋮","title": "Ellipsis (vertical)"}, - {"key": "\\cdots","preview": "⋯","title": "Ellipsis (centered)"}, - {"key": "\\ddots","preview": "⋱","title": "Ellipsis (diagonal)"}, - {"key": "\\aleph","preview": "ℵ","title": "Cardinality"}, - {"key": "\\bigcap","preview": "⋂","title": "Intersection"}, - {"key": "\\cap","preview": "∩","title": "Intersection"}, - {"key": "\\sqcap","preview": "⊓","title": "Intersection"}, - {"key": "\\bigcup","preview": "⋃","title": "Union"}, - {"key": "\\cup","preview": "∪","title": "Union"}, - {"key": "\\sqcup","preview": "⊔","title": "Union"}, - {"key": "\\emptyset","preview": "∅","title": "Empty set"}, - {"key": "\\in","preview": "∈","title": "In"}, - {"key": "\\notin","preview": "∉","title": "Not in"}, - {"key": "\\ni","preview": "∋","title": "Contains"}, - {"key": "\\ominus","preview": "⊖","title": "Symmetric difference"}, - {"key": "\\setminus","preview": "∖","title": "Set difference"}, - {"key": "\\subset","preview": "⊂","title": "Subset"}, - {"key": "\\sqsubset","preview": "⊏","title": "Subset"}, - {"key": "\\subseteq","preview": "⊆","title": "Subset or equal"}, - {"key": "\\sqsubseteq","preview": "⊑","title": "Subset or equal"}, - {"key": "\\nsubseteq","preview": "⊈","title": "Not a subset or equal"}, - {"key": "\\supset","preview": "⊃","title": "Superset"}, - {"key": "\\sqsupset","preview": "⊐","title": "Superset"}, - {"key": "\\supseteq","preview": "⊇","title": "Superset or equal"}, - {"key": "\\sqsupseteq","preview": "⊒","title": "Superset or equal"}, - {"key": "\\nsupseteq","preview": "⊉","title": "Not a superset or equal"}, - {"key": "\\wr","preview": "≀","title": "Wreath product"}, - {"key": "\\bowtie","preview": "⋈","title": "Natural join"} - ] - }, - { - "title": "Directional", - "symbols": [ - {"key": "\\downarrow","preview": "↓","title": "Down"}, - {"key": "\\Downarrow","preview": "⇓","title": "Down (double)"}, - {"key": "\\bigtriangledown","preview": "▽","title": "Triangle down"}, - {"key": "\\uparrow","preview": "↑","title": "Up"}, - {"key": "\\Uparrow","preview": "⇑","title": "Up (double)"}, - {"key": "\\bigtriangleup","preview": "△","title": "Triangle up"}, - {"key": "\\updownarrow","preview": "↕","title": "Up-down"}, - {"key": "\\Updownarrow","preview": "⇕","title": "Up-down (double)"}, - {"key": "\\leftarrow","preview": "←","title": "Left"}, - {"key": "\\Leftarrow","preview": "⇐","title": "Left (double)"}, - {"key": "\\longleftarrow","preview": "⟵","title": "Left (long)"}, - {"key": "\\Longleftarrow","preview": "⟸","title": "Left (long, double)"}, - {"key": "\\hookleftarrow","preview": "↩","title": "Left (hooked)"}, - {"key": "\\leftharpoondown","preview": "↽","title": "Left (harpoon down)"}, - {"key": "\\leftharpoonup","preview": "↼","title": "Left (harpoon up)"}, - {"key": "\\rightarrow","preview": "→","title": "Right"}, - {"key": "\\Rightarrow","preview": "⇒","title": "Right (double)"}, - {"key": "\\longrightarrow","preview": "⟶","title": "Right (long)"}, - {"key": "\\Longrightarrow","preview": "⟹","title": "Right (long, double)"}, - {"key": "\\hookrightarrow","preview": "↪","title": "Right (hooked)"}, - {"key": "\\rightharpoondown","preview": "⇁","title": "Right (harpoon down)"}, - {"key": "\\rightharpoonup","preview": "⇀","title": "Right (harpoon up)"}, - {"key": "\\leftrightarrow","preview": "↔","title": "Left-right"}, - {"key": "\\Leftrightarrow","preview": "⇔","title": "Left-right (double)"}, - {"key": "\\longleftrightarrow","preview": "⟷","title": "Left-right (long)"}, - {"key": "\\Longleftrightarrow","preview": "⟺","title": "Left-right (long, double)"}, - {"key": "\\nearrow","preview": "↗","title": "Northeast"}, - {"key": "\\nwarrow","preview": "↖","title": "Northwest"}, - {"key": "\\searrow","preview": "↘","title": "Southeast"}, - {"key": "\\swarrow","preview": "↙","title": "Southwest"} - ] - }, - { - "title": "Characters", - "symbols": [ - {"key": "\\alpha","preview": "α","title": "alpha"}, - {"key": "\\beta","preview": "β","title": "beta"}, - {"key": "\\chi","preview": "χ","title": "chi"}, - {"key": "\\Delta","preview": "Δ","title": "Delta"}, - {"key": "\\delta","preview": "δ","title": "delta"}, - {"key": "\\digamma","preview": "ϝ","title": "digamma"}, - {"key": "\\ell","preview": "ℓ","title": "ell"}, - {"key": "\\epsilon","preview": "ϵ","title": "epsilon"}, - {"key": "\\varepsilon","preview": "ε","title": "epsilon"}, - {"key": "\\eta","preview": "η","title": "eta"}, - {"key": "\\Gamma","preview": "Γ","title": "Gamma"}, - {"key": "\\gamma","preview": "γ","title": "gamma"}, - {"key": "\\hbar","preview": "ℏ","title": "Planck's constant"}, - {"key": "\\iota","preview": "ι","title": "iota"}, - {"key": "\\kappa","preview": "κ","title": "kappa"}, - {"key": "\\varkappa","preview": "ϰ","title": "kappa"}, - {"key": "\\Lambda","preview": "Λ","title": "Lambda"}, - {"key": "\\lambda","preview": "λ","title": "lambda"}, - {"key": "\\mu","preview": "μ","title": "mu"}, - {"key": "\\nu","preview": "ν","title": "nu"}, - {"key": "\\Omega","preview": "Ω","title": "Omega"}, - {"key": "\\omega","preview": "ω","title": "omega"}, - {"key": "\\Phi","preview": "Φ","title": "Phi"}, - {"key": "\\phi","preview": "ϕ","title": "phi"}, - {"key": "\\varphi","preview": "φ","title": "phi"}, - {"key": "\\Pi","preview": "Π","title": "Pi"}, - {"key": "\\pi","preview": "π","title": "pi"}, - {"key": "\\varpi","preview": "ϖ","title": "pi"}, - {"key": "\\Psi","preview": "Ψ","title": "Psi"}, - {"key": "\\psi","preview": "ψ","title": "psi"}, - {"key": "\\rho","preview": "ρ","title": "rho"}, - {"key": "\\varrho","preview": "ϱ","title": "rho"}, - {"key": "\\Sigma","preview": "Σ","title": "Sigma"}, - {"key": "\\sigma","preview": "σ","title": "sigma"}, - {"key": "\\varsigma","preview": "ς","title": "sigma"}, - {"key": "\\tau","preview": "τ","title": "tau"}, - {"key": "\\Theta","preview": "Θ","title": "Theta"}, - {"key": "\\theta","preview": "θ","title": "theta"}, - {"key": "\\vartheta","preview": "ϑ","title": "theta"}, - {"key": "\\Upsilon","preview": "Υ","title": "Upsilon"}, - {"key": "\\upsilon","preview": "υ","title": "upsilon"}, - {"key": "\\wp","preview": "℘","title": "P"}, - {"key": "\\Xi","preview": "Ξ","title": "Xi"}, - {"key": "\\xi","preview": "ξ","title": "xi"}, - {"key": "\\zeta","preview": "ζ","title": "zeta"} - ] - }, - { - "title": "Miscellaneous", - "symbols": [ - {"key": "\\bigcirc","preview": "◯","title": "Circle"}, - {"key": "\\clubsuit","preview": "♣","title": "Club"}, - {"key": "\\diamondsuit","preview": "♢","title": "Diamond"}, - {"key": "\\heartsuit","preview": "♡","title": "Heart"}, - {"key": "\\spadesuit","preview": "♠","title": "Spade"}, - {"key": "\\flat","preview": "♭","title": "Flat"}, - {"key": "\\natural","preview": "♮","title": "Natural"}, - {"key": "\\sharp","preview": "♯","title": "Sharp"} - ] - } -] diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/ImagesMenu/ImagesMenu.vue b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/ImagesMenu/ImagesMenu.vue deleted file mode 100644 index 303292d99b..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/ImagesMenu/ImagesMenu.vue +++ /dev/null @@ -1,338 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/MarkdownEditor.vue b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/MarkdownEditor.vue deleted file mode 100644 index 82ca995bd3..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/MarkdownEditor.vue +++ /dev/null @@ -1,907 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/__mocks__/MarkdownEditor.vue b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/__mocks__/MarkdownEditor.vue deleted file mode 100644 index 1c1f563e8d..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/__mocks__/MarkdownEditor.vue +++ /dev/null @@ -1,28 +0,0 @@ - - - - diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/utils.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/utils.js deleted file mode 100644 index f5ce62e98e..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownEditor/utils.js +++ /dev/null @@ -1,117 +0,0 @@ -import * as Showdown from 'showdown'; -import Editor from '@toast-ui/editor'; -import { stripHtml } from 'string-strip-html'; - -import imagesHtmlToMd from '../plugins/image-upload/image-html-to-md'; -import formulaHtmlToMd from '../plugins/formulas/formula-html-to-md'; - -/** - * Clear DOM node by keeping only its text content. - * - * @param {Node} node - * @param {Array} ignore An array of selectors. All nodes - * corresponding to these selectors - * won't be changed. - */ -export const clearNodeFormat = ({ node, ignore = [] }) => { - let clonedNode = node.cloneNode(true); - - if (clonedNode.hasChildNodes()) { - clonedNode.querySelectorAll('*').forEach(childNode => { - childNode.parentNode.replaceChild(clearNodeFormat({ node: childNode, ignore }), childNode); - }); - } - - if ( - clonedNode.nodeType === clonedNode.ELEMENT_NODE && - !ignore.some(selector => clonedNode.matches(selector)) - ) { - const textNode = document.createTextNode(clonedNode.innerHTML); - - if (clonedNode.parentNode) { - clonedNode.parentNode.replaceChild(textNode, clonedNode); - } else { - // use `document.createDocumentFragment` insted of `new DocumentFragment - // otherwise Jest test for this helper would fail - // https://github.com/jsdom/jsdom/issues/2274 - clonedNode = document.createDocumentFragment(); - clonedNode.appendChild(textNode); - } - } - - return clonedNode; -}; - -/** - * Calculate the formulas menu position within markdown editor. - * If the formulas menu is to be shown in the second half (horizontally) - * of the editor, it's right corner should be clipped to the target - * => this position of the right corner is returned as `right`. - * Otherwise left corner is used to clip the menu and position - * of the left corner is return as `left`. - * Position is returned relative to editor element. - * - * @param {Object} editor Markdown editor element - * @param {Number} targetX Viewport X position of a point in editor - * to which formulas menu should be clipped to - * @param {Number} targetY Viewport Y position of a point in editor - * to which formulas menu should be clipped to - */ -export const getExtensionMenuPosition = ({ editorEl, targetX, targetY }) => { - const editorWidth = editorEl.getBoundingClientRect().width; - const editorTop = editorEl.getBoundingClientRect().top; - const editorLeft = editorEl.getBoundingClientRect().left; - const editorRight = editorEl.getBoundingClientRect().right; - const editorMiddle = editorLeft + editorWidth / 2; - - const menuTop = targetY - editorTop; - - let menuLeft = null; - let menuRight = null; - - if (targetX < editorMiddle) { - menuLeft = targetX - editorLeft; - } else { - menuRight = editorRight - targetX; - } - - return { - top: menuTop, - left: menuLeft, - right: menuRight, - }; -}; - -export const generateCustomConverter = el => { - // This is currently the only way of inheriting and adjusting - // default TUI's convertor methods - // see https://github.com/nhn/tui.editor/issues/615 - const tmpEditor = new Editor({ el }); - const showdown = new Showdown.Converter(); - const Convertor = tmpEditor.convertor.constructor; - class CustomConvertor extends Convertor { - toMarkdown(content) { - content = showdown.makeMarkdown(content); - content = imagesHtmlToMd(content); - content = formulaHtmlToMd(content); - // TUI.editor sprinkles in extra `
` tags that Kolibri renders literally - // When showdown has already added linebreaks to render these in markdown - // so we just remove these here. - content = content.replaceAll('
', ''); - - // any copy pasted rich text that renders as HTML but does not get converted - // will linger here, so remove it as Kolibri will render it literally also. - content = stripHtml(content).result; - return content; - } - toHTML(content) { - // Kolibri and showdown assume double newlines for a single line break, - // wheras TUI.editor prefers single newline characters. - content = content.replaceAll('\n\n', '\n'); - content = super.toHTML(content); - return content; - } - } - tmpEditor.remove(); - return CustomConvertor; -}; diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownViewer/MarkdownViewer.vue b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownViewer/MarkdownViewer.vue deleted file mode 100644 index 66aed89bf1..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownViewer/MarkdownViewer.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownViewer/__mocks__/MarkdownViewer.vue b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownViewer/__mocks__/MarkdownViewer.vue deleted file mode 100644 index 41de8fcef9..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/MarkdownViewer/__mocks__/MarkdownViewer.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - - diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/formulas/formula-html-to-md.spec.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/formulas/formula-html-to-md.spec.js deleted file mode 100644 index 2b91c6b1b1..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/formulas/formula-html-to-md.spec.js +++ /dev/null @@ -1,23 +0,0 @@ -import formulaHtmlToMd from '../../../plugins/formulas/formula-html-to-md'; - -describe('MarkdownEditor - extensions - formulas', () => { - describe('formulaHtmlToMd', () => { - it('converts all elements with `is="markdown-formula-field"` to markdown', () => { - const input = - 'Please solve following equation: 3x+5y+2, 5x+8y+3.'; - - expect(formulaHtmlToMd(input)).toBe( - 'Please solve following equation: $$3x+5y+2$$, $$5x+8y+3$$.', - ); - }); - - it('converts a markdown-formula element with extra attributes', () => { - const input = ` - - {a}^{b} - Have fun!`; - - expect(formulaHtmlToMd(input)).toBe('$${a}^{b}$$ Have fun!'); - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/formulas/formula-md-to-html.spec.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/formulas/formula-md-to-html.spec.js deleted file mode 100644 index b601121e56..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/formulas/formula-md-to-html.spec.js +++ /dev/null @@ -1,11 +0,0 @@ -import formulaMdToHtml from '../../../plugins/formulas/formula-md-to-html'; - -describe('formulaMdToHtml', () => { - it('converts formulas markdown to html', () => { - const input = 'Please solve following equation: $$3x+5y+2$$, $$5x+8y+3$$.'; - - expect(formulaMdToHtml(input)).toBe( - 'Please solve following equation: 3x+5y+2, 5x+8y+3.', - ); - }); -}); 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 deleted file mode 100644 index 44a6956bfe..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/formulas/markdownFormulaField.spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import { registerMarkdownFormulaField } from 'shared/views/MarkdownEditor/plugins/formulas/MarkdownFormulaField'; - -// we need to mock the component's style import for the element to successfully register in jsdom -jest.mock('./style.css', () => ''); - -let formulaEl; - -describe('MarkdownFormulaField custom element', () => { - beforeAll(() => { - document.body.innerHTML = ` - x^y - `; - formulaEl = document.querySelector('span[is="markdown-formula-field"]'); - registerMarkdownFormulaField(); - }); - - test('getting formula latex from the custom element', async () => { - await window.customElements.whenDefined('markdown-formula-field'); - expect(formulaEl.getVueInstance().latex).toBe('x^y'); - }); - - it('renders some MathQuill markup in a shadowRoot', async () => { - await window.customElements.whenDefined('markdown-formula-field'); - const shadowRoot = formulaEl.shadowRoot; - expect(shadowRoot).toBeTruthy(); - const varEls = shadowRoot.querySelectorAll('var'); - expect(varEls[0].innerHTML).toBe('x'); - expect(varEls[1].innerHTML).toBe('y'); - }); - - it('sets `contenteditable=false` on its host element', async () => { - await window.customElements.whenDefined('markdown-formula-field'); - expect(formulaEl).toHaveAttribute('contenteditable', 'false'); - }); -}); diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/image-upload/image-html-to-md.spec.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/image-upload/image-html-to-md.spec.js deleted file mode 100644 index d6d2f9c224..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/image-upload/image-html-to-md.spec.js +++ /dev/null @@ -1,18 +0,0 @@ -import imageHtmlToMd from '../../../plugins/image-upload/image-html-to-md'; - -describe('imageHtmlToMd', () => { - it('converts images html to markdown', () => { - const input = ` - First image: - ![](\${☣ CONTENTSTORAGE}/checksum.ext =100x200) - - Second image: - ![Second image](\${☣ CONTENTSTORAGE}/94ffaf.png) - `; - - expect(imageHtmlToMd(input)).toBe( - `First image: ![](\${☣ CONTENTSTORAGE}/checksum.ext =100x200) - Second image: ![Second image](\${☣ CONTENTSTORAGE}/94ffaf.png)`, - ); - }); -}); diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/image-upload/image-md-to-html.spec.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/image-upload/image-md-to-html.spec.js deleted file mode 100644 index f06e1e485f..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/image-upload/image-md-to-html.spec.js +++ /dev/null @@ -1,26 +0,0 @@ -import imageMdToHtml from '../../../plugins/image-upload/image-md-to-html'; - -describe('imageMdToHtml', () => { - it('converts images markdown to html', () => { - const input = ` - First image: ![](\${☣ CONTENTSTORAGE}/checksum.ext =100x200) - Second image: ![Second image](\${☣ CONTENTSTORAGE}/94ffaf.png) - `; - - expect(imageMdToHtml(input)).toBe(` - First image: ![](\${☣ CONTENTSTORAGE}/checksum.ext =100x200) - Second image: ![Second image](\${☣ CONTENTSTORAGE}/94ffaf.png) - `); - }); - it('handles duplicate images', () => { - const input = ` - First image: ![](\${☣ CONTENTSTORAGE}/checksum.ext =100x200) - First image, again: ![](\${☣ CONTENTSTORAGE}/checksum.ext =100x200) - `; - - expect(imageMdToHtml(input)).toBe(` - First image: ![](\${☣ CONTENTSTORAGE}/checksum.ext =100x200) - First image, again: ![](\${☣ CONTENTSTORAGE}/checksum.ext =100x200) - `); - }); -}); diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/image-upload/markdownImageField.spec.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/image-upload/markdownImageField.spec.js deleted file mode 100644 index b831fa362c..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/plugins/image-upload/markdownImageField.spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import { nextTick } from 'vue'; -import { registerMarkdownImageField } from 'shared/views/MarkdownEditor/plugins/image-upload/MarkdownImageField'; - -// we need to mock the component's style import for the element to successfully register in jsdom -jest.mock('./style.css', () => ''); - -let imageEl; -const imageMd = '![](${☣ CONTENTSTORAGE}/checksum.jpg =100x200)'; - -describe('MarkdownImageField custom element', () => { - beforeAll(() => { - document.body.innerHTML = ` - ${imageMd} - `; - imageEl = document.querySelector('span[is="markdown-image-field"]'); - registerMarkdownImageField(); - }); - - test('renders some image markdown as an `img` element', async () => { - await window.customElements.whenDefined('markdown-image-field'); - const innerImgEl = imageEl.shadowRoot.querySelector('img'); - expect(innerImgEl).toHaveAttribute('src', '/content/storage/c/h/checksum.jpg'); - - expect(innerImgEl).toHaveAttribute('width', '100'); - }); - - it('can update its markdown upon resizing', async () => { - await window.customElements.whenDefined('markdown-image-field'); - const imageVueComponent = imageEl.getVueInstance(); - imageVueComponent.image.width = 5000000; - imageVueComponent.image.height = 1; - - const expectedMd = '![](${☣ CONTENTSTORAGE}/checksum.jpg =5000000x1)'; - imageVueComponent.exportParamsToMarkdown(); - - await nextTick(); - await nextTick(); // wait another tick for prop to update - - expect(imageEl.innerHTML).toBe(expectedMd); - expect(imageVueComponent.markdown).toBe(expectedMd); - }); -}); diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/utils.spec.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/utils.spec.js deleted file mode 100644 index 19eac02c33..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/__tests__/utils.spec.js +++ /dev/null @@ -1,97 +0,0 @@ -import { clearNodeFormat, generateCustomConverter } from '../MarkdownEditor/utils'; - -const htmlStringToFragment = htmlString => { - const template = document.createElement('template'); - template.innerHTML = htmlString; - - return template.content; -}; - -const fragmentToHtmlString = fragment => { - let htmlString = ''; - - fragment.childNodes.forEach(childNode => { - if (childNode.nodeType === childNode.TEXT_NODE) { - htmlString += childNode.textContent; - } else { - htmlString += childNode.outerHTML; - } - }); - - return htmlString; -}; - -describe('clearNodeFormat', () => { - it('clears all tags by default', () => { - const fragment = htmlStringToFragment( - 'What color is the sky', - ); - - const clearedFragment = clearNodeFormat({ node: fragment }); - - expect(fragmentToHtmlString(clearedFragment)).toBe('What color is the sky'); - }); - - it('does not clear format that should be ignored - tag selector', () => { - const fragment = htmlStringToFragment( - 'What color is the sky', - ); - const ignore = ['b']; - - const clearedFragment = clearNodeFormat({ node: fragment, ignore }); - - expect(fragmentToHtmlString(clearedFragment)).toBe('What color is the sky'); - }); - - it('does not clear format that should be ignored - class selector', () => { - const fragment = htmlStringToFragment( - 'What color is the sky', - ); - const ignore = ['.keep']; - - const clearedFragment = clearNodeFormat({ node: fragment, ignore }); - - expect(fragmentToHtmlString(clearedFragment)).toBe( - 'What color is the sky', - ); - }); -}); - -describe('markdown conversion', () => { - let documentCreateRange; - - beforeAll(() => { - documentCreateRange = document.createRange; - document.createRange = () => { - const range = new Range(); - - range.getBoundingClientRect = jest.fn(); - - range.getClientRects = () => { - return { - item: () => null, - length: 0, - [Symbol.iterator]: jest.fn(), - }; - }; - - return range; - }; - }); - - afterAll(() => { - document.createRange = documentCreateRange; - }); - - it('converts image tags to markdown without escaping them', () => { - const el = document.createElement('div'); - const CustomConvertor = generateCustomConverter(el); - const converter = new CustomConvertor(); - const html = - '![](${☣ CONTENTSTORAGE}/bc1c5a86e1e46f20a6b4ee2c1bb6d6ff.png =485.453125x394)'; - - expect(converter.toMarkdown(html)).toBe( - '![](${☣ CONTENTSTORAGE}/bc1c5a86e1e46f20a6b4ee2c1bb6d6ff.png =485.453125x394)', - ); - }); -}); diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/constants.js deleted file mode 100644 index 3d384e9342..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/constants.js +++ /dev/null @@ -1,12 +0,0 @@ -export const CLASS_MATH_FIELD = 'math-field'; -export const CLASS_MATH_FIELD_ACTIVE = `${CLASS_MATH_FIELD}-active`; -export const CLASS_MATH_FIELD_NEW = `${CLASS_MATH_FIELD}-new`; - -export const CLASS_IMG_FIELD = 'image-field'; -export const CLASS_IMG_FIELD_NEW = `${CLASS_IMG_FIELD}-new`; - -export const KEY_ARROW_RIGHT = 'ArrowRight'; -export const KEY_ARROW_LEFT = 'ArrowLeft'; -export const KEY_BACKSPACE = 'Backspace'; - -export const IMAGE_PLACEHOLDER = '${☣ CONTENTSTORAGE}'; diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/mathquill/mathquill.css b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/mathquill/mathquill.css deleted file mode 100644 index 7a42a648c3..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/mathquill/mathquill.css +++ /dev/null @@ -1,469 +0,0 @@ -/* stylelint-disable */ - -/* * * * * * * * * * * ATTENTION * * * * * * * * * * * - * This file contains some LEq customizations, - * so there's a need to be careful to reflect them - * if we upgrade MathQuill one day (or eventually - * create MathQuill fork if there's a need to upgrade - * often). For more information see the formulas plugin - * documentation docs/markdown_editor_viewer.md - * or commit 9c85577761a75d1c3c216496f4e3373e57623699 - * * * * * * * * * * * * * * * * * * * * * * * * * * */ - -/* - * MathQuill v0.10.1 http://mathquill.com - * by Han, Jeanine, and Mary maintainers@mathquill.com - * - * This Source Code Form is subject to the terms of the - * Mozilla Public License, v. 2.0. If a copy of the MPL - * was not distributed with this file, You can obtain - * one at http://mozilla.org/MPL/2.0/. - */ -.mq-editable-field { - display: -moz-inline-box; - display: inline-block; -} -.mq-editable-field .mq-cursor { - position: relative; - z-index: 1; - display: -moz-inline-box; - display: inline-block; - padding: 0; - margin-left: -1px; - border-left: 1px solid black; -} -.mq-editable-field .mq-cursor.mq-blink { - visibility: hidden; -} -.mq-editable-field, -.mq-math-mode .mq-editable-field { - border: 1px solid gray; -} -.mq-editable-field.mq-focused, -.mq-math-mode .mq-editable-field.mq-focused { - border-color: #709ac0; - border-radius: 1px; - -webkit-box-shadow: - #88bbdd 0 0 1px 2px, - inset #66aaee 0 0 2px 0; - -moz-box-shadow: - #88bbdd 0 0 1px 2px, - inset #66aaee 0 0 2px 0; - box-shadow: - #88bbdd 0 0 1px 2px, - inset #66aaee 0 0 2px 0; -} -.mq-math-mode .mq-editable-field { - margin: 1px; -} -.mq-editable-field .mq-latex-command-input { - padding-right: 1px; - margin-right: 1px; - margin-left: 2px; - font-family: 'Courier New', monospace; - color: inherit; - border: 1px solid gray; -} -.mq-editable-field .mq-latex-command-input.mq-empty { - background: transparent; -} -.mq-editable-field .mq-latex-command-input.mq-hasCursor { - border-color: ActiveBorder; -} -.mq-editable-field.mq-empty::after, -.mq-editable-field.mq-text-mode::after, -.mq-math-mode .mq-empty::after { - visibility: hidden; - content: 'c'; -} -.mq-editable-field .mq-cursor:only-child::after, -.mq-editable-field .mq-textarea + .mq-cursor:last-child::after { - visibility: hidden; - content: 'c'; -} -.mq-editable-field .mq-text-mode .mq-cursor:only-child::after { - content: ''; -} -.mq-editable-field.mq-text-mode { - overflow-x: auto; - overflow-y: hidden; -} -.mq-root-block, -.mq-math-mode .mq-root-block { - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - display: -moz-inline-box; - display: inline-block; - width: 100%; - padding: 2px; - overflow: hidden; - white-space: nowrap; - vertical-align: middle; -} -.mq-math-mode { - display: -moz-inline-box; - display: inline-block; - font-size: 115%; - font-style: normal; - font-weight: normal; - font-variant: normal; - line-height: 1 !important; -} -.mq-math-mode .mq-non-leaf, -.mq-math-mode .mq-scaled { - display: -moz-inline-box; - display: inline-block; -} -.mq-math-mode var, -.mq-math-mode .mq-text-mode, -.mq-math-mode .mq-nonSymbola { - font-family: MathJax, serif; - line-height: 0.9 !important; -} -.mq-math-mode * { - box-sizing: border-box; - padding: 0; - margin: 0; - font-size: inherit; - line-height: inherit !important; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - border-color: black; -} -.mq-math-mode .mq-empty { - background: #cccccc; -} -.mq-math-mode .mq-empty.mq-root-block { - background: transparent; -} -.mq-math-mode.mq-empty { - background: transparent; -} -.mq-math-mode .mq-text-mode { - display: inline-block; -} -.mq-math-mode .mq-text-mode.mq-hasCursor { - min-width: 1ex; - padding: 0 0.1em; - margin: 0 -0.1em; - box-shadow: inset darkgray 0 0.1em 0.2em; -} -.mq-math-mode .mq-font { - font: - 1em MathJax, - serif; -} -.mq-math-mode .mq-font * { - font-family: inherit; - font-style: inherit; -} -.mq-math-mode b, -.mq-math-mode b.mq-font { - font-weight: bolder; -} -.mq-math-mode var, -.mq-math-mode i, -.mq-math-mode i.mq-font { - font-style: italic; -} -.mq-math-mode var.mq-f { - margin-right: 0.2em; - margin-left: 0.1em; -} -.mq-math-mode .mq-roman var.mq-f { - margin: 0; -} -.mq-math-mode big { - font-size: 200%; -} -.mq-math-mode .mq-int > big { - display: inline-block; - vertical-align: -0.16em; - -webkit-transform: scaleX(0.7); - -moz-transform: scaleX(0.7); - -ms-transform: scaleX(0.7); - -o-transform: scaleX(0.7); - transform: scaleX(0.7); -} -.mq-math-mode .mq-int > .mq-supsub { - padding-right: 0.2em; - font-size: 80%; - vertical-align: -1.1em; -} -.mq-math-mode .mq-int > .mq-supsub > .mq-sup > .mq-sup-inner { - vertical-align: 1.3em; -} -.mq-math-mode .mq-int > .mq-supsub > .mq-sub { - margin-left: -0.35em; -} -.mq-math-mode .mq-roman { - font-style: normal; -} -.mq-math-mode .mq-sans-serif { - font-family: sans-serif, MathJax, serif; -} -.mq-math-mode .mq-monospace { - font-family: monospace, MathJax, serif; -} -.mq-math-mode .mq-overline { - margin-top: 1px; - border-top: 1px solid black; -} -.mq-math-mode .mq-underline { - margin-bottom: 1px; - border-bottom: 1px solid black; -} -.mq-math-mode .mq-binary-operator { - display: -moz-inline-box; - display: inline-block; - padding: 0 0.2em; -} -.mq-math-mode .mq-supsub { - font-size: 90%; - text-align: left; - vertical-align: -0.5em; -} -.mq-math-mode .mq-supsub.mq-sup-only { - vertical-align: 0.5em; -} -.mq-math-mode .mq-supsub.mq-sup-only .mq-sup { - display: inline-block; - vertical-align: text-bottom; -} -.mq-math-mode .mq-supsub .mq-sup { - display: block; -} -.mq-math-mode .mq-supsub .mq-sub { - display: block; - float: left; -} -.mq-math-mode .mq-supsub .mq-binary-operator { - padding: 0 0.1em; -} -.mq-math-mode .mq-supsub .mq-fraction { - font-size: 70%; -} -.mq-math-mode sup.mq-nthroot { - min-width: 0.5em; - margin-right: -0.6em; - margin-left: 0.2em; - font-size: 80%; - vertical-align: 0.8em; -} -.mq-math-mode .mq-paren { - padding: 0 0.1em; - vertical-align: top; - -webkit-transform-origin: center 0.06em; - -moz-transform-origin: center 0.06em; - -ms-transform-origin: center 0.06em; - -o-transform-origin: center 0.06em; - transform-origin: center 0.06em; -} -.mq-math-mode .mq-paren.mq-ghost { - color: silver; -} -.mq-math-mode .mq-paren + span { - margin-top: 0.1em; - margin-bottom: 0.1em; -} -.mq-math-mode .mq-array { - text-align: center; - vertical-align: middle; -} -.mq-math-mode .mq-array > span { - display: block; -} -.mq-math-mode .mq-operator-name { - font-family: MathJax, 'Times New Roman', serif; - font-style: normal; - line-height: 0.9 !important; -} -.mq-math-mode var.mq-operator-name.mq-first { - padding-left: 0.2em; -} -.mq-math-mode var.mq-operator-name.mq-last, -.mq-math-mode .mq-supsub.mq-after-operator-name { - padding-right: 0.2em; -} -.mq-math-mode .mq-fraction { - padding: 0 0.2em; - font-size: 90%; - text-align: center; - vertical-align: -0.4em; -} -.mq-math-mode .mq-fraction, -.mq-math-mode .mq-large-operator, -.mq-math-mode x:-moz-any-link { - display: -moz-groupbox; -} -.mq-math-mode .mq-fraction, -.mq-math-mode .mq-large-operator, -.mq-math-mode x:-moz-any-link, -.mq-math-mode x:default { - display: inline-block; -} -.mq-math-mode .mq-numerator, -.mq-math-mode .mq-denominator { - display: block; -} -.mq-math-mode .mq-numerator { - padding: 0 0.1em; -} -.mq-math-mode .mq-denominator { - float: right; - width: 100%; - padding: 0.1em; - border-top: 1px solid; -} -.mq-math-mode .mq-sqrt-prefix { - position: relative; - top: 0.1em; - padding-top: 0; - vertical-align: top; - -webkit-transform-origin: top; - -moz-transform-origin: top; - -ms-transform-origin: top; - -o-transform-origin: top; - transform-origin: top; -} -.mq-math-mode .mq-sqrt-stem { - padding-top: 1px; - padding-right: 0.2em; - padding-left: 0.15em; - margin-top: 1px; - margin-right: 0.1em; - border-top: 1px solid; -} -.mq-math-mode .mq-vector-prefix { - display: block; - margin-bottom: -0.1em; - font-size: 0.75em; - line-height: 0.25em !important; - text-align: center; -} -.mq-math-mode .mq-vector-stem { - display: block; -} -.mq-math-mode .mq-large-operator { - padding: 0.2em; - text-align: center; - vertical-align: -0.2em; -} - -.mq-from { - padding-top: 5px; -} - -.mq-math-mode .mq-large-operator .mq-from, -.mq-math-mode .mq-large-operator big, -.mq-math-mode .mq-large-operator .mq-to { - display: block; -} -.mq-math-mode .mq-large-operator .mq-from, -.mq-math-mode .mq-large-operator .mq-to { - font-size: 80%; -} -.mq-math-mode .mq-large-operator .mq-from { - float: right; - - /* take out of normal flow to manipulate baseline */ - width: 100%; -} -.mq-math-mode, -.mq-math-mode .mq-editable-field { - font-family: MathJax, 'Times New Roman', serif; - cursor: text; -} -.mq-math-mode .mq-overarrow { - padding-top: 0.2em; - margin-top: 1px; - border-top: 1px solid black; -} -.mq-math-mode .mq-overarrow::before { - position: relative; - top: -0.34em; - display: block; - font-size: 0.5em; - line-height: 0; - text-align: right; - content: '\27A4'; -} -.mq-math-mode .mq-overarrow.mq-arrow-left::before { - -ms-filter: 'FlipH'; - filter: FlipH; - -moz-transform: scaleX(-1); - -o-transform: scaleX(-1); - -webkit-transform: scaleX(-1); - transform: scaleX(-1); -} -.mq-math-mode .mq-selection, -.mq-editable-field .mq-selection, -.mq-math-mode .mq-selection .mq-non-leaf, -.mq-editable-field .mq-selection .mq-non-leaf, -.mq-math-mode .mq-selection .mq-scaled, -.mq-editable-field .mq-selection .mq-scaled { - color: HighlightText; - background: #b4d5fe !important; - background: Highlight !important; - border-color: HighlightText; -} -.mq-math-mode .mq-selection .mq-matrixed, -.mq-editable-field .mq-selection .mq-matrixed { - background: #3399ff !important; -} -.mq-math-mode .mq-selection .mq-matrixed-container, -.mq-editable-field .mq-selection .mq-matrixed-container { - filter: progid:dximagetransform.microsoft.chroma(color='#3399FF') !important; -} -.mq-math-mode .mq-selection.mq-blur, -.mq-editable-field .mq-selection.mq-blur, -.mq-math-mode .mq-selection.mq-blur .mq-non-leaf, -.mq-editable-field .mq-selection.mq-blur .mq-non-leaf, -.mq-math-mode .mq-selection.mq-blur .mq-scaled, -.mq-editable-field .mq-selection.mq-blur .mq-scaled, -.mq-math-mode .mq-selection.mq-blur .mq-matrixed, -.mq-editable-field .mq-selection.mq-blur .mq-matrixed { - color: black; - background: #d4d4d4 !important; - border-color: black; -} -.mq-math-mode .mq-selection.mq-blur .mq-matrixed-container, -.mq-editable-field .mq-selection.mq-blur .mq-matrixed-container { - filter: progid:dximagetransform.microsoft.chroma(color='#D4D4D4') !important; -} -.mq-editable-field .mq-textarea, -.mq-math-mode .mq-textarea { - position: relative; - -webkit-user-select: text; - -moz-user-select: text; - user-select: text; -} -.mq-editable-field .mq-textarea *, -.mq-math-mode .mq-textarea *, -.mq-editable-field .mq-selectable, -.mq-math-mode .mq-selectable { - position: absolute; - width: 1px; - height: 1px; - clip: rect(1em 1em 1em 1em); - resize: none; - -webkit-user-select: text; - -moz-user-select: text; - user-select: text; - -webkit-transform: scale(0); - -moz-transform: scale(0); - -ms-transform: scale(0); - -o-transform: scale(0); - transform: scale(0); -} -.mq-math-mode .mq-matrixed { - display: -moz-inline-box; - display: inline-block; - background: white; -} -.mq-math-mode .mq-matrixed-container { - margin-top: -0.1em; - filter: progid:dximagetransform.microsoft.chroma(color='white'); -} diff --git a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/mathquill/mathquill.js b/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/mathquill/mathquill.js deleted file mode 100644 index f020feddd5..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/MarkdownEditor/mathquill/mathquill.js +++ /dev/null @@ -1,5403 +0,0 @@ -/* eslint-disable */ - -/* * * * * * * * * * * ATTENTION * * * * * * * * * * * - * This file contains some LEq customizations, - * so there's a need to be careful to reflect them - * if we upgrade MathQuill one day (or eventually - * create MathQuill fork if there's a need to upgrade - * often). For more information see the formulas plugin - * documentation docs/markdown_editor_viewer.md - * or commit 9c85577761a75d1c3c216496f4e3373e57623699 - * * * * * * * * * * * * * * * * * * * * * * * * * * */ - -/** - * MathQuill v0.10.1 http://mathquill.com - * by Han, Jeanine, and Mary maintainers@mathquill.com - * - * This Source Code Form is subject to the terms of the - * Mozilla Public License, v. 2.0. If a copy of the MPL - * was not distributed with this file, You can obtain - * one at http://mozilla.org/MPL/2.0/. - */ - -(function () { - var jQuery = require('jquery'), - undefined, - mqCmdId = 'mathquill-command-id', - mqBlockId = 'mathquill-block-id', - min = Math.min, - max = Math.max; - - function noop() {} - - /** - * A utility higher-order function that makes defining variadic - * functions more convenient by letting you essentially define functions - * with the last argument as a splat, i.e. the last argument "gathers up" - * remaining arguments to the function: - * var doStuff = variadic(function(first, rest) { return rest; }); - * doStuff(1, 2, 3); // => [2, 3] - */ - var __slice = [].slice; - function variadic(fn) { - var numFixedArgs = fn.length - 1; - return function () { - var args = __slice.call(arguments, 0, numFixedArgs); - var varArg = __slice.call(arguments, numFixedArgs); - return fn.apply(this, args.concat([varArg])); - }; - } - - /** - * A utility higher-order function that makes combining object-oriented - * programming and functional programming techniques more convenient: - * given a method name and any number of arguments to be bound, returns - * a function that calls it's first argument's method of that name (if - * it exists) with the bound arguments and any additional arguments that - * are passed: - * var sendMethod = send('method', 1, 2); - * var obj = { method: function() { return Array.apply(this, arguments); } }; - * sendMethod(obj, 3, 4); // => [1, 2, 3, 4] - * // or more specifically, - * var obj2 = { method: function(one, two, three) { return one*two + three; } }; - * sendMethod(obj2, 3); // => 5 - * sendMethod(obj2, 4); // => 6 - */ - var send = variadic(function (method, args) { - return variadic(function (obj, moreArgs) { - if (method in obj) return obj[method].apply(obj, args.concat(moreArgs)); - }); - }); - - /** - * A utility higher-order function that creates "implicit iterators" - * from "generators": given a function that takes in a sole argument, - * a "yield_" function, that calls "yield_" repeatedly with an object as - * a sole argument (presumably objects being iterated over), returns - * a function that calls it's first argument on each of those objects - * (if the first argument is a function, it is called repeatedly with - * each object as the first argument, otherwise it is stringified and - * the method of that name is called on each object (if such a method - * exists)), passing along all additional arguments: - * var a = [ - * { method: function(list) { list.push(1); } }, - * { method: function(list) { list.push(2); } }, - * { method: function(list) { list.push(3); } } - * ]; - * a.each = iterator(function(yield_) { - * for (var i in this) yield_(this[i]); - * }); - * var list = []; - * a.each('method', list); - * list; // => [1, 2, 3] - * // Note that the for-in loop will yield 'each', but 'each' maps to - * // the function object created by iterator() which does not have a - * // .method() method, so that just fails silently. - */ - function iterator(generator) { - return variadic(function (fn, args) { - if (typeof fn !== 'function') fn = send(fn); - var yield_ = function (obj) { - return fn.apply(obj, [obj].concat(args)); - }; - return generator.call(this, yield_); - }); - } - - /** - * sugar to make defining lots of commands easier. - * TODO: rethink this. - */ - function bind(cons /*, args... */) { - var args = __slice.call(arguments, 1); - return function () { - return cons.apply(this, args); - }; - } - - /** - * a development-only debug method. This definition and all - * calls to `pray` will be stripped from the minified - * build of mathquill. - * - * This function must be called by name to be removed - * at compile time. Do not define another function - * with the same name, and only call this function by - * name. - */ - function pray(message, cond) { - if (!cond) throw new Error('prayer failed: ' + message); - } - var P = (function (prototype, ownProperty, undefined) { - // helper functions that also help minification - function isObject(o) { - return typeof o === 'object'; - } - function isFunction(f) { - return typeof f === 'function'; - } - - // used to extend the prototypes of superclasses (which might not - // have `.Bare`s) - function SuperclassBare() {} - - return function P(_superclass /* = Object */, definition) { - // handle the case where no superclass is given - if (definition === undefined) { - definition = _superclass; - _superclass = Object; - } - - // C is the class to be returned. - // - // It delegates to instantiating an instance of `Bare`, so that it - // will always return a new instance regardless of the calling - // context. - // - // TODO: the Chrome inspector shows all created objects as `C` - // rather than `Object`. Setting the .name property seems to - // have no effect. Is there a way to override this behavior? - function C() { - var self = new Bare(); - if (isFunction(self.init)) self.init.apply(self, arguments); - return self; - } - - // C.Bare is a class with a noop constructor. Its prototype is the - // same as C, so that instances of C.Bare are also instances of C. - // New objects can be allocated without initialization by calling - // `new MyClass.Bare`. - function Bare() {} - C.Bare = Bare; - - // Set up the prototype of the new class. - var _super = (SuperclassBare[prototype] = _superclass[prototype]); - var proto = (Bare[prototype] = C[prototype] = C.p = new SuperclassBare()); - - // other variables, as a minifier optimization - var extensions; - - // set the constructor property on the prototype, for convenience - proto.constructor = C; - - C.extend = function (def) { - return P(C, def); - }; - - return (C.open = function (def) { - extensions = {}; - - if (isFunction(def)) { - // call the defining function with all the arguments you need - // extensions captures the return value. - extensions = def.call(C, proto, _super, C, _superclass); - } else if (isObject(def)) { - // if you passed an object instead, we'll take it - extensions = def; - } - - // ...and extend it - if (isObject(extensions)) { - for (var ext in extensions) { - if (ownProperty.call(extensions, ext)) { - proto[ext] = extensions[ext]; - } - } - } - - // if there's no init, we assume we're inheriting a non-pjs class, so - // we default to applying the superclass's constructor. - if (!isFunction(proto.init)) { - proto.init = _superclass; - } - - return C; - })(definition); - }; - - // as a minifier optimization, we've closured in a few helper functions - // and the string 'prototype' (C[p] is much shorter than C.prototype) - })('prototype', {}.hasOwnProperty); - /************************************************* - * Base classes of edit tree-related objects - * - * Only doing tree node manipulation via these - * adopt/ disown methods guarantees well-formedness - * of the tree. - ************************************************/ - - // L = 'left' - // R = 'right' - // - // the contract is that they can be used as object properties - // and (-L) === R, and (-R) === L. - var L = -1; - var R = 1; - - function prayDirection(dir) { - pray('a direction was passed', dir === L || dir === R); - } - - /** - * Tiny extension of jQuery adding directionalized DOM manipulation methods. - * - * Funny how Pjs v3 almost just works with `jQuery.fn.init`. - * - * jQuery features that don't work on $: - * - jQuery.*, like jQuery.ajax, obviously (Pjs doesn't and shouldn't - * copy constructor properties) - * - * - jQuery(function), the shortcut for `jQuery(document).ready(function)`, - * because `jQuery.fn.init` is idiosyncratic and Pjs doing, essentially, - * `jQuery.fn.init.apply(this, arguments)` isn't quite right, you need: - * - * _.init = function(s, c) { jQuery.fn.init.call(this, s, c, $(document)); }; - * - * if you actually give a shit (really, don't bother), - * see https://github.com/jquery/jquery/blob/1.7.2/src/core.js#L889 - * - * - jQuery(selector), because jQuery translates that to - * `jQuery(document).find(selector)`, but Pjs doesn't (should it?) let - * you override the result of a constructor call - * + note that because of the jQuery(document) shortcut-ness, there's also - * the 3rd-argument-needs-to-be-`$(document)` thing above, but the fix - * for that (as can be seen above) is really easy. This problem requires - * a way more intrusive fix - * - * And that's it! Everything else just magically works because jQuery internally - * uses `this.constructor()` everywhere (hence calling `$`), but never ever does - * `this.constructor.find` or anything like that, always doing `jQuery.find`. - */ - var $ = P(jQuery, function (_) { - _.insDirOf = function (dir, el) { - return dir === L ? this.insertBefore(el.first()) : this.insertAfter(el.last()); - }; - _.insAtDirEnd = function (dir, el) { - return dir === L ? this.prependTo(el) : this.appendTo(el); - }; - }); - - var Point = P(function (_) { - _.parent = 0; - _[L] = 0; - _[R] = 0; - - _.init = function (parent, leftward, rightward) { - this.parent = parent; - this[L] = leftward; - this[R] = rightward; - }; - - this.copy = function (pt) { - return Point(pt.parent, pt[L], pt[R]); - }; - }); - - /** - * MathQuill virtual-DOM tree-node abstract base class - */ - var Node = P(function (_) { - _[L] = 0; - _[R] = 0; - _.parent = 0; - - var id = 0; - function uniqueNodeId() { - return (id += 1); - } - this.byId = {}; - - _.init = function () { - this.id = uniqueNodeId(); - Node.byId[this.id] = this; - - this.ends = {}; - this.ends[L] = 0; - this.ends[R] = 0; - }; - - _.dispose = function () { - delete Node.byId[this.id]; - }; - - _.toString = function () { - return '{{ MathQuill Node #' + this.id + ' }}'; - }; - - _.jQ = $(); - _.jQadd = function (jQ) { - return (this.jQ = this.jQ.add(jQ)); - }; - _.jQize = function (jQ) { - // jQuery-ifies this.html() and links up the .jQ of all corresponding Nodes - var jQ = $(jQ || this.html()); - - function jQadd(el) { - if (el.getAttribute) { - var cmdId = el.getAttribute('mathquill-command-id'); - var blockId = el.getAttribute('mathquill-block-id'); - if (cmdId) Node.byId[cmdId].jQadd(el); - if (blockId) Node.byId[blockId].jQadd(el); - } - for (el = el.firstChild; el; el = el.nextSibling) { - jQadd(el); - } - } - - for (var i = 0; i < jQ.length; i += 1) jQadd(jQ[i]); - return jQ; - }; - - _.createDir = function (dir, cursor) { - prayDirection(dir); - var node = this; - node.jQize(); - node.jQ.insDirOf(dir, cursor.jQ); - cursor[dir] = node.adopt(cursor.parent, cursor[L], cursor[R]); - return node; - }; - _.createLeftOf = function (el) { - return this.createDir(L, el); - }; - - _.selectChildren = function (leftEnd, rightEnd) { - return Selection(leftEnd, rightEnd); - }; - - _.bubble = iterator(function (yield_) { - for (var ancestor = this; ancestor; ancestor = ancestor.parent) { - var result = yield_(ancestor); - if (result === false) break; - } - - return this; - }); - - _.postOrder = iterator(function (yield_) { - (function recurse(descendant) { - descendant.eachChild(recurse); - yield_(descendant); - })(this); - - return this; - }); - - _.isEmpty = function () { - return this.ends[L] === 0 && this.ends[R] === 0; - }; - - _.children = function () { - return Fragment(this.ends[L], this.ends[R]); - }; - - _.eachChild = function () { - var children = this.children(); - children.each.apply(children, arguments); - return this; - }; - - _.foldChildren = function (fold, fn) { - return this.children().fold(fold, fn); - }; - - _.withDirAdopt = function (dir, parent, withDir, oppDir) { - Fragment(this, this).withDirAdopt(dir, parent, withDir, oppDir); - return this; - }; - - _.adopt = function (parent, leftward, rightward) { - Fragment(this, this).adopt(parent, leftward, rightward); - return this; - }; - - _.disown = function () { - Fragment(this, this).disown(); - return this; - }; - - _.remove = function () { - this.jQ.remove(); - this.postOrder('dispose'); - return this.disown(); - }; - }); - - function prayWellFormed(parent, leftward, rightward) { - pray('a parent is always present', parent); - pray( - 'leftward is properly set up', - (function () { - // either it's empty and `rightward` is the left end child (possibly empty) - if (!leftward) return parent.ends[L] === rightward; - - // or it's there and its [R] and .parent are properly set up - return leftward[R] === rightward && leftward.parent === parent; - })(), - ); - - pray( - 'rightward is properly set up', - (function () { - // either it's empty and `leftward` is the right end child (possibly empty) - if (!rightward) return parent.ends[R] === leftward; - - // or it's there and its [L] and .parent are properly set up - return rightward[L] === leftward && rightward.parent === parent; - })(), - ); - } - - /** - * An entity outside the virtual tree with one-way pointers (so it's only a - * "view" of part of the tree, not an actual node/entity in the tree) that - * delimits a doubly-linked list of sibling nodes. - * It's like a fanfic love-child between HTML DOM DocumentFragment and the Range - * classes: like DocumentFragment, its contents must be sibling nodes - * (unlike Range, whose contents are arbitrary contiguous pieces of subtrees), - * but like Range, it has only one-way pointers to its contents, its contents - * have no reference to it and in fact may still be in the visible tree (unlike - * DocumentFragment, whose contents must be detached from the visible tree - * and have their 'parent' pointers set to the DocumentFragment). - */ - var Fragment = P(function (_) { - _.init = function (withDir, oppDir, dir) { - if (dir === undefined) dir = L; - prayDirection(dir); - - pray('no half-empty fragments', !withDir === !oppDir); - - this.ends = {}; - - if (!withDir) return; - - pray('withDir is passed to Fragment', withDir instanceof Node); - pray('oppDir is passed to Fragment', oppDir instanceof Node); - pray('withDir and oppDir have the same parent', withDir.parent === oppDir.parent); - - this.ends[dir] = withDir; - this.ends[-dir] = oppDir; - - // To build the jquery collection for a fragment, accumulate elements - // into an array and then call jQ.add once on the result. jQ.add sorts the - // collection according to document order each time it is called, so - // building a collection by folding jQ.add directly takes more than - // quadratic time in the number of elements. - // - // https://github.com/jquery/jquery/blob/2.1.4/src/traversing.js#L112 - var accum = this.fold([], function (accum, el) { - accum.push.apply(accum, el.jQ.get()); - return accum; - }); - - this.jQ = this.jQ.add(accum); - }; - _.jQ = $(); - - // like Cursor::withDirInsertAt(dir, parent, withDir, oppDir) - _.withDirAdopt = function (dir, parent, withDir, oppDir) { - return dir === L ? this.adopt(parent, withDir, oppDir) : this.adopt(parent, oppDir, withDir); - }; - _.adopt = function (parent, leftward, rightward) { - prayWellFormed(parent, leftward, rightward); - - var self = this; - self.disowned = false; - - var leftEnd = self.ends[L]; - if (!leftEnd) return this; - - var rightEnd = self.ends[R]; - - if (leftward) { - // NB: this is handled in the ::each() block - // leftward[R] = leftEnd - } else { - parent.ends[L] = leftEnd; - } - - if (rightward) { - rightward[L] = rightEnd; - } else { - parent.ends[R] = rightEnd; - } - - self.ends[R][R] = rightward; - - self.each(function (el) { - el[L] = leftward; - el.parent = parent; - if (leftward) leftward[R] = el; - - leftward = el; - }); - - return self; - }; - - _.disown = function () { - var self = this; - var leftEnd = self.ends[L]; - - // guard for empty and already-disowned fragments - if (!leftEnd || self.disowned) return self; - - self.disowned = true; - - var rightEnd = self.ends[R]; - var parent = leftEnd.parent; - - prayWellFormed(parent, leftEnd[L], leftEnd); - prayWellFormed(parent, rightEnd, rightEnd[R]); - - if (leftEnd[L]) { - leftEnd[L][R] = rightEnd[R]; - } else { - parent.ends[L] = rightEnd[R]; - } - - if (rightEnd[R]) { - rightEnd[R][L] = leftEnd[L]; - } else { - parent.ends[R] = leftEnd[L]; - } - - return self; - }; - - _.remove = function () { - this.jQ.remove(); - this.each('postOrder', 'dispose'); - return this.disown(); - }; - - _.each = iterator(function (yield_) { - var self = this; - var el = self.ends[L]; - if (!el) return self; - - for (; el !== self.ends[R][R]; el = el[R]) { - var result = yield_(el); - if (result === false) break; - } - - return self; - }); - - _.fold = function (fold, fn) { - this.each(function (el) { - fold = fn.call(this, fold, el); - }); - - return fold; - }; - }); - - /** - * Registry of LaTeX commands and commands created when typing - * a single character. - * - * (Commands are all subclasses of Node.) - */ - var LatexCmds = {}, - CharCmds = {}; - /******************************************** - * Cursor and Selection "singleton" classes - *******************************************/ - - /* The main thing that manipulates the Math DOM. Makes sure to manipulate the -HTML DOM to match. */ - - /* Sort of singletons, since there should only be one per editable math -textbox, but any one HTML document can contain many such textboxes, so any one -JS environment could actually contain many instances. */ - - //A fake cursor in the fake textbox that the math is rendered in. - var Cursor = P(Point, function (_) { - _.init = function (initParent, options) { - this.parent = initParent; - this.options = options; - - var jQ = (this.jQ = this._jQ = $('')); - //closured for setInterval - this.blink = function () { - jQ.toggleClass('mq-blink'); - }; - - this.upDownCache = {}; - }; - - _.show = function () { - this.jQ = this._jQ.removeClass('mq-blink'); - if ('intervalId' in this) - //already was shown, just restart interval - clearInterval(this.intervalId); - else { - //was hidden and detached, insert this.jQ back into HTML DOM - if (this[R]) { - if (this.selection && this.selection.ends[L][L] === this[L]) - this.jQ.insertBefore(this.selection.jQ); - else this.jQ.insertBefore(this[R].jQ.first()); - } else this.jQ.appendTo(this.parent.jQ); - this.parent.focus(); - } - this.intervalId = setInterval(this.blink, 500); - return this; - }; - _.hide = function () { - if ('intervalId' in this) clearInterval(this.intervalId); - delete this.intervalId; - this.jQ.detach(); - this.jQ = $(); - return this; - }; - - _.withDirInsertAt = function (dir, parent, withDir, oppDir) { - var oldParent = this.parent; - this.parent = parent; - this[dir] = withDir; - this[-dir] = oppDir; - // by contract, .blur() is called after all has been said and done - // and the cursor has actually been moved - // FIXME pass cursor to .blur() so text can fix cursor pointers when removing itself - if (oldParent !== parent && oldParent.blur) oldParent.blur(this); - }; - _.insDirOf = function (dir, el) { - prayDirection(dir); - this.jQ.insDirOf(dir, el.jQ); - this.withDirInsertAt(dir, el.parent, el[dir], el); - this.parent.jQ.addClass('mq-hasCursor'); - return this; - }; - _.insLeftOf = function (el) { - return this.insDirOf(L, el); - }; - _.insRightOf = function (el) { - return this.insDirOf(R, el); - }; - - _.insAtDirEnd = function (dir, el) { - prayDirection(dir); - this.jQ.insAtDirEnd(dir, el.jQ); - this.withDirInsertAt(dir, el, 0, el.ends[dir]); - el.focus(); - return this; - }; - _.insAtLeftEnd = function (el) { - return this.insAtDirEnd(L, el); - }; - _.insAtRightEnd = function (el) { - return this.insAtDirEnd(R, el); - }; - - /** - * jump up or down from one block Node to another: - * - cache the current Point in the node we're jumping from - * - check if there's a Point in it cached for the node we're jumping to - * + if so put the cursor there, - * + if not seek a position in the node that is horizontally closest to - * the cursor's current position - */ - _.jumpUpDown = function (from, to) { - var self = this; - self.upDownCache[from.id] = Point.copy(self); - var cached = self.upDownCache[to.id]; - if (cached) { - cached[R] ? self.insLeftOf(cached[R]) : self.insAtRightEnd(cached.parent); - } else { - var pageX = self.offset().left; - to.seek(pageX, self); - } - }; - _.offset = function () { - //in Opera 11.62, .getBoundingClientRect() and hence jQuery::offset() - //returns all 0's on inline elements with negative margin-right (like - //the cursor) at the end of their parent, so temporarily remove the - //negative margin-right when calling jQuery::offset() - //Opera bug DSK-360043 - //http://bugs.jquery.com/ticket/11523 - //https://github.com/jquery/jquery/pull/717 - var self = this, - offset = self.jQ.removeClass('mq-cursor').offset(); - self.jQ.addClass('mq-cursor'); - return offset; - }; - _.unwrapGramp = function () { - var gramp = this.parent.parent; - var greatgramp = gramp.parent; - var rightward = gramp[R]; - var cursor = this; - - var leftward = gramp[L]; - gramp.disown().eachChild(function (uncle) { - if (uncle.isEmpty()) return; - - uncle - .children() - .adopt(greatgramp, leftward, rightward) - .each(function (cousin) { - cousin.jQ.insertBefore(gramp.jQ.first()); - }); - - leftward = uncle.ends[R]; - }); - - if (!this[R]) { - //then find something to be rightward to insLeftOf - if (this[L]) this[R] = this[L][R]; - else { - while (!this[R]) { - this.parent = this.parent[R]; - if (this.parent) this[R] = this.parent.ends[L]; - else { - this[R] = gramp[R]; - this.parent = greatgramp; - break; - } - } - } - } - if (this[R]) this.insLeftOf(this[R]); - else this.insAtRightEnd(greatgramp); - - gramp.jQ.remove(); - - if (gramp[L].siblingDeleted) gramp[L].siblingDeleted(cursor.options, R); - if (gramp[R].siblingDeleted) gramp[R].siblingDeleted(cursor.options, L); - }; - _.startSelection = function () { - var anticursor = (this.anticursor = Point.copy(this)); - var ancestors = (anticursor.ancestors = {}); // a map from each ancestor of - // the anticursor, to its child that is also an ancestor; in other words, - // the anticursor's ancestor chain in reverse order - for (var ancestor = anticursor; ancestor.parent; ancestor = ancestor.parent) { - ancestors[ancestor.parent.id] = ancestor; - } - }; - _.endSelection = function () { - delete this.anticursor; - }; - _.select = function () { - var anticursor = this.anticursor; - if (this[L] === anticursor[L] && this.parent === anticursor.parent) return false; - - // Find the lowest common ancestor (`lca`), and the ancestor of the cursor - // whose parent is the LCA (which'll be an end of the selection fragment). - for (var ancestor = this; ancestor.parent; ancestor = ancestor.parent) { - if (ancestor.parent.id in anticursor.ancestors) { - var lca = ancestor.parent; - break; - } - } - pray('cursor and anticursor in the same tree', lca); - // The cursor and the anticursor should be in the same tree, because the - // mousemove handler attached to the document, unlike the one attached to - // the root HTML DOM element, doesn't try to get the math tree node of the - // mousemove target, and Cursor::seek() based solely on coordinates stays - // within the tree of `this` cursor's root. - - // The other end of the selection fragment, the ancestor of the anticursor - // whose parent is the LCA. - var antiAncestor = anticursor.ancestors[lca.id]; - - // Now we have two either Nodes or Points, guaranteed to have a common - // parent and guaranteed that if both are Points, they are not the same, - // and we have to figure out which is the left end and which the right end - // of the selection. - var leftEnd, - rightEnd, - dir = R; - - // This is an extremely subtle algorithm. - // As a special case, `ancestor` could be a Point and `antiAncestor` a Node - // immediately to `ancestor`'s left. - // In all other cases, - // - both Nodes - // - `ancestor` a Point and `antiAncestor` a Node - // - `ancestor` a Node and `antiAncestor` a Point - // `antiAncestor[R] === rightward[R]` for some `rightward` that is - // `ancestor` or to its right, if and only if `antiAncestor` is to - // the right of `ancestor`. - if (ancestor[L] !== antiAncestor) { - for (var rightward = ancestor; rightward; rightward = rightward[R]) { - if (rightward[R] === antiAncestor[R]) { - dir = L; - leftEnd = ancestor; - rightEnd = antiAncestor; - break; - } - } - } - if (dir === R) { - leftEnd = antiAncestor; - rightEnd = ancestor; - } - - // only want to select Nodes up to Points, can't select Points themselves - if (leftEnd instanceof Point) leftEnd = leftEnd[R]; - if (rightEnd instanceof Point) rightEnd = rightEnd[L]; - - this.hide().selection = lca.selectChildren(leftEnd, rightEnd); - this.insDirOf(dir, this.selection.ends[dir]); - this.selectionChanged(); - return true; - }; - - _.clearSelection = function () { - if (this.selection) { - this.selection.clear(); - delete this.selection; - this.selectionChanged(); - } - return this; - }; - _.deleteSelection = function () { - if (!this.selection) return; - - this[L] = this.selection.ends[L][L]; - this[R] = this.selection.ends[R][R]; - this.selection.remove(); - this.selectionChanged(); - delete this.selection; - }; - _.replaceSelection = function () { - var seln = this.selection; - if (seln) { - this[L] = seln.ends[L][L]; - this[R] = seln.ends[R][R]; - delete this.selection; - } - return seln; - }; - }); - - var Selection = P(Fragment, function (_, super_) { - _.init = function () { - super_.init.apply(this, arguments); - this.jQ = this.jQ.wrapAll('').parent(); - //can't do wrapAll(this.jQ = $(...)) because wrapAll will clone it - }; - _.adopt = function () { - this.jQ.replaceWith((this.jQ = this.jQ.children())); - return super_.adopt.apply(this, arguments); - }; - _.clear = function () { - // using the browser's native .childNodes property so that we - // don't discard text nodes. - this.jQ.replaceWith(this.jQ[0].childNodes); - return this; - }; - _.join = function (methodName) { - return this.fold('', function (fold, child) { - return fold + child[methodName](); - }); - }; - }); - /********************************************* - * Controller for a MathQuill instance, - * on which services are registered with - * - * Controller.open(function(_) { ... }); - * - ********************************************/ - - var Controller = P(function (_) { - _.init = function (root, container, options) { - this.id = root.id; - this.data = {}; - - this.root = root; - this.container = container; - this.options = options; - - root.controller = this; - - this.cursor = root.cursor = Cursor(root, options); - // TODO: stop depending on root.cursor, and rm it - }; - - _.handle = function (name, dir) { - var handlers = this.options.handlers; - if (handlers && handlers.fns[name]) { - var mq = handlers.APIClasses[this.KIND_OF_MQ](this); - if (dir === L || dir === R) handlers.fns[name](dir, mq); - else handlers.fns[name](mq); - } - }; - - var notifyees = []; - this.onNotify = function (f) { - notifyees.push(f); - }; - _.notify = function () { - for (var i = 0; i < notifyees.length; i += 1) { - notifyees[i].apply(this.cursor, arguments); - } - return this; - }; - }); - /********************************************************* - * The publicly exposed MathQuill API. - ********************************************************/ - - var API = {}, - Options = P(), - optionProcessors = {}, - Progenote = P(), - EMBEDS = {}; - - /** - * Interface Versioning (#459, #495) to allow us to virtually guarantee - * backcompat. v0.10.x introduces it, so for now, don't completely break the - * API for people who don't know about it, just complain with console.warn(). - * - * The methods are shimmed in outro.js so that MQ.MathField.prototype etc can - * be accessed. - */ - function insistOnInterVer() { - if (window.console) - console.warn( - 'You are using the MathQuill API without specifying an interface version, ' + - 'which will fail in v1.0.0. Easiest fix is to do the following before ' + - 'doing anything else:\n' + - '\n' + - ' MathQuill = MathQuill.getInterface(1);\n' + - ' // now MathQuill.MathField() works like it used to\n' + - '\n' + - 'See also the "`dev` branch (2014\u20132015) \u2192 v0.10.0 Migration Guide" at\n' + - ' https://github.com/mathquill/mathquill/wiki/%60dev%60-branch-(2014%E2%80%932015)-%E2%86%92-v0.10.0-Migration-Guide', - ); - } - // globally exported API object - function MathQuill(el) { - insistOnInterVer(); - return MQ1(el); - } - MathQuill.prototype = Progenote.p; - MathQuill.interfaceVersion = function (v) { - // shim for #459-era interface versioning (ended with #495) - if (v !== 1) throw 'Only interface version 1 supported. You specified: ' + v; - insistOnInterVer = function () { - if (window.console) - console.warn( - 'You called MathQuill.interfaceVersion(1); to specify the interface ' + - 'version, which will fail in v1.0.0. You can fix this easily by doing ' + - 'this before doing anything else:\n' + - '\n' + - ' MathQuill = MathQuill.getInterface(1);\n' + - ' // now MathQuill.MathField() works like it used to\n' + - '\n' + - 'See also the "`dev` branch (2014\u20132015) \u2192 v0.10.0 Migration Guide" at\n' + - ' https://github.com/mathquill/mathquill/wiki/%60dev%60-branch-(2014%E2%80%932015)-%E2%86%92-v0.10.0-Migration-Guide', - ); - }; - insistOnInterVer(); - return MathQuill; - }; - MathQuill.getInterface = getInterface; - - var MIN = (getInterface.MIN = 1), - MAX = (getInterface.MAX = 2); - function getInterface(v) { - if (!(MIN <= v && v <= MAX)) - throw ( - 'Only interface versions between ' + MIN + ' and ' + MAX + ' supported. You specified: ' + v - ); - - /** - * Function that takes an HTML element and, if it's the root HTML element of a - * static math or math or text field, returns an API object for it (else, null). - * - * var mathfield = MQ.MathField(mathFieldSpan); - * assert(MQ(mathFieldSpan).id === mathfield.id); - * assert(MQ(mathFieldSpan).id === MQ(mathFieldSpan).id); - * - */ - function MQ(el) { - if (!el || !el.nodeType) return null; // check that `el` is a HTML element, using the - // same technique as jQuery: https://github.com/jquery/jquery/blob/679536ee4b7a92ae64a5f58d90e9cc38c001e807/src/core/init.js#L92 - var blockId = $(el).children('.mq-root-block').attr(mqBlockId); - var ctrlr = blockId && Node.byId[blockId].controller; - return ctrlr ? APIClasses[ctrlr.KIND_OF_MQ](ctrlr) : null; - } - var APIClasses = {}; - - MQ.L = L; - MQ.R = R; - - function config(currentOptions, newOptions) { - if (newOptions && newOptions.handlers) { - newOptions.handlers = { fns: newOptions.handlers, APIClasses: APIClasses }; - } - for (var name in newOptions) - if (Object.prototype.hasOwnProperty.call(newOptions, name)) { - var value = newOptions[name], - processor = optionProcessors[name]; - currentOptions[name] = processor ? processor(value) : value; - } - } - MQ.config = function (opts) { - config(Options.p, opts); - return this; - }; - MQ.registerEmbed = function (name, options) { - if (!/^[a-z][a-z0-9]*$/i.test(name)) { - throw 'Embed name must start with letter and be only letters and digits'; - } - EMBEDS[name] = options; - }; - - var AbstractMathQuill = (APIClasses.AbstractMathQuill = P(Progenote, function (_) { - _.init = function (ctrlr) { - this.__controller = ctrlr; - this.__options = ctrlr.options; - this.id = ctrlr.id; - this.data = ctrlr.data; - }; - _.__mathquillify = function (classNames) { - var ctrlr = this.__controller, - root = ctrlr.root, - el = ctrlr.container; - ctrlr.createTextarea(); - - el.attr('data-formula', el.text()); - - var contents = el.addClass(classNames).contents().detach(); - root.jQ = $('').attr(mqBlockId, root.id).appendTo(el); - this.latex(contents.text()); - - this.revert = function () { - return el - .empty() - .unbind('.mathquill') - .removeClass('mq-editable-field mq-math-mode mq-text-mode') - .removeAttr('data-formula') - .append(contents); - }; - }; - _.config = function (opts) { - config(this.__options, opts); - return this; - }; - _.el = function () { - return this.__controller.container[0]; - }; - _.text = function () { - return this.__controller.exportText(); - }; - _.latex = function (latex) { - if (arguments.length > 0) { - this.__controller.renderLatexMath(latex); - if (this.__controller.blurred) this.__controller.cursor.hide().parent.blur(); - return this; - } - return this.__controller.exportLatex(); - }; - _.html = function () { - return this.__controller.root.jQ - .html() - .replace(/ mathquill-(?:command|block)-id="?\d+"?/g, '') - .replace(/.?<\/span>/i, '') - .replace(/ mq-hasCursor|mq-hasCursor ?/, '') - .replace(/ class=(""|(?= |>))/g, ''); - }; - _.reflow = function () { - this.__controller.root.postOrder('reflow'); - return this; - }; - })); - MQ.prototype = AbstractMathQuill.prototype; - - APIClasses.EditableField = P(AbstractMathQuill, function (_, super_) { - _.__mathquillify = function () { - super_.__mathquillify.apply(this, arguments); - this.__controller.editable = true; - this.__controller.delegateMouseEvents(); - this.__controller.editablesTextareaEvents(); - return this; - }; - _.focus = function () { - this.__controller.textarea.focus(); - return this; - }; - _.blur = function () { - this.__controller.textarea.blur(); - return this; - }; - _.write = function (latex) { - this.__controller.writeLatex(latex); - this.__controller.scrollHoriz(); - if (this.__controller.blurred) this.__controller.cursor.hide().parent.blur(); - return this; - }; - _.cmd = function (cmd) { - var ctrlr = this.__controller.notify(), - cursor = ctrlr.cursor; - if (/^\\[a-z]+$/i.test(cmd)) { - cmd = cmd.slice(1); - var klass = LatexCmds[cmd]; - if (klass) { - cmd = klass(cmd); - if (cursor.selection) cmd.replaces(cursor.replaceSelection()); - cmd.createLeftOf(cursor.show()); - this.__controller.scrollHoriz(); - } /* TODO: API needs better error reporting */ else; - } else cursor.parent.write(cursor, cmd); - if (ctrlr.blurred) cursor.hide().parent.blur(); - return this; - }; - _.select = function () { - var ctrlr = this.__controller; - ctrlr.notify('move').cursor.insAtRightEnd(ctrlr.root); - while (ctrlr.cursor[L]) ctrlr.selectLeft(); - return this; - }; - _.clearSelection = function () { - this.__controller.cursor.clearSelection(); - return this; - }; - - _.moveToDirEnd = function (dir) { - this.__controller.notify('move').cursor.insAtDirEnd(dir, this.__controller.root); - return this; - }; - _.moveToLeftEnd = function () { - return this.moveToDirEnd(L); - }; - _.moveToRightEnd = function () { - return this.moveToDirEnd(R); - }; - - _.keystroke = function (keys) { - var keys = keys.replace(/^\s+|\s+$/g, '').split(/\s+/); - for (var i = 0; i < keys.length; i += 1) { - this.__controller.keystroke(keys[i], { preventDefault: noop }); - } - return this; - }; - _.typedText = function (text) { - for (var i = 0; i < text.length; i += 1) this.__controller.typedText(text.charAt(i)); - return this; - }; - _.dropEmbedded = function (pageX, pageY, options) { - var clientX = pageX - $(window).scrollLeft(); - var clientY = pageY - $(window).scrollTop(); - - var el = document.elementFromPoint(clientX, clientY); - this.__controller.seek($(el), pageX, pageY); - var cmd = Embed().setOptions(options); - cmd.createLeftOf(this.__controller.cursor); - }; - _.clickAt = function (clientX, clientY, target) { - target = target || document.elementFromPoint(clientX, clientY); - - var ctrlr = this.__controller, - root = ctrlr.root; - if (!jQuery.contains(root.jQ[0], target)) target = root.jQ[0]; - ctrlr.seek($(target), clientX + pageXOffset, clientY + pageYOffset); - if (ctrlr.blurred) this.focus(); - return this; - }; - _.ignoreNextMousedown = function (fn) { - this.__controller.cursor.options.ignoreNextMousedown = fn; - return this; - }; - }); - MQ.EditableField = function () { - throw "wtf don't call me, I'm 'abstract'"; - }; - MQ.EditableField.prototype = APIClasses.EditableField.prototype; - - /** - * Export the API functions that MathQuill-ify an HTML element into API objects - * of each class. If the element had already been MathQuill-ified but into a - * different kind (or it's not an HTML element), return null. - */ - for (var kind in API) - (function (kind, defAPIClass) { - var APIClass = (APIClasses[kind] = defAPIClass(APIClasses)); - MQ[kind] = function (el, opts) { - var mq = MQ(el); - if (mq instanceof APIClass || !el || !el.nodeType) return mq; - var ctrlr = Controller(APIClass.RootBlock(), $(el), Options()); - ctrlr.KIND_OF_MQ = kind; - return APIClass(ctrlr).__mathquillify(opts, v); - }; - MQ[kind].prototype = APIClass.prototype; - })(kind, API[kind]); - - return MQ; - } - - MathQuill.noConflict = function () { - window.MathQuill = origMathQuill; - return MathQuill; - }; - var origMathQuill = window.MathQuill; - window.MathQuill = MathQuill; - - function RootBlockMixin(_) { - var names = 'moveOutOf deleteOutOf selectOutOf upOutOf downOutOf'.split(' '); - for (var i = 0; i < names.length; i += 1) - (function (name) { - _[name] = function (dir) { - this.controller.handle(name, dir); - }; - })(names[i]); - _.reflow = function () { - this.controller.handle('reflow'); - this.controller.handle('edited'); - this.controller.handle('edit'); - }; - } - var Parser = P(function (_, super_, Parser) { - // The Parser object is a wrapper for a parser function. - // Externally, you use one to parse a string by calling - // var result = SomeParser.parse('Me Me Me! Parse Me!'); - // You should never call the constructor, rather you should - // construct your Parser from the base parsers and the - // parser combinator methods. - - function parseError(stream, message) { - if (stream) { - stream = "'" + stream + "'"; - } else { - stream = 'EOF'; - } - - throw 'Parse Error: ' + message + ' at ' + stream; - } - - _.init = function (body) { - this._ = body; - }; - - _.parse = function (stream) { - return this.skip(eof)._('' + stream, success, parseError); - - function success(stream, result) { - return result; - } - }; - - // -*- primitive combinators -*- // - _.or = function (alternative) { - pray('or is passed a parser', alternative instanceof Parser); - - var self = this; - - return Parser(function (stream, onSuccess, onFailure) { - return self._(stream, onSuccess, failure); - - function failure(newStream) { - return alternative._(stream, onSuccess, onFailure); - } - }); - }; - - _.then = function (next) { - var self = this; - - return Parser(function (stream, onSuccess, onFailure) { - return self._(stream, success, onFailure); - - function success(newStream, result) { - var nextParser = next instanceof Parser ? next : next(result); - pray('a parser is returned', nextParser instanceof Parser); - return nextParser._(newStream, onSuccess, onFailure); - } - }); - }; - - // -*- optimized iterative combinators -*- // - _.many = function () { - var self = this; - - return Parser(function (stream, onSuccess, onFailure) { - var xs = []; - while (self._(stream, success, failure)); - return onSuccess(stream, xs); - - function success(newStream, x) { - stream = newStream; - xs.push(x); - return true; - } - - function failure() { - return false; - } - }); - }; - - _.times = function (min, max) { - if (arguments.length < 2) max = min; - var self = this; - - return Parser(function (stream, onSuccess, onFailure) { - var xs = []; - var result = true; - var failure; - - for (var i = 0; i < min; i += 1) { - result = self._(stream, success, firstFailure); - if (!result) return onFailure(stream, failure); - } - - for (; i < max && result; i += 1) { - result = self._(stream, success, secondFailure); - } - - return onSuccess(stream, xs); - - function success(newStream, x) { - xs.push(x); - stream = newStream; - return true; - } - - function firstFailure(newStream, msg) { - failure = msg; - stream = newStream; - return false; - } - - function secondFailure(newStream, msg) { - return false; - } - }); - }; - - // -*- higher-level combinators -*- // - _.result = function (res) { - return this.then(succeed(res)); - }; - _.atMost = function (n) { - return this.times(0, n); - }; - _.atLeast = function (n) { - var self = this; - return self.times(n).then(function (start) { - return self.many().map(function (end) { - return start.concat(end); - }); - }); - }; - - _.map = function (fn) { - return this.then(function (result) { - return succeed(fn(result)); - }); - }; - - _.skip = function (two) { - return this.then(function (result) { - return two.result(result); - }); - }; - - // -*- primitive parsers -*- // - var string = (this.string = function (str) { - var len = str.length; - var expected = "expected '" + str + "'"; - - return Parser(function (stream, onSuccess, onFailure) { - var head = stream.slice(0, len); - - if (head === str) { - return onSuccess(stream.slice(len), head); - } else { - return onFailure(stream, expected); - } - }); - }); - - var regex = (this.regex = function (re) { - pray('regexp parser is anchored', re.toString().charAt(1) === '^'); - - var expected = 'expected ' + re; - - return Parser(function (stream, onSuccess, onFailure) { - var match = re.exec(stream); - - if (match) { - var result = match[0]; - return onSuccess(stream.slice(result.length), result); - } else { - return onFailure(stream, expected); - } - }); - }); - - var succeed = (Parser.succeed = function (result) { - return Parser(function (stream, onSuccess) { - return onSuccess(stream, result); - }); - }); - - var fail = (Parser.fail = function (msg) { - return Parser(function (stream, _, onFailure) { - return onFailure(stream, msg); - }); - }); - - var letter = (Parser.letter = regex(/^[a-z]/i)); - var letters = (Parser.letters = regex(/^[a-z]*/i)); - var digit = (Parser.digit = regex(/^[0-9]/)); - var digits = (Parser.digits = regex(/^[0-9]*/)); - var whitespace = (Parser.whitespace = regex(/^\s+/)); - var optWhitespace = (Parser.optWhitespace = regex(/^\s*/)); - - var any = (Parser.any = Parser(function (stream, onSuccess, onFailure) { - if (!stream) return onFailure(stream, 'expected any character'); - - return onSuccess(stream.slice(1), stream.charAt(0)); - })); - - var all = (Parser.all = Parser(function (stream, onSuccess, onFailure) { - return onSuccess('', stream); - })); - - var eof = (Parser.eof = Parser(function (stream, onSuccess, onFailure) { - if (stream) return onFailure(stream, 'expected EOF'); - - return onSuccess(stream, stream); - })); - }); - /************************************************* - * Sane Keyboard Events Shim - * - * An abstraction layer wrapping the textarea in - * an object with methods to manipulate and listen - * to events on, that hides all the nasty cross- - * browser incompatibilities behind a uniform API. - * - * Design goal: This is a *HARD* internal - * abstraction barrier. Cross-browser - * inconsistencies are not allowed to leak through - * and be dealt with by event handlers. All future - * cross-browser issues that arise must be dealt - * with here, and if necessary, the API updated. - * - * Organization: - * - key values map and stringify() - * - saneKeyboardEvents() - * + defer() and flush() - * + event handler logic - * + attach event handlers and export methods - ************************************************/ - - var saneKeyboardEvents = (function () { - // The following [key values][1] map was compiled from the - // [DOM3 Events appendix section on key codes][2] and - // [a widely cited report on cross-browser tests of key codes][3], - // except for 10: 'Enter', which I've empirically observed in Safari on iOS - // and doesn't appear to conflict with any other known key codes. - // - // [1]: http://www.w3.org/TR/2012/WD-DOM-Level-3-Events-20120614/#keys-keyvalues - // [2]: http://www.w3.org/TR/2012/WD-DOM-Level-3-Events-20120614/#fixed-virtual-key-codes - // [3]: http://unixpapa.com/js/key.html - var KEY_VALUES = { - 8: 'Backspace', - 9: 'Tab', - - 10: 'Enter', // for Safari on iOS - - 13: 'Enter', - - 16: 'Shift', - 17: 'Control', - 18: 'Alt', - 20: 'CapsLock', - - 27: 'Esc', - - 32: 'Spacebar', - - 33: 'PageUp', - 34: 'PageDown', - 35: 'End', - 36: 'Home', - - 37: 'Left', - 38: 'Up', - 39: 'Right', - 40: 'Down', - - 45: 'Insert', - - 46: 'Del', - - 144: 'NumLock', - }; - - // To the extent possible, create a normalized string representation - // of the key combo (i.e., key code and modifier keys). - function stringify(evt) { - var which = evt.which || evt.keyCode; - var keyVal = KEY_VALUES[which]; - var key; - var modifiers = []; - - if (evt.ctrlKey) modifiers.push('Ctrl'); - if (evt.originalEvent && evt.originalEvent.metaKey) modifiers.push('Meta'); - if (evt.altKey) modifiers.push('Alt'); - if (evt.shiftKey) modifiers.push('Shift'); - - key = keyVal || String.fromCharCode(which); - - if (!modifiers.length && !keyVal) return key; - - modifiers.push(key); - return modifiers.join('-'); - } - - // create a keyboard events shim that calls callbacks at useful times - // and exports useful public methods - return function saneKeyboardEvents(el, handlers) { - var keydown = null; - var keypress = null; - - var textarea = jQuery(el); - var target = jQuery(handlers.container || textarea); - - // checkTextareaFor() is called after keypress or paste events to - // say "Hey, I think something was just typed" or "pasted" (resp.), - // so that at all subsequent opportune times (next event or timeout), - // will check for expected typed or pasted text. - // Need to check repeatedly because #135: in Safari 5.1 (at least), - // after selecting something and then typing, the textarea is - // incorrectly reported as selected during the input event (but not - // subsequently). - var checkTextarea = noop, - timeoutId; - function checkTextareaFor(checker) { - checkTextarea = checker; - clearTimeout(timeoutId); - timeoutId = setTimeout(checker); - } - target.bind('keydown keypress input keyup focusout paste', function (e) { - checkTextarea(e); - }); - - // -*- public methods -*- // - function select(text) { - // check textarea at least once/one last time before munging (so - // no race condition if selection happens after keypress/paste but - // before checkTextarea), then never again ('cos it's been munged) - checkTextarea(); - checkTextarea = noop; - clearTimeout(timeoutId); - - textarea.val(text); - if (text && textarea[0].select) textarea[0].select(); - shouldBeSelected = !!text; - } - var shouldBeSelected = false; - - // -*- helper subroutines -*- // - - // Determine whether there's a selection in the textarea. - // This will always return false in IE < 9, which don't support - // HTMLTextareaElement::selection{Start,End}. - function hasSelection() { - var dom = textarea[0]; - - if (!('selectionStart' in dom)) return false; - return dom.selectionStart !== dom.selectionEnd; - } - - function handleKey() { - handlers.keystroke(stringify(keydown), keydown); - } - - // -*- event handlers -*- // - function onKeydown(e) { - keydown = e; - keypress = null; - - if (shouldBeSelected) - checkTextareaFor(function (e) { - if (!(e && e.type === 'focusout') && textarea[0].select) { - textarea[0].select(); // re-select textarea in case it's an unrecognized - } - checkTextarea = noop; // key that clears the selection, then never - clearTimeout(timeoutId); // again, 'cos next thing might be blur - }); - - handleKey(); - } - - function onKeypress(e) { - // call the key handler for repeated keypresses. - // This excludes keypresses that happen directly - // after keydown. In that case, there will be - // no previous keypress, so we skip it here - if (keydown && keypress) handleKey(); - - keypress = e; - - checkTextareaFor(typedText); - } - function typedText() { - // If there is a selection, the contents of the textarea couldn't - // possibly have just been typed in. - // This happens in browsers like Firefox and Opera that fire - // keypress for keystrokes that are not text entry and leave the - // selection in the textarea alone, such as Ctrl-C. - // Note: we assume that browsers that don't support hasSelection() - // also never fire keypress on keystrokes that are not text entry. - // This seems reasonably safe because: - // - all modern browsers including IE 9+ support hasSelection(), - // making it extremely unlikely any browser besides IE < 9 won't - // - as far as we know IE < 9 never fires keypress on keystrokes - // that aren't text entry, which is only as reliable as our - // tests are comprehensive, but the IE < 9 way to do - // hasSelection() is poorly documented and is also only as - // reliable as our tests are comprehensive - // If anything like #40 or #71 is reported in IE < 9, see - // b1318e5349160b665003e36d4eedd64101ceacd8 - if (hasSelection()) return; - - var text = textarea.val(); - if (text.length === 1) { - textarea.val(''); - handlers.typedText(text); - } // in Firefox, keys that don't type text, just clear seln, fire keypress - // https://github.com/mathquill/mathquill/issues/293#issuecomment-40997668 - else if (text && textarea[0].select) textarea[0].select(); // re-select if that's why we're here - } - - function onBlur() { - keydown = keypress = null; - } - - function onPaste(e) { - // browsers are dumb. - // - // In Linux, middle-click pasting causes onPaste to be called, - // when the textarea is not necessarily focused. We focus it - // here to ensure that the pasted text actually ends up in the - // textarea. - // - // It's pretty nifty that by changing focus in this handler, - // we can change the target of the default action. (This works - // on keydown too, FWIW). - // - // And by nifty, we mean dumb (but useful sometimes). - textarea.focus(); - - checkTextareaFor(pastedText); - } - function pastedText() { - var text = textarea.val(); - textarea.val(''); - if (text) handlers.paste(text); - } - - // -*- attach event handlers -*- // - target.bind({ - keydown: onKeydown, - keypress: onKeypress, - focusout: onBlur, - paste: onPaste, - }); - - // -*- export public methods -*- // - return { - select: select, - }; - }; - })(); - /*********************************************** - * Export math in a human-readable text format - * As you can see, only half-baked so far. - **********************************************/ - - Controller.open(function (_, super_) { - _.exportText = function () { - return this.root.foldChildren('', function (text, child) { - return text + child.text(); - }); - }; - }); - Controller.open(function (_) { - _.focusBlurEvents = function () { - var ctrlr = this, - root = ctrlr.root, - cursor = ctrlr.cursor; - var blurTimeout; - ctrlr.textarea - .focus(function () { - ctrlr.blurred = false; - clearTimeout(blurTimeout); - ctrlr.container.addClass('mq-focused'); - if (!cursor.parent) cursor.insAtRightEnd(root); - if (cursor.selection) { - cursor.selection.jQ.removeClass('mq-blur'); - ctrlr.selectionChanged(); //re-select textarea contents after tabbing away and back - } else cursor.show(); - }) - .blur(function () { - ctrlr.blurred = true; - blurTimeout = setTimeout(function () { - // wait for blur on window; if - root.postOrder('intentionalBlur'); // none, intentional blur: #264 - cursor.clearSelection().endSelection(); - blur(); - }); - $(window).on('blur', windowBlur); - }); - function windowBlur() { - // blur event also fired on window, just switching - clearTimeout(blurTimeout); // tabs/windows, not intentional blur - if (cursor.selection) cursor.selection.jQ.addClass('mq-blur'); - blur(); - } - function blur() { - // not directly in the textarea blur handler so as to be - cursor.hide().parent.blur(); // synchronous with/in the same frame as - ctrlr.container.removeClass('mq-focused'); // clearing/blurring selection - $(window).off('blur', windowBlur); - } - ctrlr.blurred = true; - cursor.hide().parent.blur(); - }; - }); - - /** - * TODO: I wanted to move MathBlock::focus and blur here, it would clean - * up lots of stuff like, TextBlock::focus is set to MathBlock::focus - * and TextBlock::blur calls MathBlock::blur, when instead they could - * use inheritance and super_. - * - * Problem is, there's lots of calls to .focus()/.blur() on nodes - * outside Controller::focusBlurEvents(), such as .postOrder('blur') on - * insertion, which if MathBlock::blur becomes Node::blur, would add the - * 'blur' CSS class to all Symbol's (because .isEmpty() is true for all - * of them). - * - * I'm not even sure there aren't other troublesome calls to .focus() or - * .blur(), so this is TODO for now. - */ - /***************************************** - * Deals with the browser DOM events from - * interaction with the typist. - ****************************************/ - - Controller.open(function (_) { - _.keystroke = function (key, evt) { - this.cursor.parent.keystroke(key, evt, this); - }; - }); - - Node.open(function (_) { - _.keystroke = function (key, e, ctrlr) { - var cursor = ctrlr.cursor; - - switch (key) { - case 'Ctrl-Shift-Backspace': - case 'Ctrl-Backspace': - ctrlr.ctrlDeleteDir(L); - break; - - case 'Shift-Backspace': - case 'Backspace': - ctrlr.backspace(); - break; - - // Tab or Esc -> go one block right if it exists, else escape right. - case 'Esc': - case 'Tab': - ctrlr.escapeDir(R, key, e); - return; - - // Shift-Tab -> go one block left if it exists, else escape left. - case 'Shift-Tab': - case 'Shift-Esc': - ctrlr.escapeDir(L, key, e); - return; - - // End -> move to the end of the current block. - case 'End': - ctrlr.notify('move').cursor.insAtRightEnd(cursor.parent); - break; - - // Ctrl-End -> move all the way to the end of the root block. - case 'Ctrl-End': - ctrlr.notify('move').cursor.insAtRightEnd(ctrlr.root); - break; - - // Shift-End -> select to the end of the current block. - case 'Shift-End': - while (cursor[R]) { - ctrlr.selectRight(); - } - break; - - // Ctrl-Shift-End -> select to the end of the root block. - case 'Ctrl-Shift-End': - while (cursor[R] || cursor.parent !== ctrlr.root) { - ctrlr.selectRight(); - } - break; - - // Home -> move to the start of the root block or the current block. - case 'Home': - ctrlr.notify('move').cursor.insAtLeftEnd(cursor.parent); - break; - - // Ctrl-Home -> move to the start of the current block. - case 'Ctrl-Home': - ctrlr.notify('move').cursor.insAtLeftEnd(ctrlr.root); - break; - - // Shift-Home -> select to the start of the current block. - case 'Shift-Home': - while (cursor[L]) { - ctrlr.selectLeft(); - } - break; - - // Ctrl-Shift-Home -> move to the start of the root block. - case 'Ctrl-Shift-Home': - while (cursor[L] || cursor.parent !== ctrlr.root) { - ctrlr.selectLeft(); - } - break; - - case 'Left': - ctrlr.moveLeft(); - break; - case 'Shift-Left': - ctrlr.selectLeft(); - break; - case 'Ctrl-Left': - break; - - case 'Right': - ctrlr.moveRight(); - break; - case 'Shift-Right': - ctrlr.selectRight(); - break; - case 'Ctrl-Right': - break; - - case 'Up': - ctrlr.moveUp(); - break; - case 'Down': - ctrlr.moveDown(); - break; - - case 'Shift-Up': - if (cursor[L]) { - while (cursor[L]) ctrlr.selectLeft(); - } else { - ctrlr.selectLeft(); - } - - case 'Shift-Down': - if (cursor[R]) { - while (cursor[R]) ctrlr.selectRight(); - } else { - ctrlr.selectRight(); - } - - case 'Ctrl-Up': - break; - case 'Ctrl-Down': - break; - - case 'Ctrl-Shift-Del': - case 'Ctrl-Del': - ctrlr.ctrlDeleteDir(R); - break; - - case 'Shift-Del': - case 'Del': - ctrlr.deleteForward(); - break; - - case 'Meta-A': - case 'Ctrl-A': - ctrlr.notify('move').cursor.insAtRightEnd(ctrlr.root); - while (cursor[L]) ctrlr.selectLeft(); - break; - - default: - return; - } - e.preventDefault(); - ctrlr.scrollHoriz(); - }; - - _.moveOutOf = - _.moveTowards = - _.deleteOutOf = - _.deleteTowards = - _.unselectInto = - _.selectOutOf = - _.selectTowards = - function () { - // called by Controller::escapeDir, moveDir // called by Controller::moveDir // called by Controller::deleteDir // called by Controller::deleteDir // called by Controller::selectDir // called by Controller::selectDir // called by Controller::selectDir - pray('overridden or never called on this node'); - }; - }); - - Controller.open(function (_) { - this.onNotify(function (e) { - if (e === 'move' || e === 'upDown') this.show().clearSelection(); - }); - _.escapeDir = function (dir, key, e) { - prayDirection(dir); - var cursor = this.cursor; - - // only prevent default of Tab if not in the root editable - if (cursor.parent !== this.root) e.preventDefault(); - - // want to be a noop if in the root editable (in fact, Tab has an unrelated - // default browser action if so) - if (cursor.parent === this.root) return; - - cursor.parent.moveOutOf(dir, cursor); - return this.notify('move'); - }; - - optionProcessors.leftRightIntoCmdGoes = function (updown) { - if (updown && updown !== 'up' && updown !== 'down') { - throw '"up" or "down" required for leftRightIntoCmdGoes option, ' + 'got "' + updown + '"'; - } - return updown; - }; - _.moveDir = function (dir) { - prayDirection(dir); - var cursor = this.cursor, - updown = cursor.options.leftRightIntoCmdGoes; - - if (cursor.selection) { - cursor.insDirOf(dir, cursor.selection.ends[dir]); - } else if (cursor[dir]) cursor[dir].moveTowards(dir, cursor, updown); - else cursor.parent.moveOutOf(dir, cursor, updown); - - return this.notify('move'); - }; - _.moveLeft = function () { - return this.moveDir(L); - }; - _.moveRight = function () { - return this.moveDir(R); - }; - - /** - * moveUp and moveDown have almost identical algorithms: - * - first check left and right, if so insAtLeft/RightEnd of them - * - else check the parent's 'upOutOf'/'downOutOf' property: - * + if it's a function, call it with the cursor as the sole argument and - * use the return value as if it were the value of the property - * + if it's a Node, jump up or down into it: - * - if there is a cached Point in the block, insert there - * - else, seekHoriz within the block to the current x-coordinate (to be - * as close to directly above/below the current position as possible) - * + unless it's exactly `true`, stop bubbling - */ - _.moveUp = function () { - return moveUpDown(this, 'up'); - }; - _.moveDown = function () { - return moveUpDown(this, 'down'); - }; - function moveUpDown(self, dir) { - var cursor = self.notify('upDown').cursor; - var dirInto = dir + 'Into', - dirOutOf = dir + 'OutOf'; - if (cursor[R][dirInto]) cursor.insAtLeftEnd(cursor[R][dirInto]); - else if (cursor[L][dirInto]) cursor.insAtRightEnd(cursor[L][dirInto]); - else { - cursor.parent.bubble(function (ancestor) { - var prop = ancestor[dirOutOf]; - if (prop) { - if (typeof prop === 'function') prop = ancestor[dirOutOf](cursor); - if (prop instanceof Node) cursor.jumpUpDown(ancestor, prop); - if (prop !== true) return false; - } - }); - } - return self; - } - this.onNotify(function (e) { - if (e !== 'upDown') this.upDownCache = {}; - }); - - this.onNotify(function (e) { - if (e === 'edit') this.show().deleteSelection(); - }); - _.deleteDir = function (dir) { - prayDirection(dir); - var cursor = this.cursor; - - var hadSelection = cursor.selection; - this.notify('edit'); // deletes selection if present - if (!hadSelection) { - if (cursor[dir]) cursor[dir].deleteTowards(dir, cursor); - else cursor.parent.deleteOutOf(dir, cursor); - } - - if (cursor[L].siblingDeleted) cursor[L].siblingDeleted(cursor.options, R); - if (cursor[R].siblingDeleted) cursor[R].siblingDeleted(cursor.options, L); - cursor.parent.bubble('reflow'); - - return this; - }; - _.ctrlDeleteDir = function (dir) { - prayDirection(dir); - var cursor = this.cursor; - if (!cursor[L] || cursor.selection) return ctrlr.deleteDir(); - - this.notify('edit'); - Fragment(cursor.parent.ends[L], cursor[L]).remove(); - cursor.insAtDirEnd(L, cursor.parent); - - if (cursor[L].siblingDeleted) cursor[L].siblingDeleted(cursor.options, R); - if (cursor[R].siblingDeleted) cursor[R].siblingDeleted(cursor.options, L); - cursor.parent.bubble('reflow'); - - return this; - }; - _.backspace = function () { - return this.deleteDir(L); - }; - _.deleteForward = function () { - return this.deleteDir(R); - }; - - this.onNotify(function (e) { - if (e !== 'select') this.endSelection(); - }); - _.selectDir = function (dir) { - var cursor = this.notify('select').cursor, - seln = cursor.selection; - prayDirection(dir); - - if (!cursor.anticursor) cursor.startSelection(); - - var node = cursor[dir]; - if (node) { - // "if node we're selecting towards is inside selection (hence retracting) - // and is on the *far side* of the selection (hence is only node selected) - // and the anticursor is *inside* that node, not just on the other side" - if (seln && seln.ends[dir] === node && cursor.anticursor[-dir] !== node) { - node.unselectInto(dir, cursor); - } else node.selectTowards(dir, cursor); - } else cursor.parent.selectOutOf(dir, cursor); - - cursor.clearSelection(); - cursor.select() || cursor.show(); - }; - _.selectLeft = function () { - return this.selectDir(L); - }; - _.selectRight = function () { - return this.selectDir(R); - }; - }); - // Parser MathBlock - var latexMathParser = (function () { - function commandToBlock(cmd) { - // can also take in a Fragment - var block = MathBlock(); - cmd.adopt(block, 0, 0); - return block; - } - function joinBlocks(blocks) { - var firstBlock = blocks[0] || MathBlock(); - - for (var i = 1; i < blocks.length; i += 1) { - blocks[i].children().adopt(firstBlock, firstBlock.ends[R], 0); - } - - return firstBlock; - } - - var string = Parser.string; - var regex = Parser.regex; - var letter = Parser.letter; - var any = Parser.any; - var optWhitespace = Parser.optWhitespace; - var succeed = Parser.succeed; - var fail = Parser.fail; - - // Parsers yielding either MathCommands, or Fragments of MathCommands - // (either way, something that can be adopted by a MathBlock) - var variable = letter.map(function (c) { - return Letter(c); - }); - var symbol = regex(/^[^${}\\_^]/).map(function (c) { - return VanillaSymbol(c); - }); - - var controlSequence = regex(/^[^\\a-eg-zA-Z]/) // hotfix #164; match MathBlock::write - .or( - string('\\').then( - regex(/^[a-z]+/i) - .or(regex(/^\s+/).result(' ')) - .or(any), - ), - ) - .then(function (ctrlSeq) { - var cmdKlass = LatexCmds[ctrlSeq]; - - if (cmdKlass) { - return cmdKlass(ctrlSeq).parser(); - } else { - return fail('unknown command: \\' + ctrlSeq); - } - }); - var command = controlSequence.or(variable).or(symbol); - // Parsers yielding MathBlocks - var mathGroup = string('{') - .then(function () { - return mathSequence; - }) - .skip(string('}')); - var mathBlock = optWhitespace.then(mathGroup.or(command.map(commandToBlock))); - var mathSequence = mathBlock.many().map(joinBlocks).skip(optWhitespace); - - var optMathBlock = string('[') - .then( - mathBlock - .then(function (block) { - return block.join('latex') !== ']' ? succeed(block) : fail(); - }) - .many() - .map(joinBlocks) - .skip(optWhitespace), - ) - .skip(string(']')); - var latexMath = mathSequence; - - latexMath.block = mathBlock; - latexMath.optBlock = optMathBlock; - return latexMath; - })(); - - Controller.open(function (_, super_) { - _.exportLatex = function () { - return this.root.latex().replace(/(\\[a-z]+) (?![a-z])/gi, '$1'); - }; - _.writeLatex = function (latex) { - var cursor = this.notify('edit').cursor; - - var all = Parser.all; - var eof = Parser.eof; - - var block = latexMathParser.skip(eof).or(all.result(false)).parse(latex); - - if (block && !block.isEmpty()) { - block.children().adopt(cursor.parent, cursor[L], cursor[R]); - var jQ = block.jQize(); - jQ.insertBefore(cursor.jQ); - cursor[L] = block.ends[R]; - block.finalizeInsert(cursor.options, cursor); - if (block.ends[R][R].siblingCreated) block.ends[R][R].siblingCreated(cursor.options, L); - if (block.ends[L][L].siblingCreated) block.ends[L][L].siblingCreated(cursor.options, R); - cursor.parent.bubble('reflow'); - } - - return this; - }; - _.renderLatexMath = function (latex) { - var root = this.root, - cursor = this.cursor; - - var all = Parser.all; - var eof = Parser.eof; - - var block = latexMathParser.skip(eof).or(all.result(false)).parse(latex); - - root.eachChild('postOrder', 'dispose'); - root.ends[L] = root.ends[R] = 0; - - if (block) { - block.children().adopt(root, 0, 0); - } - - var jQ = root.jQ; - - if (block) { - var html = block.join('html'); - jQ.html(html); - root.jQize(jQ.children()); - root.finalizeInsert(cursor.options); - } else { - jQ.empty(); - } - - delete cursor.selection; - cursor.insAtRightEnd(root); - }; - _.renderLatexText = function (latex) { - var root = this.root, - cursor = this.cursor; - - root.jQ.children().slice(1).remove(); - root.eachChild('postOrder', 'dispose'); - root.ends[L] = root.ends[R] = 0; - delete cursor.selection; - cursor.show().insAtRightEnd(root); - - var regex = Parser.regex; - var string = Parser.string; - var eof = Parser.eof; - var all = Parser.all; - - // Parser RootMathCommand - var mathMode = string('$') - .then(latexMathParser) - // because TeX is insane, math mode doesn't necessarily - // have to end. So we allow for the case that math mode - // continues to the end of the stream. - .skip(string('$').or(eof)) - .map(function (block) { - // HACK FIXME: this shouldn't have to have access to cursor - var rootMathCommand = RootMathCommand(cursor); - - rootMathCommand.createBlocks(); - var rootMathBlock = rootMathCommand.ends[L]; - block.children().adopt(rootMathBlock, 0, 0); - - return rootMathCommand; - }); - var escapedDollar = string('\\$').result('$'); - var textChar = escapedDollar.or(regex(/^[^$]/)).map(VanillaSymbol); - var latexText = mathMode.or(textChar).many(); - var commands = latexText.skip(eof).or(all.result(false)).parse(latex); - - if (commands) { - for (var i = 0; i < commands.length; i += 1) { - commands[i].adopt(root, root.ends[R], 0); - } - - root.jQize().appendTo(root.jQ); - - root.finalizeInsert(cursor.options); - } - }; - }); - /******************************************************** - * Deals with mouse events for clicking, drag-to-select - *******************************************************/ - - Controller.open(function (_) { - Options.p.ignoreNextMousedown = noop; - _.delegateMouseEvents = function () { - var ultimateRootjQ = this.root.jQ; - //drag-to-select event handling - this.container.bind('mousedown.mathquill', function (e) { - var rootjQ = $(e.target).closest('.mq-root-block'); - var root = Node.byId[rootjQ.attr(mqBlockId) || ultimateRootjQ.attr(mqBlockId)]; - var ctrlr = root.controller, - cursor = ctrlr.cursor, - blink = cursor.blink; - var textareaSpan = ctrlr.textareaSpan, - textarea = ctrlr.textarea; - - e.preventDefault(); // doesn't work in IE\u22648, but it's a one-line fix: - e.target.unselectable = true; // http://jsbin.com/yagekiji/1 - - if (cursor.options.ignoreNextMousedown(e)) return; - else cursor.options.ignoreNextMousedown = noop; - - var target; - function mousemove(e) { - target = $(e.target); - } - function docmousemove(e) { - if (!cursor.anticursor) cursor.startSelection(); - ctrlr.seek(target, e.pageX, e.pageY).cursor.select(); - target = undefined; - } - // outside rootjQ, the MathQuill node corresponding to the target (if any) - // won't be inside this root, so don't mislead Controller::seek with it - - function mouseup(e) { - cursor.blink = blink; - if (!cursor.selection) { - if (ctrlr.editable) { - cursor.show(); - } else { - textareaSpan.detach(); - } - } - - // delete the mouse handlers now that we're not dragging anymore - rootjQ.unbind('mousemove', mousemove); - $(e.target.ownerDocument).unbind('mousemove', docmousemove).unbind('mouseup', mouseup); - } - - if (ctrlr.blurred) { - if (!ctrlr.editable) rootjQ.prepend(textareaSpan); - textarea.focus(); - } - - cursor.blink = noop; - ctrlr.seek($(e.target), e.pageX, e.pageY).cursor.startSelection(); - - rootjQ.mousemove(mousemove); - $(e.target.ownerDocument).mousemove(docmousemove).mouseup(mouseup); - // listen on document not just body to not only hear about mousemove and - // mouseup on page outside field, but even outside page, except iframes: https://github.com/mathquill/mathquill/commit/8c50028afcffcace655d8ae2049f6e02482346c5#commitcomment-6175800 - }); - }; - }); - - Controller.open(function (_) { - _.seek = function (target, pageX, pageY) { - var cursor = this.notify('select').cursor; - - if (target) { - var nodeId = target.attr(mqBlockId) || target.attr(mqCmdId); - if (!nodeId) { - var targetParent = target.parent(); - nodeId = targetParent.attr(mqBlockId) || targetParent.attr(mqCmdId); - } - } - var node = nodeId ? Node.byId[nodeId] : this.root; - pray('nodeId is the id of some Node that exists', node); - - // don't clear selection until after getting node from target, in case - // target was selection span, otherwise target will have no parent and will - // seek from root, which is less accurate (e.g. fraction) - cursor.clearSelection().show(); - - node.seek(pageX, cursor); - this.scrollHoriz(); // before .selectFrom when mouse-selecting, so - // always hits no-selection case in scrollHoriz and scrolls slower - return this; - }; - }); - /*********************************************** - * Horizontal panning for editable fields that - * overflow their width - **********************************************/ - - Controller.open(function (_) { - _.scrollHoriz = function () { - var cursor = this.cursor, - seln = cursor.selection; - var rootRect = this.root.jQ[0].getBoundingClientRect(); - if (!seln) { - var x = cursor.jQ[0].getBoundingClientRect().left; - if (x > rootRect.right - 20) var scrollBy = x - (rootRect.right - 20); - else if (x < rootRect.left + 20) var scrollBy = x - (rootRect.left + 20); - else return; - } else { - var rect = seln.jQ[0].getBoundingClientRect(); - var overLeft = rect.left - (rootRect.left + 20); - var overRight = rect.right - (rootRect.right - 20); - if (seln.ends[L] === cursor[R]) { - if (overLeft < 0) var scrollBy = overLeft; - else if (overRight > 0) { - if (rect.left - overRight < rootRect.left + 20) var scrollBy = overLeft; - else var scrollBy = overRight; - } else return; - } else { - if (overRight > 0) var scrollBy = overRight; - else if (overLeft < 0) { - if (rect.right - overLeft > rootRect.right - 20) var scrollBy = overRight; - else var scrollBy = overLeft; - } else return; - } - } - this.root.jQ.stop().animate({ scrollLeft: '+=' + scrollBy }, 100); - }; - }); - /********************************************* - * Manage the MathQuill instance's textarea - * (as owned by the Controller) - ********************************************/ - - Controller.open(function (_) { - Options.p.substituteTextarea = function () { - return $( - '