diff --git a/contentcuration/contentcuration/frontend/administration/components/sidePanels/ReviewSubmissionSidePanel.vue b/contentcuration/contentcuration/frontend/administration/components/sidePanels/ReviewSubmissionSidePanel.vue index f661a9bb63..34d5ff788f 100644 --- a/contentcuration/contentcuration/frontend/administration/components/sidePanels/ReviewSubmissionSidePanel.vue +++ b/contentcuration/contentcuration/frontend/administration/components/sidePanels/ReviewSubmissionSidePanel.vue @@ -1,6 +1,7 @@ - Language(s) + Language(s) - Categories + Categories - License(s) + License(s) - Status + Status
-
-

Submission notes

+
+

+ Submission notes +

{ clearTimeout(timer); + // Do not emit close just yet, so that the component isn't unmounted + // this will keep the component live until the submit function finishes, allowing + // to keep communicating with the parent component, and in particular allowing the + // "change" event to be emitted. It also allows us to keep the working information + // on the component, and show the side panel in the same state if the user cancels + isModalVisible.value = true; + currentlySubmitting.value = false; showSnackbar({ text: 'Action cancelled', }); }, }); - - emit('close'); + isModalVisible.value = false; } const chipColor = computed(() => paletteTheme.grey.v_200); @@ -519,6 +560,7 @@ return { isLoading, + isModalVisible, chipColor, chipTextColor, annotationColor, @@ -596,8 +638,6 @@ height: 20px; padding: 2px 5px; font-size: 10px; - color: v-bind('chipTextColor'); - background-color: v-bind('chipColor'); border-radius: 16px; } @@ -612,7 +652,6 @@ .detail-annotation { grid-column-start: 1; - color: v-bind('annotationColor'); } .box { @@ -620,7 +659,6 @@ flex-direction: column; gap: 8px; padding: 12px; - background-color: v-bind('boxBackgroundColor'); border-radius: 8px; } @@ -634,7 +672,6 @@ .box-title { font-size: 12px; font-weight: 600; - color: v-bind('boxTitleColor'); } .details-box-title { diff --git a/contentcuration/contentcuration/frontend/administration/components/sidePanels/__tests__/ReviewSubmissionSidePanel.spec.js b/contentcuration/contentcuration/frontend/administration/components/sidePanels/__tests__/ReviewSubmissionSidePanel.spec.js index bce8d51c37..e41f1095a4 100644 --- a/contentcuration/contentcuration/frontend/administration/components/sidePanels/__tests__/ReviewSubmissionSidePanel.spec.js +++ b/contentcuration/contentcuration/frontend/administration/components/sidePanels/__tests__/ReviewSubmissionSidePanel.spec.js @@ -344,15 +344,6 @@ describe('ReviewSubmissionSidePanel', () => { 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' }); @@ -403,6 +394,22 @@ describe('ReviewSubmissionSidePanel', () => { store.replaceState(origStoreState); }); + it('the panel closes', 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(); + + store.replaceState(origStoreState); + + expect(wrapper.emitted('close')).toBeTruthy(); + }); + it('the channel latest submission status is updated in the store', async () => { const origStoreState = store.state; store.commit('channel/ADD_CHANNEL', channel); diff --git a/contentcuration/contentcuration/frontend/administration/constants.js b/contentcuration/contentcuration/frontend/administration/constants.js index c0af1bb794..5efcebe5d5 100644 --- a/contentcuration/contentcuration/frontend/administration/constants.js +++ b/contentcuration/contentcuration/frontend/administration/constants.js @@ -3,6 +3,7 @@ export const RouteNames = { CHANNEL: 'CHANNEL', USERS: 'USERS', USER: 'USER', + COMMUNITY_LIBRARY_SUBMISSION: 'COMMUNITY_LIBRARY_SUBMISSION', }; export const rowsPerPageItems = [25, 50, 75, 100]; diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue index e2f3407d5e..296c6025cd 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue @@ -22,16 +22,13 @@ @@ -226,7 +220,6 @@ 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'; @@ -241,7 +234,6 @@ ClipboardChip, Checkbox, CommunityLibraryStatusButton, - ReviewSubmissionSidePanel, }, mixins: [fileSizeMixin], props: { @@ -254,11 +246,6 @@ required: true, }, }, - data() { - return { - submissionToReview: null, - }; - }, computed: { ...mapGetters('channel', ['getChannel']), selected: { @@ -320,6 +307,15 @@ this.$store.dispatch('showSnackbarSimple', 'Source URL saved'); }); }, + onCommunityLibraryButtonClick() { + this.$router.push({ + name: RouteNames.COMMUNITY_LIBRARY_SUBMISSION, + params: { + channelId: this.channelId, + submissionId: this.channel.latest_community_library_submission_id.toString(), + }, + }); + }, }, }; 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 ca876160c6..e8a92e8a3f 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelItem.spec.js +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelItem.spec.js @@ -1,6 +1,5 @@ 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'; @@ -185,10 +184,11 @@ describe('channelItem', () => { 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'); + await wrapper.vm.$nextTick(); - expect(wrapper.findComponent(ReviewSubmissionSidePanel).exists()).toBe(true); + // assert that page is redirected to PageNames.COMMUNITY_LIBRARY_SUBMISSION + expect(wrapper.vm.$route.name).toEqual(RouteNames.COMMUNITY_LIBRARY_SUBMISSION); + expect(wrapper.vm.$route.params.submissionId).toEqual(submissionId); }); }); diff --git a/contentcuration/contentcuration/frontend/administration/router.js b/contentcuration/contentcuration/frontend/administration/router.js index 207445f11d..d5de3506f2 100644 --- a/contentcuration/contentcuration/frontend/administration/router.js +++ b/contentcuration/contentcuration/frontend/administration/router.js @@ -4,6 +4,7 @@ import ChannelTable from './pages/Channels/ChannelTable'; import ChannelDetails from './pages/Channels/ChannelDetails'; import UserTable from './pages/Users/UserTable'; import UserDetails from './pages/Users/UserDetails'; +import SubmissionDetailsModal from 'shared/views/communityLibrary/SubmissionDetailsModal/index.vue'; const router = new VueRouter({ routes: [ @@ -29,6 +30,16 @@ const router = new VueRouter({ props: true, component: UserDetails, }, + { + name: RouteNames.COMMUNITY_LIBRARY_SUBMISSION, + path: '/community-library/:channelId/:submissionId', + component: SubmissionDetailsModal, + props: route => ({ + channelId: route.params.channelId, + submissionId: route.params.submissionId, + adminReview: true, + }), + }, // Catch-all redirect to channels tab { path: '*', diff --git a/contentcuration/contentcuration/frontend/channelList/constants.js b/contentcuration/contentcuration/frontend/channelList/constants.js index e2caafa367..176bd4fc0e 100644 --- a/contentcuration/contentcuration/frontend/channelList/constants.js +++ b/contentcuration/contentcuration/frontend/channelList/constants.js @@ -26,6 +26,7 @@ export const RouteNames = { CATALOG_DETAILS: 'CATALOG_DETAILS', CATALOG_FAQ: 'CATALOG_FAQ', NEW_CHANNEL: 'NEW_CHANNEL', + COMMUNITY_LIBRARY_SUBMISSION: 'COMMUNITY_LIBRARY_SUBMISSION', }; export const ListTypeToRouteMapping = { diff --git a/contentcuration/contentcuration/frontend/channelList/router.js b/contentcuration/contentcuration/frontend/channelList/router.js index 46c50e94e2..e6bda8aed2 100644 --- a/contentcuration/contentcuration/frontend/channelList/router.js +++ b/contentcuration/contentcuration/frontend/channelList/router.js @@ -7,6 +7,7 @@ import ChannelSetModal from './views/ChannelSet/ChannelSetModal'; import CatalogList from './views/Channel/CatalogList'; import { RouteNames } from './constants'; import CatalogFAQ from './views/Channel/CatalogFAQ'; +import SubmissionDetailsModal from 'shared/views/communityLibrary/SubmissionDetailsModal/index.vue'; import ChannelModal from 'shared/views/channel/ChannelModal'; import ChannelDetailsModal from 'shared/views/channel/ChannelDetailsModal'; @@ -79,6 +80,12 @@ const router = new VueRouter({ path: '/faq', component: CatalogFAQ, }, + { + name: RouteNames.COMMUNITY_LIBRARY_SUBMISSION, + path: '/community-library/:channelId/:submissionId', + component: SubmissionDetailsModal, + props: true, + }, // Catch-all for unrecognized URLs { path: '*', diff --git a/contentcuration/contentcuration/frontend/shared/composables/useSpecialPermissions.js b/contentcuration/contentcuration/frontend/shared/composables/useSpecialPermissions.js index 306ce4d83c..ed852ea349 100644 --- a/contentcuration/contentcuration/frontend/shared/composables/useSpecialPermissions.js +++ b/contentcuration/contentcuration/frontend/shared/composables/useSpecialPermissions.js @@ -23,7 +23,7 @@ const ITEMS_PER_PAGE = 3; * Reactive state for the fetched, flattened permissions and pagination * helpers used by `SpecialPermissionsList.vue`. */ -export function useSpecialPermissions(channelVersionId) { +export function useSpecialPermissions(channelVersionId, { distributable } = {}) { const permissions = ref([]); const isLoading = ref(false); const error = ref(null); @@ -46,10 +46,13 @@ export function useSpecialPermissions(channelVersionId) { try { if (versionId) { - const response = await AuditedSpecialPermissionsLicense.fetchCollection({ + const filters = { channel_version: versionId, - distributable: false, - }); + }; + if (distributable != null) { + filters.distributable = distributable; + } + const response = await AuditedSpecialPermissionsLicense.fetchCollection(filters); permissions.value = response; } diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 652572a3bc..2a458cd2a3 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -2415,6 +2415,10 @@ export const CommunityLibrarySubmission = new APIResource({ return response.data || []; }); }, + async fetchModel(id) { + const response = await client.get(this.modelUrl(id)); + return response.data; + }, create(params) { return client.post(this.collectionUrl(), params).then(response => { return response.data; diff --git a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js index 14f9d7518a..299ddd3d1e 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js @@ -10,6 +10,14 @@ export const commonStrings = createTranslator('CommonStrings', { message: 'Clear', context: 'A label for an action that clears a selection or input field', }, + seeAllAction: { + message: 'See all', + context: 'A label for an action that shows all items in a list or collection', + }, + seeLessAction: { + message: 'See less', + context: 'A label for an action that shows fewer items in a list or collection', + }, closeAction: { message: 'Close', context: 'A label for an action that closes a dialog or window', @@ -18,4 +26,8 @@ export const commonStrings = createTranslator('CommonStrings', { message: 'Sorry! Something went wrong, please try again.', context: 'Default error message for operation errors.', }, + channelDetailsLabel: { + message: 'Channel Details', + context: 'Label for a section that displays details about a channel', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js index 8c1c9186bc..f3563bb186 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js @@ -35,7 +35,7 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin context: 'Label for the version description text area', }, modeLiveDescription: { - message: 'This edition will be accessible to the public through the Kolibri public library.', + message: 'This edition will be accessible in Kolibri through a channel token.', context: 'Description for the live publishing mode', }, modeDraftDescription: { @@ -118,6 +118,11 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin message: 'Submitted', context: 'Status indicating that an Community Library submission is pending', }, + supersededStatus: { + message: 'Superseded', + context: + 'Status indicating that an Community Library submission is superseded by a newer submission', + }, approvedStatus: { message: 'Approved', context: 'Status indicating that an Community Library submission is approved', @@ -126,6 +131,10 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin message: 'Flagged', context: 'Status indicating that an Community Library submission is rejected', }, + liveStatus: { + message: 'Live', + context: 'Status indicating that an Community Library submission is live', + }, // Submit to Community Library panel strings submitToCommunityLibrary: { message: 'Submit to Community Library', @@ -235,21 +244,35 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin 'Snackbar message shown when submission fails from the "Submit to Community Library" panel', }, countryLabel: { - message: 'Country', + message: 'Countries', context: 'Label for the country selection field in the "Submit to Community Library" panel', }, - languagesDetected: { - message: 'Language(s) detected', + languagesLabel: { + message: 'Languages', context: 'Label for detected languages in the "Submit to Community Library" panel', }, - licensesDetected: { - message: 'License(s) detected', + licensesLabel: { + message: 'Licenses', context: 'Label for detected licenses in the "Submit to Community Library" panel', }, - categoriesDetected: { + categoriesLabel: { message: 'Categories', context: 'Label for detected categories in the "Submit to Community Library" panel', }, + submissionNotesLabel: { + message: 'Submission notes', + context: 'Label for the notes the editor can add to their submission to the Community Library', + }, + feedbackNotesLabel: { + message: 'Feedback notes', + context: + 'Label for the feedback notes that reviewers can add to a submission in the Community Library ', + }, + internalNotesLabel: { + message: 'Internal notes', + context: + 'Label for the notes that admins can add to a submission in the Community Library for themselves', + }, confirmReplacementText: { message: 'I understand this will replace my earlier submission on the review queue', context: 'Checkbox text shown when there is a pending submission to confirm replacement', @@ -287,7 +310,7 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin context: 'Message shown after listing compatible licenses when license check passes', }, incompatibleLicensesDetected: { - message: 'Incompatible license(s) detected', + message: 'Incompatible licenses detected', context: 'Title shown when invalid licenses are detected in the channel', }, channelCannotBeDistributed: { @@ -310,7 +333,7 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin 'Description shown when all licenses are compatible, includes the license names and confirmation message', }, specialPermissionsDetected: { - message: 'Special Permissions license(s) detected', + message: 'Special Permissions licenses detected', context: 'Title shown when special permissions licenses are detected in the channel', }, confirmDistributionRights: { @@ -393,6 +416,14 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin message: '{author} ({userType}) flagged {channelVersion}', context: 'Notification message shown when a user flags a channel version for review', }, + submissionNotification: { + message: '{author} ({userType}) submitted {channelVersion}', + context: 'Notification message shown when a user submits a channel version for review', + }, + approvedNotification: { + message: '{author} ({userType}) approved {channelVersion}', + context: 'Notification message shown when a user approves a channel version', + }, showOlderAction: { message: 'Show older', context: 'Action button to load older items in a list', @@ -401,6 +432,10 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin message: 'Admin', context: 'Label indicating administrative status', }, + editorLabel: { + message: 'Editor', + context: 'Label indicating editor status', + }, emptyNotificationsNotice: { message: 'You have no notifications at this time.', context: 'Notice shown when there are no notifications to display', @@ -414,4 +449,51 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin context: 'Notice for screen readers on the new notifications badge to indicate that new notifications have arrived', }, + communityLibrarySubmissionLabel: { + message: 'Community Library submission', + context: 'Label for notifications related to Community Library submissions', + }, + channelVersionTokenLabel: { + message: 'Channel version token', + context: 'Label for the channel version token included in submission details page', + }, + liveVersionLabel: { + message: 'Live version:', + context: 'Label indicating the live version of a channel', + }, + activityHistoryLabel: { + message: 'Activity history', + context: 'Label for the activity history section in the submission details page', + }, + + // Resolution reasons strings + reasonLabel: { + message: 'Reason: {reason}', + context: 'Label for the reason provided for a given action (e.g., rejection reason)', + }, + invalidLicensingReason: { + message: 'Invalid or non-compliant licenses', + context: 'Rejection reason indicating that the channel has invalid or non-compliant licenses', + }, + qualityAssuranceReason: { + message: 'Quality assurance issues', + context: 'Rejection reason indicating that the channel has quality assurance issues', + }, + invalidMetadataReason: { + message: 'Invalid or missing metadata', + context: 'Rejection reason indicating that the channel has invalid or missing metadata', + }, + portabilityIssuesReason: { + message: 'Portability problems', + context: 'Rejection reason indicating that the channel has portability problems', + }, + otherIssuesReason: { + message: 'Other issues', + context: + 'Rejection reason indicating that the channel has other issues not covered by other reasons', + }, + reviewAction: { + message: 'Review', + context: 'Action button to review a channel version submission to the Community Library', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/utils/communityLibrary.js b/contentcuration/contentcuration/frontend/shared/utils/communityLibrary.js index 4449c3111b..f07d658a67 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/communityLibrary.js +++ b/contentcuration/contentcuration/frontend/shared/utils/communityLibrary.js @@ -1,4 +1,5 @@ -import { CommunityLibraryStatus } from 'shared/constants'; +import { CommunityLibraryResolutionReason, CommunityLibraryStatus } from 'shared/constants'; +import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; export const getUiSubmissionStatus = status => { // We do not need to distinguish LIVE from APPROVED in many parts of the UI @@ -9,3 +10,23 @@ export const getUiSubmissionStatus = status => { }; return uiStatusMap[status] || status; }; + +export const getResolutionReasonLabel = reason => { + const { + invalidLicensingReason$, + qualityAssuranceReason$, + invalidMetadataReason$, + portabilityIssuesReason$, + otherIssuesReason$, + } = communityChannelsStrings; + + const reasonLabelMap = { + [CommunityLibraryResolutionReason.INVALID_LICENSING]: invalidLicensingReason$(), + [CommunityLibraryResolutionReason.TECHNICAL_QUALITY_ASSURANCE]: qualityAssuranceReason$(), + [CommunityLibraryResolutionReason.INVALID_METADATA]: invalidMetadataReason$(), + [CommunityLibraryResolutionReason.PORTABILITY_ISSUES]: portabilityIssuesReason$(), + [CommunityLibraryResolutionReason.OTHER]: otherIssuesReason$(), + }; + + return reasonLabelMap[reason] || reason; +}; diff --git a/contentcuration/contentcuration/frontend/shared/utils/helpers.js b/contentcuration/contentcuration/frontend/shared/utils/helpers.js index f5fa5a16d8..2545b91c85 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/helpers.js +++ b/contentcuration/contentcuration/frontend/shared/utils/helpers.js @@ -656,3 +656,7 @@ export function getMergedMapFields(node, contentNodeData) { } return mergedMapFields; } + +export function getCommunityLibrarySubmissionDetailsUrl(channelId, submissionId) { + return `/channels/#/community-library/${channelId}/${submissionId}`; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue index 9323c3210b..618a575147 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/NotificationList.vue @@ -29,6 +29,7 @@ v-for="notification in notifications" :key="`${notification.id}-${notification.type}`" :notification="notification" + @viewMore="goToSubmissionDetails(notification)" />
{ + const channelId = notification.channel_id; + const submissionId = notification.id; + const url = getCommunityLibrarySubmissionDetailsUrl(channelId, submissionId); + window.location.href = url; + }; + const { newLabel$, clearAllAction$, diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js index 7bc563cb7a..432e429638 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/composables/useCommunityLibraryUpdates.js @@ -78,17 +78,22 @@ export default function useCommunityLibraryUpdates({ queryParams } = {}) { date_updated: submission.date_updated && new Date(submission.date_updated), }; + // If the status is not PENDING or SUPERSEDED, it means there is also a status update + const hasUpdate = ![ + CommunityLibraryStatus.PENDING, + CommunityLibraryStatus.SUPERSEDED, + ].includes(sub.status); + // Always add creation update updates.push({ ...sub, + // If it has updates, it means the current status is not the initial creation status + status: hasUpdate ? CommunityLibraryStatus.PENDING : sub.status, type: NotificationType.COMMUNITY_LIBRARY_SUBMISSION_CREATED, date: sub.date_created, }); - // If the status is not PENDING or SUPERSEDED, it means there is also a status update - if ( - ![CommunityLibraryStatus.PENDING, CommunityLibraryStatus.SUPERSEDED].includes(sub.status) - ) { + if (hasUpdate) { updates.push({ ...sub, type: statusToNotificationType[sub.status], @@ -150,6 +155,7 @@ export default function useCommunityLibraryUpdates({ queryParams } = {}) { date_updated__gte: getNewerDate(_params?.date_updated__gte, _params?.lastRead), status__in: _params?.status__in, search: _params?.keywords, + channel: _params?.channel, max_results: MAX_RESULTS_PER_PAGE, }); } diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue index 2faabb905e..291141a8ce 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/index.vue @@ -95,6 +95,8 @@ ALL: 1, }; + const NOTIFICATIONS_TAB_QUERY_PARAM = 'notificationsTab'; + const router = useRouter(); const route = useRoute(); const store = useStore(); @@ -133,7 +135,23 @@ { immediate: true }, ); - const selectedTab = ref(NotificationsTab.UNREAD); + const selectedTab = computed({ + get() { + const tabParam = Number(route.query[NOTIFICATIONS_TAB_QUERY_PARAM]); + if (tabParam === NotificationsTab.UNREAD || tabParam === NotificationsTab.ALL) { + return tabParam; + } + return NotificationsTab.UNREAD; + }, + set(value) { + router.replace({ + query: { + ...route.query, + [NOTIFICATIONS_TAB_QUERY_PARAM]: String(value), + }, + }); + }, + }); const filters = ref(null); const { notificationsLabel$, unreadNotificationsLabel$, allNotificationsLabel$ } = diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue index 845560f67d..ef44fdc57d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionApproval.vue @@ -23,6 +23,7 @@ diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue index 88499564b9..2b1d82488d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionCreation.vue @@ -17,6 +17,7 @@ diff --git a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue index 8e58fc6f64..c61c71bf1e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue +++ b/contentcuration/contentcuration/frontend/shared/views/NotificationsModal/notificationTypes/CommunityLibrarySubmissionRejection.vue @@ -23,6 +23,7 @@ diff --git a/contentcuration/contentcuration/frontend/shared/views/SidePanelModal/index.vue b/contentcuration/contentcuration/frontend/shared/views/SidePanelModal/index.vue index 30b695e5d0..5fa53d6f0e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/SidePanelModal/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/SidePanelModal/index.vue @@ -158,7 +158,6 @@ headerStyles() { return { backgroundColor: this.immersive ? this.$themeTokens.appBar : this.$themeTokens.surface, - borderBottom: `1px solid ${this.$themePalette.grey.v_400}`, }; }, sidePanelStyles() { diff --git a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/CommunityLibraryStatusChip.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/CommunityLibraryStatusChip.vue index e04ec58d2a..a11b3bd51c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/CommunityLibraryStatusChip.vue +++ b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/CommunityLibraryStatusChip.vue @@ -26,8 +26,10 @@ required: true, validator: value => [ + CommunityLibraryStatus.LIVE, CommunityLibraryStatus.APPROVED, CommunityLibraryStatus.PENDING, + CommunityLibraryStatus.SUPERSEDED, CommunityLibraryStatus.REJECTED, ].includes(value), }, @@ -35,9 +37,17 @@ const theme = themePalette(); - const { pendingStatus$, approvedStatus$, flaggedStatus$ } = communityChannelsStrings; + const { pendingStatus$, supersededStatus$, approvedStatus$, flaggedStatus$, liveStatus$ } = + communityChannelsStrings; const configChoices = { + [CommunityLibraryStatus.SUPERSEDED]: { + text: supersededStatus$(), + backgroundColor: theme.yellow.v_100, + labelColor: theme.orange.v_600, + borderColor: theme.orange.v_400, + icon: 'timer', + }, [CommunityLibraryStatus.PENDING]: { text: pendingStatus$(), backgroundColor: theme.yellow.v_100, @@ -52,6 +62,13 @@ borderColor: theme.green.v_400, icon: 'circleCheckmark', }, + [CommunityLibraryStatus.LIVE]: { + text: liveStatus$(), + backgroundColor: theme.green.v_100, + labelColor: theme.green.v_600, + borderColor: theme.green.v_400, + icon: 'circleCheckmark', + }, [CommunityLibraryStatus.REJECTED]: { text: flaggedStatus$(), backgroundColor: theme.red.v_100, diff --git a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SpecialPermissionsList.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SpecialPermissionsList.vue index 5a449d1015..eb4f59d9c0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SpecialPermissionsList.vue +++ b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SpecialPermissionsList.vue @@ -13,7 +13,10 @@
{{ specialPermissionsDetected$() }}
-
+
{{ confirmDistributionRights$() }} @@ -23,9 +26,10 @@ @@ -89,9 +93,21 @@ totalPages, nextPage, previousPage, - } = useSpecialPermissions(props.channelVersionId); + } = useSpecialPermissions(props.channelVersionId, { + // only load non-distributable permissions for editable special permission lists + // as those are the ones that the user should check + distributable: props.readOnly ? null : false, + }); + + function isPermissionChecked(permission) { + if (props.readOnly) { + return permission.distributable; + } + return props.value.includes(permission.id); + } function togglePermission(permissionId) { + if (props.disabled || props.readOnly) return; const currentChecked = [...props.value]; const index = currentChecked.indexOf(permissionId); if (index === -1) { @@ -104,7 +120,7 @@ const allChecked = computed(() => { if (isLoading.value) return false; - return permissions.value.every(p => props.value.includes(p.id)); + return permissions.value.every(p => isPermissionChecked(p)); }); watch( @@ -123,6 +139,7 @@ togglePermission, nextPage, previousPage, + isPermissionChecked, specialPermissionsDetected$, confirmDistributionRights$, previousPageAction$, @@ -146,6 +163,11 @@ required: false, default: false, }, + readOnly: { + type: Boolean, + required: false, + default: false, + }, }, emits: ['input', 'update:allChecked'], }; diff --git a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/ActivityHistory.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/ActivityHistory.vue new file mode 100644 index 0000000000..a2807af929 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/ActivityHistory.vue @@ -0,0 +1,330 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/ChannelDetails.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/ChannelDetails.vue new file mode 100644 index 0000000000..43ae950b9a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/ChannelDetails.vue @@ -0,0 +1,282 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/ExpandableContainer.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/ExpandableContainer.vue new file mode 100644 index 0000000000..4701fdc244 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/ExpandableContainer.vue @@ -0,0 +1,90 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/index.vue b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/index.vue new file mode 100644 index 0000000000..72025bd130 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/index.vue @@ -0,0 +1,348 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue index e8542cad76..79cc4921bd 100644 --- a/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue +++ b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue @@ -4,32 +4,33 @@ :class="{ printing }" data-testid="details-panel" > - - -
-

- {{ _details.name }} -

-

- {{ _details.description }} -

-
- + +
+

+ {{ _details.name }} +

+

+ {{ _details.description }} +

+
+ -
+ - + - + - + @@ -251,7 +274,10 @@ - + @@ -455,6 +486,10 @@ type: Boolean, default: true, }, + hideChannelHeader: { + type: Boolean, + default: false, + }, }, computed: { _details() { @@ -502,6 +537,9 @@ return orderBy(this._details.tags, ['count'], ['desc']); }, includesPrintable() { + if (!this._details.includes) { + return this.defaultText; + } const includes = []; if (this._details.includes.coach_content) { includes.push(this.$tr('coachHeading')); @@ -514,12 +552,15 @@ return includes.length ? includes.join(', ') : this.defaultText; }, licensesPrintable() { - return this._details.licenses.map(this.translateConstant).join(', '); + return this._details.licenses?.map(this.translateConstant).join(', ') || this.defaultText; }, tagPrintable() { return this.sortedTags.map(tag => tag.tag_name).join(', '); }, levels() { + if (!this._details.levels) { + return null; + } return this._details.levels.map(level => { level = LevelsLookup[level]; let translationKey; @@ -536,16 +577,19 @@ }); }, levelsPrintable() { - return this.levels.join(', '); + return this.levels?.join(', ') || this.defaultText; }, categories() { + if (!this._details.categories) { + return null; + } return this._details.categories.map(category => { category = CategoriesLookup[category]; return this.translateMetadataString(camelCase(category)); }); }, categoriesPrintable() { - return this.categories.join(', '); + return this.categories?.join(', ') || this.defaultText; }, }, methods: { @@ -660,4 +704,8 @@ word-break: break-word; } + .sample-wrapper { + grid-column: 1 / -1; + } + diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 9a3a76c2e2..5030aa229a 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -564,12 +564,6 @@ def test_process_added_to_community_library_change(self, mock_export_func): channel.editors.add(editor_user) channel.save() - channel_version = ChannelVersion.objects.get(channel=channel, version=2) - special_license = AuditedSpecialPermissionsLicense.objects.create( - description="Community library special permissions" - ) - channel_version.special_permissions_included.add(special_license) - current_live_submission = CommunityLibrarySubmission.objects.create( channel=channel, channel_version=1, @@ -646,44 +640,6 @@ def test_process_added_to_community_library_change(self, mock_export_func): new_submission.status, community_library_submission.STATUS_LIVE, ) - special_license.refresh_from_db() - self.assertTrue(special_license.distributable) - - def test_publish_public_channel_marks_special_permissions_distributable(self): - user = testdata.user() - channel = testdata.channel() - channel.public = True - channel.editors.add(user) - channel.save() - - special_permissions_license = models.License.objects.get( - license_name="Special Permissions" - ) - special_license_description = "Public channel special permissions" - contentnode = ( - channel.main_tree.get_descendants() - .exclude(kind_id=content_kinds.TOPIC) - .first() - ) - contentnode.license = special_permissions_license - contentnode.license_description = special_license_description - contentnode.copyright_holder = "Public channel" - contentnode.save() - - self.client.force_authenticate(user) - response = self.sync_changes([generate_publish_channel_event(channel.id)]) - - self.assertEqual(response.status_code, 200, response.content) - channel.refresh_from_db() - audited_license = AuditedSpecialPermissionsLicense.objects.get( - description=special_license_description - ) - self.assertTrue(audited_license.distributable) - self.assertTrue( - channel.version_info.special_permissions_included.filter( - id=audited_license.id - ).exists() - ) class CRUDTestCase(StudioAPITestCase): diff --git a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py index b7445f8951..5dfe4ae0be 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py +++ b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py @@ -7,6 +7,7 @@ from contentcuration.constants import ( community_library_submission as community_library_submission_constants, ) +from contentcuration.models import AuditedSpecialPermissionsLicense from contentcuration.models import Change from contentcuration.models import Channel from contentcuration.models import ChannelVersion @@ -508,6 +509,12 @@ def setUp(self): self.submission.channel_version = 2 self.submission.save() + # Create the ChannelVersion for this submission (needed when approving) + self.channel_version = ChannelVersion.objects.create( + channel=self.submission.channel, + version=self.submission.channel_version, + ) + self.editor_user = self.submission.channel.editors.first() self.superseded_submission = CommunityLibrarySubmission.objects.create( @@ -820,7 +827,10 @@ def test_resolve_submission__not_pending(self): ) self.assertEqual(response.status_code, 400, response.content) - def test_resolve_submission__overrite_categories(self): + @mock.patch( + "contentcuration.viewsets.community_library_submission.apply_channel_changes_task" + ) + def test_resolve_submission__overrite_categories(self, apply_task_mock): self.client.force_authenticate(user=self.admin_user) categories = ["Category 1"] self.resolve_approve_metadata["categories"] = categories @@ -840,7 +850,10 @@ def test_resolve_submission__overrite_categories(self): ) self.assertListEqual(resolved_submission.categories, categories) - def test_resolve_submission__accept_mark_superseded(self): + @mock.patch( + "contentcuration.viewsets.community_library_submission.apply_channel_changes_task" + ) + def test_resolve_submission__accept_mark_superseded(self, apply_task_mock): self.client.force_authenticate(user=self.admin_user) response = self.client.post( @@ -896,6 +909,44 @@ def test_resolve_submission__reject_do_not_mark_superseded(self): community_library_submission_constants.STATUS_PENDING, ) + @mock.patch( + "contentcuration.viewsets.community_library_submission.apply_channel_changes_task" + ) + def test_resolve_submission__approve_marks_special_permissions_distributable( + self, apply_task_mock + ): + """Test that approving a submission marks special permissions as distributable.""" + self.client.force_authenticate(user=self.admin_user) + + # Create an audited special permissions license + special_license = AuditedSpecialPermissionsLicense.objects.create( + description="Community library special permissions" + ) + self.assertFalse(special_license.distributable) + + # Add the special permission to the channel version + self.channel_version.special_permissions_included.add(special_license) + + # Approve the submission + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + self.resolve_approve_metadata, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + + # Verify the special permission is marked as distributable + special_license.refresh_from_db() + self.assertTrue(special_license.distributable) + self.assertTrue( + self.channel_version.special_permissions_included.filter( + id=special_license.id + ).exists() + ) + class FilteringAndSearchTestCase(StudioAPITestCase): """ diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index ae0cb760c0..939a033508 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -834,14 +834,6 @@ def add_to_community_library( countries=countries, ) - published_version = ChannelVersion.objects.get( - channel_id=channel_id, - version=channel_version, - ) - models.AuditedSpecialPermissionsLicense.mark_channel_version_as_distributable( - published_version.id - ) - new_live_submission = CommunityLibrarySubmission.objects.get( channel_id=channel_id, channel_version=channel_version, @@ -1060,6 +1052,9 @@ class ChannelVersionViewSet(ReadOnlyValuesViewset): "id", "channel", "version", + "size", + "resource_count", + "kind_count", "date_published", "version_notes", "included_languages", diff --git a/contentcuration/contentcuration/viewsets/community_library_submission.py b/contentcuration/contentcuration/viewsets/community_library_submission.py index aef0d87f0a..22c549cc8f 100644 --- a/contentcuration/contentcuration/viewsets/community_library_submission.py +++ b/contentcuration/contentcuration/viewsets/community_library_submission.py @@ -1,3 +1,5 @@ +from django.db.models import OuterRef +from django.db.models import Subquery from django_filters import BaseInFilter from django_filters import ChoiceFilter from django_filters.rest_framework import DateTimeFilter @@ -13,8 +15,10 @@ from contentcuration.constants import ( community_library_submission as community_library_submission_constants, ) +from contentcuration.models import AuditedSpecialPermissionsLicense from contentcuration.models import Change from contentcuration.models import Channel +from contentcuration.models import ChannelVersion from contentcuration.models import CommunityLibrarySubmission from contentcuration.models import Country from contentcuration.tasks import apply_channel_changes_task @@ -278,9 +282,23 @@ class AdminCommunityLibrarySubmissionViewSet( ): permission_classes = [IsAdminUser] - values = CommunityLibrarySubmissionViewSetMixin.values + ("internal_notes",) + values = CommunityLibrarySubmissionViewSetMixin.values + ( + "internal_notes", + "version_token", + ) field_map = CommunityLibrarySubmissionViewSetMixin.field_map.copy() + def annotate_queryset(self, queryset): + queryset = super().annotate_queryset(queryset) + return queryset.annotate( + version_token=Subquery( + ChannelVersion.objects.filter( + channel_id=OuterRef("channel_id"), + version=OuterRef("channel_version"), + ).values("secret_token__token")[:1] + ) + ) + def _mark_previous_pending_submissions_as_superseded(self, submission): CommunityLibrarySubmission.objects.filter( status=community_library_submission_constants.STATUS_PENDING, @@ -326,5 +344,12 @@ def resolve(self, request, pk=None): if submission.status == community_library_submission_constants.STATUS_APPROVED: self._mark_previous_pending_submissions_as_superseded(submission) self._add_to_community_library(submission) + published_version = ChannelVersion.objects.get( + channel=submission.channel, + version=submission.channel_version, + ) + AuditedSpecialPermissionsLicense.mark_channel_version_as_distributable( + published_version.id + ) return Response(self.serialize_object())