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$() }}
+
+
+
+ handleFeedbackCheckboxChange(option.value, value)"
+ />
+
+
+
@@ -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