diff --git a/bundlesize.config.json b/bundlesize.config.json index 3f164d93a2d..b4339f6ab83 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -30,7 +30,7 @@ }, { "path": "static/build/page-book.css", - "maxSize": "9.4KB" + "maxSize": "9.5KB" }, { "path": "static/build/page-edit.css", diff --git a/openlibrary/core/observations.py b/openlibrary/core/observations.py index 37693015200..4869565dd09 100644 --- a/openlibrary/core/observations.py +++ b/openlibrary/core/observations.py @@ -340,61 +340,85 @@ def get_patron_observations(cls, username, work_id=None): return list(oldb.query(query, vars=data)) + def get_multi_choice(type): + """ + Searches for the given type in the observations object, and returns the type's 'multi_choice' value. + + return: The multi_choice value for the given type + """ + for o in OBSERVATIONS['observations']: + if o['label'] == type: + return o['multi_choice'] + @classmethod - def persist_observations(cls, username, work_id, observations, edition_id=NULL_EDITION_VALUE): + def persist_observation(cls, username, work_id, observation, action, edition_id=NULL_EDITION_VALUE): """ - Insert or update a collection of observations. If no records exist - for the given work_id, new observations are inserted. + Inserts or deletes a single observation, depending on the given action. + + If the action is 'delete', the observation will be deleted from the observations table. + + If the action is 'add', and the observation type only allows a single value (multi_choice == True), + an attempt is made to delete previous observations of the same type before the new observation is + persisted. + Otherwise, the new observation is stored in the DB. """ - def get_observation_ids(observations): + def get_observation_ids(observation): """ - Given a list of observation key-value pairs, returns a list of observation IDs. + Given an observation key-value pair, returns an ObservationIds tuple. - return: List of observation IDs + return: An ObservationsIds tuple """ - observation_ids = [] + key = list(observation)[0] + item = next((o for o in OBSERVATIONS['observations'] if o['label'] == key)) - for o in observations: - key = list(o)[0] - observation = next((o for o in OBSERVATIONS['observations'] if o['label'] == key)) - - observation_ids.append( - ObservationIds( - observation['id'], - next((v['id'] for v in observation['values'] if v['name'] == o[key])) - ) - ) - - return observation_ids + return ObservationIds( + item['id'], + next((v['id'] for v in item['values'] if v['name'] == observation[key])) + ) oldb = db.get_db() - records = cls.get_patron_observations(username, work_id) - - observation_ids = get_observation_ids(observations) - - for r in records: - record_ids = ObservationIds(r['type'], r['value']) - # Delete values that are in existing records but not in submitted observations - if record_ids not in observation_ids: - cls.remove_observations( - username, - work_id, - edition_id=edition_id, - observation_type=r['type'], - observation_value=r['value'] - ) - else: - # If same value exists in both existing records and observations, remove from observations - observation_ids.remove(record_ids) - - if len(observation_ids): - # Insert all remaining observations - oldb.multiple_insert('observations', - [{'username': username, 'work_id': work_id, 'edition_id': edition_id, 'observation_value': id.value_id, 'observation_type': id.type_id} for id in observation_ids] + observation_ids = get_observation_ids(observation) + + data = { + 'username': username, + 'work_id': work_id, + 'edition_id': edition_id, + 'observation_type': observation_ids.type_id, + 'observation_value': observation_ids.value_id + } + + where_clause = 'username=$username AND work_id=$work_id AND observation_type=$observation_type ' + + + if action == 'delete': + # Delete observation and return: + where_clause += 'AND observation_value=$observation_value' + + return oldb.delete( + 'observations', + vars=data, + where=where_clause + ) + elif not cls.get_multi_choice(list(observation)[0]): + # A radio button value has changed. Delete old value, if one exists: + oldb.delete( + 'observations', + vars=data, + where=where_clause ) + # Insert new value and return: + return oldb.insert( + 'observations', + username=username, + work_id=work_id, + edition_id=edition_id, + observation_type=observation_ids.type_id, + observation_value=observation_ids.value_id + ) + @classmethod def remove_observations(cls, username, work_id, edition_id=NULL_EDITION_VALUE, observation_type=None, observation_value=None): """ diff --git a/openlibrary/plugins/openlibrary/api.py b/openlibrary/plugins/openlibrary/api.py index ba170f27500..913e2edc60a 100644 --- a/openlibrary/plugins/openlibrary/api.py +++ b/openlibrary/plugins/openlibrary/api.py @@ -462,10 +462,11 @@ def POST(self, work_id): data = json.loads(web.data()) - Observations.persist_observations( + Observations.persist_observation( data['username'], work_id, - data['observations'] + data['observation'], + data['action'] ) def response(msg, status="success"): diff --git a/openlibrary/plugins/openlibrary/js/patron-metadata/index.js b/openlibrary/plugins/openlibrary/js/patron-metadata/index.js index 6dda7101e0e..2b90ee9c110 100644 --- a/openlibrary/plugins/openlibrary/js/patron-metadata/index.js +++ b/openlibrary/plugins/openlibrary/js/patron-metadata/index.js @@ -1,5 +1,19 @@ import '../../../../../static/css/components/metadata-form.less'; +// Event name for submission status updates: +const OBSERVATION_SUBMISSION = 'observationSubmission'; + +// Used to denote a submission state change for all sections: +const ANY_SECTION_TYPE = 'allSections'; + +// Denotes all possible states of an observation submission: +const SubmissionState = { + INITIAL: 1, // Initial state --- nothing has been submitted yet. + PENDING: 2, // A submission has been made, but the server has not yet responded. + SUCCESS: 3, // The observation was successfully processed by the server. + FAILURE: 4 // Something went wrong while the observation was being processed by the server. +}; + export function initPatronMetadata() { function displayModal() { $.colorbox({ @@ -16,39 +30,84 @@ export function initPatronMetadata() { let className = observation.multi_choice ? 'multi-choice' : 'single-choice'; let $choices = $(`
`); let choiceIndex = observation.values.length; + let type = observation.label; for (const value of observation.values) { - let choiceId = `${observation.label}Choice${choiceIndex--}`; + let choiceId = `${type}Choice${choiceIndex--}`; let checked = ''; - if (observation.label in selectedValues - && selectedValues[observation.label].includes(value)) { + if (type in selectedValues + && selectedValues[type].includes(value)) { checked = 'checked'; } $choices.append(` `); + + ${value} + `); } - $form.append(` -
- ${observation.label} -
-

${observation.description}

- ${$choices.prop('outerHTML')} -
-
- `); - } + let $formSection = $(`
+ ${type} +
+

${observation.description}

+ + + + ${$choices.prop('outerHTML')} +
+
`); + + /* + Adds an observation submission state change event handler to this section of the form. + + The handler displays the appropriate submission state indicator depending on the given submission + state. + + The handler takes a section type, which identifies which section's submission state should + change, and the new submission state. + */ + $formSection.on(OBSERVATION_SUBMISSION, function(event, sectionType, submissionState) { + let pendingSpan = $(this).find('.pending-indicator')[0]; + let successSpan = $(this).find('.success-indicator')[0]; + let failureSpan = $(this).find('.failure-indicator')[0]; + + if (sectionType === type || sectionType === ANY_SECTION_TYPE) { + switch (submissionState) { + case SubmissionState.INITIAL: + pendingSpan.classList.add('hidden'); + successSpan.classList.add('hidden'); + failureSpan.classList.add('hidden'); + break; + case SubmissionState.PENDING: + pendingSpan.classList.remove('hidden'); + + successSpan.classList.add('hidden'); + failureSpan.classList.add('hidden'); + break; + case SubmissionState.SUCCESS: + successSpan.classList.remove('hidden'); + + pendingSpan.classList.add('hidden'); + failureSpan.classList.add('hidden'); + break; + case SubmissionState.FAILURE: + failureSpan.classList.remove('hidden'); + + pendingSpan.classList.add('hidden'); + successSpan.classList.add('hidden'); + break; + } + } + }) + $form.append($formSection); + } $form.append(`
${i18nStrings.close_text} -
`); @@ -75,6 +134,7 @@ export function initPatronMetadata() { }) .done(function(data) { populateForm($('#user-metadata'), data.observations, selectedValues); + addChangeListeners(context); $('#cancel-submission').click(function() { $.colorbox.close(); }) @@ -85,49 +145,11 @@ export function initPatronMetadata() { }) }) } else { + // Hide all submission state indicators when the modal is reopened: + $('.aspect-section').trigger(OBSERVATION_SUBMISSION, [ANY_SECTION_TYPE, SubmissionState.INITIAL]); displayModal(); } }); - - $('#user-metadata').on('submit', function(event) { - event.preventDefault(); - - let context = JSON.parse(document.querySelector('#modal-link').dataset.context); - let result = {}; - - result['username'] = context.username; - let workId = context.work.split('/')[2]; - - if (context.edition) { - result['edition_id'] = context.edition.split('/')[2]; - } - - result['observations'] = []; - - $(this).find('input[type=radio]:checked').each(function() { - let currentPair = {}; - currentPair[$(this).attr('name')] = $(this).val() - result['observations'].push(currentPair); - }) - - $(this).find('input[type=checkbox]:checked').each(function() { - let currentPair = {}; - currentPair[$(this).attr('name')] = $(this).val() - result['observations'].push(currentPair); - }) - - if (result['observations'].length > 0) { - $.ajax({ - type: 'POST', - url: `/works/${workId}/observations`, - contentType: 'application/json', - data: JSON.stringify(result) - }); - $.colorbox.close(); - } else { - // TODO: Handle case where no data was submitted - } - }); } /** @@ -150,3 +172,70 @@ function addToggleListeners($toggleElements) { $(this).on('toggle', toggleHandler); }) } + + +/** + * Adds change listeners to each input in the observations section of the modal. + * + * For each checkbox and radio button in the observations form, a change listener + * that triggers observation submissions is added. On change, a payload containing + * the username, action type ('add' when an input is checked, 'delete' when unchecked), + * and observation type and value are sent to the back-end server. + * + * @param {Object} context An object containing the patron's username and the work's OLID. + */ +function addChangeListeners(context) { + let $questionSections = $('.aspect-section'); + let username = context.username; + let workOlid = context.work.split('/')[2]; + + $questionSections.each(function() { + let $inputs = $(this).find('input') + + $inputs.each(function() { + $(this).on('change', function() { + let type = $(this).attr('name'); + let value = $(this).attr('value'); + let observation = {}; + observation[type] = value; + + let data = { + username: username, + action: `${$(this).prop('checked') ? 'add': 'delete'}`, + observation: observation + } + + submitObservation($(this), workOlid, data, type); + }); + }) + }); +} + +/** + * Submits an observation to the server and triggers submission status change events. + * + * @param {JQuery} $input The checkbox or radio button that is firing the change event. + * @param {String} workOlid The OLID for the work being observed. + * @param {Object} data Payload that will be sent to the back-end server. + * @param {String} sectionType Name of the input's section. + */ +function submitObservation($input, workOlid, data, sectionType) { + // Show spinner: + $input.trigger(OBSERVATION_SUBMISSION, [sectionType, SubmissionState.PENDING]); + + // Make AJAX call + $.ajax({ + type: 'POST', + url: `/works/${workOlid}/observations`, + contentType: 'application/json', + data: JSON.stringify(data) + }) + .done(function() { + // Show success message: + $input.trigger(OBSERVATION_SUBMISSION, [sectionType, SubmissionState.SUCCESS]); + }) + .fail(function() { + // Show failure message: + $input.trigger(OBSERVATION_SUBMISSION, [sectionType, SubmissionState.FAILURE]); + }); +} diff --git a/static/css/components/metadata-form.less b/static/css/components/metadata-form.less index 0472237fec6..c22e4a0726e 100644 --- a/static/css/components/metadata-form.less +++ b/static/css/components/metadata-form.less @@ -2,6 +2,40 @@ @import (reference) "../less/colors.less"; @import (reference) "./buttonBtn.less"; +.failure-indicator { + color: @red; +} + +.success-indicator { + color: @green; +} + +.pending-indicator{ + display: inline-block; + width: 1em; + height: 1em; +} + +.pending-indicator:after { + content: " "; + display: block; + width: 1em; + height: 1em; + border-radius: 50%; + border: 1px solid @black; + border-color: @black transparent; + animation: pending-indicator 1.2s linear infinite; +} + +@keyframes pending-indicator { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + .single-choice { display: flex; margin-bottom: .5em; @@ -73,6 +107,11 @@ padding-left: .5em; padding-bottom: .5em; } + + h3 { + display: inline-block; + margin-right: .5em; + } } .book-notes-form { @@ -137,4 +176,18 @@ margin-top: .5em; } } + + .aspect-section { + h3 { + margin-right: unset; + display: block; + } + } + + .pending-indicator, + .success-indicator, + .failure-indicator { + display: block; + height: 1em; + } }