diff --git a/frontend/src/utils/exam-correct/exam-correct.js b/frontend/src/utils/exam-correct/exam-correct.js index 70a9bdb63..0e57d86e3 100644 --- a/frontend/src/utils/exam-correct/exam-correct.js +++ b/frontend/src/utils/exam-correct/exam-correct.js @@ -19,16 +19,29 @@ const EXAM_CORRECT_HEADERS = { const EXAM_CORRECT_IDENT = 'uw-exam-correct'; const EXAM_CORRECT_PART_INPUT_ATTR = 'uw-exam-correct--part-input'; const EXAM_CORRECT_SEND_BTN_ID = 'exam-correct__send-btn'; -const EXAM_CORRECT_PARTICIPANT_INPUT_ID = 'exam-correct__user'; +const EXAM_CORRECT_USER_INPUT_ID = 'exam-correct__user'; +const EXAM_CORRECT_USER_INPUT_STATUS_ID = 'exam-correct__user-status'; const EXAM_CORRECT_USER_INPUT_CANDIDATES_ID = 'exam-correct__user-candidates'; const EXAM_CORRECT_INPUT_BODY_ID = 'exam-correct__new'; -const EXAM_CORRECT_PARTICIPANT_ATTR = 'exam-correct--user'; +const EXAM_CORRECT_USER_ATTR = 'exam-correct--user-id'; +const EXAM_CORRECT_USER_DNAME_ATTR = 'exam-correct--user-dname'; const INPUT_EMPTY_CLASS = 'input--invalid'; // TODO get from settings const MOMENT_FORMAT = 'DD.MM.YY HH:mm:ss'; +// TODO 1 for debugging only, 3 would be a better choice +const USER_VALIDATION_MIN_LENGTH = 1; + +const STATUS = { + NONE: 0, + SUCCESS: 1, + AMBIGUOUS: 2, + ERROR: 3, + LOADING: 4, +}; + @Utility({ selector: `table[${EXAM_CORRECT_IDENT}]`, @@ -41,6 +54,7 @@ export class ExamCorrect { _sendBtn; _userInput; + _userInputStatus; _userInputCandidates; _partInputs; @@ -56,7 +70,8 @@ export class ExamCorrect { this._element = element; this._app = app; this._sendBtn = document.getElementById(EXAM_CORRECT_SEND_BTN_ID); - this._userInput = document.getElementById(EXAM_CORRECT_PARTICIPANT_INPUT_ID); + this._userInput = document.getElementById(EXAM_CORRECT_USER_INPUT_ID); + this._userInputStatus = document.getElementById(EXAM_CORRECT_USER_INPUT_STATUS_ID); this._userInputCandidates = document.getElementById(EXAM_CORRECT_USER_INPUT_CANDIDATES_ID); this._partInputs = [...this._element.querySelectorAll(`input[${EXAM_CORRECT_PART_INPUT_ATTR}]`)]; @@ -68,6 +83,10 @@ export class ExamCorrect { this._userInput.addEventListener('focusout', this._validateUserInput.bind(this)); else throw new Error('ExamCorrect utility could not detect user input!'); + if (!this._userInputStatus) { + throw new Error('ExamCorrect utility could not detect user input status element!'); + } + if (!this._userInputCandidates) { throw new Error('ExamCorrect utility could not detect user input candidate list!'); } @@ -77,17 +96,34 @@ export class ExamCorrect { this._sendBtn.removeEventListener('click', this._sendCorrectionHandler); this._userInput.removeEventListener('change', this._validateUserInput); this._userInput.removeEventListener('input', this._removeInputEmptyClassHandler); + // TODO destroy handlers on user input candidate elements } _validateUserInput() { const user = this._userInput.value; - if (!user) return; + // do nothing in case of empty or too short input + if (!user || user.length < USER_VALIDATION_MIN_LENGTH) { + removeAllChildren(this._userInputCandidates); + setStatus(this._userInputStatus, STATUS.NONE); + return; + } + + if (this._userInput.getAttribute(EXAM_CORRECT_USER_ATTR) !== null) { + if (this._userInput.value === this._userInput.getAttribute(EXAM_CORRECT_USER_DNAME_ATTR)) { + setStatus(this._userInputStatus, STATUS.NONE); + return; + } else { + this._userInput.removeAttribute(EXAM_CORRECT_USER_ATTR); + } + } + + setStatus(this._userInputStatus, STATUS.LOADING); const body = { - name: user, + name: user, results: {}, - op: false, + op: false, }; this._app.httpClient.post({ @@ -103,9 +139,7 @@ export class ExamCorrect { }); } - _sendCorrectionHandler(event) { - console.log('WIP _sendCorrectionHandler', event); - + _sendCorrectionHandler() { // refocus user input element for convenience this._userInput.focus(); @@ -119,15 +153,19 @@ export class ExamCorrect { } const results = {}; - this._partInputs.forEach((input) => { - if (input.value && !isNaN(input.value)) { - // TODO instead of ignoring NaN values, abort send and add error class to corresponding input - // (latter is optional) + for (const input of this._partInputs) { + if (input.value) { + if (isNaN(input.value)) { + input.classList.add('input--invalid'); + input.focus(); + return; + } + const partKey = input.getAttribute(EXAM_CORRECT_PART_INPUT_ATTR); if (!partKey) throw new Error('Exam part input without part attribute!'); results[partKey] = parseFloat(input.value); } - }); + } // abort send if there are no results (after validation) if (Object.keys(results).length <= 0) return; @@ -141,7 +179,7 @@ export class ExamCorrect { dateTD.setAttribute('date', moment()); const userTD = document.createElement('TD'); userTD.appendChild(document.createTextNode(user)); - userTD.setAttribute(EXAM_CORRECT_PARTICIPANT_ATTR, user); + userTD.setAttribute(EXAM_CORRECT_USER_ATTR, user); const partTDs = this._partInputs.map((input) => { const partTD = document.createElement('TD'); const partKey = input.getAttribute(EXAM_CORRECT_PART_INPUT_ATTR); @@ -163,6 +201,7 @@ export class ExamCorrect { // clear input values on validation success // TODO only clear input on post success [this._userInput, ...this._partInputs].forEach(clearInput); + setStatus(this._userInputStatus, STATUS.NONE); const body = { name: user, @@ -189,29 +228,49 @@ export class ExamCorrect { if (response) { if (response.status === 'no-op') { if (response.users) { - // TODO directly replace input value and add attr if list contains only one element + // delete candidate list entries from previous requests + removeAllChildren(this._userInputCandidates); - // TODO add event handler on links -> display name as input value and set id as attribute + // show error if there are no matches for this input + if (response.users.length === 0) { + setStatus(this._userInputStatus, STATUS.FAILURE); + return; + } + + // directly accept response if there is exactly one candidate (input not ambiguous) + if (response.users.length === 1) { + const candidate = response.users[0]; + this._userInput.value = candidate['display-name']; + this._userInput.setAttribute(EXAM_CORRECT_USER_ATTR, candidate.id); + this._userInput.setAttribute(EXAM_CORRECT_USER_DNAME_ATTR, candidate['display-name']); + setStatus(this._userInputStatus, STATUS.SUCCESS); + return; + } + + setStatus(this._userInputStatus, STATUS.AMBIGUOUS); // TODO how to destroy candidate handlers? response.users.forEach((userCandidate) => { const candidateItem = document.createElement('li'); - userAsInnerHTML(candidateItem, userCandidate); - candidateItem.setAttribute(EXAM_CORRECT_PARTICIPANT_ATTR, userCandidate.id); + candidateItem.innerHTML = userToHTML(userCandidate); + candidateItem.setAttribute(EXAM_CORRECT_USER_ATTR, userCandidate.id); const acceptCandidateHandler = () => { - console.log('candidate accepted'); this._userInput.value = userCandidate['display-name'] || userCandidate.surname || userCandidate['mat-nr']; - this._userInput.setAttribute(EXAM_CORRECT_PARTICIPANT_ATTR, userCandidate.id); + this._userInput.setAttribute(EXAM_CORRECT_USER_ATTR, userCandidate.id); - // remove all candidates - while (this._userInputCandidates.firstChild) { - this._userInputCandidates.removeChild(this._userInputCandidates.firstChild); - } + // remove all candidates on accept + removeAllChildren(this._userInputCandidates); + + setStatus(this._userInputStatus, STATUS.SUCCESS); + + // TODO focus first part result input element for convenience }; - candidateItem.addEventListener('click', acceptCandidateHandler, { once: true }); + candidateItem.addEventListener('click', acceptCandidateHandler); this._userInputCandidates.appendChild(candidateItem); }); + } else { + setStatus(this._userInputStatus, STATUS.FAILURE); } return; @@ -219,18 +278,16 @@ export class ExamCorrect { for (let row of [...this._element.rows]) { const userElem = row.cells.item(1); - const userIdent = userElem && userElem.getAttribute(EXAM_CORRECT_PARTICIPANT_ATTR); + const userIdent = userElem && userElem.getAttribute(EXAM_CORRECT_USER_ATTR); if (userIdent === user) { - let faIcon, ecClass; + let status = STATUS.ERROR; switch (response.status) { // TODO fetch update time from response and replace case 'success': - faIcon = 'fa-check'; - ecClass = 'exam-correct--success'; + status = STATUS.SUCCESS; if (response.user) { - userElem.setAttribute(EXAM_CORRECT_PARTICIPANT_ATTR, response.user.id); - userElem.innerHTML = ''; - userAsInnerHTML(userElem, response.user); + userElem.setAttribute(EXAM_CORRECT_USER_ATTR, response.user.id); + userElem.innerHTML = userToHTML(response.user); } // TODO replace results with results from response // TODO set edit button visibility @@ -238,27 +295,21 @@ export class ExamCorrect { case 'ambiguous': // TODO show tooltip with error message // TODO set edit button visibility - faIcon = 'fa-times'; - ecClass = 'exam-correct--error'; - // TODO show users + status = STATUS.AMBIGUOUS; if (response.users) { - showUsers(userElem, response.users); + this._showUserList(userElem, response.users); } break; case 'failure': - faIcon = 'fa-times'; - ecClass = 'exam-correct--error'; + status = STATUS.FAILURE; break; default: // TODO show tooltip with 'invalid response' // TODO set edit button visibility console.error('Invalid response'); - faIcon = 'fa-times'; - ecClass = 'exam-correct--error'; } row.querySelectorAll('.exam-correct--loading').forEach((elem) => { - elem.classList.remove('exam-correct--loading'); - elem.classList.add('fa', faIcon, ecClass); + setStatus(elem, status); }); break; } @@ -275,30 +326,76 @@ export class ExamCorrect { } } + // TODO better name + _showUserList(elem, users) { + if (users) { + removeAllChildren(elem); + const list = document.createElement('UL'); + for (const user of users) { + const listItem = document.createElement('LI'); + listItem.innerHTML = userToHTML(user); + listItem.setAttribute(EXAM_CORRECT_USER_ATTR, user.id); + listItem.setAttribute(EXAM_CORRECT_USER_DNAME_ATTR, user['display-name']); + // TODO destroy event handler + //const acceptCandidateHandler = () => { + // elem.innerHTML = userToHTML(user); + //}; + //listItem.addEventListener('click', acceptCandidateHandler); + list.appendChild(listItem); + } + elem.appendChild(list); + } else { + console.error('Unable to show users from invalid response'); + } + } + } // TODO move to general util section? function clearInput(inputElement) { inputElement.value = null; } +function removeAllChildren(element) { + while (element.firstChild) { + element.removeChild(element.firstChild); + } +} -function userAsInnerHTML(elem, user) { + +function userToHTML(user) { if (user && user['display-name'] && user['surname']) { - elem.innerHTML += user['display-name'].replace(new RegExp(user['surname']), `${user['surname']}`) + (user['mat-nr'] ? ` (${user['mat-nr']})` : ''); + return user['display-name'].replace(new RegExp(user['surname']), `${user['surname']}`) + (user['mat-nr'] ? ` (${user['mat-nr']})` : ''); } else { console.error('Unable to format invalid user response'); + return ''; } } -// TODO better name -function showUsers(elem, users) { - elem.innerHTML = ''; - if (users) { - for (const user of users) { - userAsInnerHTML(elem, user); - elem.innerHTML += '
'; - } - } else { - console.error('Unable to show users from invalid response'); +function setStatus(elem, status) { + const successClasses = ['fas', 'fa-fw', 'fa-check', 'exam-correct--success']; + const ambiguousClasses = ['fas', 'fa-fw', 'fa-question', 'exam-correct--ambiguous']; + const errorClasses = ['fas', 'fa-fw', 'fa-times', 'exam-correct--error']; + const loadingClasses = ['fas', 'fa-fw', 'fa-spinner-third', 'exam-correct--loading']; + + elem.classList.remove(...successClasses, ...ambiguousClasses, ...errorClasses, ...loadingClasses); + + switch (status) { + case STATUS.NONE: + break; + case STATUS.SUCCESS: + elem.classList.add(...successClasses); + break; + case STATUS.AMBIGUOUS: + elem.classList.add(...ambiguousClasses); + break; + case STATUS.FAILURE: + elem.classList.add(...errorClasses); + break; + case STATUS.LOADING: + elem.classList.add(...loadingClasses); + break; + default: + console.error('Invalid user input status!'); } } + diff --git a/frontend/src/utils/exam-correct/exam-correct.sass b/frontend/src/utils/exam-correct/exam-correct.sass index 63fd21145..821feecd2 100644 --- a/frontend/src/utils/exam-correct/exam-correct.sass +++ b/frontend/src/utils/exam-correct/exam-correct.sass @@ -1,18 +1,25 @@ -#exam-correct__participant.input--invalid +input.input--invalid border: 2px solid var(--color-error) +.exam-correct--input-status + $exam-correct--input-status-margin: 10px + + input + width: calc(100% - 18em/14 - #{$exam-correct--input-status-margin}) + i + margin-left: $exam-correct--input-status-margin + .exam-correct--success color: var(--color-success) +.exam-correct--ambiguous + color: var(--color-warning) + .exam-correct--error color: var(--color-error) +// TODO move to general app style .exam-correct--loading - border: 3px solid var(--color-grey-light) - border-top: 3px solid var(--color-primary) - border-radius: 50% - width: 20px - height: 20px animation: spin 2s linear infinite @keyframes spin diff --git a/messages/uniworx/de-de-formal.msg b/messages/uniworx/de-de-formal.msg index 88f55b4ec..e47822fed 100644 --- a/messages/uniworx/de-de-formal.msg +++ b/messages/uniworx/de-de-formal.msg @@ -1405,8 +1405,8 @@ ExamCorrectHeadStatus: Status ExamCorrectButtonSend: Senden -ExamCorrectErrorMultipleMatchingParticipants: Dem Identifikator konnten mehrere Pruefungsteilnehmer zugeordnet werden. -ExamCorrectErrorNoMatchingParticipants: Dem Identifikator konnte kein Pruefungsteilnehmer zugeordnet werden. +ExamCorrectErrorMultipleMatchingParticipants: Dem Identifikator konnten mehrere Prüfungsteilnehmer zugeordnet werden. +ExamCorrectErrorNoMatchingParticipants: Dem Identifikator konnte kein Prüfungsteilnehmer zugeordnet werden. SubmissionUserInvitationAccepted shn@SheetName: Sie wurden als Mitabgebende(r) für eine Abgabe zu #{shn} eingetragen SubmissionUserInvitationDeclined shn@SheetName: Sie haben die Einladung, Mitabgebende(r) für #{shn} zu werden, abgelehnt @@ -2289,4 +2289,4 @@ ExternalExamUserMustBeStaff: Sie selbst müssen stets assoziierte Person sein, f ExternalExamCourseExists: Der angegebene Kurs existiert im System. Prüfungen sollten daher direkt beim Kurs (statt extern) hinterlegt werden. ExternalExamExists coursen@CourseName examn@ExamName: Prüfung „#{examn}“ für Kurs „#{coursen}“ existiert bereits. ExternalExamCreated coursen@CourseName examn@ExamName: Prüfung „#{examn}“ für Kurs „#{coursen}“ erfolgreich angelegt. -ExternalExamEdited coursen@CourseName examn@ExamName: Prüfung „#{examn}“ für Kurs „#{coursen}“ erfolgreich bearbeitet. \ No newline at end of file +ExternalExamEdited coursen@CourseName examn@ExamName: Prüfung „#{examn}“ für Kurs „#{coursen}“ erfolgreich bearbeitet. diff --git a/templates/exam-correct.hamlet b/templates/exam-correct.hamlet index b825dda09..9abfdc8d7 100644 --- a/templates/exam-correct.hamlet +++ b/templates/exam-correct.hamlet @@ -25,8 +25,9 @@ $newline never - + +