CLASSROOMIO-T-14: Make exercises draggable + replace buttons with icons

This commit is contained in:
Rotimi Best
2021-09-27 08:38:23 +03:00
parent 70230b3451
commit eab51fe1a9
8 changed files with 294 additions and 177 deletions
@@ -1,4 +1,5 @@
<script> <script>
import { dndzone } from 'svelte-dnd-action';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { import {
questionnaire, questionnaire,
@@ -18,6 +19,7 @@
// import Subtract24 from "carbon-icons-svelte/lib/Subtract24"; // import Subtract24 from "carbon-icons-svelte/lib/Subtract24";
import TextField from '../../../../Form/TextField.svelte'; import TextField from '../../../../Form/TextField.svelte';
import Modal from '../../../../Modal/index.svelte';
import TextArea from '../../../../Form/TextArea.svelte'; import TextArea from '../../../../Form/TextArea.svelte';
import Checkbox from '../../../../Form/Checkbox.svelte'; import Checkbox from '../../../../Form/Checkbox.svelte';
import DateTime from '../../../../Form/DateTime.svelte'; import DateTime from '../../../../Form/DateTime.svelte';
@@ -43,11 +45,42 @@
const extraActions = ['Code Snippets', 'Upload image']; const extraActions = ['Code Snippets', 'Upload image'];
const initialQuestionsLength = $questionnaire.questions.length; const initialQuestionsLength = $questionnaire.questions.length;
export let editDescription = false;
let errors = {}; let errors = {};
let questionIdToDelete = null; let questionIdToDelete = null;
let questions = [];
let isDragging = false;
function shouldScrollToLast(index, questions) { function handleDndConsider(e) {
questions = e.detail.items;
}
function handleDndFinalize(e) {
const prevQuestionsByOrder = questions.reduce(
(acc, l, index) => ({ ...acc, [l.id]: l.order }),
{}
);
questions = e.detail.items;
// // Only update the lesson order that changed
e.detail.items.forEach((item, index) => {
const order = index + 1;
if (order !== prevQuestionsByOrder[item.id]) {
$questionnaire.questions.some((q) => {
if (q.id === item.id) {
q.is_dirty = true;
q.order = order;
}
});
}
});
}
function shouldScrollToLast(questionId, questions) {
const currentQuestionsLength = questions.length; const currentQuestionsLength = questions.length;
const index = questions.findIndex((question) => question.id === questionId);
const isLast = index + 1 === currentQuestionsLength; const isLast = index + 1 === currentQuestionsLength;
return isLast && initialQuestionsLength !== currentQuestionsLength; return isLast && initialQuestionsLength !== currentQuestionsLength;
@@ -84,6 +117,7 @@
} }
$: errors = $questionnaireValidation; $: errors = $questionnaireValidation;
$: questions = filterOutDeleted($questionnaire.questions);
</script> </script>
<DeleteConfirmationModal <DeleteConfirmationModal
@@ -91,64 +125,86 @@
onDelete={onFinalDeleteClicked} onDelete={onFinalDeleteClicked}
/> />
<div class="w-11/12 m-auto"> <Modal
<QuestionContainer isTitle={true}> onClose={() => (editDescription = false)}
<TextField bind:open={editDescription}
placeholder="Title" width="w-2/4"
bind:value={$questionnaire.title} modalHeading="Update description"
className="mb-2" >
onChange={() => ($questionnaire.is_title_dirty = true)} <div>
/> <QuestionContainer isTitle={true}>
<DateTime <TextField
label="Due by" placeholder="Title"
className="w-50" bind:value={$questionnaire.title}
value={$questionnaire.due_by} className="mb-2"
onInput={(e) => { onChange={() => ($questionnaire.is_title_dirty = true)}
$questionnaire.due_by = e.target.value; />
$questionnaire.is_due_by_dirty = true; <DateTime
}} label="Due by"
/> className="w-50"
<EditContent value={$questionnaire.due_by}
writeLabel="Description" onInput={(e) => {
bind:value={$questionnaire.description} $questionnaire.due_by = e.target.value;
placeholder="Start typing your lesson" $questionnaire.is_due_by_dirty = true;
textAreaHeight="100px" }}
onInputChange={() => ($questionnaire.is_description_dirty = true)} />
/> <EditContent
</QuestionContainer> writeLabel="Description"
bind:value={$questionnaire.description}
placeholder="Start typing your lesson"
textAreaHeight="100px"
onInputChange={() => ($questionnaire.is_description_dirty = true)}
/>
</QuestionContainer>
</div>
</Modal>
<div class="w-full">
{#if Object.values(errors).length} {#if Object.values(errors).length}
<div class="w-full flex justify-center mb-4"> <div class="w-full flex justify-center mb-4">
<ErrorMessage message="You have some errors" /> <ErrorMessage message="You have some errors" />
</div> </div>
{/if} {/if}
<div
use:dndzone={{
items: questions,
flipDurationMs: 300,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed',
},
}}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}
class="questions pt-6 px-6"
>
{#each questions as question (question.id)}
<!-- {#key question.id} -->
<QuestionContainer
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(
question.id,
$questionnaire.questions
)}
bind:points={question.points}
hasError={!!errors[question.id]}
onPointsChange={() => {
question.is_dirty = true;
}}
>
<div class="flex justify-between items-center">
<div class="mr-2 w-3/5">
<TextField
placeholder="Question"
bind:value={question.title}
isRequired={true}
onChange={() => {
question.is_dirty = true;
}}
/>
</div>
{#each filterOutDeleted($questionnaire.questions) as question, index} <!-- <Dropdown
<!-- {#key question.id}
<div out:fly={{ x: 500, duration: 1000 }}> -->
<QuestionContainer
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(index, $questionnaire.questions)}
bind:points={question.points}
hasError={!!errors[question.id]}
onPointsChange={() => {
question.is_dirty = true;
}}
>
<div class="flex justify-between items-center">
<div class="mr-2 w-3/5">
<TextField
placeholder="Question"
bind:value={question.title}
isRequired={true}
onChange={() => {
console.log('title changed', question.title);
question.is_dirty = true;
}}
/>
</div>
<!-- <Dropdown
options={extraActions} options={extraActions}
handleOptionClick={handleOptionClick(question.id)} handleOptionClick={handleOptionClick(question.id)}
> >
@@ -156,120 +212,132 @@
<p class="ml-2 text-gray-600">Add</p> <p class="ml-2 text-gray-600">Add</p>
</Dropdown> --> </Dropdown> -->
<Select <Select
bind:value={question.question_type} bind:value={question.question_type}
options={QUESTION_TYPES} options={QUESTION_TYPES}
onChange={() => { onChange={() => {
question.is_dirty = true; question.is_dirty = true;
}} }}
/>
</div>
{#if typeof question.code === 'string'}
<div class="flex justify-between items-center my-3 w-3/5">
<TextArea
bind:value={question.code}
rows="2"
placeholder="Write your code"
/> />
<IconButton
value="write-code"
onClick={() => handleCode(question.id, false)}
>
<Delete24 class="carbon-icon" />
</IconButton>
</div> </div>
{/if}
<div class="flex flex-col mt-2"> {#if typeof question.code === 'string'}
{#if QUESTION_TYPE.RADIO === question.question_type.id} <div class="flex justify-between items-center my-3 w-3/5">
{#each filterOutDeleted(question.options) as option} <TextArea
<RadioItem bind:value={question.code}
isEditable={true} rows="2"
name={question.title || 'radio-name'} placeholder="Write your code"
bind:label={option.label} />
onChange={addDynamicValue(question.id, option.id)} <IconButton
value="write-code"
onClick={() => handleCode(question.id, false)}
> >
<div slot="iconbutton" class="flex items-center"> <Delete24 class="carbon-icon" />
<IconButton </IconButton>
value={option.id} </div>
onClick={handleRemoveOption(question.id, option.id)}
>
<Delete24 class="carbon-icon" />
</IconButton>
<IconButton
value={option.id}
onClick={handleAnswerSelect(question.id, option.id)}
buttonClassName={option.is_correct && 'success'}
>
{#if option.is_correct}
<CheckmarkFilled24 class="carbon-icon" />
{:else}
<CheckmarkOutline24 class="carbon-icon" />
{/if}
</IconButton>
</div>
</RadioItem>
{/each}
{/if} {/if}
{#if QUESTION_TYPE.CHECKBOX === question.question_type.id} <div class="flex flex-col mt-2">
{#each filterOutDeleted(question.options) as option} {#if QUESTION_TYPE.RADIO === question.question_type.id}
<Checkbox {#each filterOutDeleted(question.options) as option}
isEditable={true} <RadioItem
name={question || 'checkbox-name'} isEditable={true}
bind:label={option.label} name={question.title || 'radio-name'}
onChange={addDynamicValue(question.id, option.id)} bind:label={option.label}
> onChange={addDynamicValue(question.id, option.id)}
<div slot="iconbutton" class="flex items-center"> >
<IconButton <div slot="iconbutton" class="flex items-center">
value={option.id} <IconButton
onClick={handleRemoveOption(question.id, option.id)} value={option.id}
> onClick={handleRemoveOption(question.id, option.id)}
<Delete24 class="carbon-icon" /> >
</IconButton> <Delete24 class="carbon-icon" />
<IconButton </IconButton>
value={option.id} <IconButton
onClick={handleAnswerSelect(question.id, option.id)} value={option.id}
buttonClassName={option.is_correct && 'success'} onClick={handleAnswerSelect(question.id, option.id)}
> buttonClassName={option.is_correct && 'success'}
{#if option.is_correct} >
<CheckmarkFilled24 class="carbon-icon" /> {#if option.is_correct}
{:else} <CheckmarkFilled24 class="carbon-icon" />
<CheckmarkOutline24 class="carbon-icon" /> {:else}
{/if} <CheckmarkOutline24 class="carbon-icon" />
</IconButton> {/if}
</div> </IconButton>
</Checkbox> </div>
{/each} </RadioItem>
{/if} {/each}
{/if}
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id} {#if QUESTION_TYPE.CHECKBOX === question.question_type.id}
<TextArea bind:value={question.value} /> {#each filterOutDeleted(question.options) as option}
{/if} <Checkbox
isEditable={true}
name={question || 'checkbox-name'}
bind:label={option.label}
onChange={addDynamicValue(question.id, option.id)}
>
<div slot="iconbutton" class="flex items-center">
<IconButton
value={option.id}
onClick={handleRemoveOption(question.id, option.id)}
>
<Delete24 class="carbon-icon" />
</IconButton>
<IconButton
value={option.id}
onClick={handleAnswerSelect(question.id, option.id)}
buttonClassName={option.is_correct && 'success'}
>
{#if option.is_correct}
<CheckmarkFilled24 class="carbon-icon" />
{:else}
<CheckmarkOutline24 class="carbon-icon" />
{/if}
</IconButton>
</div>
</Checkbox>
{/each}
{/if}
{#if getQuestionErrorMsg(errors, question, 'option')} {#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
<ErrorMessage <TextArea bind:value={question.value} />
message={getQuestionErrorMsg(errors, question, 'option')} {/if}
/>
{/if}
</div>
{#if QUESTION_TYPE.TEXTAREA !== question.question_type.id} {#if getQuestionErrorMsg(errors, question, 'option')}
<div class="flex items-center mt-3"> <ErrorMessage
<PrimaryButton message={getQuestionErrorMsg(errors, question, 'option')}
disablePadding={true} />
className="p-2" {/if}
variant={VARIANTS.OUTLINED}
onClick={handleAddOption(question.id)}
>
<AddFilled24 class="carbon-icon" />
<p class="ml-2">Add option</p>
</PrimaryButton>
</div> </div>
{/if}
</QuestionContainer> {#if QUESTION_TYPE.TEXTAREA !== question.question_type.id}
<!-- </div> <div class="flex items-center mt-3">
{/key} --> <PrimaryButton
{/each} disablePadding={true}
className="p-2"
variant={VARIANTS.OUTLINED}
onClick={handleAddOption(question.id)}
>
<AddFilled24 class="carbon-icon" />
<p class="ml-2">Add option</p>
</PrimaryButton>
</div>
{/if}
</QuestionContainer>
<!-- {/key} -->
{/each}
</div>
</div> </div>
<style>
.questions {
height: 80vh;
overflow-y: auto;
}
:global(#dnd-action-dragged-el div) {
z-index: 100;
}
</style>
@@ -91,7 +91,7 @@
function getTotalPossibleGrade() { function getTotalPossibleGrade() {
return $questionnaire.questions.reduce((acc, question) => { return $questionnaire.questions.reduce((acc, question) => {
acc += parseInt(question.points, 10); acc += parseFloat(question.points, 10);
return acc; return acc;
}, 0); }, 0);
} }
@@ -1,6 +1,10 @@
<script> <script>
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import AddAlt16 from 'carbon-icons-svelte/lib/AddAlt16'; import AddAlt24 from 'carbon-icons-svelte/lib/AddAlt24';
import Delete24 from 'carbon-icons-svelte/lib/Delete24';
import Settings24 from 'carbon-icons-svelte/lib/Settings24';
import View32 from 'carbon-icons-svelte/lib/View32';
import ViewFilled32 from 'carbon-icons-svelte/lib/ViewFilled32';
import { import {
handleAddQuestion, handleAddQuestion,
questionnaire, questionnaire,
@@ -9,6 +13,7 @@
} from '../store/exercise'; } from '../store/exercise';
import PrimaryButton from '../../../../PrimaryButton/index.svelte'; import PrimaryButton from '../../../../PrimaryButton/index.svelte';
import PageBody from '../../../../PageBody/index.svelte'; import PageBody from '../../../../PageBody/index.svelte';
import IconButton from '../../../../IconButton/index.svelte';
import RoleBasedSecurity from '../../../../RoleBasedSecurity/index.svelte'; import RoleBasedSecurity from '../../../../RoleBasedSecurity/index.svelte';
import ViewMode from './ViewMode.svelte'; import ViewMode from './ViewMode.svelte';
import EditMode from './EditMode.svelte'; import EditMode from './EditMode.svelte';
@@ -21,6 +26,7 @@
let mode = MODES.view; let mode = MODES.view;
let preview = false; let preview = false;
let editDescription = false;
async function handleMode() { async function handleMode() {
if (mode === MODES.edit) { if (mode === MODES.edit) {
@@ -47,6 +53,8 @@
} }
} }
function handleDeleteExercise() {}
function onCancel() { function onCancel() {
mode = MODES.view; mode = MODES.view;
refetchExercise(); refetchExercise();
@@ -57,7 +65,7 @@
}); });
</script> </script>
<PageBody> <PageBody padding="px-4">
<svelte:fragment slot="header"> <svelte:fragment slot="header">
<div class="flex items-center"> <div class="flex items-center">
<RoleBasedSecurity allowedRoles="[1,2]"> <RoleBasedSecurity allowedRoles="[1,2]">
@@ -78,25 +86,48 @@
</RoleBasedSecurity> </RoleBasedSecurity>
{#if mode !== MODES.edit} {#if mode !== MODES.edit}
<PrimaryButton <IconButton
variant={preview ? VARIANTS.CONTAINED : VARIANTS.OUTLINED}
label={'Toggle Preview'}
onClick={() => (preview = !preview)} onClick={() => (preview = !preview)}
/> toolTipProps={{ title: 'Toggle preview', direction: 'right' }}
selected={preview}
contained={true}
>
{#if preview}
<ViewFilled32 class="carbon-icon" />
{:else}
<View32 class="carbon-icon" />
{/if}
</IconButton>
{/if} {/if}
</div> </div>
{#if mode === MODES.edit} {#if mode === MODES.edit}
<div class="flex items-center"> <div class="flex items-center">
<PrimaryButton variant={VARIANTS.OUTLINED} onClick={handleAddQuestion}> <IconButton
Add Question <AddAlt16 class="carbon-icon" /> onClick={() => (editDescription = true)}
</PrimaryButton> toolTipProps={{ title: 'Edit description', direction: 'bottom' }}
>
<Settings24 class="carbon-icon" />
</IconButton>
<!-- <IconButton
onClick={handleDeleteExercise}
toolTipProps={{ title: 'Delete questionnaire', direction: 'bottom' }}
>
<Delete24 class="carbon-icon" />
</IconButton> -->
<IconButton
onClick={handleAddQuestion}
toolTipProps={{ title: 'Add question', direction: 'bottom' }}
>
<AddAlt24 class="carbon-icon" />
</IconButton>
</div> </div>
{/if} {/if}
</svelte:fragment> </svelte:fragment>
{#if mode === MODES.edit && !preview} {#if mode === MODES.edit && !preview}
<EditMode /> <EditMode bind:editDescription />
{:else} {:else}
<ViewMode {preview} {exerciseId} /> <ViewMode {preview} {exerciseId} />
{/if} {/if}
@@ -86,6 +86,7 @@
); );
return question; return question;
}); });
data.questions = data.questions.sort((a, b) => a.order - b.order);
data.is_title_dirty = false; data.is_title_dirty = false;
data.is_description_dirty = false; data.is_description_dirty = false;
+2 -2
View File
@@ -9,7 +9,7 @@
<div class="root mt-4 mx-auto {width} {className} relative {padding}"> <div class="root mt-4 mx-auto {width} {className} relative {padding}">
{#if $$slots.header} {#if $$slots.header}
<div <div
class="head flex items-center justify-between sticky right-0 w-full py-3" class="head flex items-center justify-between sticky right-0 w-full p-3"
> >
<slot name="header" /> <slot name="header" />
</div> </div>
@@ -21,7 +21,7 @@
<style> <style>
.root { .root {
overflow-y: auto; overflow-y: auto;
height: 85vh; height: 90vh;
} }
.head { .head {
top: 0px; top: 0px;
@@ -29,15 +29,15 @@
<div <div
bind:this={ref} bind:this={ref}
class="{isTitle ? 'border' : 'border-2'} {hasError class="bg-white border {hasError
? 'border-red-700' ? 'border-red-700'
: 'border-gray hover:border-blue-700'} border-r-2 rounded-md mb-6 relative root" : 'border-gray hover:border-blue-700'} rounded-md mb-6 relative root"
> >
{#if isTitle} {#if isTitle}
<div class="title absolute bg-blue-700" /> <div class="title absolute bg-blue-700" />
{/if} {/if}
{#if onClose} {#if onClose && !isTitle}
<div class="close-button hidden absolute -top-6 -right-6"> <div class="close-button hidden absolute -top-6 -right-6">
<CloseButton onClick={onClose} contained={true} /> <CloseButton onClick={onClose} contained={true} />
</div> </div>
+16 -2
View File
@@ -6,7 +6,15 @@
{#if !!title} {#if !!title}
<div class="tooltip"> <div class="tooltip">
<span class="tooltiptext z-20 {direction === 'right' && 'right'}"> <span
class="tooltiptext z-20 {direction === 'right'
? 'right'
: direction === 'top'
? 'top'
: direction === 'bottom'
? 'bottom'
: 'left'}"
>
{title} {title}
{#if Array.isArray(hotkeys) && hotkeys.length} {#if Array.isArray(hotkeys) && hotkeys.length}
<span class="shortcut"> <span class="shortcut">
@@ -29,11 +37,17 @@
left: 100%; left: 100%;
right: unset !important; right: unset !important;
} }
.bottom {
bottom: -75% !important;
left: -80%;
right: unset !important;
top: unset !important;
}
.tooltip .tooltiptext { .tooltip .tooltiptext {
color: #fff; color: #fff;
padding: 8px; padding: 8px;
font-size: 0.625rem; font-size: 0.625rem;
font-family: "Roboto", "Helvetica", "Arial", sans-serif; font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
font-weight: 500; font-weight: 500;
line-height: 1.4em; line-height: 1.4em;
border-radius: 8px; border-radius: 8px;
+4 -1
View File
@@ -205,6 +205,7 @@ export async function upsertExercise(questionnaire, exerciseId) {
question_type, question_type,
options, options,
deleted_at, deleted_at,
order,
points, points,
is_dirty, is_dirty,
} = question; } = question;
@@ -239,6 +240,7 @@ export async function upsertExercise(questionnaire, exerciseId) {
name: isNew(id) ? undefined : name, name: isNew(id) ? undefined : name,
title, title,
points, points,
order,
question_type_id: question_type.id, question_type_id: question_type.id,
exercise_id: exerciseId, exercise_id: exerciseId,
}; };
@@ -256,7 +258,7 @@ export async function upsertExercise(questionnaire, exerciseId) {
} }
if (questionSupabaseRes) { if (questionSupabaseRes) {
const { question_type_id, id, name } = questionSupabaseRes; const { question_type_id, id, name, order } = questionSupabaseRes;
// Delete cause this is not a field in the table // Delete cause this is not a field in the table
delete newQuestion.question_type_id; delete newQuestion.question_type_id;
@@ -264,6 +266,7 @@ export async function upsertExercise(questionnaire, exerciseId) {
newQuestion.question_type = { id: question_type_id }; newQuestion.question_type = { id: question_type_id };
newQuestion.id = id; newQuestion.id = id;
newQuestion.name = name; newQuestion.name = name;
newQuestion.order = order;
newQuestion.options = []; newQuestion.options = [];
// Don't map options for 'Paragraph' questions // Don't map options for 'Paragraph' questions