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..92782fe904 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue @@ -0,0 +1,130 @@ + + + + + + + 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/StatusChip.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/StatusChip.vue new file mode 100644 index 0000000000..8e2b8c4d37 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/StatusChip.vue @@ -0,0 +1,103 @@ + + + + + + + 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..e66e568157 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js @@ -0,0 +1,560 @@ +import { computed, ref } from 'vue'; +import { mount } from '@vue/test-utils'; +import { factory } from '../../../../store'; + +import SubmitToCommunityLibrarySidePanel from '../'; +import Box from '../Box.vue'; +import StatusChip from '../StatusChip.vue'; + +import { usePublishedData } from '../composables/usePublishedData'; +import { useLatestCommunityLibrarySubmission } from '../composables/useLatestCommunityLibrarySubmission'; +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/usePublishedData', () => ({ + usePublishedData: jest.fn(), +})); +jest.mock('../composables/useLatestCommunityLibrarySubmission', () => ({ + useLatestCommunityLibrarySubmission: jest.fn(), +})); +jest.mock('shared/data/resources', () => ({ + CommunityLibrarySubmission: { + create: jest.fn(() => Promise.resolve()), + }, +})); + +const store = factory(); + +const { + nonePrimaryInfo$, + flaggedPrimaryInfo$, + approvedPrimaryInfo$, + submittedPrimaryInfo$, + reviewersWillSeeLatestFirst$, +} = communityChannelsStrings; + +async function makeWrapper({ channel, publishedData, latestSubmission }) { + const isLoading = ref(true); + const isFinished = ref(false); + + usePublishedData.mockReturnValue({ + isLoading, + isFinished, + data: computed(() => publishedData), + }); + + useLatestCommunityLibrarySubmission.mockReturnValue({ + isLoading, + isFinished, + data: computed(() => latestSubmission), + }); + + 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: 'published-non-public-channel', + version: 2, + name: 'Published Non-Public Channel', + published: true, + public: true, +}; + +const nonPublishedChannel = { + id: 'non-published-channel', + version: 0, + name: 'Non-public Channel', + published: false, + public: false, +}; + +const publishedData = { + 2: { + included_languages: ['en', null], + included_licenses: [1], + included_categories: [Categories.SCHOOL], + }, + 1: { + included_languages: ['en', null], + included_licenses: [1], + included_categories: [Categories.SCHOOL], + }, +}; + +const submittedLatestSubmission = { channel_version: 2, status: CommunityLibraryStatus.PENDING }; + +describe('SubmitToCommunityLibrarySidePanel', () => { + 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: {}, + 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 infoBoxes = wrapper.findAllComponents(Box).filter(box => box.props('kind') === 'info'); + expect(infoBoxes.length).toBe(1); + const infoBox = infoBoxes.wrappers[0]; + expect(infoBox.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 infoBoxes = wrapper.findAllComponents(Box).filter(box => box.props('kind') === 'info'); + expect(infoBoxes.length).toBe(1); + const infoBox = infoBoxes.wrappers[0]; + expect(infoBox.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 infoBoxes = wrapper.findAllComponents(Box).filter(box => box.props('kind') === 'info'); + expect(infoBoxes.length).toBe(1); + const infoBox = infoBoxes.wrappers[0]; + expect(infoBox.text()).toContain(approvedPrimaryInfo$()); + expect(infoBox.text()).toContain(reviewersWillSeeLatestFirst$()); + }); + + it('when the previous submission is pending', async () => { + const wrapper = await makeWrapper({ + channel: publishedNonPublicChannel, + publishedData, + latestSubmission: { channel_version: 1, status: CommunityLibraryStatus.PENDING }, + }); + + const infoBoxes = wrapper.findAllComponents(Box).filter(box => box.props('kind') === 'info'); + expect(infoBoxes.length).toBe(1); + const infoBox = infoBoxes.wrappers[0]; + expect(infoBox.text()).toContain(submittedPrimaryInfo$()); + expect(infoBox.text()).toContain(reviewersWillSeeLatestFirst$()); + }); + }); + + 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, + }); + + let moreDetails = wrapper.find('[data-test="more-details"]'); + expect(moreDetails.exists()).toBe(false); + + let moreDetailsButton = wrapper.find('[data-test="more-details-button"]'); + await moreDetailsButton.trigger('click'); + + moreDetails = wrapper.find('.more-details-text'); + expect(moreDetails.exists()).toBe(true); + + moreDetailsButton = wrapper.find('[data-test="more-details-button"]'); + expect(moreDetailsButton.exists()).toBe(false); + }); + }); + + 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(StatusChip); + 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(StatusChip); + expect(statusChip.props('status')).toBe(chipStatus); + }); + } + + testStatusChip(CommunityLibraryStatus.APPROVED, CommunityLibraryStatus.APPROVED); + testStatusChip(CommunityLibraryStatus.LIVE, CommunityLibraryStatus.APPROVED); + testStatusChip(CommunityLibraryStatus.REJECTED, CommunityLibraryStatus.REJECTED); + testStatusChip(CommunityLibraryStatus.PENDING, 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: {}, + 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: {}, + 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 () => { + 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(); + }); + + it('a submission snackbar is shown', 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"]'); + await submitButton.trigger('click'); + + jest.useFakeTimers(); + + expect(store.getters['snackbarIsVisible']).toBe(true); + expect(CommunityLibrarySubmission.create).not.toHaveBeenCalled(); + }); + + it('the submission is created after a timeout', async () => { + 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']); + + jest.useFakeTimers(); + + const submitButton = wrapper.find('[data-test="submit-button"]'); + await submitButton.trigger('click'); + + jest.runAllTimers(); + + expect(CommunityLibrarySubmission.create).toHaveBeenCalledWith({ + description: 'Some description', + channel: publishedNonPublicChannel.id, + countries: ['CZ'], + categories: [Categories.SCHOOL], + }); + }); + }); + + 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/useLatestCommunityLibrarySubmission.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLatestCommunityLibrarySubmission.js new file mode 100644 index 0000000000..ee65710b7f --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLatestCommunityLibrarySubmission.js @@ -0,0 +1,19 @@ +import { useFetch } from '../../../../composables/useFetch'; +import { CommunityLibrarySubmission } from 'shared/data/resources'; + +export function useLatestCommunityLibrarySubmission(channelId) { + function fetchLatestSubmission() { + // Submissions are ordered by most recent first in the backend + return CommunityLibrarySubmission.fetchCollection({ channel: channelId, max_results: 1 }).then( + response => { + if (response.results.length > 0) { + return response.results[0]; + } + return null; + }, + ); + } + return useFetch({ + asyncFetchFunc: fetchLatestSubmission, + }); +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js new file mode 100644 index 0000000000..3b35b3b7dc --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js @@ -0,0 +1,6 @@ +import { useFetch } from '../../../../composables/useFetch'; +import { Channel } from 'shared/data/resources'; + +export function usePublishedData(channelId) { + return useFetch({ asyncFetchFunc: () => Channel.getPublishedData(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..d3d4e91d5f --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -0,0 +1,541 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/useFetch.js b/contentcuration/contentcuration/frontend/channelEdit/composables/useFetch.js new file mode 100644 index 0000000000..eadf93ed35 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/composables/useFetch.js @@ -0,0 +1,28 @@ +import { ref } from 'vue'; + +export function useFetch({ asyncFetchFunc }) { + const isLoading = ref(true); + const isFinished = ref(false); + const data = ref(null); + const error = ref(null); + + async function fetchData() { + isLoading.value = true; + isFinished.value = false; + data.value = null; + error.value = null; + try { + data.value = await asyncFetchFunc(); + isLoading.value = false; + isFinished.value = true; + } catch (error) { + error.value = error; + throw error; + } finally { + isLoading.value = false; + } + } + fetchData(); + + return { isLoading, isFinished, data, error }; +} 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/TreeView/TreeViewBase.vue b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue index 95bb47fa68..6ca82f5d2d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue @@ -84,6 +84,31 @@ > {{ $tr('apiGenerated') }} + + + + + {{ $tr('submitToCommunityLibrary') }} + + + {{ $tr('inviteCollaborators') }} + + + {{ $tr('shareToken') }} + + + +