diff --git a/contentcuration/contentcuration/frontend/administration/components/sidePanels/ReviewSubmissionSidePanel.vue b/contentcuration/contentcuration/frontend/administration/components/sidePanels/ReviewSubmissionSidePanel.vue new file mode 100644 index 0000000000..e05d042bd3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/administration/components/sidePanels/ReviewSubmissionSidePanel.vue @@ -0,0 +1,639 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/administration/components/sidePanels/__tests__/ReviewSubmissionSidePanel.spec.js b/contentcuration/contentcuration/frontend/administration/components/sidePanels/__tests__/ReviewSubmissionSidePanel.spec.js new file mode 100644 index 0000000000..5caa13a57c --- /dev/null +++ b/contentcuration/contentcuration/frontend/administration/components/sidePanels/__tests__/ReviewSubmissionSidePanel.spec.js @@ -0,0 +1,497 @@ +import Vue, { ref, computed } from 'vue'; +import { mount } from '@vue/test-utils'; +import { factory } from '../../../store'; +import router from '../../../router'; + +import ReviewSubmissionSidePanel from '../ReviewSubmissionSidePanel'; +import { useLatestCommunityLibrarySubmission } from 'shared/composables/useLatestCommunityLibrarySubmission'; +import { + Categories, + CommunityLibraryResolutionReason, + CommunityLibraryStatus, +} from 'shared/constants'; +import { CommunityLibrarySubmission } from 'shared/data/resources'; +import CommunityLibraryStatusChip from 'shared/views/communityLibrary/CommunityLibraryStatusChip.vue'; + +jest.mock('shared/composables/useLatestCommunityLibrarySubmission'); +jest.mock('shared/data/resources', () => ({ + CommunityLibrarySubmission: { + resolveAsAdmin: jest.fn(() => Promise.resolve()), + }, +})); + +const store = factory(); + +let isLoading, isFinished; + +async function makeWrapper({ channel, latestSubmission }) { + isLoading = ref(true); + isFinished = ref(false); + const fetchLatestSubmission = jest.fn(() => Promise.resolve()); + + useLatestCommunityLibrarySubmission.mockReturnValue({ + isLoading, + isFinished, + data: computed(() => latestSubmission), + fetchData: fetchLatestSubmission, + }); + + const wrapper = mount(ReviewSubmissionSidePanel, { + store, + router, + propsData: { + channel, + }, + mocks: { + $formatRelative: data => + Vue.prototype.$formatRelative(data, { now: new Date('2024-01-01T00:15:00Z') }), + }, + }); + + // To simulate 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 channelCommon = { + id: 'channel-id', + name: 'Test Channel', + published_data: { + 1: { + included_languages: ['en', null], + included_licenses: [1], + included_categories: [Categories.SCHOOL], + }, + 2: { + included_languages: ['en', 'cs', null], + included_licenses: [1, 2], + included_categories: [Categories.SCHOOL, Categories.ALGEBRA], + }, + 3: { + included_languages: ['en', null], + included_licenses: [1], + included_categories: [Categories.SCHOOL], + }, + }, + version: 3, +}; + +const submissionCommon = { + id: 'submission-id', + author_name: 'Test Author', + description: 'Author description', + date_created: '2024-01-01T00:00:00Z', + channel_name: 'Test Channel', + channel_version: 2, + countries: ['US', 'CZ'], + categories: [Categories.SCHOOL, Categories.ALGEBRA], + resolution_reason: null, + feedback_notes: null, + internal_notes: null, +}; + +const testData = { + submitted: { + channel: { + ...channelCommon, + latest_community_library_submission_status: CommunityLibraryStatus.PENDING, + }, + submission: { + ...submissionCommon, + status: CommunityLibraryStatus.PENDING, + }, + }, + approved: { + channel: { + ...channelCommon, + latest_community_library_submission_status: CommunityLibraryStatus.APPROVED, + }, + submission: { + ...submissionCommon, + status: CommunityLibraryStatus.APPROVED, + feedback_notes: 'Feedback notes', + internal_notes: 'Internal notes', + }, + }, + flagged: { + channel: { + ...channelCommon, + latest_community_library_submission_status: CommunityLibraryStatus.REJECTED, + }, + submission: { + ...submissionCommon, + status: CommunityLibraryStatus.REJECTED, + resolution_reason: CommunityLibraryResolutionReason.PORTABILITY_ISSUES, + feedback_notes: 'Feedback notes', + internal_notes: 'Internal notes', + }, + }, +}; + +describe('ReviewSubmissionSidePanel', () => { + it('submission data is prefilled', async () => { + const { channel, submission } = testData.flagged; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + expect(wrapper.find('.author-name').text()).toBe(submission.author_name); + expect(wrapper.find('.channel-link').text()).toBe( + `${channel.name} v${submission.channel_version}`, + ); + expect(wrapper.find('[data-test="submission-date"]').text()).toBe('15 minutes ago'); + expect(wrapper.find('[data-test="countries"]').text()).toBe( + 'United States of America, Czech Republic', + ); + expect(wrapper.find('[data-test="languages"]').text()).toBe('English, Czech'); + expect(wrapper.find('[data-test="categories"]').text()).toBe('School, Algebra'); + expect(wrapper.find('[data-test="licenses"]').text()).toBe('CC BY, CC BY-SA'); + expect(wrapper.findComponent(CommunityLibraryStatusChip).props('status')).toEqual( + submission.status, + ); + expect(wrapper.find('[data-test="submission-notes"]').text()).toBe(submission.description); + expect(wrapper.find(`input[type="radio"][value="${submission.status}"]`).element.checked).toBe( + true, + ); + expect(wrapper.findComponent({ ref: 'flagReasonSelectRef' }).vm.value.value).toBe( + submission.resolution_reason, + ); + expect(wrapper.findComponent({ ref: 'editorNotesRef' }).vm.value).toBe( + submission.feedback_notes, + ); + expect(wrapper.findComponent({ ref: 'personalNotesRef' }).vm.value).toBe( + submission.internal_notes, + ); + }); + + describe('resolution reason is', () => { + it('displayed when selected status is flagged', async () => { + const { channel, submission } = testData.flagged; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + expect(wrapper.findComponent({ ref: 'flagReasonSelectRef' }).exists()).toBe(true); + }); + + it('hidden when selected status is approved', async () => { + const { channel, submission } = testData.approved; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + expect(wrapper.findComponent({ ref: 'flagReasonSelectRef' }).exists()).toBe(false); + }); + + it('hidden when selected status is submitted', async () => { + const { channel, submission } = testData.submitted; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + expect(wrapper.findComponent({ ref: 'flagReasonSelectRef' }).exists()).toBe(false); + }); + }); + + it('is editable when loading latest submission data has finished and when the submission state is submitted', async () => { + const { channel, submission } = testData.submitted; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + const flagForReviewRadio = wrapper.find( + `input[type="radio"][value="${CommunityLibraryStatus.REJECTED}"]`, + ); + await flagForReviewRadio.trigger('click'); + + expect(flagForReviewRadio.attributes('disabled')).toBeFalsy(); + expect(wrapper.findComponent({ ref: 'flagReasonSelectRef' }).props('disabled')).toBe(false); + expect(wrapper.findComponent({ ref: 'editorNotesRef' }).props('disabled')).toBe(false); + expect(wrapper.findComponent({ ref: 'personalNotesRef' }).props('disabled')).toBe(false); + }); + + describe('is not editable', () => { + it('when latest submission data are still loading', async () => { + const { channel, submission } = testData.submitted; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + isLoading.value = true; + isFinished.value = false; + + await wrapper.vm.$nextTick(); + + const flagForReviewRadio = wrapper.find( + `input[type="radio"][value="${CommunityLibraryStatus.REJECTED}"]`, + ); + await flagForReviewRadio.trigger('click'); + + expect(flagForReviewRadio.attributes('disabled')).toBeTruthy(); + expect(wrapper.findComponent({ ref: 'editorNotesRef' }).props('disabled')).toBe(true); + expect(wrapper.findComponent({ ref: 'personalNotesRef' }).props('disabled')).toBe(true); + }); + + it('when the submission is approved', async () => { + const { channel, submission } = testData.approved; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + const flagForReviewRadio = wrapper.find( + `input[type="radio"][value="${CommunityLibraryStatus.REJECTED}"]`, + ); + await flagForReviewRadio.trigger('click'); + + expect(flagForReviewRadio.attributes('disabled')).toBeTruthy(); + expect(wrapper.findComponent({ ref: 'editorNotesRef' }).props('disabled')).toBe(true); + expect(wrapper.findComponent({ ref: 'personalNotesRef' }).props('disabled')).toBe(true); + }); + + it('when the submission is flagged', async () => { + const { channel, submission } = testData.flagged; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + const flagForReviewRadio = wrapper.find( + `input[type="radio"][value="${CommunityLibraryStatus.REJECTED}"]`, + ); + await flagForReviewRadio.trigger('click'); + + expect(flagForReviewRadio.attributes('disabled')).toBeTruthy(); + expect(wrapper.findComponent({ ref: 'editorNotesRef' }).props('disabled')).toBe(true); + expect(wrapper.findComponent({ ref: 'personalNotesRef' }).props('disabled')).toBe(true); + }); + }); + + describe('can be submitted', () => { + it('when selected status is approved', async () => { + const { channel, submission } = testData.submitted; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + const approveRadio = wrapper.find( + `input[type="radio"][value="${CommunityLibraryStatus.APPROVED}"]`, + ); + await approveRadio.trigger('click'); + + expect(wrapper.findComponent({ ref: 'confirmButtonRef' }).props('disabled')).toBe(false); + }); + + it("when selected status is flagged and editor's notes are provided", async () => { + const { channel, submission } = testData.submitted; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + const flagForReviewRadio = wrapper.find( + `input[type="radio"][value="${CommunityLibraryStatus.REJECTED}"]`, + ); + await flagForReviewRadio.trigger('click'); + + const editorNotes = wrapper.findComponent({ ref: 'editorNotesRef' }); + await editorNotes.vm.$emit('input', 'Some editor notes'); + + expect(wrapper.findComponent({ ref: 'confirmButtonRef' }).props('disabled')).toBe(false); + }); + }); + + describe('cannot be submitted', () => { + it('when latest submission data are still loading', async () => { + const { channel, submission } = testData.submitted; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + isLoading.value = true; + isFinished.value = false; + + await wrapper.vm.$nextTick(); + + const approveRadio = wrapper.find( + `input[type="radio"][value="${CommunityLibraryStatus.APPROVED}"]`, + ); + await approveRadio.trigger('click'); + + expect(wrapper.findComponent({ ref: 'confirmButtonRef' }).props('disabled')).toBe(true); + }); + + it("when selected status is flagged and editor's notes are not provided", async () => { + const { channel, submission } = testData.submitted; + const wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + const flagForReviewRadio = wrapper.find( + `input[type="radio"][value="${CommunityLibraryStatus.REJECTED}"]`, + ); + await flagForReviewRadio.trigger('click'); + + expect(wrapper.findComponent({ ref: 'confirmButtonRef' }).props('disabled')).toBe(true); + }); + }); + + describe('when submit button is clicked', () => { + let channel, submission, wrapper; + + beforeEach(async () => { + CommunityLibrarySubmission.resolveAsAdmin.mockClear(); + + const { channel: _channel, submission: _submission } = testData.submitted; + channel = _channel; + submission = _submission; + + wrapper = await makeWrapper({ channel, latestSubmission: submission }); + + const approveRadio = wrapper.find( + `input[type="radio"][value="${CommunityLibraryStatus.APPROVED}"]`, + ); + await approveRadio.trigger('click'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('the panel closes', async () => { + jest.useFakeTimers(); + + const confirmButton = wrapper.findComponent({ ref: 'confirmButtonRef' }); + await confirmButton.trigger('click'); + + expect(wrapper.emitted('close')).toBeTruthy(); + }); + + it('a submission snackbar is shown', async () => { + jest.useFakeTimers(); + const confirmButton = wrapper.findComponent({ ref: 'confirmButtonRef' }); + await confirmButton.trigger('click'); + + expect(store.getters['snackbarIsVisible']).toBe(true); + expect(CommunityLibrarySubmission.resolveAsAdmin).not.toHaveBeenCalled(); + }); + + describe('after a timeout', () => { + describe('if the submission is approved', () => { + it('the submission is correctly resolved', async () => { + jest.useFakeTimers(); + + const feedbackNotes = 'Some feedback notes'; + const feedbackNotesComponent = wrapper.findComponent({ ref: 'editorNotesRef' }); + await feedbackNotesComponent.vm.$emit('input', feedbackNotes); + + const personalNotes = 'Some personal notes'; + const personalNotesComponent = wrapper.findComponent({ ref: 'personalNotesRef' }); + await personalNotesComponent.vm.$emit('input', personalNotes); + + const confirmButton = wrapper.findComponent({ ref: 'confirmButtonRef' }); + await confirmButton.trigger('click'); + + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + + expect(CommunityLibrarySubmission.resolveAsAdmin).toHaveBeenCalledWith(submission.id, { + status: CommunityLibraryStatus.APPROVED, + feedback_notes: feedbackNotes, + internal_notes: personalNotes, + }); + }); + + it('a snackbar with correct status is shown', async () => { + const origStoreState = store.state; + store.commit('channel/ADD_CHANNEL', channel); + + jest.useFakeTimers(); + const confirmButton = wrapper.findComponent({ ref: 'confirmButtonRef' }); + await confirmButton.trigger('click'); + + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + + expect(store.getters['snackbarOptions'].text).toBe('Submission approved'); + store.replaceState(origStoreState); + }); + + it('the channel latest submission status is updated in the store', async () => { + const origStoreState = store.state; + store.commit('channel/ADD_CHANNEL', channel); + + jest.useFakeTimers(); + const confirmButton = wrapper.findComponent({ ref: 'confirmButtonRef' }); + await confirmButton.trigger('click'); + + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + + expect( + store.getters['channel/getChannel'](channel.id) + .latest_community_library_submission_status, + ).toBe(CommunityLibraryStatus.APPROVED); + store.replaceState(origStoreState); + }); + }); + + describe('if the submission is flagged for review', () => { + const feedbackNotes = 'Some feedback notes'; + const personalNotes = 'Some personal notes'; + + beforeEach(async () => { + const flagForReviewRadio = wrapper.find( + `input[type="radio"][value="${CommunityLibraryStatus.REJECTED}"]`, + ); + await flagForReviewRadio.trigger('click'); + + const flagReasonComponent = wrapper.findComponent({ ref: 'flagReasonSelectRef' }); + await flagReasonComponent.vm.setValue({ + value: CommunityLibraryResolutionReason.INVALID_METADATA, + text: 'Invalid or missing metadata', + }); + + const feedbackNotesComponent = wrapper.findComponent({ ref: 'editorNotesRef' }); + await feedbackNotesComponent.vm.$emit('input', feedbackNotes); + + const personalNotesComponent = wrapper.findComponent({ ref: 'personalNotesRef' }); + await personalNotesComponent.vm.$emit('input', personalNotes); + }); + + it('the submission is correctly resolved', async () => { + jest.useFakeTimers(); + + const confirmButton = wrapper.findComponent({ ref: 'confirmButtonRef' }); + await confirmButton.trigger('click'); + + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + + expect(CommunityLibrarySubmission.resolveAsAdmin).toHaveBeenCalledWith(submission.id, { + status: CommunityLibraryStatus.REJECTED, + feedback_notes: feedbackNotes, + internal_notes: personalNotes, + resolution_reason: CommunityLibraryResolutionReason.INVALID_METADATA, + }); + }); + + it('a snackbar with correct status is shown', async () => { + const origStoreState = store.state; + store.commit('channel/ADD_CHANNEL', channel); + + jest.useFakeTimers(); + + const confirmButton = wrapper.findComponent({ ref: 'confirmButtonRef' }); + await confirmButton.trigger('click'); + + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + + expect(store.getters['snackbarOptions'].text).toBe('Submission flagged for review'); + store.replaceState(origStoreState); + }); + + it('the channel latest submission status is updated in the store', async () => { + const origStoreState = store.state; + store.commit('channel/ADD_CHANNEL', channel); + + jest.useFakeTimers(); + const confirmButton = wrapper.findComponent({ ref: 'confirmButtonRef' }); + await confirmButton.trigger('click'); + + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + + expect( + store.getters['channel/getChannel'](channel.id) + .latest_community_library_submission_status, + ).toBe(CommunityLibraryStatus.REJECTED); + store.replaceState(origStoreState); + }); + }); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelItem.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelItem.vue index 54251994e8..38ef557b19 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelItem.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelItem.vue @@ -201,6 +201,7 @@ @@ -210,6 +211,11 @@ flat /> + @@ -219,6 +225,7 @@ import { mapGetters, mapActions } from 'vuex'; import ClipboardChip from '../../components/ClipboardChip'; + import ReviewSubmissionSidePanel from '../../components/sidePanels/ReviewSubmissionSidePanel'; import CommunityLibraryStatusButton from '../../components/CommunityLibraryStatusButton.vue'; import { RouteNames } from '../../constants'; import ChannelActionsDropdown from './ChannelActionsDropdown'; @@ -233,6 +240,7 @@ ClipboardChip, Checkbox, CommunityLibraryStatusButton, + ReviewSubmissionSidePanel, }, mixins: [fileSizeMixin], props: { @@ -245,6 +253,11 @@ required: true, }, }, + data() { + return { + showReviewSubmissionPanel: false, + }; + }, computed: { ...mapGetters('channel', ['getChannel']), selected: { diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelItem.spec.js b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelItem.spec.js index ea03f7c05f..ca876160c6 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelItem.spec.js +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelItem.spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import CommunityLibraryStatusButton from '../../../components/CommunityLibraryStatusButton.vue'; +import ReviewSubmissionSidePanel from '../../../components/sidePanels/ReviewSubmissionSidePanel'; import router from '../../../router'; import { factory } from '../../../store'; import { RouteNames } from '../../../constants'; @@ -176,4 +177,18 @@ describe('channelItem', () => { expect(statusButton.props('status')).toBe(CommunityLibraryStatus.REJECTED); }); }); + + it('Clicking on the status button opens the review submission side panel', async () => { + wrapper.setData({ testedChannel: submittedChannel }); + await wrapper.vm.$nextTick(); + + const statusCell = wrapper.find('[data-test="community-library-status"]'); + const statusButton = statusCell.findComponent(CommunityLibraryStatusButton); + + expect(wrapper.findComponent(ReviewSubmissionSidePanel).exists()).toBe(false); + + await statusButton.trigger('click'); + + expect(wrapper.findComponent(ReviewSubmissionSidePanel).exists()).toBe(true); + }); }); 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 index 13f47630bf..ccdf2c8072 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js @@ -4,18 +4,18 @@ 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 { 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/usePublishedData'); -jest.mock('../composables/useLatestCommunityLibrarySubmission'); +jest.mock('../composables/useVersionDetail'); jest.mock('../composables/useLicenseAudit'); +jest.mock('shared/composables/useLatestCommunityLibrarySubmission'); jest.mock('shared/data/resources', () => ({ CommunityLibrarySubmission: { create: jest.fn(() => Promise.resolve()), @@ -40,7 +40,7 @@ async function makeWrapper({ channel, publishedData, latestSubmission }) { store.state.currentChannel.currentChannelId = channel.id; store.commit('channel/ADD_CHANNEL', channel); - usePublishedData.mockReturnValue({ + useVersionDetail.mockReturnValue({ isLoading, isFinished, data: computed(() => publishedData), @@ -377,7 +377,7 @@ describe('SubmitToCommunityLibrarySidePanel', () => { latestSubmission: null, }); - const statusChip = wrapper.findAllComponents(StatusChip); + const statusChip = wrapper.findAllComponents(CommunityLibraryStatusChip); expect(statusChip.exists()).toBe(false); }); @@ -389,7 +389,7 @@ describe('SubmitToCommunityLibrarySidePanel', () => { latestSubmission: { channel_version: 1, status: submissionStatus }, }); - const statusChip = wrapper.findComponent(StatusChip); + const statusChip = wrapper.findComponent(CommunityLibraryStatusChip); expect(statusChip.props('status')).toBe(chipStatus); }); } diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLatestCommunityLibrarySubmission.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLatestCommunityLibrarySubmission.js deleted file mode 100644 index ee65710b7f..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLatestCommunityLibrarySubmission.js +++ /dev/null @@ -1,19 +0,0 @@ -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/useVersionDetail.js similarity index 55% rename from contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js rename to contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useVersionDetail.js index 9f35e56fa6..5d70248310 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useVersionDetail.js @@ -1,6 +1,6 @@ -import { useFetch } from '../../../../composables/useFetch'; +import { useFetch } from 'shared/composables/useFetch'; import { Channel } from 'shared/data/resources'; -export function usePublishedData(channelId) { +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 index 6f21f15bcb..70cdf52043 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -61,7 +61,7 @@ {{ infoText }} - {{ detectedLanguages }} @@ -130,8 +130,8 @@ class="metadata-line" > {{ detectedCategories }} @@ -245,14 +245,14 @@ import Box from './Box'; import LoadingText from './LoadingText'; - import StatusChip from './StatusChip'; - import { useLatestCommunityLibrarySubmission } from './composables/useLatestCommunityLibrarySubmission'; import { useLicenseAudit } from './composables/useLicenseAudit'; - import { usePublishedData } from './composables/usePublishedData'; + import { useVersionDetail } from './composables/useVersionDetail'; import InvalidLicensesNotice from './licenseCheck/InvalidLicensesNotice.vue'; import CompatibleLicensesNotice from './licenseCheck/CompatibleLicensesNotice.vue'; import SpecialPermissionsList from './licenseCheck/SpecialPermissionsList.vue'; + import CommunityLibraryStatusChip from 'shared/views/communityLibrary/CommunityLibraryStatusChip'; + import { useLatestCommunityLibrarySubmission } from 'shared/composables/useLatestCommunityLibrarySubmission'; import { translateMetadataString } from 'shared/utils/metadataStringsTranslation'; import countriesUtil from 'shared/utils/countries'; import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; @@ -269,7 +269,7 @@ SidePanelModal, Box, LoadingText, - StatusChip, + CommunityLibraryStatusChip, CountryField, InvalidLicensesNotice, CompatibleLicensesNotice, @@ -330,7 +330,7 @@ isFinished: latestSubmissionIsFinished, data: latestSubmissionData, fetchData: fetchLatestSubmission, - } = useLatestCommunityLibrarySubmission(props.channel.id); + } = useLatestCommunityLibrarySubmission({ channelId: props.channel.id }); function countryCodeToName(code) { return countriesUtil.getName(code, 'en'); @@ -416,11 +416,11 @@ }); const { - isLoading: publishedDataIsLoading, - isFinished: publishedDataIsFinished, + isLoading: versionDetailIsLoading, + isFinished: versionDetailIsFinished, data: versionDetail, - fetchData: fetchPublishedData, - } = usePublishedData(props.channel.id); + fetchData: fetchVersionDetail, + } = useVersionDetail(props.channel.id); // Use the latest version available from either channel or versionDetail const displayedVersion = computed(() => { @@ -453,7 +453,7 @@ !hasInvalidLicenses.value, licenseAuditIsFinished.value, canBeEdited.value, - publishedDataIsFinished.value, + versionDetailIsFinished.value, description.value.length >= 1, ]; @@ -467,7 +467,7 @@ // Watch for when publishing completes - fetch publishedData to get the new version's data watch(isPublishing, async (newIsPublishing, oldIsPublishing) => { if (oldIsPublishing === true && newIsPublishing === false) { - await fetchPublishedData(); + await fetchVersionDetail(); await checkAndTriggerLicenseAudit(); } }); @@ -476,7 +476,7 @@ await fetchLatestSubmission(); if (!isPublishing.value) { - await fetchPublishedData(); + await fetchVersionDetail(); await checkAndTriggerLicenseAudit(); } }); @@ -575,8 +575,8 @@ canBeEdited, displayedVersion, canBeSubmitted, - publishedDataIsLoading, - publishedDataIsFinished, + versionDetailIsLoading, + versionDetailIsFinished, detectedLanguages, detectedCategories, licenseAuditIsLoading, diff --git a/contentcuration/contentcuration/frontend/shared/composables/__tests__/useLatestCommunityLibrarySubmission.spec.js b/contentcuration/contentcuration/frontend/shared/composables/__tests__/useLatestCommunityLibrarySubmission.spec.js new file mode 100644 index 0000000000..2373d6412c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/composables/__tests__/useLatestCommunityLibrarySubmission.spec.js @@ -0,0 +1,40 @@ +import { useLatestCommunityLibrarySubmission } from '../useLatestCommunityLibrarySubmission'; +import { CommunityLibrarySubmission } from 'shared/data/resources'; + +const mockResponse = { + results: [], +}; + +jest.mock('shared/data/resources', () => { + return { + CommunityLibrarySubmission: { + fetchCollection: jest.fn(() => Promise.resolve(mockResponse)), + fetchCollectionAsAdmin: jest.fn(() => Promise.resolve(mockResponse)), + }, + }; +}); + +describe('useLatestCommunityLibrarySubmission', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('by default uses non-admin endpoint', async () => { + const { fetchData } = useLatestCommunityLibrarySubmission({ channelId: 'channel-id' }); + await fetchData(); + + expect(CommunityLibrarySubmission.fetchCollection).toHaveBeenCalled(); + expect(CommunityLibrarySubmission.fetchCollectionAsAdmin).not.toHaveBeenCalled(); + }); + + it('uses admin endpoint when initialized with admin=true', async () => { + const { fetchData } = useLatestCommunityLibrarySubmission({ + channelId: 'channel-id', + admin: true, + }); + await fetchData(); + + expect(CommunityLibrarySubmission.fetchCollection).not.toHaveBeenCalled(); + expect(CommunityLibrarySubmission.fetchCollectionAsAdmin).toHaveBeenCalled(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/useFetch.js b/contentcuration/contentcuration/frontend/shared/composables/useFetch.js similarity index 86% rename from contentcuration/contentcuration/frontend/channelEdit/composables/useFetch.js rename to contentcuration/contentcuration/frontend/shared/composables/useFetch.js index 7fef1922c8..2e3ccfa975 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/composables/useFetch.js +++ b/contentcuration/contentcuration/frontend/shared/composables/useFetch.js @@ -15,9 +15,9 @@ export function useFetch({ asyncFetchFunc }) { data.value = await asyncFetchFunc(); isLoading.value = false; isFinished.value = true; - } catch (error) { - error.value = error; - throw error; + } catch (caughtError) { + error.value = caughtError; + throw caughtError; } finally { isLoading.value = false; } diff --git a/contentcuration/contentcuration/frontend/shared/composables/useLatestCommunityLibrarySubmission.js b/contentcuration/contentcuration/frontend/shared/composables/useLatestCommunityLibrarySubmission.js new file mode 100644 index 0000000000..4a5ec70bdf --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/composables/useLatestCommunityLibrarySubmission.js @@ -0,0 +1,21 @@ +import { useFetch } from './useFetch'; +import { CommunityLibrarySubmission } from 'shared/data/resources'; + +export function useLatestCommunityLibrarySubmission({ channelId, admin = false }) { + const fetchSubmissionFunc = admin + ? params => CommunityLibrarySubmission.fetchCollectionAsAdmin(params) + : params => CommunityLibrarySubmission.fetchCollection(params); + + function fetchLatestSubmission() { + // Submissions are ordered by most recent first in the backend + return fetchSubmissionFunc({ 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/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index 431c12838a..0270613b60 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -336,3 +336,11 @@ export const CommunityLibraryStatus = { SUPERSEDED: 'SUPERSEDED', LIVE: 'LIVE', }; + +export const CommunityLibraryResolutionReason = { + INVALID_LICENSING: 'INVALID_LICENSING', + TECHNICAL_QUALITY_ASSURANCE: 'TECHNICAL_QUALITY_ASSURANCE', + INVALID_METADATA: 'INVALID_METADATA', + PORTABILITY_ISSUES: 'PORTABILITY_ISSUES', + OTHER: 'OTHER', +}; diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index e7a941fbda..be31f03939 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -2413,11 +2413,25 @@ export const CommunityLibrarySubmission = new APIResource({ return response.data || []; }); }, + fetchCollectionAsAdmin(params) { + return client + .get(window.Urls.adminCommunityLibrarySubmissionList(), { params }) + .then(response => { + return response.data || []; + }); + }, create(params) { return client.post(this.collectionUrl(), params).then(response => { return response.data; }); }, + resolveAsAdmin(id, params) { + return client + .post(window.Urls.adminCommunityLibrarySubmissionResolve(id), params) + .then(response => { + return response.data; + }); + }, }); export const AuditedSpecialPermissionsLicense = new APIResource({ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/StatusChip.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/CommunityLibraryStatusChip.vue similarity index 96% rename from contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/StatusChip.vue rename to contentcuration/contentcuration/frontend/shared/views/communityLibrary/CommunityLibraryStatusChip.vue index 8e2b8c4d37..2102f07ab7 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/StatusChip.vue +++ b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/CommunityLibraryStatusChip.vue @@ -20,7 +20,7 @@ import { CommunityLibraryStatus } from 'shared/constants'; export default { - name: 'StatusChip', + name: 'CommunityLibraryStatusChip', setup(props) { const theme = themePalette(); @@ -79,9 +79,10 @@