dragable question in Editor

This commit is contained in:
Philippe 2025-04-05 15:34:44 -04:00
parent 5ff5b2018f
commit 11b719e2ca
4 changed files with 284 additions and 52 deletions

113
client/package-lock.json generated
View file

@ -28,6 +28,7 @@
"marked": "^14.1.2", "marked": "^14.1.2",
"nanoid": "^5.1.2", "nanoid": "^5.1.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-modal": "^3.16.3", "react-modal": "^3.16.3",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
@ -48,6 +49,7 @@
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react": "^18.2.15", "@types/react": "^18.2.15",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/react-latex": "^2.0.3", "@types/react-latex": "^2.0.3",
"@typescript-eslint/eslint-plugin": "^8.25.0", "@typescript-eslint/eslint-plugin": "^8.25.0",
@ -4555,6 +4557,15 @@
"@types/unist": "*" "@types/unist": "*"
} }
}, },
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz",
"integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==",
"dependencies": {
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0"
}
},
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@ -4695,6 +4706,15 @@
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"node_modules/@types/react-beautiful-dnd": {
"version": "13.1.8",
"resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz",
"integrity": "sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "18.3.5", "version": "18.3.5",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
@ -4715,6 +4735,17 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/react-redux": {
"version": "7.1.34",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz",
"integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==",
"dependencies": {
"@types/hoist-non-react-statics": "^3.3.0",
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0",
"redux": "^4.0.0"
}
},
"node_modules/@types/react-transition-group": { "node_modules/@types/react-transition-group": {
"version": "4.4.12", "version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
@ -5978,6 +6009,14 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-box-model": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
"dependencies": {
"tiny-invariant": "^1.0.6"
}
},
"node_modules/css.escape": { "node_modules/css.escape": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@ -9945,6 +9984,11 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"node_modules/merge-stream": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -11188,6 +11232,11 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@ -11200,6 +11249,25 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-beautiful-dnd": {
"version": "13.1.1",
"resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz",
"integrity": "sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==",
"deprecated": "react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672",
"dependencies": {
"@babel/runtime": "^7.9.2",
"css-box-model": "^1.2.0",
"memoize-one": "^5.1.1",
"raf-schd": "^4.0.2",
"react-redux": "^7.2.0",
"redux": "^4.0.4",
"use-memo-one": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8.5 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@ -11241,6 +11309,35 @@
"react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19" "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-redux": {
"version": "7.2.9",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz",
"integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==",
"dependencies": {
"@babel/runtime": "^7.15.4",
"@types/react-redux": "^7.1.20",
"hoist-non-react-statics": "^3.3.2",
"loose-envify": "^1.4.0",
"prop-types": "^15.7.2",
"react-is": "^17.0.2"
},
"peerDependencies": {
"react": "^16.8.3 || ^17 || ^18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-redux/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"node_modules/react-router": { "node_modules/react-router": {
"version": "6.30.0", "version": "6.30.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz",
@ -11316,6 +11413,14 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -12841,6 +12946,14 @@
"requires-port": "^1.0.0" "requires-port": "^1.0.0"
} }
}, },
"node_modules/use-memo-one": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz",
"integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",

View file

@ -32,6 +32,7 @@
"marked": "^14.1.2", "marked": "^14.1.2",
"nanoid": "^5.1.2", "nanoid": "^5.1.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-modal": "^3.16.3", "react-modal": "^3.16.3",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
@ -52,6 +53,7 @@
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react": "^18.2.15", "@types/react": "^18.2.15",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/react-latex": "^2.0.3", "@types/react-latex": "^2.0.3",
"@typescript-eslint/eslint-plugin": "^8.25.0", "@typescript-eslint/eslint-plugin": "^8.25.0",

View file

@ -1,7 +1,10 @@
import React from 'react'; import React, { useState } from 'react';
import { TextField, Typography, IconButton, Box } from '@mui/material'; import { TextField, Typography, IconButton, Box, Collapse, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Button } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
interface EditorProps { interface EditorProps {
label: string; label: string;
@ -11,6 +14,10 @@ interface EditorProps {
} }
const Editor: React.FC<EditorProps> = ({ label, values, onValuesChange, onFocusQuestion }) => { const Editor: React.FC<EditorProps> = ({ label, values, onValuesChange, onFocusQuestion }) => {
const [collapsed, setCollapsed] = useState<boolean[]>(Array(values.length).fill(false));
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
const handleChange = (index: number) => (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (index: number) => (event: React.ChangeEvent<HTMLInputElement>) => {
const newValues = [...values]; const newValues = [...values];
newValues[index] = event.target.value; newValues[index] = event.target.value;
@ -18,16 +25,67 @@ const Editor: React.FC<EditorProps> = ({ label, values, onValuesChange, onFocusQ
}; };
const handleDeleteQuestion = (index: number) => () => { const handleDeleteQuestion = (index: number) => () => {
const newValues = values.filter((_, i) => i !== index); // Remove the question at the specified index if (values[index].trim() === '') {
const newValues = values.filter((_, i) => i !== index);
onValuesChange(newValues); onValuesChange(newValues);
setCollapsed((prev) => prev.filter((_, i) => i !== index));
} else {
setDeleteIndex(index);
setDialogOpen(true);
}
};
const handleConfirmDelete = () => {
if (deleteIndex !== null) {
const newValues = values.filter((_, i) => i !== deleteIndex);
onValuesChange(newValues);
setCollapsed((prev) => prev.filter((_, i) => i !== deleteIndex));
}
setDialogOpen(false);
setDeleteIndex(null);
};
const handleCancelDelete = () => {
setDialogOpen(false);
setDeleteIndex(null);
}; };
const handleFocusQuestion = (index: number) => () => { const handleFocusQuestion = (index: number) => () => {
if (onFocusQuestion) { if (onFocusQuestion) {
onFocusQuestion(index); // Call the focus function if provided onFocusQuestion(index);
}
} }
};
const handleToggleCollapse = (index: number) => () => {
setCollapsed((prev) => {
const newCollapsed = [...prev];
newCollapsed[index] = !newCollapsed[index];
return newCollapsed;
});
};
const onDragEnd = (result: any) => {
if (!result.destination) return;
const newValues = [...values];
const [reorderedItem] = newValues.splice(result.source.index, 1);
newValues.splice(result.destination.index, 0, reorderedItem);
onValuesChange(newValues);
const newCollapsed = [...collapsed];
const [reorderedCollapsed] = newCollapsed.splice(result.source.index, 1);
newCollapsed.splice(result.destination.index, 0, reorderedCollapsed);
setCollapsed(newCollapsed);
};
if (collapsed.length !== values.length) {
setCollapsed((prev) => {
const newCollapsed = [...prev];
while (newCollapsed.length < values.length) newCollapsed.push(false);
while (newCollapsed.length > values.length) newCollapsed.pop();
return newCollapsed;
});
}
return ( return (
<div> <div>
@ -35,26 +93,53 @@ const Editor: React.FC<EditorProps> = ({ label, values, onValuesChange, onFocusQ
{label} {label}
</Typography> </Typography>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="questions">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
{values.map((value, index) => ( {values.map((value, index) => (
<Box key={index} style={{ marginBottom: '24px' }}> <Draggable key={index} draggableId={`question-${index}`} index={index}>
{(provided) => (
<Box
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
sx={{
marginBottom: '24px',
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)',
border: '1px solid rgba(0, 0, 0, 0.1)',
padding: '16px',
borderRadius: '4px',
...provided.draggableProps.style,
}}
>
<Box display="flex" alignItems="center" justifyContent="space-between"> <Box display="flex" alignItems="center" justifyContent="space-between">
<Typography variant="subtitle1" fontWeight="bold" style={{ marginBottom: '8px' }}> <Typography variant="subtitle1" fontWeight="bold" style={{ marginBottom: '8px' }}>
Question {index + 1} Question {index + 1}
</Typography> </Typography>
<Box> <Box>
{/* Focus (Eye) Button */} <IconButton
onClick={handleToggleCollapse(index)}
aria-label="toggle collapse"
sx={{
color: 'gray',
'&:hover': { color: 'blue' },
mr: 1,
}}
>
{collapsed[index] ? <ExpandMoreIcon /> : <ExpandLessIcon />}
</IconButton>
<IconButton <IconButton
onClick={handleFocusQuestion(index)} onClick={handleFocusQuestion(index)}
aria-label="focus question" aria-label="focus question"
sx={{ sx={{
color: 'gray', color: 'gray',
'&:hover': { color: 'blue' }, '&:hover': { color: 'blue' },
marginRight: '8px', // Space between eye and delete mr: 1,
}} }}
> >
<VisibilityIcon /> <VisibilityIcon />
</IconButton> </IconButton>
{/* Delete Button */}
<IconButton <IconButton
onClick={handleDeleteQuestion(index)} onClick={handleDeleteQuestion(index)}
aria-label="delete" aria-label="delete"
@ -67,6 +152,7 @@ const Editor: React.FC<EditorProps> = ({ label, values, onValuesChange, onFocusQ
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
<Collapse in={!collapsed[index]}>
<TextField <TextField
value={value} value={value}
onChange={handleChange(index)} onChange={handleChange(index)}
@ -77,8 +163,39 @@ const Editor: React.FC<EditorProps> = ({ label, values, onValuesChange, onFocusQ
variant="outlined" variant="outlined"
style={{ overflow: 'auto' }} style={{ overflow: 'auto' }}
/> />
</Collapse>
</Box> </Box>
)}
</Draggable>
))} ))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{/* Confirmation Dialog */}
<Dialog
open={dialogOpen}
onClose={handleCancelDelete}
aria-labelledby="delete-confirmation-title"
aria-describedby="delete-confirmation-description"
>
<DialogTitle id="delete-confirmation-title" sx={{ textAlign: 'center'}}>Suppression</DialogTitle>
<DialogContent>
<DialogContentText id="delete-confirmation-description">
Confirmez vous la suppression de Question {deleteIndex !== null ? deleteIndex + 1 : ''} ?
</DialogContentText>
</DialogContent>
<DialogActions sx={{ justifyContent: 'center', pb: 2 }}>
<Button onClick={handleCancelDelete} color="primary" sx={{ mx: 1 }}>
Annuler
</Button>
<Button onClick={handleConfirmDelete} color="error" sx={{ mx: 1 }} autoFocus>
Supprimer
</Button>
</DialogActions>
</Dialog>
</div> </div>
); );
}; };

View file

@ -272,7 +272,7 @@ const QuizForm: React.FC = () => {
<div className='edit'> <div className='edit'>
<Editor <Editor
label="Contenu GIFT de chaque question:" label=""
values={values} values={values}
onValuesChange={handleUpdatePreview} onValuesChange={handleUpdatePreview}
onFocusQuestion={handleFocusQuestion} /> onFocusQuestion={handleFocusQuestion} />