diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue index 6486230c77..e47f52c4e9 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue @@ -17,6 +17,7 @@
@@ -63,6 +64,8 @@ switch (props.kind) { case 'warning': return paletteTheme.red.v_100; + case 'success': + return paletteTheme.green.v_100; case 'info': return paletteTheme.grey.v_100; default: @@ -73,6 +76,8 @@ switch (props.kind) { case 'warning': return paletteTheme.red.v_300; + case 'success': + return paletteTheme.green.v_300; case 'info': return 'transparent'; default: @@ -83,6 +88,8 @@ switch (props.kind) { case 'warning': return 'error'; + case 'success': + return 'circleCheckmark'; case 'info': return 'infoOutline'; default: @@ -91,19 +98,28 @@ }); const titleColor = computed(() => { - return props.kind === 'warning' ? paletteTheme.red.v_600 : tokensTheme.text; + if (props.kind === 'warning') return paletteTheme.red.v_600; + if (props.kind === 'success') return paletteTheme.green.v_600; + return tokensTheme.text; }); const descriptionColor = computed(() => { return props.kind === 'warning' ? paletteTheme.grey.v_800 : tokensTheme.text; }); + const iconColor = computed(() => { + if (props.kind === 'warning') return paletteTheme.red.v_600; + if (props.kind === 'success') return paletteTheme.green.v_600; + return tokensTheme.text; + }); + return { boxBackgroundColor, boxBorderColor, titleColor, descriptionColor, icon, + iconColor, }; }, props: { @@ -111,7 +127,7 @@ type: String, required: false, default: 'info', - validator: value => ['warning', 'info'].includes(value), + validator: value => ['warning', 'success', 'info'].includes(value), }, loading: { type: Boolean, 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 5d3d907f88..3abe807c72 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 @@ -13,12 +13,9 @@ import { communityChannelsStrings } from 'shared/strings/communityChannelsString import { CommunityLibrarySubmission } from 'shared/data/resources'; import CountryField from 'shared/views/form/CountryField.vue'; -jest.mock('../composables/usePublishedData', () => ({ - usePublishedData: jest.fn(), -})); -jest.mock('../composables/useLatestCommunityLibrarySubmission', () => ({ - useLatestCommunityLibrarySubmission: jest.fn(), -})); +jest.mock('../composables/usePublishedData'); +jest.mock('../composables/useLatestCommunityLibrarySubmission'); +jest.mock('../composables/useLicenseAudit'); jest.mock('shared/data/resources', () => ({ CommunityLibrarySubmission: { create: jest.fn(() => Promise.resolve()), diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLatestCommunityLibrarySubmission.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLatestCommunityLibrarySubmission.js new file mode 100644 index 0000000000..5cc223bcfc --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLatestCommunityLibrarySubmission.js @@ -0,0 +1,19 @@ +import { computed, ref } from 'vue'; + +const MOCK_DEFAULTS = { + isLoading: ref(true), + isFinished: ref(false), + data: computed(() => null), + fetchData: jest.fn(() => Promise.resolve()), +}; + +export function useLatestCommunityLibrarySubmissionMock(overrides = {}) { + return { + ...MOCK_DEFAULTS, + ...overrides, + }; +} + +export const useLatestCommunityLibrarySubmission = jest.fn(() => + useLatestCommunityLibrarySubmissionMock(), +); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLicenseAudit.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLicenseAudit.js new file mode 100644 index 0000000000..a8d77b7526 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLicenseAudit.js @@ -0,0 +1,25 @@ +import { computed, ref } from 'vue'; + +const MOCK_DEFAULTS = { + isLoading: computed(() => false), + isFinished: computed(() => true), + invalidLicenses: computed(() => []), + specialPermissions: computed(() => []), + includedLicenses: computed(() => []), + isAuditing: ref(false), + hasAuditData: computed(() => false), + auditTaskId: ref(null), + error: ref(null), + checkAndTriggerAudit: jest.fn(), + triggerAudit: jest.fn(), + fetchPublishedData: jest.fn(), +}; + +export function useLicenseAuditMock(overrides = {}) { + return { + ...MOCK_DEFAULTS, + ...overrides, + }; +} + +export const useLicenseAudit = jest.fn(() => useLicenseAuditMock()); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/usePublishedData.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/usePublishedData.js new file mode 100644 index 0000000000..8759458f73 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/usePublishedData.js @@ -0,0 +1,17 @@ +import { computed, ref } from 'vue'; + +const MOCK_DEFAULTS = { + isLoading: ref(true), + isFinished: ref(false), + data: computed(() => null), + fetchData: jest.fn(() => Promise.resolve()), +}; + +export function usePublishedDataMock(overrides = {}) { + return { + ...MOCK_DEFAULTS, + ...overrides, + }; +} + +export const usePublishedData = jest.fn(() => usePublishedDataMock()); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLicenseAudit.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLicenseAudit.js new file mode 100644 index 0000000000..ea4cc092d4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLicenseAudit.js @@ -0,0 +1,127 @@ +import { computed, ref, unref, watch } from 'vue'; +import { Channel } from 'shared/data/resources'; + +export function useLicenseAudit(channelRef, channelVersionRef) { + const isAuditing = ref(false); + const auditTaskId = ref(null); + const auditError = ref(null); + const publishedData = ref(null); + + watch( + () => unref(channelRef)?.published_data, + newPublishedData => { + if (newPublishedData) { + publishedData.value = newPublishedData; + if (isAuditing.value) { + isAuditing.value = false; + auditError.value = null; + } + } + }, + { immediate: true, deep: true }, + ); + + const currentVersionData = computed(() => { + const version = unref(channelVersionRef); + if (!publishedData.value || version == null) { + return undefined; + } + return publishedData.value[version]; + }); + + const hasAuditData = computed(() => { + const versionData = currentVersionData.value; + if (!versionData) { + return false; + } + + return ( + 'community_library_invalid_licenses' in versionData && + 'community_library_special_permissions' in versionData + ); + }); + + const invalidLicenses = computed(() => { + const versionData = currentVersionData.value; + return versionData?.community_library_invalid_licenses || []; + }); + + const specialPermissions = computed(() => { + const versionData = currentVersionData.value; + return versionData?.community_library_special_permissions || []; + }); + + const includedLicenses = computed(() => { + const versionData = currentVersionData.value; + return versionData?.included_licenses || []; + }); + + const isAuditComplete = computed(() => { + return publishedData.value !== null && hasAuditData.value; + }); + + async function triggerAudit() { + if (isAuditing.value) return; + + try { + isAuditing.value = true; + auditError.value = null; + + const channelId = unref(channelRef)?.id; + if (!channelId) { + throw new Error('Channel ID is required to trigger audit'); + } + + const response = await Channel.auditLicenses(channelId); + auditTaskId.value = response.task_id; + } catch (error) { + isAuditing.value = false; + auditError.value = error; + throw error; + } + } + + async function fetchPublishedData() { + const channelId = unref(channelRef)?.id; + if (!channelId) return; + + try { + const data = await Channel.getPublishedData(channelId); + publishedData.value = data; + } catch (error) { + auditError.value = error; + throw error; + } + } + + async function checkAndTriggerAudit() { + if (!publishedData.value) { + await fetchPublishedData(); + } + + if (hasAuditData.value || isAuditing.value) { + return; + } + + await triggerAudit(); + } + + return { + isLoading: computed(() => { + if (isAuditComplete.value || auditError.value) return false; + return isAuditing.value; + }), + isFinished: computed(() => isAuditComplete.value), + isAuditing, + invalidLicenses, + specialPermissions, + includedLicenses, + hasAuditData, + auditTaskId, + error: auditError, + + checkAndTriggerAudit, + triggerAudit, + fetchPublishedData, + }; +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useSpecialPermissions.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useSpecialPermissions.js new file mode 100644 index 0000000000..e8338b5709 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useSpecialPermissions.js @@ -0,0 +1,108 @@ +import { computed, ref, unref, watch } from 'vue'; +import { AuditedSpecialPermissionsLicense } from 'shared/data/resources'; + +const ITEMS_PER_PAGE = 3; + +/** + * Composable that fetches and paginates audited special-permissions licenses + * for a given set of permission IDs. + * + * @param {Array