diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/ImportFromChannelsModal.vue b/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/ImportFromChannelsModal.vue index d61f54b023..38ad88af19 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/ImportFromChannelsModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/ImportFromChannelsModal.vue @@ -138,7 +138,7 @@ RouteNames, }, computed: { - ...mapState('importFromChannels', ['selected']), + ...mapState('importFromChannels', ['selected', 'recommendationsData']), ...mapGetters('contentNode', ['getContentNode']), dialog: { get() { @@ -206,6 +206,7 @@ }, methods: { ...mapActions('contentNode', ['copyContentNodes', 'waitForCopyingStatus']), + ...mapActions('importFromChannels', ['captureFeedbackEvent']), ...mapMutations('importFromChannels', { selectNode: 'SELECT_NODE', deselectNode: 'DESELECT_NODE', @@ -243,6 +244,8 @@ target: this.$route.params.destNodeId, sourceNodes, }).then(nodes => { + this.handleRecommendationInteractionEvent(); + // When exiting, do not show snackbar when clearing selections this.showSnackbar = false; this.$store.commit('importFromChannels/CLEAR_NODES'); @@ -278,6 +281,15 @@ this.updateTabTitle(this.$store.getters.appendChannelName(this.$tr('importTitle'))); } }, + handleRecommendationInteractionEvent() { + // captureFeedbackEvent runs async to avoid blocking the UI during navigation + const { selected = {}, ignored = {} } = this.recommendationsData; + if (selected.data && selected.data.length > 0) { + this.captureFeedbackEvent(selected); + } else if (ignored.data && ignored.data.length > 0) { + this.captureFeedbackEvent(ignored); + } + }, }, $trs: { resourcesAddedSnackbar: diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/SearchOrBrowseWindow.vue b/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/SearchOrBrowseWindow.vue index 88484cec8b..8783dcc822 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/SearchOrBrowseWindow.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/SearchOrBrowseWindow.vue @@ -79,7 +79,7 @@ ref="contentTreeList" :topicNode="topicNode" :selected.sync="selected" - :topicId="topicId" + :topicId="$route.params.nodeId" @preview="preview($event)" @change_selected="handleChangeSelected" @copy_to_clipboard="handleCopyToClipboard" @@ -117,7 +117,13 @@ :key="recommendation.id" :node="recommendation" @change_selected="handleChangeSelected" - @preview="preview($event)" + @preview=" + node => { + preview(node); + handlePreviewRecommendationEvent(node); + } + " + @irrelevant="handleNotRelevantRecommendation" /> @@ -172,6 +178,46 @@ {{ aboutRecommendationsFeedbackDescription$() }}

+ +

{{ giveFeedbackDescription$() }}

+

+ +

+ + + +
@@ -194,6 +240,7 @@ import RecommendedResourceCard from 'shared/views/RecommendedResourceCard'; import { withChangeTracker } from 'shared/data/changes'; import { formatUUID4 } from 'shared/data/resources'; + import { FeedbackTypeOptions } from 'shared/feedbackApiUtils'; import { searchRecommendationsStrings } from 'shared/strings/searchRecommendationsStrings'; import { compile } from 'shared/utils/jsonSchema'; @@ -213,17 +260,36 @@ const { windowWidth } = useKResponsiveWindow(); const { + otherLabel$, closeAction$, + cancelAction$, + submitAction$, tryAgainLink$, viewMoreLink$, + giveFeedbackText$, + enterFeedbackLabel$, + feedbackFailedMessage$, noDirectMatchesMessage$, showOtherResourcesLink$, + giveFeedbackDescription$, aboutRecommendationsText$, + alreadyUsedResourceLabel$, + feedbackSubmittedMessage$, + notRelatedToSubjectLabel$, + resourceNotWellMadeLabel$, + tooBasicForLearnersLabel$, showOtherResourcesMessage$, + feedbackConfirmationMessage$, + tooAdvancedForLearnersLabel$, showOtherRecommendationsLink$, + notSuitableForCurriculumLabel$, resourcesMightBeRelevantTitle$, + feedbackInputValidationMessage$, + noFeedbackSelectedErrorMessage$, problemShowingResourcesMessage$, aboutRecommendationsDescription$, + notSpecificLearningActivityLabel$, + notSuitableForCulturalBackgroundLabel$, aboutRecommendationsFeedbackDescription$, } = searchRecommendationsStrings; @@ -232,17 +298,36 @@ }); return { + otherLabel$, closeAction$, + cancelAction$, + submitAction$, tryAgainLink$, viewMoreLink$, + giveFeedbackText$, + enterFeedbackLabel$, + feedbackFailedMessage$, noDirectMatchesMessage$, showOtherResourcesLink$, + giveFeedbackDescription$, aboutRecommendationsText$, + alreadyUsedResourceLabel$, + feedbackSubmittedMessage$, + notRelatedToSubjectLabel$, + resourceNotWellMadeLabel$, + tooBasicForLearnersLabel$, showOtherResourcesMessage$, + feedbackConfirmationMessage$, + tooAdvancedForLearnersLabel$, showOtherRecommendationsLink$, + notSuitableForCurriculumLabel$, resourcesMightBeRelevantTitle$, + feedbackInputValidationMessage$, + noFeedbackSelectedErrorMessage$, problemShowingResourcesMessage$, aboutRecommendationsDescription$, + notSpecificLearningActivityLabel$, + notSuitableForCulturalBackgroundLabel$, aboutRecommendationsFeedbackDescription$, layoutFitsTwoColumns, }; @@ -267,6 +352,15 @@ showShowOtherResources: false, showViewMoreRecommendations: false, otherRecommendationsLoaded: false, + recommendationsEvent: null, + recommendationsInteractionEvent: null, + feedbackReason: [], + showFeedbackModal: false, + otherFeedback: '', + showOtherFeedbackInvalidText: false, + importedNodeIds: [], + rejectedNode: null, + showFeedbackErrorMessage: false, }; }, computed: { @@ -360,9 +454,6 @@ maxWidth: this.shouldShowRecommendations ? '1200px' : '800px', }; }, - topicId() { - return this.$route.params.destNodeId; - }, recommendationsSectionTitle() { return this.resourcesMightBeRelevantTitle$({ topic: this.importDestinationTitle, @@ -395,7 +486,7 @@ }; }, importDestinationAncestors() { - return this.getContentNodeAncestors(this.topicId, true); + return this.getContentNodeAncestors(this.$route.params.destNodeId, true); }, importDestinationFolder() { return this.importDestinationAncestors.slice(-1)[0]; @@ -413,6 +504,75 @@ } return this.currentChannel?.language || ''; }, + feedbackCheckboxOptions() { + return [ + { + value: 'not_suitable_for_curriculum', + label: this.notSuitableForCurriculumLabel$(), + }, + { + value: 'not_related', + label: this.notRelatedToSubjectLabel$(), + }, + { + value: 'not_suitable_for_culture', + label: this.notSuitableForCulturalBackgroundLabel$(), + }, + { + value: 'not_specific', + label: this.notSpecificLearningActivityLabel$(), + }, + { + value: 'too_advanced', + label: this.tooAdvancedForLearnersLabel$(), + }, + { + value: 'too_basic', + label: this.tooBasicForLearnersLabel$(), + }, + { + value: 'not_well_made', + label: this.resourceNotWellMadeLabel$(), + }, + { + value: 'already_used', + label: this.alreadyUsedResourceLabel$(), + }, + { + value: 'other', + label: this.otherLabel$(), + }, + ]; + }, + recommendationsFeedback() { + return this.feedbackCheckboxOptions + .filter(option => this.feedbackReason.includes(option.value)) + .map(option => option.label) + .join(', '); + }, + userId() { + return this.$store.state.session.currentUser.id; + }, + isOtherFeedbackValid() { + return ( + !this.isFeedbackReasonSelected('other') || + (this.isFeedbackReasonSelected('other') && Boolean(this.otherFeedback.trim())) + ); + }, + selectedRecommendations() { + const allRecommendations = [...this.recommendations, ...this.otherRecommendations]; + return allRecommendations.filter(node => this.importedNodeIds.includes(node.id)); + }, + ignoredRecommendations() { + const allRecommendations = [...this.recommendations, ...this.otherRecommendations]; + return allRecommendations.filter(node => !this.importedNodeIds.includes(node.id)); + }, + isAnyFeedbackReasonSelected() { + return this.feedbackReason.length > 0; + }, + validateFeedbackForm() { + return this.isAnyFeedbackReasonSelected && this.isOtherFeedbackValid; + }, }, beforeRouteEnter(to, from, next) { next(vm => { @@ -437,9 +597,14 @@ this.loadRecommendations(this.recommendationsBelowThreshold); }, methods: { + ...mapActions(['showSnackbar']), ...mapActions('clipboard', ['copy']), ...mapActions('contentNode', ['loadPublicContentNode']), - ...mapActions('importFromChannels', ['fetchRecommendations']), + ...mapActions('importFromChannels', [ + 'fetchRecommendations', + 'captureFeedbackEvent', + 'setRecommendationsData', + ]), ...mapMutations('importFromChannels', { selectNodes: 'SELECT_NODES', deselectNodes: 'DESELECT_NODES', @@ -473,9 +638,23 @@ handleChangeSelected({ isSelected, nodes }) { if (isSelected) { this.selectNodes(nodes); + this.importedNodeIds.push(...nodes.map(node => node.id)); } else { this.deselectNodes(nodes); + this.importedNodeIds = this.importedNodeIds.filter( + id => !nodes.some(node => node.id === id), + ); } + this.setRecommendationsData({ + selected: this.formatRecommendationInteractionEventData( + FeedbackTypeOptions.imported, + this.selectedRecommendations, + ), + ignored: this.formatRecommendationInteractionEventData( + FeedbackTypeOptions.ignored, + this.ignoredRecommendations, + ), + }); }, handleCopyToClipboard(node) { this.copyNode = node; @@ -504,6 +683,9 @@ closeAboutRecommendations() { this.showAboutRecommendations = false; }, + closeGiveFeedbackModal() { + this.showFeedbackModal = false; + }, handleViewMoreRecommendations() { if (!this.recommendationsLoadingError) { const pageSize = this.recommendationsPageSize; @@ -542,6 +724,7 @@ const recommendedNodes = await this.fetchRecommendedNodes(recommendations); const pageSize = this.recommendationsPageSize; const currentIndex = this.recommendationsCurrentIndex; + const isLoadingOthers = this.shouldLoadOtherRecommendations; if (belowThreshold) { const nodeIds = new Set(this.recommendations.map(node => node.id)); @@ -565,6 +748,9 @@ } this.recommendationsBelowThreshold = belowThreshold; this.recommendationsLoading = false; + + await this.handleRecommendationsEvent(data, recommendations); + await this.handleShowMoreRecommendationsEvent(recommendedNodes, isLoadingOthers); } catch (error) { this.recommendationsLoading = false; this.recommendationsLoadingError = true; @@ -590,6 +776,142 @@ ...this.otherRecommendations.slice(0, limit), ]; }, + formatRecommendationsEventData(request, response) { + const nodeToRank = {}; + const content = [...this.recommendations, ...this.otherRecommendations]; + const recommendedNodes = response || []; + recommendedNodes.forEach(node => { + nodeToRank[node.node_id] = node.rank; + }); + + return { + event: 'recommendations', + data: { + context: request, + contentnode_id: this.importDestinationFolder.id, + content_id: this.importDestinationFolder.content_id, + target_channel_id: this.importDestinationFolder.channel_id, + user: this.userId, + content: content.map(node => ({ + content_id: node.content_id, + node_id: node.id, + channel_id: node.channel_id, + rank: nodeToRank[node.id], + })), + }, + }; + }, + async handleRecommendationsEvent(request, response) { + this.recommendationsEvent = await this.captureFeedbackEvent( + this.formatRecommendationsEventData(request, response), + ); + }, + formatNotRelevantRecommendationEventData(node) { + const type = FeedbackTypeOptions.rejected; + const reason = this.recommendationsFeedback ? this.recommendationsFeedback : type; + return { + event: 'interaction', + data: { + recommendation_event_id: this.recommendationsEvent.id, + contentnode_id: node.id, + content_id: node.content_id, + context: { + other_feedback: this.otherFeedback, + }, + feedback_type: type, + feedback_reason: reason, + }, + }; + }, + async handleNotRelevantRecommendation(node) { + this.rejectedNode = node; + this.recommendationsInteractionEvent = await this.captureFeedbackEvent( + this.formatNotRelevantRecommendationEventData(node), + ); + if (this.recommendationsInteractionEvent) { + this.showSnackbar({ + text: this.feedbackConfirmationMessage$(), + actionText: this.giveFeedbackText$(), + actionCallback: () => (this.showFeedbackModal = true), + }); + } else { + this.showSnackbar({ text: this.feedbackFailedMessage$() }); + } + }, + isFeedbackReasonSelected(value) { + return this.feedbackReason.includes(value); + }, + handleFeedbackCheckboxChange(value, isChecked) { + if (isChecked) { + if (!this.feedbackReason.includes(value)) { + this.feedbackReason.push(value); + } + } else { + this.feedbackReason = this.feedbackReason.filter(item => item !== value); + } + this.clearOtherFeedbackText(); + }, + clearOtherFeedbackText() { + if (!this.isFeedbackReasonSelected('other')) { + this.otherFeedback = ''; + this.showOtherFeedbackInvalidText = false; + } + }, + formatRejectedRecommendationFeedbackEventData() { + return { + eventId: this.recommendationsInteractionEvent.id, + method: 'patch', + event: 'interaction', + data: { + recommendation_event_id: this.recommendationsEvent.id, + context: { + other_feedback: this.otherFeedback, + }, + feedback_reason: this.recommendationsFeedback, + }, + }; + }, + async handleRejectedRecommendationFeedback() { + if (this.validateFeedbackForm) { + const rejectedEvent = await this.captureFeedbackEvent( + this.formatRejectedRecommendationFeedbackEventData(), + ); + if (rejectedEvent) { + this.showSnackbar({ text: this.feedbackSubmittedMessage$() }); + } else { + this.showSnackbar({ text: this.feedbackFailedMessage$() }); + } + this.showFeedbackModal = false; + } else { + this.showOtherFeedbackInvalidText = !this.isOtherFeedbackValid; + } + this.showFeedbackErrorMessage = !this.isAnyFeedbackReasonSelected; + }, + formatRecommendationInteractionEventData(feedbackType, nodes) { + const data = nodes.map(node => ({ + recommendation_event_id: this.recommendationsEvent.id, + contentnode_id: node.id, + content_id: node.content_id, + context: { + //ToDo: Add appropriate context to be sent with the interaction event + }, + feedback_type: feedbackType, + feedback_reason: feedbackType, + })); + return { event: 'interaction', data }; + }, + async handlePreviewRecommendationEvent(node) { + await this.captureFeedbackEvent( + this.formatRecommendationInteractionEventData(FeedbackTypeOptions.previewed, [node]), + ); + }, + async handleShowMoreRecommendationsEvent(nodes, loadedOthers) { + if (loadedOthers) { + await this.captureFeedbackEvent( + this.formatRecommendationInteractionEventData(FeedbackTypeOptions.showmore, nodes), + ); + } + }, }, $trs: { backToBrowseAction: 'Back to browse', @@ -634,4 +956,14 @@ margin-top: 24px; } + .feedback-options { + margin-top: 8px; + } + + .feedback-form-error { + padding: 16px; + margin: 16px 0; + border-radius: 4px; + } + diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/__tests__/SearchOrBrowseWindow.spec.js b/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/__tests__/SearchOrBrowseWindow.spec.js new file mode 100644 index 0000000000..d579142977 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/__tests__/SearchOrBrowseWindow.spec.js @@ -0,0 +1,254 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex, { Store } from 'vuex'; +import VueRouter from 'vue-router'; +import SearchOrBrowseWindow from '../SearchOrBrowseWindow'; +import { RouteNames } from '../../../constants'; + +// Mock the jsonSchema compile function to always return true +jest.mock('shared/utils/jsonSchema', () => ({ + compile: () => () => true, +})); + +// Mock dependencies +jest.mock('shared/feedbackApiUtils', () => ({ + RecommendationsEvent: jest.fn().mockImplementation(() => ({ + id: 'mock-event-id', + })), + RecommendationsInteractionEvent: jest.fn().mockImplementation(() => ({ + id: 'mock-interaction-id', + })), + FeedbackTypeOptions: { + rejected: 'rejected', + }, + sendRequest: jest.fn().mockResolvedValue({}), +})); + +const localVue = createLocalVue(); +localVue.use(Vuex); +localVue.use(VueRouter); + +describe('SearchOrBrowseWindow', () => { + let wrapper; + let store; + let router; + let actions; + let getters; + let mutations; + + beforeEach(() => { + actions = { + showSnackbar: jest.fn(), + 'clipboard/copy': jest.fn().mockResolvedValue(), + 'contentNode/loadPublicContentNode': jest.fn().mockImplementation(({ id }) => + Promise.resolve({ + id, + title: `Resource ${id}`, + content_id: `content-${id}`, + channel_id: 'channel-id', + }), + ), + 'importFromChannels/fetchRecommendations': jest.fn().mockResolvedValue([ + { + id: 'rec-1', + node_id: 'rec-1', + content_id: 'content-1', + rank: 1, + main_tree_id: 'tree-1', + parent_id: 'parent-1', + }, + { + id: 'rec-2', + node_id: 'rec-2', + content_id: 'content-2', + rank: 2, + main_tree_id: 'tree-1', + parent_id: 'parent-1', + }, + ]), + 'importFromChannels/captureFeedbackEvent': jest.fn().mockResolvedValue(null), + }; + + mutations = { + 'importFromChannels/SELECT_NODES': jest.fn(), + 'importFromChannels/DESELECT_NODES': jest.fn(), + 'importFromChannels/CLEAR_NODES': jest.fn(), + }; + + getters = { + 'currentChannel/currentChannel': () => ({ language: 'en' }), + 'importFromChannels/savedSearchesExist': () => true, + isAIFeatureEnabled: () => true, + 'contentNode/getContentNodeAncestors': () => () => [{ id: 'node-1' }], + }; + + store = new Store({ + modules: { + importFromChannels: { + namespaced: true, + state: { + selected: [{ id: 'selected-1' }], + }, + actions: { + fetchRecommendations: actions['importFromChannels/fetchRecommendations'], + captureFeedbackEvent: actions['importFromChannels/captureFeedbackEvent'], + }, + mutations: { + SELECT_NODES: mutations['importFromChannels/SELECT_NODES'], + DESELECT_NODES: mutations['importFromChannels/DESELECT_NODES'], + CLEAR_NODES: mutations['importFromChannels/CLEAR_NODES'], + }, + getters: { + savedSearchesExist: getters['importFromChannels/savedSearchesExist'], + selected: state => state.selected, // Add selected getter + }, + }, + contentNode: { + namespaced: true, + actions: { + loadPublicContentNode: actions['contentNode/loadPublicContentNode'], + }, + getters: { + getContentNodeAncestors: getters['contentNode/getContentNodeAncestors'], + }, + }, + currentChannel: { + namespaced: true, + getters: { + currentChannel: getters['currentChannel/currentChannel'], + }, + }, + clipboard: { + namespaced: true, + actions: { + copy: actions['clipboard/copy'], + }, + }, + session: { + state: { + currentUser: { id: 'user-1' }, + }, + }, + }, + actions: { + showSnackbar: actions.showSnackbar, + }, + getters: { + isAIFeatureEnabled: getters.isAIFeatureEnabled, + }, + }); + + const routes = [ + { + path: '/import/browse/:destNodeId', // Fixed path to include param + name: RouteNames.IMPORT_FROM_CHANNELS_BROWSE, + }, + { + path: '/import/search/:searchTerm/:destNodeId', // Fixed path to include params + name: RouteNames.IMPORT_FROM_CHANNELS_SEARCH, + }, + ]; + + router = new VueRouter({ routes }); + router.push({ + name: RouteNames.IMPORT_FROM_CHANNELS_BROWSE, + params: { destNodeId: 'dest-1' }, + }); + + wrapper = shallowMount(SearchOrBrowseWindow, { + localVue, + store, + router, + mocks: { + $analytics: { + trackAction: jest.fn(), + }, + $tr: key => key, + $computedClass: () => ({}), + }, + stubs: { + KModal: true, + ImportFromChannelsModal: true, + KGridItem: true, + KGrid: true, + ChannelList: true, + ContentTreeList: true, + SearchResultsList: true, + }, + }); + }); + + it('initializes correctly', () => { + expect(wrapper.vm.searchTerm).toBe(''); + }); + + it('validates search term correctly', async () => { + wrapper.vm.searchTerm = ' '; + await wrapper.vm.$nextTick(); + expect(wrapper.vm.searchIsValid).toBe(false); + + wrapper.vm.searchTerm = 'valid search'; + await wrapper.vm.$nextTick(); + expect(wrapper.vm.searchIsValid).toBe(true); + }); + + it('handles search term submission', async () => { + wrapper.vm.searchTerm = 'new search'; + await wrapper.vm.handleSearchTerm(); + + expect(mutations['importFromChannels/CLEAR_NODES']).toHaveBeenCalled(); + expect(wrapper.vm.$route.name).toBe(RouteNames.IMPORT_FROM_CHANNELS_SEARCH); + expect(wrapper.vm.$route.params.searchTerm).toBe('new search'); + }); + + it('loads recommendations', async () => { + await wrapper.vm.loadRecommendations(false); + + expect(actions['importFromChannels/fetchRecommendations']).toHaveBeenCalled(); + expect(wrapper.vm.recommendations.length).toBe(2); + expect(wrapper.vm.displayedRecommendations.length).toBe(2); + }); + + it('handles view more recommendations', async () => { + await wrapper.vm.loadRecommendations(false); + // Set up state for viewing more + wrapper.vm.recommendations = Array(15) + .fill() + .map((_, i) => ({ id: `rec-${i}` })); + wrapper.vm.displayedRecommendations = wrapper.vm.recommendations.slice(0, 10); + wrapper.vm.recommendationsCurrentIndex = 10; + wrapper.vm.showViewMoreRecommendations = true; + + await wrapper.vm.handleViewMoreRecommendations(); + + expect(wrapper.vm.displayedRecommendations.length).toBe(10); + }); + + it('validates feedback form correctly', () => { + wrapper.vm.feedbackReason = ['other']; + wrapper.vm.otherFeedback = ''; + + const result = wrapper.vm.validateFeedbackForm; + expect(result).toBe(false); + + wrapper.vm.otherFeedback = 'valid feedback'; + const validResult = wrapper.vm.validateFeedbackForm; + expect(validResult).toBe(true); + }); + + it('handles feedback checkbox changes', () => { + wrapper.vm.feedbackReason = []; + + wrapper.vm.handleFeedbackCheckboxChange('not_related', true); + expect(wrapper.vm.feedbackReason).toContain('not_related'); + + wrapper.vm.handleFeedbackCheckboxChange('not_related', false); + expect(wrapper.vm.feedbackReason).not.toContain('not_related'); + }); + + it('checks if feedback reason is selected', () => { + wrapper.vm.feedbackReason = ['not_related']; + + expect(wrapper.vm.isFeedbackReasonSelected('not_related')).toBe(true); + expect(wrapper.vm.isFeedbackReasonSelected('other')).toBe(false); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/actions.js index 20aa6f7d08..4e5419e7b0 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/actions.js @@ -5,6 +5,7 @@ import urls from 'shared/urls'; import { ChannelListTypes } from 'shared/constants'; import { Channel, Recommendation, SavedSearch } from 'shared/data/resources'; +import { FeedbackEventTypes, sendRequest } from 'shared/feedbackApiUtils'; export async function fetchResourceSearchResults(context, params) { params = { ...params }; @@ -115,3 +116,55 @@ export function deleteSearch({ commit }, searchId) { export function fetchRecommendations(context, params) { return Recommendation.fetchCollection(params); } + +export function setRecommendationsData(context, data) { + context.commit('SET_RECOMMENDATIONS_DATA', data); +} + +export async function captureFeedbackEvent(context, params = {}) { + /** + * Captures a feedback event based on the provided parameters. + * + * @param {Object} context - The Vuex context object. + * @param {Object} params - Parameters for the feedback event. + * @param {string} params.event - The type of event ('flag', 'recommendations', 'interaction'). + * @param {string} [params.method='post'] - The HTTP method for request ('post', 'put', 'patch'). + * @param {Object|Array} params.data - The event data. It can be an object or an array of objects. + * @param {string} [params.eventId] - The unique ID of an event. + * @throws {Error} If the event is invalid or not provided. + * @returns {Promise} A promise that resolves to the response data from the API. + */ + const event = params.event; + const method = params.method; + const eventId = params.eventId; + const rawData = params.data; + const isDataArray = Array.isArray(rawData); + const isDataObject = rawData && typeof rawData === 'object'; + + if (!event || !FeedbackEventTypes[event]) { + throw new Error( + `Invalid event: '${event}'. Event must be provided and be one of the valid FeedbackEventTypes.`, + ); + } + + const dataObject = item => ({ + recommendation_event_id: item.recommendation_event_id, + contentnode_id: item.contentnode_id, + content_id: item.content_id, + target_channel_id: item.target_channel_id, + user: item.user, + content: item.content, + context: item.context, + feedback_type: item.feedback_type, + feedback_reason: item.feedback_reason, + }); + const data = isDataArray ? rawData.map(dataObject) : isDataObject ? dataObject(rawData) : {}; + try { + return await sendRequest(new FeedbackEventTypes[event]({ method, data, eventId })); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error capturing feedback event:', error); + // Return null if the request fails, to avoid breaking the application + return null; + } +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/index.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/index.js index 47d14ee403..60b32f7c30 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/index.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/index.js @@ -9,6 +9,7 @@ export default { return { savedSearches: {}, selected: [], + recommendationsData: {}, }; }, getters, diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/mutations.js index 4ef8a7d582..b0469ef49b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/importFromChannels/mutations.js @@ -45,3 +45,7 @@ export function DESELECT_NODES(state, nodes) { export function CLEAR_NODES(state) { state.selected = []; } + +export function SET_RECOMMENDATIONS_DATA(state, data) { + state.recommendationsData = data; +} diff --git a/contentcuration/contentcuration/frontend/shared/__tests__/feedbackUtils.spec.js b/contentcuration/contentcuration/frontend/shared/__tests__/feedbackUtils.spec.js index ae9dd8b23e..d6bc98b277 100644 --- a/contentcuration/contentcuration/frontend/shared/__tests__/feedbackUtils.spec.js +++ b/contentcuration/contentcuration/frontend/shared/__tests__/feedbackUtils.spec.js @@ -5,40 +5,21 @@ import { RecommendationsEvent, RecommendationsInteractionEvent, FeedbackTypeOptions, - FLAG_FEEDBACK_EVENT_URL, - RECCOMMENDATION_EVENT_URL, - RECCOMMENDATION_INTERACTION_EVENT_URL, } from '../feedbackApiUtils'; import client from '../client'; jest.mock('uuid', () => ({ v4: jest.fn(() => 'mocked-uuid') })); jest.mock('../client'); -describe('FeedBackUtility Tests', () => { - let flagFeedbackEvent; - let recommendationsEvent; - let recommendationsInteractionEvent; - - afterEach(() => { - jest.clearAllMocks(); - }); - - beforeEach(() => { - flagFeedbackEvent = new FlagFeedbackEvent({ - context: { key: 'value' }, - contentnode_id: uuidv4(), - content_id: uuidv4(), - target_topic_id: uuidv4(), - feedback_type: FeedbackTypeOptions.flagged, - feedback_reason: 'Inappropriate Language', - }); - - recommendationsEvent = new RecommendationsEvent({ +function setupRecommendationsEvent({ method, eventId }) { + return new RecommendationsEvent({ + method: method, + data: { context: { model_version: 1, breadcrumbs: '#Title#->Random' }, contentnode_id: uuidv4(), content_id: uuidv4(), target_channel_id: uuidv4(), - user_id: uuidv4(), + user: uuidv4(), content: [ { content_id: uuidv4(), @@ -47,17 +28,58 @@ describe('FeedBackUtility Tests', () => { score: 4, }, ], + }, + eventId: eventId, + }); +} + +function setupRecommendationsInteractionEvent({ + method, + bulk = false, + dataOverride = null, + override = false, + eventId = null, +}) { + const data = { + context: { test_key: 'test_value' }, + contentnode_id: uuidv4(), + content_id: uuidv4(), + feedback_type: FeedbackTypeOptions.ignored, + feedback_reason: '----', + recommendation_event_id: uuidv4(), + }; + return new RecommendationsInteractionEvent({ + method: method, + data: override ? dataOverride : bulk ? [data] : data, + eventId: eventId, + }); +} + +describe('FeedBackUtility Tests', () => { + let flagFeedbackEvent; + let recommendationsEvent; + let recommendationsInteractionEvent; + + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + flagFeedbackEvent = new FlagFeedbackEvent({ + data: { + context: { key: 'value' }, + contentnode_id: uuidv4(), + content_id: uuidv4(), + target_topic_id: uuidv4(), + feedback_type: FeedbackTypeOptions.flagged, + feedback_reason: 'Inappropriate Language', + }, }); - recommendationsInteractionEvent = new RecommendationsInteractionEvent({ - context: { test_key: 'test_value' }, - contentnode_id: uuidv4(), - content_id: uuidv4(), - feedback_type: FeedbackTypeOptions.ignored, - feedback_reason: '----', - recommendation_event_id: uuidv4(), //currently this is random to test but should have the actual - // recommendation event id of the recommendation event + recommendationsEvent = setupRecommendationsEvent({ + method: 'post', }); + recommendationsInteractionEvent = setupRecommendationsInteractionEvent({ method: 'post' }); // Reset all client method mocks client.post.mockRestore(); @@ -69,28 +91,29 @@ describe('FeedBackUtility Tests', () => { describe('FlagFeedbackEvent Tests', () => { it('should generate data object without functions', () => { - const dataObject = flagFeedbackEvent.getDataObject(); - expect(dataObject.id).toEqual('mocked-uuid'); + const dataObject = flagFeedbackEvent.getData(); expect(dataObject.context).toEqual({ key: 'value' }); expect(dataObject.contentnode_id).toEqual('mocked-uuid'); expect(dataObject.content_id).toEqual('mocked-uuid'); - expect(dataObject.getDataObject).toBeUndefined(); + expect(dataObject.data).toBeUndefined(); expect(dataObject.target_topic_id).toEqual('mocked-uuid'); expect(dataObject.feedback_type).toEqual(FeedbackTypeOptions.flagged); expect(dataObject.feedback_reason).toEqual('Inappropriate Language'); - expect(dataObject.URL).toBeUndefined(); + expect(dataObject.endpoint).toBeUndefined(); }); - it('should throw an error when URL is not defined', () => { - flagFeedbackEvent.URL = undefined; + it('should throw an error when endpoint is not defined', () => { + flagFeedbackEvent.endpoint = undefined; expect(() => flagFeedbackEvent.getUrl()).toThrowError( - 'URL is not defined for the FeedBack Object.', + 'Resource is not defined for the FeedBack Object.', ); }); - it('should return the correct URL when URL is defined', () => { + it('should return the correct url when endpoint is defined', () => { + const testUrl = 'http://example.com/api/flagged'; + jest.spyOn(flagFeedbackEvent, 'getUrl').mockReturnValue(testUrl); const result = flagFeedbackEvent.getUrl(); - expect(result).toEqual(FLAG_FEEDBACK_EVENT_URL); + expect(result).toEqual(testUrl); }); it('should send a request using sendRequest function', async () => { @@ -100,8 +123,8 @@ describe('FeedBackUtility Tests', () => { expect(result).toEqual('Mocked API Response'); expect(client.post).toHaveBeenCalledWith( - FLAG_FEEDBACK_EVENT_URL, - flagFeedbackEvent.getDataObject(), + flagFeedbackEvent.getUrl(), + flagFeedbackEvent.getData(), ); }); @@ -109,21 +132,20 @@ describe('FeedBackUtility Tests', () => { client.post.mockRejectedValue(new Error('Mocked API Error')); await expect(sendRequest(flagFeedbackEvent)).rejects.toThrowError('Mocked API Error'); expect(client.post).toHaveBeenCalledWith( - FLAG_FEEDBACK_EVENT_URL, - flagFeedbackEvent.getDataObject(), + flagFeedbackEvent.getUrl(), + flagFeedbackEvent.getData(), ); }); }); describe('RecommendationsEvent Tests', () => { it('should generate data object without functions', () => { - const dataObject = recommendationsEvent.getDataObject(); - expect(dataObject.id).toEqual('mocked-uuid'); + const dataObject = recommendationsEvent.getData(); expect(dataObject.context).toEqual({ model_version: 1, breadcrumbs: '#Title#->Random' }); expect(dataObject.contentnode_id).toEqual('mocked-uuid'); expect(dataObject.content_id).toEqual('mocked-uuid'); expect(dataObject.target_channel_id).toEqual('mocked-uuid'); - expect(dataObject.user_id).toEqual('mocked-uuid'); + expect(dataObject.user).toEqual('mocked-uuid'); expect(dataObject.content).toEqual([ { content_id: 'mocked-uuid', @@ -132,94 +154,120 @@ describe('FeedBackUtility Tests', () => { score: 4, }, ]); - expect(dataObject.getDataObject).toBeUndefined(); - expect(dataObject.URL).toBeUndefined(); + expect(dataObject.data).toBeUndefined(); + expect(dataObject.endpoint).toBeUndefined(); }); - it('should throw an error when URL is not defined', () => { - recommendationsEvent.URL = undefined; + it('should throw an error when endpoint is not defined', () => { + recommendationsEvent.endpoint = undefined; expect(() => recommendationsEvent.getUrl()).toThrowError( - 'URL is not defined for the FeedBack Object.', + 'Resource is not defined for the FeedBack Object.', ); }); - it('should return the correct URL when URL is defined', () => { + it('should return the correct url when endpoint is defined', () => { + const testUrl = 'http://example.com/api/recommendations'; + jest.spyOn(recommendationsEvent, 'getUrl').mockReturnValue(testUrl); const result = recommendationsEvent.getUrl(); - expect(result).toEqual(RECCOMMENDATION_EVENT_URL); + expect(result).toEqual(result); }); describe('HTTP Methods', () => { it('should send POST request successfully', async () => { client.post.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); - const result = await sendRequest(recommendationsEvent, 'post'); + recommendationsEvent = setupRecommendationsEvent({ + method: 'post', + }); + const result = await sendRequest(recommendationsEvent); expect(result).toEqual('Mocked API Response'); expect(client.post).toHaveBeenCalledWith( - RECCOMMENDATION_EVENT_URL, - recommendationsEvent.getDataObject(), + recommendationsEvent.getUrl(), + recommendationsEvent.getData(), ); }); it('should send PUT request successfully', async () => { client.put.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); - const result = await sendRequest(recommendationsEvent, 'put'); + recommendationsEvent = setupRecommendationsEvent({ + method: 'put', + eventId: uuidv4(), + }); + const result = await sendRequest(recommendationsEvent); expect(result).toEqual('Mocked API Response'); expect(client.put).toHaveBeenCalledWith( - RECCOMMENDATION_EVENT_URL, - recommendationsEvent.getDataObject(), + recommendationsEvent.getUrl(), + recommendationsEvent.getData(), ); }); it('should send PATCH request successfully', async () => { client.patch.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); - const result = await sendRequest(recommendationsEvent, 'patch'); + recommendationsEvent = setupRecommendationsEvent({ + method: 'patch', + eventId: uuidv4(), + }); + const result = await sendRequest(recommendationsEvent); expect(result).toEqual('Mocked API Response'); expect(client.patch).toHaveBeenCalledWith( - RECCOMMENDATION_EVENT_URL, - recommendationsEvent.getDataObject(), + recommendationsEvent.getUrl(), + recommendationsEvent.getData(), ); }); it('should handle errors for POST request', async () => { client.post.mockRejectedValue(new Error('Mocked API Error')); - await expect(sendRequest(recommendationsEvent, 'post')).rejects.toThrowError( - 'Mocked API Error', - ); + recommendationsEvent = setupRecommendationsEvent({ + method: 'post', + }); + await expect(sendRequest(recommendationsEvent)).rejects.toThrowError('Mocked API Error'); expect(client.post).toHaveBeenCalledWith( - RECCOMMENDATION_EVENT_URL, - recommendationsEvent.getDataObject(), + recommendationsEvent.getUrl(), + recommendationsEvent.getData(), ); }); it('should handle errors for PUT request', async () => { client.put.mockRejectedValue(new Error('Mocked API Error')); - await expect(sendRequest(recommendationsEvent, 'put')).rejects.toThrowError( - 'Mocked API Error', - ); + recommendationsEvent = setupRecommendationsEvent({ + method: 'put', + eventId: uuidv4(), + }); + await expect(sendRequest(recommendationsEvent)).rejects.toThrowError('Mocked API Error'); expect(client.put).toHaveBeenCalledWith( - RECCOMMENDATION_EVENT_URL, - recommendationsEvent.getDataObject(), + recommendationsEvent.getUrl(), + recommendationsEvent.getData(), ); }); it('should handle errors for PATCH request', async () => { client.patch.mockRejectedValue(new Error('Mocked API Error')); - await expect(sendRequest(recommendationsEvent, 'patch')).rejects.toThrowError( - 'Mocked API Error', - ); + recommendationsEvent = setupRecommendationsEvent({ + method: 'patch', + eventId: uuidv4(), + }); + await expect(sendRequest(recommendationsEvent)).rejects.toThrowError('Mocked API Error'); expect(client.patch).toHaveBeenCalledWith( - RECCOMMENDATION_EVENT_URL, - recommendationsEvent.getDataObject(), + recommendationsEvent.getUrl(), + recommendationsEvent.getData(), ); }); it('should throw error for unsupported DELETE method', async () => { - await expect(sendRequest(recommendationsEvent, 'delete')).rejects.toThrowError( + recommendationsEvent = setupRecommendationsEvent({ + method: 'delete', + eventId: uuidv4(), + }); + await expect(sendRequest(recommendationsEvent)).rejects.toThrowError( 'Unsupported HTTP method: delete', ); }); it('should throw error for unsupported GET method', async () => { - await expect(sendRequest(recommendationsEvent, 'get')).rejects.toThrowError( + recommendationsEvent = setupRecommendationsEvent({ + method: 'get', + eventId: uuidv4(), + }); + await expect(sendRequest(recommendationsEvent)).rejects.toThrowError( 'Unsupported HTTP method: get', ); }); @@ -228,102 +276,224 @@ describe('FeedBackUtility Tests', () => { describe('RecommendationsInteractionEvent Tests', () => { it('should generate data object without functions', () => { - const dataObject = recommendationsInteractionEvent.getDataObject(); - expect(dataObject.id).toEqual('mocked-uuid'); + const dataObject = recommendationsInteractionEvent.getData(); expect(dataObject.context).toEqual({ test_key: 'test_value' }); expect(dataObject.contentnode_id).toEqual('mocked-uuid'); expect(dataObject.content_id).toEqual('mocked-uuid'); expect(dataObject.feedback_type).toEqual(FeedbackTypeOptions.ignored); expect(dataObject.feedback_reason).toEqual('----'); expect(dataObject.recommendation_event_id).toEqual('mocked-uuid'); - expect(dataObject.getDataObject).toBeUndefined(); - expect(dataObject.URL).toBeUndefined(); + expect(dataObject.data).toBeUndefined(); + expect(dataObject.endpoint).toBeUndefined(); + }); + + it('should throw an error when data is not defined', () => { + expect(() => + setupRecommendationsInteractionEvent({ + method: 'post', + bulk: true, + dataOverride: null, + override: true, + }), + ).toThrowError('The data property cannot be null or undefined'); + }); + + it('should throw an error when data is an array but method is not a POST', () => { + expect(() => + setupRecommendationsInteractionEvent({ + method: 'put', + bulk: true, + dataOverride: [], + override: true, + eventId: uuidv4(), + }), + ).toThrowError("Array 'data' is only allowed for 'post' requests"); + }); + + it('should throw an error when data is an empty array and method is a POST', () => { + expect(() => + setupRecommendationsInteractionEvent({ + method: 'post', + bulk: true, + dataOverride: [], + override: true, + }), + ).toThrowError("The 'data' array cannot be empty"); }); - it('should throw an error when URL is not defined', () => { - recommendationsInteractionEvent.URL = undefined; + it('should throw an error when data is any of any type other than array or object', () => { + expect(() => + setupRecommendationsInteractionEvent({ + method: 'post', + bulk: true, + dataOverride: 'invalid data type', + override: true, + }), + ).toThrowError("The 'data' must be either a non-null object or an array of objects"); + }); + + it('should throw an error when submitted data has missing fields', () => { + expect(() => + setupRecommendationsInteractionEvent({ + method: 'post', + bulk: false, + dataOverride: {}, + override: true, + }), + ).toThrowError(/The 'data' object is missing required property: \w+/); + }); + + it('should throw an error when submitted data array has invalid data', () => { + expect(() => + setupRecommendationsInteractionEvent({ + method: 'post', + bulk: false, + dataOverride: [null], + override: true, + }), + ).toThrowError(/Item at position \w+ in 'data' is not a valid object/); + }); + + it('should throw an error when submitted data array has valid data but with missing fields', () => { + expect(() => + setupRecommendationsInteractionEvent({ + method: 'post', + bulk: false, + dataOverride: [{}], + override: true, + }), + ).toThrowError(/Missing required property in 'data': \w+ at position: \w+/); + }); + + it('should throw an error when endpoint is not defined', () => { + recommendationsInteractionEvent.endpoint = undefined; expect(() => recommendationsInteractionEvent.getUrl()).toThrowError( - 'URL is not defined for the FeedBack Object.', + 'Resource is not defined for the FeedBack Object.', ); }); - it('should return the correct URL when URL is defined', () => { + it('should return a url when the endpoint is defined', () => { + const testUrl = 'http://example.com/api/recommendations_interaction'; + jest.spyOn(recommendationsInteractionEvent, 'getUrl').mockReturnValue(testUrl); const result = recommendationsInteractionEvent.getUrl(); - expect(result).toEqual(RECCOMMENDATION_INTERACTION_EVENT_URL); + expect(result).toEqual(testUrl); }); describe('HTTP Methods', () => { it('should send POST request successfully', async () => { client.post.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); - const result = await sendRequest(recommendationsInteractionEvent, 'post'); + recommendationsInteractionEvent = setupRecommendationsInteractionEvent({ + method: 'post', + }); + const result = await sendRequest(recommendationsInteractionEvent); + expect(result).toEqual('Mocked API Response'); + expect(client.post).toHaveBeenCalledWith( + recommendationsInteractionEvent.getUrl(), + recommendationsInteractionEvent.getData(), + ); + }); + + it('should send Bulk POST request successfully', async () => { + client.post.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); + recommendationsInteractionEvent = setupRecommendationsInteractionEvent({ + method: 'post', + bulk: true, + }); + const result = await sendRequest(recommendationsInteractionEvent); expect(result).toEqual('Mocked API Response'); expect(client.post).toHaveBeenCalledWith( - RECCOMMENDATION_INTERACTION_EVENT_URL, - recommendationsInteractionEvent.getDataObject(), + recommendationsInteractionEvent.getUrl(), + recommendationsInteractionEvent.getData(), ); }); it('should send PUT request successfully', async () => { client.put.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); - const result = await sendRequest(recommendationsInteractionEvent, 'put'); + recommendationsInteractionEvent = setupRecommendationsInteractionEvent({ + method: 'put', + eventId: uuidv4(), + }); + const result = await sendRequest(recommendationsInteractionEvent); expect(result).toEqual('Mocked API Response'); expect(client.put).toHaveBeenCalledWith( - RECCOMMENDATION_INTERACTION_EVENT_URL, - recommendationsInteractionEvent.getDataObject(), + recommendationsInteractionEvent.getUrl(), + recommendationsInteractionEvent.getData(), ); }); it('should send PATCH request successfully', async () => { client.patch.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); - const result = await sendRequest(recommendationsInteractionEvent, 'patch'); + recommendationsInteractionEvent = setupRecommendationsInteractionEvent({ + method: 'patch', + eventId: uuidv4(), + }); + const result = await sendRequest(recommendationsInteractionEvent); expect(result).toEqual('Mocked API Response'); expect(client.patch).toHaveBeenCalledWith( - RECCOMMENDATION_INTERACTION_EVENT_URL, - recommendationsInteractionEvent.getDataObject(), + recommendationsInteractionEvent.getUrl(), + recommendationsInteractionEvent.getData(), ); }); it('should handle errors for POST request', async () => { client.post.mockRejectedValue(new Error('Mocked API Error')); - await expect(sendRequest(recommendationsInteractionEvent, 'post')).rejects.toThrowError( + recommendationsInteractionEvent = setupRecommendationsInteractionEvent({ + method: 'post', + }); + await expect(sendRequest(recommendationsInteractionEvent)).rejects.toThrowError( 'Mocked API Error', ); expect(client.post).toHaveBeenCalledWith( - RECCOMMENDATION_INTERACTION_EVENT_URL, - recommendationsInteractionEvent.getDataObject(), + recommendationsInteractionEvent.getUrl(), + recommendationsInteractionEvent.getData(), ); }); it('should handle errors for PUT request', async () => { client.put.mockRejectedValue(new Error('Mocked API Error')); - await expect(sendRequest(recommendationsInteractionEvent, 'put')).rejects.toThrowError( + recommendationsInteractionEvent = setupRecommendationsInteractionEvent({ + method: 'put', + eventId: uuidv4(), + }); + await expect(sendRequest(recommendationsInteractionEvent)).rejects.toThrowError( 'Mocked API Error', ); expect(client.put).toHaveBeenCalledWith( - RECCOMMENDATION_INTERACTION_EVENT_URL, - recommendationsInteractionEvent.getDataObject(), + recommendationsInteractionEvent.getUrl(), + recommendationsInteractionEvent.getData(), ); }); it('should handle errors for PATCH request', async () => { client.patch.mockRejectedValue(new Error('Mocked API Error')); - await expect(sendRequest(recommendationsInteractionEvent, 'patch')).rejects.toThrowError( + recommendationsInteractionEvent = setupRecommendationsInteractionEvent({ + method: 'patch', + eventId: uuidv4(), + }); + await expect(sendRequest(recommendationsInteractionEvent)).rejects.toThrowError( 'Mocked API Error', ); expect(client.patch).toHaveBeenCalledWith( - RECCOMMENDATION_INTERACTION_EVENT_URL, - recommendationsInteractionEvent.getDataObject(), + recommendationsInteractionEvent.getUrl(), + recommendationsInteractionEvent.getData(), ); }); it('should throw error for unsupported DELETE method', async () => { - await expect(sendRequest(recommendationsInteractionEvent, 'delete')).rejects.toThrowError( + recommendationsInteractionEvent = setupRecommendationsInteractionEvent({ + method: 'delete', + }); + await expect(sendRequest(recommendationsInteractionEvent)).rejects.toThrowError( 'Unsupported HTTP method: delete', ); }); it('should throw error for unsupported GET method', async () => { - await expect(sendRequest(recommendationsInteractionEvent, 'get')).rejects.toThrowError( + recommendationsInteractionEvent = setupRecommendationsInteractionEvent({ + method: 'get', + id: uuidv4(), + }); + await expect(sendRequest(recommendationsInteractionEvent)).rejects.toThrowError( 'Unsupported HTTP method: get', ); }); diff --git a/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js b/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js index 692c1e1d8d..49ba10d165 100644 --- a/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js +++ b/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js @@ -1,23 +1,20 @@ // Helper functions and Utils for creating an API request to // Feedback mechanism endpoints -import { v4 as uuidv4 } from 'uuid'; import client from './client'; import urls from 'shared/urls'; export const FeedbackTypeOptions = { - imported: 'imported', - rejected: 'rejected', - previewed: 'previewed', - showmore: 'showmore', - ignored: 'ignored', - flagged: 'flagged', + imported: 'IMPORTED', + rejected: 'REJECTED', + previewed: 'PREVIEWED', + showmore: 'SHOWMORE', + ignored: 'IGNORED', + flagged: 'FLAGGED', }; -// This is mock currently, fixed value of URL still to be decided -// referencing the url by name -export const FLAG_FEEDBACK_EVENT_URL = urls[`${'flagged'}_${'list'}`]; -export const RECCOMMENDATION_EVENT_URL = urls['recommendations']; -export const RECCOMMENDATION_INTERACTION_EVENT_URL = urls['recommendations-interaction']; +export const FLAG_FEEDBACK_EVENT_ENDPOINT = 'flagged'; +export const RECOMMENDATION_EVENT_ENDPOINT = 'recommendations'; +export const RECOMMENDATION_INTERACTION_EVENT_ENDPOINT = 'recommendations-interaction'; /** * @typedef {Object} BaseFeedbackParams @@ -37,35 +34,85 @@ class BaseFeedback { * @classdesc Represents a base feedback object with common properties and methods. * @param {BaseFeedbackParams} object */ - constructor({ context = {}, contentnode_id, content_id }) { - this.id = uuidv4(); - this.context = context; - this.contentnode_id = contentnode_id; - this.content_id = content_id; - } - - // Creates a data object according to Backends expectation, - // excluding functions and the "URL" property. - getDataObject() { - const dataObject = {}; - for (const key in this) { - if ( - Object.prototype.hasOwnProperty.call(this, key) && - typeof this[key] !== 'function' && - key !== 'URL' - ) { - dataObject[key] = this[key]; + constructor({ method, endpoint, data, eventId }) { + this.method = method || 'post'; + this.endpoint = endpoint; + this.data = data; + this.eventId = eventId; + + this.validateData(); + this.validateEventId(); + } + + validateData() { + const required = this.getRequiredDataFields(); + + if (this.data === null || this.data === undefined) { + throw new Error('The data property cannot be null or undefined'); + } + + if (Array.isArray(this.data)) { + if (this.getMethod() !== 'post') { + throw new Error("Array 'data' is only allowed for 'post' requests"); + } + + if (!this.data.length) { + throw new Error("The 'data' array cannot be empty"); } + + this.data.forEach((item, idx) => { + if (typeof item !== 'object' || item === null) { + throw new Error(`Item at position ${idx} in 'data' is not a valid object`); + } + required.forEach(field => { + if (typeof item[field] === 'undefined') { + throw new Error(`Missing required property in 'data': ${field} at position: ${idx}`); + } + }); + }); + } else if (typeof this.data === 'object') { + required.forEach(field => { + if (this.getMethod() !== 'patch' && typeof this.data[field] === 'undefined') { + throw new Error(`The 'data' object is missing required property: ${field}`); + } + }); + } else { + throw new Error("The 'data' must be either a non-null object or an array of objects"); + } + } + + validateEventId() { + if (!this.eventId && ['put', 'patch'].includes(this.getMethod())) { + throw new Error("The 'eventId' is required for 'put' and 'patch' requests"); } - return dataObject; } - // Return URL associated with the ObjectType + // Returns the data based on the backend contract + getData() { + return this.data; + } + + getRequiredDataFields() { + return ['context', 'contentnode_id', 'content_id']; + } + + // Return the url associated with the ObjectType getUrl() { - if (this.defaultURL === null || this.URL === undefined) { - throw new Error('URL is not defined for the FeedBack Object.'); + if (!this.endpoint) { + throw new Error('Resource is not defined for the FeedBack Object.'); + } + + let url; + if (['patch', 'put'].includes(this.getMethod())) { + url = urls[`${this.endpoint}-detail`](this.eventId); + } else { + url = urls[`${this.endpoint}-list`](); } - return this.URL; + return url; + } + + getMethod() { + return this.method.toLowerCase(); } } @@ -80,10 +127,8 @@ class BaseFeedback { */ // eslint-disable-next-line no-unused-vars class BaseFeedbackEvent extends BaseFeedback { - constructor({ user_id, target_channel_id, ...baseFeedbackParams }) { - super(baseFeedbackParams); - this.user_id = user_id; - this.target_channel_id = target_channel_id; + getRequiredDataFields() { + return [...super.getRequiredDataFields(), 'user', 'target_channel_id']; } } @@ -96,10 +141,8 @@ class BaseFeedbackEvent extends BaseFeedback { * base feedbackclass. */ class BaseFeedbackInteractionEvent extends BaseFeedback { - constructor({ feedback_type, feedback_reason, ...baseFeedbackParams }) { - super(baseFeedbackParams); - this.feedback_type = feedback_type; - this.feedback_reason = feedback_reason; + getRequiredDataFields() { + return [...super.getRequiredDataFields(), 'feedback_type', 'feedback_reason']; } } @@ -113,9 +156,8 @@ class BaseFeedbackInteractionEvent extends BaseFeedback { * base interaction event class. */ class BaseFlagFeedback extends BaseFeedbackInteractionEvent { - constructor({ target_topic_id, ...baseFeedbackParams }) { - super({ ...baseFeedbackParams }); - this.target_topic_id = target_topic_id; + getRequiredDataFields() { + return [...super.getRequiredDataFields(), 'target_topic_id']; } } @@ -129,9 +171,9 @@ class BaseFlagFeedback extends BaseFeedbackInteractionEvent { * base flag feedback class. */ export class FlagFeedbackEvent extends BaseFlagFeedback { - constructor({ target_topic_id, ...baseFeedbackParams }) { - super({ target_topic_id, ...baseFeedbackParams }); - this.URL = FLAG_FEEDBACK_EVENT_URL; + constructor(baseFeedbackParams) { + super(baseFeedbackParams); + this.endpoint = FLAG_FEEDBACK_EVENT_ENDPOINT; } } @@ -143,10 +185,13 @@ export class FlagFeedbackEvent extends BaseFlagFeedback { * each representing a recommended content item. */ export class RecommendationsEvent extends BaseFeedbackEvent { - constructor({ content, ...basefeedbackEventParams }) { - super(basefeedbackEventParams); - this.content = content; - this.URL = RECCOMMENDATION_EVENT_URL; + constructor(baseFeedbackEventParams) { + super(baseFeedbackEventParams); + this.endpoint = RECOMMENDATION_EVENT_ENDPOINT; + } + + getRequiredDataFields() { + return [...super.getRequiredDataFields(), 'content']; } } @@ -160,30 +205,39 @@ export class RecommendationsEvent extends BaseFeedbackEvent { * base feedback interaction event class. */ export class RecommendationsInteractionEvent extends BaseFeedbackInteractionEvent { - constructor({ recommendation_event_id, ...feedbackInteractionEventParams }) { + constructor(feedbackInteractionEventParams) { super(feedbackInteractionEventParams); - this.recommendation_event_id = recommendation_event_id; - this.URL = RECCOMMENDATION_INTERACTION_EVENT_URL; + this.endpoint = RECOMMENDATION_INTERACTION_EVENT_ENDPOINT; + } + + getRequiredDataFields() { + return [...super.getRequiredDataFields(), 'recommendation_event_id']; } } +export const FeedbackEventTypes = { + flag: FlagFeedbackEvent, + recommendations: RecommendationsEvent, + interaction: RecommendationsInteractionEvent, +}; + /** * Sends a request using the provided feedback object. * * @function * * @param {BaseFeedback} feedbackObject - The feedback object to use for the request. - * @param {string} [method='post'] - The HTTP method to use (post, put, patch). - * @throws {Error} Throws an error if the URL is not defined for the feedback object. + * @throws {Error} An error if an unsupported HTTP method is specified in the feedback object. * @returns {Promise} A promise that resolves to the response data from the API. */ -export async function sendRequest(feedbackObject, method = 'post') { +export async function sendRequest(feedbackObject) { try { const url = feedbackObject.getUrl(); - const data = feedbackObject.getDataObject(); + const data = feedbackObject.getData(); + const method = feedbackObject.getMethod(); let response; - switch (method.toLowerCase()) { + switch (method) { case 'post': response = await client.post(url, data); break; diff --git a/contentcuration/contentcuration/frontend/shared/strings/searchRecommendationsStrings.js b/contentcuration/contentcuration/frontend/shared/strings/searchRecommendationsStrings.js index 85614f7dd8..d1a2f559c5 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/searchRecommendationsStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/searchRecommendationsStrings.js @@ -155,4 +155,17 @@ export const searchRecommendationsStrings = createTranslator('SearchRecommendati context: 'A label to an input that asks the user to enter their specific reason for marking a resource as not relevant', }, + feedbackFailedMessage: { + message: 'Feedback submission failed', + context: + 'A message that explains to the user that there was an error submitting their feedback', + }, + feedbackInputValidationMessage: { + message: 'Please enter your feedback', + context: 'A validation message that prompts the user to enter feedback before submitting', + }, + noFeedbackSelectedErrorMessage: { + message: 'Please select at least one option in order to submit your feedback', + context: 'An error message that prompts the user to select at least one feedback option', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/RecommendedResourceCard.vue b/contentcuration/contentcuration/frontend/shared/views/RecommendedResourceCard.vue index 2d68b1c0f1..3a672d0678 100644 --- a/contentcuration/contentcuration/frontend/shared/views/RecommendedResourceCard.vue +++ b/contentcuration/contentcuration/frontend/shared/views/RecommendedResourceCard.vue @@ -42,7 +42,11 @@ :tooltip="$tr('goToLocationTooltip')" @click.stop="goToLocation" /> - + @@ -111,10 +115,14 @@ goToLocation() { window.open(this.goToLocationUrl, '_blank'); }, + markNotRelevant() { + this.$emit('irrelevant', this.node); + }, }, $trs: { selectCard: 'Select { title }', goToLocationTooltip: 'Go to location', + markNotRelevantTooltip: 'Mark as not relevant', }, }; diff --git a/contentcuration/contentcuration/tests/test_serializers.py b/contentcuration/contentcuration/tests/test_serializers.py index d8730a2bd8..0b5c2b2661 100644 --- a/contentcuration/contentcuration/tests/test_serializers.py +++ b/contentcuration/contentcuration/tests/test_serializers.py @@ -308,6 +308,34 @@ def test_deserialization_and_validation(self): str(instance.recommendation_event_id), data["recommendation_event_id"] ) + def test_bulk_deserialization_and_validation(self): + bulk_data = [ + { + "context": {"test_key": "test_value_1"}, + "contentnode_id": str(self.interaction_node.id), + "content_id": str(self.interaction_node.content_id), + "feedback_type": "IGNORED", + "feedback_reason": "----", + "recommendation_event_id": str(self.recommendation_event.id), + }, + { + "context": {"test_key": "test_value_2"}, + "contentnode_id": str(self.interaction_node.id), + "content_id": str(self.interaction_node.content_id), + "feedback_type": "PREVIEWED", + "feedback_reason": "++++", + "recommendation_event_id": str(self.recommendation_event.id), + }, + ] + serializer = RecommendationsInteractionEventSerializer( + data=bulk_data, many=True + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + instances = serializer.save() + self.assertEqual(len(instances), 2) + self.assertEqual(instances[0].context, bulk_data[0]["context"]) + self.assertEqual(instances[1].feedback_type, bulk_data[1]["feedback_type"]) + def test_invalid_data(self): data = {"context": "invalid"} serializer = RecommendationsInteractionEventSerializer(data=data) @@ -324,6 +352,31 @@ def test_invalid_data(self): serializer = RecommendationsInteractionEventSerializer(data=data) self.assertFalse(serializer.is_valid()) + def test_invalid_bulk_data(self): + # Missing 'feedback_type' + bulk_data = [ + { + "context": {"test_key": "test_value_1"}, + "contentnode_id": str(self.interaction_node.id), + "content_id": str(self.interaction_node.content_id), + "feedback_type": "IGNORED", + "feedback_reason": "----", + "recommendation_event_id": str(self.recommendation_event.id), + }, + { + "context": {"test_key": "test_value_2"}, + "contentnode_id": str(self.interaction_node.id), + "content_id": str(self.interaction_node.content_id), + "feedback_reason": "----", + "recommendation_event_id": str(self.recommendation_event.id), + }, + ] + serializer = RecommendationsInteractionEventSerializer( + data=bulk_data, many=True + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("feedback_type", str(serializer.errors)) + class RecommendationsEventSerializerTestCase(BaseAPITestCase): def setUp(self): diff --git a/contentcuration/contentcuration/tests/viewsets/test_recommendations.py b/contentcuration/contentcuration/tests/viewsets/test_recommendations.py index b335ecca62..e792cfc75b 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_recommendations.py +++ b/contentcuration/contentcuration/tests/viewsets/test_recommendations.py @@ -48,12 +48,16 @@ def recommendations_list(self): "node_id": "00000000000000000000000000000002", "main_tree_id": "1", "parent_id": "00000000000000000000000000000003", + "channel_id": "00000000000000000000000000000007", + "rank": 1, }, { "id": "00000000000000000000000000000004", "node_id": "00000000000000000000000000000005", "main_tree_id": "2", "parent_id": "00000000000000000000000000000006", + "channel_id": "00000000000000000000000000000008", + "rank": 2, }, ] @@ -347,6 +351,61 @@ def test_create_recommendations_interaction(self): ) self.assertEqual(response.status_code, 201, response.content) + def test_bulk_create_recommendations_interaction(self): + recommendations_interactions = [ + { + "context": {"test_key": "test_value_1"}, + "contentnode_id": self.interaction_node.id, + "content_id": self.interaction_node.content_id, + "feedback_type": "IGNORED", + "feedback_reason": "----", + "recommendation_event_id": str(self.recommendation_event.id), + }, + { + "context": {"test_key": "test_value_2"}, + "contentnode_id": self.interaction_node.id, + "content_id": self.interaction_node.content_id, + "feedback_type": "PREVIEWED", + "feedback_reason": "----", + "recommendation_event_id": str(self.recommendation_event.id), + }, + ] + response = self.client.post( + reverse("recommendations-interaction-list"), + recommendations_interactions, + format="json", + ) + self.assertEqual(response.status_code, 201, response.content) + self.assertEqual(len(response.json()), len(recommendations_interactions)) + + def test_bulk_create_recommendations_interaction_failure(self): + # One valid, one invalid (missing required field) + recommendations_interactions = [ + { + "context": {"test_key": "test_value_1"}, + "contentnode_id": self.interaction_node.id, + "content_id": self.interaction_node.content_id, + "feedback_type": "IGNORED", + "feedback_reason": "----", + "recommendation_event_id": str(self.recommendation_event.id), + }, + { + # Missing 'feedback_type' + "context": {"test_key": "test_value_2"}, + "contentnode_id": self.interaction_node.id, + "content_id": self.interaction_node.content_id, + "feedback_reason": "----", + "recommendation_event_id": str(self.recommendation_event.id), + }, + ] + response = self.client.post( + reverse("recommendations-interaction-list"), + recommendations_interactions, + format="json", + ) + self.assertEqual(response.status_code, 400, response.content) + self.assertIn("feedback_type", str(response.content)) + def test_list_fails(self): response = self.client.get( reverse("recommendations-interaction-list"), format="json" diff --git a/contentcuration/contentcuration/utils/recommendations.py b/contentcuration/contentcuration/utils/recommendations.py index 241238199f..f24e88fe9f 100644 --- a/contentcuration/contentcuration/utils/recommendations.py +++ b/contentcuration/contentcuration/utils/recommendations.py @@ -304,14 +304,18 @@ def get_recommendations( ) ) - # Add the corresponding channel_id to the recommendations - node_to_channel = { - node["node_id"]: node["channel_id"] for node in recommended_nodes + # Add the corresponding channel_id and rank to the recommendations + node_info = { + node["node_id"]: { + "channel_id": node["channel_id"], + "rank": node["rank"], + } + for node in recommended_nodes } for recommendation in recommendations: - recommendation["channel_id"] = node_to_channel.get( - recommendation["node_id"] - ) + node_data = node_info.get(recommendation["node_id"], {}) + recommendation["channel_id"] = node_data.get("channel_id") + recommendation["rank"] = node_data.get("rank") return RecommendationsResponse(results=list(recommendations)) diff --git a/contentcuration/contentcuration/viewsets/feedback.py b/contentcuration/contentcuration/viewsets/feedback.py index 600d6944d1..07a3bc6cb0 100644 --- a/contentcuration/contentcuration/viewsets/feedback.py +++ b/contentcuration/contentcuration/viewsets/feedback.py @@ -1,8 +1,11 @@ +from django.db import transaction from django.utils import timezone from rest_framework import permissions from rest_framework import serializers +from rest_framework import status from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from contentcuration.models import FlagFeedbackEvent from contentcuration.models import RecommendationsEvent @@ -97,7 +100,21 @@ def update(self, instance, validated_data): return super().update(instance, validated_data) -class RecommendationsInteractionEventViewSet(viewsets.ModelViewSet): +class BulkCreateModelMixin: + def create(self, request, *args, **kwargs): + if isinstance(request.data, list): + with transaction.atomic(): + serializer = self.get_serializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return super().create(request, *args, **kwargs) + + +class RecommendationsInteractionEventViewSet( + BulkCreateModelMixin, viewsets.ModelViewSet +): # TODO: decide export procedure queryset = RecommendationsInteractionEvent.objects.all() serializer_class = RecommendationsInteractionEventSerializer