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/ChannelItem.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelItem.vue index 8aa1300d1e..37b46cd1bc 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelItem.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelItem.vue @@ -201,7 +201,7 @@ @@ -211,12 +211,6 @@ flat /> - @@ -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 01aa769d29..ec064fccd9 100644 --- a/contentcuration/contentcuration/frontend/channelList/router.js +++ b/contentcuration/contentcuration/frontend/channelList/router.js @@ -5,6 +5,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'; import { ChannelListTypes } from 'shared/constants'; @@ -82,6 +83,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 f7c0bd578c..e83c3ded1d 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js @@ -85,6 +85,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', @@ -93,6 +98,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', @@ -202,21 +211,35 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin 'Snackbar message shown when submission fails from the "Submit to Community Library" panel', }, countryLabel: { - message: 'Country', + message: 'Country(s)', context: 'Label for the country selection field in the "Submit to Community Library" panel', }, - languagesDetected: { - message: 'Language(s) detected', + languagesLabel: { + message: 'Language(s)', context: 'Label for detected languages in the "Submit to Community Library" panel', }, - licensesDetected: { - message: 'License(s) detected', + licensesLabel: { + message: 'License(s)', 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', @@ -360,6 +383,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', @@ -368,6 +399,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', @@ -381,4 +416,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/StudioImmersiveModal.vue b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue index 90d4e4602c..85d4030eb8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue @@ -73,8 +73,19 @@ offline: state => !state.connection.online, }), }, + watch: { + value: { + handler(newValue) { + if (newValue) { + document.documentElement.classList.add('modal-open'); + } else { + document.documentElement.classList.remove('modal-open'); + } + }, + immediate: true, + }, + }, mounted() { - document.documentElement.classList.add('modal-open'); const handleKeyDown = event => { if (event.key === 'Escape') { this.$emit('input', false); 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..ba39c7285d --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/index.vue @@ -0,0 +1,337 @@ + + + + + + + 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/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 82ff6e0c34..d0fc0d0a2f 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -1059,6 +1059,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..2e91e08bf5 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 @@ -15,6 +17,7 @@ ) 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 +281,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,