diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d963de7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.idea +/*.iml diff --git a/assets/images/checkmark.svg b/assets/images/checkmark.svg new file mode 100644 index 0000000..213cb74 --- /dev/null +++ b/assets/images/checkmark.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..179647f --- /dev/null +++ b/icon.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + diff --git a/index.css b/index.css new file mode 100644 index 0000000..92be186 --- /dev/null +++ b/index.css @@ -0,0 +1,104 @@ +.codio-assessment-answers { + padding: 1em 0; + display: flex; + flex-direction: column; + gap: 1em; +} + +.codio-assessment-mcq-answer-input-label { + display: flex; + gap: 10px; +} + +.codio-assessment-mcq-answer-input-indicator { + transition: 0.3s; +} + +.codio-assessment-mcq-answer-input:not([disabled]) .codio-assessment-mcq-answer-input-label { + cursor: pointer; +} + +.codio-assessment-mcq-answer-checkbox { + .codio-assessment-mcq-answer-input-indicator { + background-color: #aaadbd; + border-radius: 2px; + height: 22px; + width: 22px; + position: relative; + } + + .codio-assessment-mcq-answer-input:not([disabled]) .codio-assessment-mcq-answer-input-label { + &:hover { + .codio-assessment-mcq-answer-input-indicator { + background-color: #797d92; + } + } + } + + .codio-assessment-mcq-answer-input:checked + .codio-assessment-mcq-answer-input-label .codio-assessment-mcq-answer-input-indicator { + background-image: url(./assets/images/checkmark.svg); + background-size: 60%; + background-color: #797d92; + background-position: center; + background-repeat: no-repeat; + } +} + +.codio-assessment-mcq-answer-radio { + .codio-assessment-mcq-answer-input-indicator { + box-sizing: border-box; + border-radius: 50%; + height: 22px; + width: 22px; + position: relative; + border: 2px solid #aaadbd; + background-color: transparent; + } + + .codio-assessment-mcq-answer-input:not([disabled]) .codio-assessment-mcq-answer-input-label { + &:hover { + .codio-assessment-mcq-answer-input-indicator { + border-color: #797d92; + } + } + } + + .codio-assessment-mcq-answer-input:checked + .codio-assessment-mcq-answer-input-label .codio-assessment-mcq-answer-input-indicator { + border-color: #797d92; + &::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + bottom: 2px; + right: 2px; + background-color: #797d92; + border-radius: 50%; + } + } +} + +.codio-assessment-mcq-answer-icon { + position: absolute; + width: 22px; + height: 22px; + left: -36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 100%; + color: white; +} + +.codio-assessment-mcq-answer-icon svg { + width: 16px; + height: 16px; +} + +.codio-assessment-mcq-answer-icon-ok { + background-color: #71b380; +} + +.codio-assessment-mcq-answer-icon-wrong { + background-color: #da8181; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..adedd40 --- /dev/null +++ b/index.html @@ -0,0 +1,30 @@ + + + + + Multiple choice assessment + + + + + + + + +
+

+
+
+
+
+
+
+
+ +
+ + diff --git a/index.js b/index.js new file mode 100644 index 0000000..7d36b39 --- /dev/null +++ b/index.js @@ -0,0 +1,340 @@ +(function (){ + let assessmentOptions = null + let assessment = null + let processing = false + let currentData = null + const taskId = location.hash.substring(1) + + const RANDOM_SEED = 'seed' + + const getInitialValue = () => { + const {result, state} = currentData || {} + const {useSubmitButtons, isDisabled} = assessmentOptions + const answered = result?.state && result.state !== window.codioAssessmentsHelper.States.RESET + const draft = state?.active + + if (!answered) { + return draft + } + + const canAnswerAgain = window.codioAssessmentsHelper.isCanAnswerAgain(assessment, result) + if (!isDisabled && !useSubmitButtons && canAnswerAgain && draft) { + return draft + } + return result.current + } + + const applyStateInitial = (data) => { + const {state, result, ...dataWithoutState} = data + assessment = dataWithoutState.assessment + assessmentOptions = dataWithoutState.options + + render() + } + + const handleEnter = (callback, event) => { + const key = event.which || event.keyCode + if (key === 13) { + callback(event) + } + } + + const onClick = (id) => { + $(`#${id}`).click() + } + + const getAnswerIcon = (ok, wrong) => { + if (!ok && !wrong) { + return null + } + const baseClass = 'codio-assessment-mcq-answer-icon' + const classes = [baseClass] + ok && classes.push(`${baseClass}-ok`) + wrong && classes.push(`${baseClass}-wrong`) + const iconContainer = $(`
`) + const {FAILED, PASSED} = window.codioAssessmentsHelper.RESULT_STATUS + const icon = $(window.codioAssessmentsHelper.getIconByResultStatus(ok ? PASSED : FAILED)) + iconContainer.append(icon) + return iconContainer + } + + const renderAnswers = () => { + const answersBlock = $('.codio-assessment-answers') + + const {isRandomized, multipleResponse} = assessment.source.settings + let answers = assessment.source.settings.answers + if (isRandomized) { + const projectId = assessmentOptions.eduStartedAssignment?.started?.projectId + answers = window.multipleChoiceAssessment.shuffle(answers, `${projectId || RANDOM_SEED}-${taskId}`) + } + + const inputType = multipleResponse ? 'checkbox' : 'radio' + + answers.forEach((answer) => { + const id = answer._id + + const classes = [`codio-assessment-mcq-answer codio-assessment-mcq-answer-${inputType}`] + + const answerEl = $(`
`) + const inputEl = $(``) + answerEl.append(inputEl) + const labelEl = $(``) + labelEl.on('keydown', handleEnter.bind(null, onClick.bind(null, id))) + const inputIndicatorEl = $('
') + labelEl.append(inputIndicatorEl) + const answerTextEl = $(`
`).html(answer.answer) + labelEl.append(answerTextEl) + answerEl.append(labelEl) + answersBlock.append(answerEl) + }) + } + + const updateAnswers = (initialValue) => { + const assessmentState = getAssessmentState() + const valueFromState = initialValue || getValue() + let value = valueFromState + if (assessment.source.settings.multipleResponse && !Array.isArray(valueFromState)) { + value = value ? [value] : [] + } + + const disableResponse = assessmentOptions.isDisabled || assessmentOptions.showUnblock || assessmentState.answered && + (!assessmentState.canAnswerAgain || assessmentState.answerFullyCorrect) + const showExpectedAnswer = window.codioAssessmentsHelper.calculateShowExpectedAnswer( + assessmentOptions.eduStartedAssignment, + assessment.source.showExpectedAnswerOption + ) + const {result} = currentData || {} + const showAnswer = assessmentState.showAsTeacher || showExpectedAnswer + const expectedAnswer = result && showAnswer && (result.right || assessment.source.expectedAnswerIds) + + const answerEls = $('.codio-assessment-mcq-answer') + answerEls.each((index, answerElRaw) => { + const answerEl = $(answerElRaw) + const inputEl = answerEl.find('.codio-assessment-mcq-answer-input') + const id = inputEl.val() + let ok, wrong + if (disableResponse && expectedAnswer) { + ok = expectedAnswer.includes(id) + if (Array.isArray(expectedAnswer) && expectedAnswer.length > 0) { + wrong = !ok + } + } + + let checked = Array.isArray(value) ? value.includes(id) : value === id + + answerEl.removeClass('codio-assessment-mcq-answer-ok codio-assessment-mcq-answer-wrong') + inputEl.prop('checked', checked) + inputEl.prop('disabled', disableResponse) + + const inputIndicatorEl = answerEl.find('.codio-assessment-mcq-answer-input-indicator') + inputIndicatorEl.empty() + const icon = disableResponse ? getAnswerIcon(ok, wrong) : null + icon && inputIndicatorEl.append(icon) + }) + } + + const applyState = (data) => { + console.log('assessment iframe applyState', data) + currentData = data + if (!assessment) { + applyStateInitial(data) + return + } + updateCheckButtonText() + updateAnswersAndFooter() + renderGuidance() + } + + const getValue = () => { + const value = $('.codio-assessment-mcq-answer input:checked').map((_, el) => el.value).get() + const {multipleResponse} = assessment.source.settings + return !multipleResponse ? value[0] : value + } + + const onCheck = (event) => { + event.preventDefault() + processing = true + updateAnswersAndFooter() + + window.codioAssessmentsHelper.send( + window.codioAssessmentsHelper.METHODS.SUBMIT_ANSWER, + {result: {action: getValue()}} + ) + } + + const onUnblock = (event) => { + event.preventDefault() + codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.UNBLOCK) + } + + const onReset = (event) => { + event.preventDefault() + codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.RESET) + } + + const renderContent = () => { + $('.instructions-text').html(assessment.source.settings.instructions) + } + + const updateVisibility = (el, visible) => { + visible ? el.removeClass('hide') : el.addClass('hide') + } + + const updateFooterButtons = () => { + const assessmentState = getAssessmentState() + const {teacherInStudentsProject, showModify, isDisabled, canAnswerAgain, answered} = assessmentState + + const checkVisibility = !showModify && assessmentOptions.useSubmitButtons + const checkBtn = $('.check-button') + updateVisibility(checkBtn, checkVisibility) + checkBtn.prop('disabled', isDisabled) + + const unblockVisibility = !teacherInStudentsProject && showModify + updateVisibility($('.unblock-button'), unblockVisibility) + + const resetVisibility = !showModify && answered && assessmentOptions.owner && + !processing && (!canAnswerAgain || assessmentState.answerFullyCorrect) + updateVisibility($('.reset-button'), resetVisibility) + } + + const updateCheckButtonText = () => { + const footerContainer = $('.codio-assessment-footer') + const {result} = currentData || {} + const caption = window.codioAssessmentsHelper.getButtonCaption( + assessmentOptions, + assessment.source.maxAttemptsCount, + result?.usedAttempts || 0 + ) + footerContainer.find('.check-button').html(caption) + } + + const renderGuidance = () => { + const guidanceBlock = $('.codio-assessment-guidance-block') + guidanceBlock.empty() + const assessmentState = getAssessmentState() + const {result} = currentData || {} + const guidance = window.codioAssessmentsHelper.calculateGuidance( + !assessmentOptions.eduStartedAssignment, + assessmentOptions.showAsTeacher, + assessmentState.answered, + assessment.source, + result ? + { + answerGuidance: result.guidance, + answerPoints: result.points, + attemptsCount: result.usedAttempts, + passed: assessmentState.answerFullyCorrect, + isCompletedAndReleased: window.codioAssessmentsHelper.calculateCompletedAndReleased( + assessmentOptions.eduStartedAssignment + ) + } : {} + ) + if (guidance) { + const guidanceContainer = $('
') + const guidanceText = $('
').html(guidance) + guidanceContainer.append(guidanceText) + guidanceBlock.append(guidanceContainer) + } + } + + const isAnswerFullyCorrect = (result) => { + if (!result?.right || result.right.length === 0) { + return false + } + if (assessment.source.points === 0) { + return result.right.sort().join('_') === result.current.sort().join('_') + } + return result.state === window.codioAssessmentsHelper.States.PASS && assessment.source.points === result.points + } + + const isSomethingChecked = () => { + return !!$('.codio-assessment-mcq-answer input:checked').length + } + + const getAssessmentState = () => { + const result = currentData ? currentData.result : null + const answered = result?.state && result.state !== window.codioAssessmentsHelper.States.RESET + const answerFullyCorrect = answered && isAnswerFullyCorrect(result, assessment.source) + const usedAttempts = result?.usedAttempts + const canAnswerAgain = !assessment.source.maxAttemptsCount || usedAttempts < assessment.source.maxAttemptsCount + const isDisabled = assessmentOptions.isDisabled || processing || !isSomethingChecked() || + answered && (!canAnswerAgain || answerFullyCorrect) + const showModify = assessmentOptions.showUnblock && !answered + const teacherInStudentsProject = assessmentOptions.showAsTeacher && !assessmentOptions.owner + + return { + isDisabled, + answered, + usedAttempts, + canAnswerAgain, + showModify, + teacherInStudentsProject, + answerFullyCorrect + } + } + + const updateAnswersAndFooter = () => { + if (!assessment) { + return + } + // processing, new state/results + updateAnswers() + updateFooterButtons() + } + + const onInputChange = () => { + updateFooterButtons() + window.codioAssessmentsHelper.send( + window.codioAssessmentsHelper.METHODS.SET_STATE, {state: {active: getValue()}} + ) + } + + const bindEvents = () => { + $('.check-button').on('click', onCheck) + $('.unblock-button').on('click', onUnblock) + $('.reset-button').on('click', onReset) + $('.codio-assessment-mcq-answer input').on('change', onInputChange) + + window.codioAssessmentsHelper.addBodyHeightListener() + } + + const render = () => { + const container = $('.codio-assessment') + const nameEl = container.find('.codio-assessment-name') + assessment.source.showName ? nameEl.text(assessment.source.name) : nameEl.remove() + renderContent() + updateCheckButtonText() + renderGuidance() + renderAnswers() + updateAnswers(getInitialValue()) + updateFooterButtons() + bindEvents() + container.removeClass('hide') + } + + const processMessage = (jsonData) => { + try { + const {method, data} = JSON.parse(jsonData) + console.log('assessment iframe processMessage', jsonData, method, data) + switch (method) { + case window.codioAssessmentsHelper.METHODS.GET_STYLES_RESPONSE: + window.codioAssessmentsHelper.addStyle(data.css) + break + case window.codioAssessmentsHelper.METHODS.GET_STATE_RESPONSE: + processing = false + applyState(data) + break + case window.codioAssessmentsHelper.METHODS.CALLBACK: { + window.codioAssessmentsHelper.processCallback(data) + break + } + } + } catch (e) { console.error(e) } + } + + window.addEventListener('load', () => { + window.codioAssessmentsHelper.registerMessageListener(processMessage) + window.codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.GET_STATE) + window.codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.GET_STYLES) + }) +})() diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..8c9c85b --- /dev/null +++ b/metadata.json @@ -0,0 +1,18 @@ +{ + "name": "Standard Code Test", + "type": "assessment", + "properties": { + "type": "multiple-choice", + "icon": "./icon.svg", + "defaultHeight": 500, + "gradingControls": { + "points": true, + "incorrectPoints": true, + "allowPartialPoints": true, + "useMaximumScore": true, + "showExpectedAnswer": true, + "definedNumberOfAttempts": true, + "rationale": true + } + } +} diff --git a/settings.css b/settings.css new file mode 100644 index 0000000..b5202d3 --- /dev/null +++ b/settings.css @@ -0,0 +1,69 @@ +.answers-container { + display: flex; + flex-direction: column; + gap: 10px; +} + +#add-answer { + align-self: start; +} + +.answer-list { + display: flex; + gap: 10px; + flex-direction: column; +} + +.answer-item { + display: flex; + border: 1px solid rgba(0, 0, 0, 0.6); + padding: 10px; + gap: 10px; + border-radius: 6px; + + .codio-assessment-settings-form-label { + flex: 0 0 30px; + } + + .answer-item-correct-input-container { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + } +} + +.answer-item-correct-container, .answer-item-answer-container { + display: flex; + flex-direction: column; +} + +.answer-item-answer-container { + flex: 1; +} + +.answer-item-actions { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0 20px; + justify-content: space-around; + + .action-delete { + color: red; + } +} + +.answer-item-action-button { + background: none; + border: none; + padding: 0; + width: 24px; + height: 24px; + cursor: pointer; + opacity: 0.8; + + &:hover { + opacity: 1; + } +} diff --git a/settings.html b/settings.html new file mode 100644 index 0000000..295ec4b --- /dev/null +++ b/settings.html @@ -0,0 +1,44 @@ + + + + + Settings + + + + + + + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ + diff --git a/settings.js b/settings.js new file mode 100644 index 0000000..1b4bcb7 --- /dev/null +++ b/settings.js @@ -0,0 +1,191 @@ +(function () { + const ICONS = { + UP: '', + DOWN: '', + DELETE: '' + } + + const ACTION_CLASS = { + UP: 'action-up', + DOWN: 'action-down', + DELETE: 'action-delete', + } + + let multipleResponse = false + + const getId = () => window.codioAssessmentsHelper.GUID() + + const collectSettings = () => { + const instructions = $('#instructions').val() + const multipleResponse = $('#multiple-response').is(':checked') + const shuffleAnswers = $('#shuffle-answers').is(':checked') + const answers = [] + $('.answer-item').each(function (index, item) { + const $item = $(item) + const answerId = $item.data('answer-id') || getId() + const answer = $item.find('.answer-item-answer-ta').val() + const correct = $item.find('[name="correct"]').is(':checked') + answers.push({_id: answerId, answer, correct}) + }) + + return {instructions, multipleResponse, shuffleAnswers, answers}; + } + + const exportSettings = () => { + const data = collectSettings(); + window.codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.EXPORT_SETTINGS_RESPONSE, data); + } + + const applySettings = (settings = {}) => { + multipleResponse = !!settings.multipleResponse + $('#instructions').val(settings.instructions || ''); + $('#multiple-response').prop('checked', settings.multipleResponse); + $('#shuffle-answers').prop('checked', settings.shuffleAnswers); + const answers = settings.answers || [] + answers.forEach((item) => { + addAnswer(item); + }) + } + + const processMessage = (jsonData) => { + console.log('settings iframe processMessage', jsonData) + try { + const {method, data} = JSON.parse(jsonData); + switch (method) { + case window.codioAssessmentsHelper.METHODS.EXPORT_SETTINGS: + exportSettings(); + break; + case window.codioAssessmentsHelper.METHODS.GET_SETTINGS_RESPONSE: + applySettings(data.settings); + break; + } + } catch {} + } + + const renderToggle = (id, name, value) => { + const labelEl = $(''); + const inputEl = $(``) + const sliderEl = $('') + labelEl.append(inputEl) + labelEl.append(sliderEl) + return labelEl + } + + const renderRadio = (id, name, value) => { + const labelEl = $('
'); + const inputEl = $(``) + return labelEl.append(inputEl) + } + + const renderCorrectControl = (id, value) => { + const name = 'correct' + const inputContainer = $('
') + const input = multipleResponse ? renderToggle(id, name, value) : renderRadio(id, name, value) + return inputContainer.append(input) + } + + const refreshCorrectControls = () => { + const items = $('.answer-item') + const firstCorrect = items.find('input[name="correct"]:checked').closest('.answer-item').first() + const firstCorrectIndex = items.index(firstCorrect) + items.each((index, element) => { + const $el = $(element) + const correctContainer = $el.find('.answer-item-correct-container') + $el.find('.answer-item-correct-input-container').remove() + correctContainer.append(renderCorrectControl(getId(), index === firstCorrectIndex)) + }) + } + + const renderIconButton = (className, icon, title) => { + return $(``) + } + + const addAnswer = (data) => { + const listContainer = $('.answer-list') + const answerItem = $('
') + const itemActionsContainer = $('
') + itemActionsContainer.append(renderIconButton(ACTION_CLASS.UP, ICONS.UP, 'Move up')) + itemActionsContainer.append(renderIconButton(ACTION_CLASS.DELETE, ICONS.DELETE, 'Delete')) + itemActionsContainer.append(renderIconButton(ACTION_CLASS.DOWN, ICONS.DOWN, 'Move down')) + const correctContainer = $('
') + const id = getId() + correctContainer.append(``) + correctContainer.append(renderCorrectControl(id, false)) + const answerContainer = $('
') + const taId = getId() + answerContainer.append(``) + const answerTa = $(``) + answerContainer.append(answerTa) + answerItem.append(itemActionsContainer) + answerItem.append(correctContainer) + answerItem.append(answerContainer) + + if (data) { + data?._id && answerItem.data('answer-id', data._id) + data.correct && answerItem.find('[name="correct"]').prop('checked', true) + data.answer && answerItem.find('.answer-item-answer-ta').val(data.answer) + } + listContainer.append(answerItem) + } + + const updateActionButtonStates = () => { + const items = $('.answer-item') + items.find(`.${ACTION_CLASS.UP}`).prop('disabled', false) + items.find(`.${ACTION_CLASS.DOWN}`).prop('disabled', false) + items.first().find(`.${ACTION_CLASS.UP}`).prop('disabled', true) + items.last().find(`.${ACTION_CLASS.DOWN}`).prop('disabled', true) + } + + const onActionUp = (e) => { + const element = $(e.currentTarget).closest('.answer-item') + const prev = element.prev() + + if (!prev?.length) { + return + } + element.insertBefore(prev); + updateActionButtonStates() + } + + const onActionDown = (e) => { + const element = $(e.currentTarget).closest('.answer-item') + const next = element.next() + if (!next?.length) { + return + } + element.insertAfter(next); + updateActionButtonStates() + } + + const onActionDelete = (e) => { + const element = $(e.currentTarget).closest('.answer-item') + element.remove() + updateActionButtonStates() + } + + const bindEvents = () => { + const settingsEl = $('#settings-content') + settingsEl.on('click', '#add-answer', () => { + addAnswer() + updateActionButtonStates() + }) + settingsEl.on('change', '#multiple-response', function() { + multipleResponse = this.checked + refreshCorrectControls() + }) + settingsEl.on('click', '.action-up', onActionUp) + settingsEl.on('click', '.action-down', onActionDown) + settingsEl.on('click', '.action-delete', onActionDelete) + } + + const onLoad = async () => { + window.codioAssessmentsHelper.registerMessageListener(processMessage) + window.codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.GET_SETTINGS) + + bindEvents() + } + + window.addEventListener('load', onLoad); +})() diff --git a/shuffle.js b/shuffle.js new file mode 100644 index 0000000..beaf129 --- /dev/null +++ b/shuffle.js @@ -0,0 +1,42 @@ +const nativeFloor = Math.floor +const seedrandom = Math.seedrandom + +function baseRandom(lower, upper, seed) { + const nativeRandom = seedrandom(seed) + return lower + nativeFloor(nativeRandom() * (upper - lower + 1)) +} + +function arrayShuffle(array, seed) { + return shuffleSelf(copyArray(array), undefined, seed) +} + +function copyArray(source, array) { + let index = -1 + const length = source.length + + array || (array = Array(length)) + while (++index < length) { + array[index] = source[index] + } + return array +} + +function shuffleSelf(array, size, seed) { + let index = -1 + const length = array.length + const lastIndex = length - 1 + + size = size === undefined ? length : size + while (++index < size) { + const rand = baseRandom(index, lastIndex, seed), + value = array[rand] + + array[rand] = array[index] + array[index] = value + } + array.length = size + return array +} + +window.multipleChoiceAssessment = window.multipleChoiceAssessment || {} +window.multipleChoiceAssessment.shuffle = arrayShuffle