diff --git a/packages/categorize/configure/src/design/__tests__/index.test.jsx b/packages/categorize/configure/src/design/__tests__/index.test.jsx index 80b9784d21..5e772dcdaa 100644 --- a/packages/categorize/configure/src/design/__tests__/index.test.jsx +++ b/packages/categorize/configure/src/design/__tests__/index.test.jsx @@ -105,4 +105,118 @@ describe('Design', () => { expect(container).toBeInTheDocument(); }); }); + + describe('onDragEnd', () => { + const createInstance = (modelExtras = {}) => { + const instance = new Design({ + model: model({ + allowAlternateEnabled: true, + ...modelExtras, + }), + onChange, + }); + + instance.setState = jest.fn(); + instance.removeChoiceFromSource = jest.fn(); + instance.moveChoice = jest.fn(); + instance.addChoiceToCategory = jest.fn(); + instance.moveChoiceInAlternate = jest.fn(); + instance.addChoiceToAlternateCategory = jest.fn(); + + return instance; + }; + + const eventFor = ({ activeData, overData }) => ({ + active: activeData ? { data: { current: activeData } } : null, + over: overData ? { data: { current: overData } } : null, + }); + + it('routes choice-preview with no target to removeChoiceFromSource', () => { + const instance = createInstance(); + const activeData = { + type: 'choice-preview', + id: 'c1-cat1-0', + categoryId: '1', + choiceIndex: 0, + }; + + instance.onDragEnd(eventFor({ activeData })); + + expect(instance.removeChoiceFromSource).toHaveBeenCalledWith( + activeData, + 0, + expect.objectContaining({ + allowAlternateEnabled: true, + categories: expect.any(Array), + choices: expect.any(Array), + }), + ); + }); + + it('routes choice-preview dropped on choice pool to removeChoiceFromSource', () => { + const instance = createInstance(); + const activeData = { + type: 'choice-preview', + id: 'c1-cat1-0', + categoryId: '1', + choiceIndex: 0, + }; + const overData = { type: 'choice' }; + + instance.onDragEnd(eventFor({ activeData, overData })); + + expect(instance.removeChoiceFromSource).toHaveBeenCalled(); + expect(instance.moveChoice).not.toHaveBeenCalled(); + }); + + it('routes choice-preview dropped on category to moveChoice', () => { + const instance = createInstance(); + const activeData = { + type: 'choice-preview', + id: 'c1-cat1-0', + categoryId: '1', + choiceIndex: 2, + }; + const overData = { type: 'category', id: '2' }; + + instance.onDragEnd(eventFor({ activeData, overData })); + + expect(instance.moveChoice).toHaveBeenCalledWith('c1', '1', '2', 2); + }); + + it('routes new choice dropped on category to addChoiceToCategory', () => { + const instance = createInstance(); + const activeData = { type: 'choice', id: '9' }; + const overData = { type: 'category', id: '2' }; + + instance.onDragEnd(eventFor({ activeData, overData })); + + expect(instance.addChoiceToCategory).toHaveBeenCalledWith({ id: '9' }, '2'); + }); + + it('routes choice-preview dropped on category-alternate to moveChoiceInAlternate', () => { + const instance = createInstance(); + const activeData = { + type: 'choice-preview', + id: 'c1-cat1-0', + categoryId: '1', + choiceIndex: 1, + }; + const overData = { type: 'category-alternate', id: '2', alternateResponseIndex: 3 }; + + instance.onDragEnd(eventFor({ activeData, overData })); + + expect(instance.moveChoiceInAlternate).toHaveBeenCalledWith('c1', '1', '2', 1, 3); + }); + + it('routes new choice dropped on category-alternate to addChoiceToAlternateCategory', () => { + const instance = createInstance({ allowAlternateEnabled: true }); + const activeData = { type: 'choice', id: '11' }; + const overData = { type: 'category-alternate', id: '2', alternateResponseIndex: 4 }; + + instance.onDragEnd(eventFor({ activeData, overData })); + + expect(instance.addChoiceToAlternateCategory).toHaveBeenCalledWith({ id: '11' }, '2', 4); + }); + }); }); diff --git a/packages/categorize/configure/src/design/index.jsx b/packages/categorize/configure/src/design/index.jsx index 74bfa85aa1..e87f079ad7 100644 --- a/packages/categorize/configure/src/design/index.jsx +++ b/packages/categorize/configure/src/design/index.jsx @@ -226,55 +226,69 @@ export class Design extends React.Component { }); }; - onDragEnd = (event) => { - const { active, over } = event; - + onDragEnd = ({ active, over }) => { this.setState({ activeDragItem: null }); + if (!active) return; + + const { model } = this.props; + const { allowAlternateEnabled, categories = [], choices = [] } = model; + + const activeData = active?.data?.current; + const overData = over?.data?.current; + + if (!activeData) return; - if (!over || !active) { + const choiceIndex = activeData.choiceIndex || 0; + const overType = overData?.type; + const isPreview = activeData.type === 'choice-preview'; + const isNewChoice = activeData.type === 'choice'; + + const choiceId = + activeData.choice?.id || (typeof activeData.id === 'string' ? activeData.id.split('-')[0] : activeData.id); + + if (isPreview && (!overData || overType === 'choice')) { + this.removeChoiceFromSource(activeData, choiceIndex, { allowAlternateEnabled, categories, choices }); return; } - const { model } = this.props; - const { allowAlternateEnabled } = model; - const activeData = active.data.current; - const overData = over.data.current; - - // moving a choice between categories (correct response) - if (activeData.type === 'choice-preview' && overData.type === 'category') { - // Extract original choice.id - if DraggableChoice uses the unique id in data, extract the first part - // Format: ${choice.id}-${categoryId}-${choiceIndex} or ${choice.id}-${categoryId}-${choiceIndex}-alt-${alternateResponseIndex} - const choiceId = - activeData.choice?.id || (typeof activeData.id === 'string' ? activeData.id.split('-')[0] : activeData.id); - this.moveChoice(choiceId, activeData.categoryId, overData.id, activeData.choiceIndex || 0); + if (isPreview && overType === 'category') { + return this.moveChoice(choiceId, activeData.categoryId, overData.id, choiceIndex); } - // placing a choice into a category (correct response) - if (activeData.type === 'choice' && overData.type === 'category') { - this.addChoiceToCategory({ id: activeData.id }, overData.id); + if (isNewChoice && overType === 'category') { + return this.addChoiceToCategory({ id: activeData.id }, overData.id); } - // moving a choice between categories (alternate response) - if (activeData.type === 'choice-preview' && overData.type === 'category-alternate') { - const toAlternateIndex = overData.alternateResponseIndex; - // Extract original choice.id - if DraggableChoice uses the unique id in data, extract the first part - const choiceId = - activeData.choice?.id || (typeof activeData.id === 'string' ? activeData.id.split('-')[0] : activeData.id); - this.moveChoiceInAlternate( + if (isPreview && overType === 'category-alternate') { + return this.moveChoiceInAlternate( choiceId, activeData.categoryId, overData.id, - activeData.choiceIndex || 0, - toAlternateIndex, + choiceIndex, + overData.alternateResponseIndex, ); } - // placing a choice into a category (alternate response) - if (allowAlternateEnabled && activeData.type === 'choice' && overData.type === 'category-alternate') { - const choiceId = activeData.id; - const categoryId = overData.id; - const toAlternateResponseIndex = overData.alternateResponseIndex; - this.addChoiceToAlternateCategory({ id: choiceId }, categoryId, toAlternateResponseIndex); + if (allowAlternateEnabled && isNewChoice && overType === 'category-alternate') { + return this.addChoiceToAlternateCategory({ id: activeData.id }, overData.id, overData.alternateResponseIndex); + } + }; + + removeChoiceFromSource = (activeData, choiceIndex, { allowAlternateEnabled, categories, choices }) => { + const isAlternateSource = activeData.alternateResponseIndex !== undefined; + + if (!isAlternateSource) { + this.deleteChoiceFromCategory(activeData.categoryId, activeData.choiceId, choiceIndex); + return; + } + + if (!allowAlternateEnabled) return; + + const category = categories?.find((c) => c.id === activeData.categoryId); + const choice = choices?.find((c) => c.id === activeData.choiceId); + + if (category && choice) { + this.deleteChoiceFromAlternateCategory(category, choice, choiceIndex, activeData.alternateResponseIndex); } }; @@ -299,9 +313,9 @@ export class Design extends React.Component { }); }; - deleteChoiceFromCategory = (category, choice, choiceIndex) => { + deleteChoiceFromCategory = (categoryId, choiceId, choiceIndex) => { const { model } = this.props; - const correctResponse = removeChoiceFromCategory(choice.id, category.id, choiceIndex, model.correctResponse); + const correctResponse = removeChoiceFromCategory(choiceId, categoryId, choiceIndex, model.correctResponse); this.updateModel({ correctResponse }); }; diff --git a/packages/categorize/src/categorize/__tests__/index.test.jsx b/packages/categorize/src/categorize/__tests__/index.test.jsx index 3cc68a909a..6f44ca901e 100644 --- a/packages/categorize/src/categorize/__tests__/index.test.jsx +++ b/packages/categorize/src/categorize/__tests__/index.test.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import { Categorize } from '../index'; +import CategorizeProvider from '../index'; jest.mock('@pie-lib/drag', () => ({ uid: { @@ -98,7 +99,7 @@ describe('categorize', () => { return render( - + , ); }; @@ -137,4 +138,102 @@ describe('categorize', () => { expect(container).toBeInTheDocument(); }); }); + + describe('provider onDragEnd', () => { + const createProvider = (extras = {}) => { + const instance = new CategorizeProvider({ + ...defaultProps, + onAnswersChange: jest.fn(), + onShowCorrectToggle: jest.fn(), + resumeMathObserver: jest.fn(), + ...extras, + }); + + instance.setState = jest.fn(); + instance.categorizeRef = { + removeChoice: jest.fn(), + dropChoice: jest.fn(), + }; + + return instance; + }; + + const dndEvent = ({ activeData, overId, overData }) => ({ + active: activeData ? { data: { current: activeData } } : null, + over: overId || overData ? { id: overId, data: { current: overData } } : null, + }); + + it('removes choice from source when dropped outside valid target', () => { + const provider = createProvider(); + const event = dndEvent({ + activeData: { + id: 'c1', + type: 'choice', + categoryId: 'cat-1', + choiceIndex: 0, + value: 'v1', + itemType: 'categorize', + }, + }); + + provider.onDragEnd(event); + + expect(provider.categorizeRef.removeChoice).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'c1', + categoryId: 'cat-1', + choiceIndex: 0, + }), + ); + expect(provider.categorizeRef.dropChoice).not.toHaveBeenCalled(); + }); + + it('removes choice from source when dropped on choices-board', () => { + const provider = createProvider(); + const event = dndEvent({ + overId: 'choices-board', + overData: { itemType: 'categorize' }, + activeData: { + id: 'c2', + type: 'choice', + categoryId: 'cat-2', + choiceIndex: 1, + value: 'v2', + itemType: 'categorize', + }, + }); + + provider.onDragEnd(event); + + expect(provider.categorizeRef.removeChoice).toHaveBeenCalled(); + expect(provider.categorizeRef.dropChoice).not.toHaveBeenCalled(); + }); + + it('drops choice into category when valid category target exists', () => { + const provider = createProvider(); + const event = dndEvent({ + overId: 'cat-target', + overData: { itemType: 'categorize' }, + activeData: { + id: 'c3', + type: 'choice', + categoryId: 'cat-source', + choiceIndex: 2, + value: 'v3', + itemType: 'categorize', + }, + }); + + provider.onDragEnd(event); + + expect(provider.categorizeRef.dropChoice).toHaveBeenCalledWith( + 'cat-target', + expect.objectContaining({ + id: 'c3', + categoryId: 'cat-source', + choiceIndex: 2, + }), + ); + }); + }); }); diff --git a/packages/categorize/src/categorize/index.jsx b/packages/categorize/src/categorize/index.jsx index 5bdec56fcc..e250da6c1a 100644 --- a/packages/categorize/src/categorize/index.jsx +++ b/packages/categorize/src/categorize/index.jsx @@ -21,7 +21,7 @@ class DragPreviewWrapper extends React.Component { static propTypes = { children: PropTypes.node, }; - + containerRef = React.createRef(); componentDidMount() { @@ -371,15 +371,10 @@ class CategorizeProvider extends React.Component { // Check if drop is valid const draggedItem = active?.data?.current; const overData = over?.data?.current; - const isValidDrop = - over && - active && - draggedItem && - draggedItem.type === 'choice' && - overData && - overData.itemType === 'categorize'; - - this.setState({ + const isValidDrop = + over && active && draggedItem && draggedItem.type === 'choice' && overData && overData.itemType === 'categorize'; + + this.setState({ activeDragItem: null, isValidDrop: isValidDrop, }); @@ -388,30 +383,36 @@ class CategorizeProvider extends React.Component { resumeMathObserver(); } - if (!over || !active) { + if (!active || !draggedItem || draggedItem.type !== 'choice') { + return; + } + + const choiceData = { + id: draggedItem.id, + categoryId: draggedItem.categoryId, + choiceIndex: draggedItem.choiceIndex, + value: draggedItem.value, + itemType: draggedItem.itemType, + }; + + // Dropped outside a valid/known target: remove from source category, + // which returns the choice to the choices pool. + if (!over) { + if (this.categorizeRef && this.categorizeRef.removeChoice && draggedItem.categoryId) { + this.categorizeRef.removeChoice(choiceData); + } return; } - if (draggedItem && draggedItem.type === 'choice') { - const choiceData = { - id: draggedItem.id, - categoryId: draggedItem.categoryId, - choiceIndex: draggedItem.choiceIndex, - value: draggedItem.value, - itemType: draggedItem.itemType, - }; - - if (over.id === 'choices-board') { - if (this.categorizeRef && this.categorizeRef.removeChoice && draggedItem.categoryId) { - this.categorizeRef.removeChoice(choiceData); - } - } else { - const categoryId = over.id; - - if (this.categorizeRef && this.categorizeRef.dropChoice) { - this.categorizeRef.dropChoice(categoryId, choiceData); - } + if (over.id === 'choices-board') { + if (this.categorizeRef && this.categorizeRef.removeChoice && draggedItem.categoryId) { + this.categorizeRef.removeChoice(choiceData); } + return; + } + + if (this.categorizeRef && this.categorizeRef.dropChoice) { + this.categorizeRef.dropChoice(over.id, choiceData); } }; @@ -436,7 +437,7 @@ class CategorizeProvider extends React.Component { // Disable drop animation for valid drops to prevent visual snap-back // Keep default animation for invalid drops to show visual feedback const dropAnimation = isValidDrop ? null : undefined; - + return (