diff --git a/docs/changes/454.new.rst b/docs/changes/454.new.rst new file mode 100644 index 00000000..a536bdad --- /dev/null +++ b/docs/changes/454.new.rst @@ -0,0 +1 @@ +Added ToO observation creation and submission capability with error handling and toast notifications. \ No newline at end of file diff --git a/src/goats_tom/static/js/gpp/brightnesses_editor.js b/src/goats_tom/static/js/gpp/brightnesses_editor.js index 57156823..6814e989 100644 --- a/src/goats_tom/static/js/gpp/brightnesses_editor.js +++ b/src/goats_tom/static/js/gpp/brightnesses_editor.js @@ -150,11 +150,10 @@ class BrightnessesEditor { col.dataset.role = "brightness-col"; const inputGroup = Utils.createElement("div", "input-group"); - const uniqueId = `brightness-${this.#idCounter++}`; + const uniqueId = `${this.#idCounter++}`; const bandSelect = this.#createSelect( - `${uniqueId}-band`, - "brightnessBand", + `brightnessBandSelect${uniqueId}`, this.#bands, band ); @@ -162,13 +161,13 @@ class BrightnessesEditor { const valueInput = Utils.createElement("input", "form-control"); valueInput.type = "number"; valueInput.step = "any"; - valueInput.name = "brightnessValue"; - valueInput.id = `${uniqueId}-value`; + const brightnessValueId = `brightnessValueInput${uniqueId}`; + valueInput.name = brightnessValueId; + valueInput.id = brightnessValueId; valueInput.value = value; const unitSelect = this.#createSelect( - `${uniqueId}-units`, - "brightnessUnits", + `brightnessUnitsSelect${uniqueId}`, this.#units, units ); @@ -196,14 +195,13 @@ class BrightnessesEditor { * * @private * @param {string} id - Element ID. - * @param {string} name - Name attribute. * @param {string[]} options - Option values. * @param {string} selectedValue - Value to select by default. * @returns {HTMLSelectElement} */ - #createSelect(id, name, options, selectedValue = "") { + #createSelect(id, options, selectedValue = "") { const select = Utils.createElement("select", "form-select"); - select.name = name; + select.name = id; select.id = id; options.forEach((opt) => { diff --git a/src/goats_tom/static/js/gpp/elevation_range_editor.js b/src/goats_tom/static/js/gpp/elevation_range_editor.js index 531d9a6a..4656c92b 100644 --- a/src/goats_tom/static/js/gpp/elevation_range_editor.js +++ b/src/goats_tom/static/js/gpp/elevation_range_editor.js @@ -89,6 +89,7 @@ class ElevationRangeEditor { #createSelect(value) { const select = Utils.createElement("select", "form-select"); select.id = "elevationRangeSelect"; + select.name = "elevationRangeSelect"; ["Hour Angle", "Air Mass"].forEach((opt) => { const o = Utils.createElement("option"); o.value = opt; @@ -108,7 +109,9 @@ class ElevationRangeEditor { const col = Utils.createElement("div", "col-md-6"); const labelEl = this.#createLabel(label, `${id}Input`); const input = Utils.createElement("input", "form-control"); - input.id = `${id}Input`; + const inputId = `${id}Input`; + input.id = inputId; + input.name = inputId; input.type = "number"; input.value = value; if (this.#readOnly) input.disabled = true; diff --git a/src/goats_tom/static/js/gpp/exposure_mode_editor.js b/src/goats_tom/static/js/gpp/exposure_mode_editor.js index f0a06c35..38851778 100644 --- a/src/goats_tom/static/js/gpp/exposure_mode_editor.js +++ b/src/goats_tom/static/js/gpp/exposure_mode_editor.js @@ -93,6 +93,7 @@ class ExposureModeEditor { #createSelect(value) { const select = Utils.createElement("select", "form-select"); select.id = "exposureModeSelect"; + select.name = "exposureModeSelect"; ["Signal / Noise", "Time & Count"].forEach((opt) => { const o = Utils.createElement("option"); o.value = opt; @@ -117,7 +118,9 @@ class ExposureModeEditor { const col = Utils.createElement("div", "col-md-6"); const labelEl = this.#createLabel(label, `${id}Input`); const input = Utils.createElement("input", "form-control"); - input.id = `${id}Input`; + const inputId = `${id}Input`; + input.id = inputId; + input.name = inputId; input.type = "number"; input.value = value; if (this.#readOnly) input.disabled = true; diff --git a/src/goats_tom/static/js/gpp/fields.js b/src/goats_tom/static/js/gpp/fields.js index b2ebb885..599bf8b1 100644 --- a/src/goats_tom/static/js/gpp/fields.js +++ b/src/goats_tom/static/js/gpp/fields.js @@ -91,17 +91,6 @@ const SHARED_FIELDS = [ suffix: "dms", colSize: "col-lg-6", }, - { - labelText: "State", - path: "execution.executionState", - id: "executionState", - colSize: "col-lg-6", - formatter: Formatters.titleCaseFromUnderscore, - showIfMode: "both", - readOnly: "normal", - options: ["Ready", "Defined", "Inative"], - element: "select", - }, { labelText: "Title", path: "title", @@ -170,33 +159,32 @@ const SHARED_FIELDS = [ labelText: "Image Quality", path: "constraintSet.imageQuality", id: "imageQuality", - lookup: Lookups.imageQuality, element: "select", options: [ - "< 0.10 arcsec", - "< 0.20 arcsec", - "< 0.30 arcsec", - "< 0.40 arcsec", - "< 0.60 arcsec", - "< 0.80 arcsec", - "< 1.00 arcsec", - "< 1.50 arcsec", - "< 2.00 arcsec", + { value: "POINT_ONE", labelText: "< 0.10" }, + { value: "POINT_TWO", labelText: "< 0.20" }, + { value: "POINT_THREE", labelText: "< 0.30" }, + { value: "POINT_FOUR", labelText: "< 0.40" }, + { value: "POINT_SIX", labelText: "< 0.60" }, + { value: "POINT_EIGHT", labelText: "< 0.80" }, + { value: "ONE_POINT_ZERO", labelText: "< 1.00" }, + { value: "ONE_POINT_FIVE", labelText: "< 1.50" }, + { value: "TWO_POINT_ZERO", labelText: "< 2.00" }, + { value: "THREE_POINT_ZERO", labelText: "< 3.00" }, ], }, { labelText: "Cloud Extinction", path: "constraintSet.cloudExtinction", id: "cloudExtinction", - lookup: Lookups.cloudExtinction, options: [ - "0.00 mag", - "< 0.10 mag", - "< 0.30 mag", - "< 0.50 mag", - "< 1.00 mag", - "< 2.00 mag", - "< 3.00 mag", + { value: "POINT_ONE", labelText: "< 0.10 mag" }, + { value: "POINT_THREE", labelText: "< 0.30 mag" }, + { value: "POINT_FIVE", labelText: "< 0.50 mag" }, + { value: "ONE_POINT_ZERO", labelText: "< 1.00 mag" }, + { value: "ONE_POINT_FIVE", labelText: "< 1.50 mag" }, + { value: "TWO_POINT_ZERO", labelText: "< 2.00 mag" }, + { value: "THREE_POINT_ZERO", labelText: "< 3.00 mag" }, ], element: "select", }, @@ -204,16 +192,24 @@ const SHARED_FIELDS = [ labelText: "Sky Background", path: "constraintSet.skyBackground", id: "skyBackground", - formatter: Formatters.capitalizeFirstLetter, - options: ["Darkest", "Dark", "Gray", "Bright"], + options: [ + { value: "DARK", labelText: "Dark" }, + { value: "GRAY", labelText: "Gray" }, + { value: "BRIGHT", labelText: "Bright" }, + { value: "DARKEST", labelText: "Darkest" }, + ], element: "select", }, { labelText: "Water Vapor", path: "constraintSet.waterVapor", id: "waterVapor", - formatter: Formatters.titleCaseFromUnderscore, - options: ["Very Dry", "Dry", "Median", "Wet"], + options: [ + { value: "DRY", labelText: "Dry" }, + { value: "MEDIAN", labelText: "Median" }, + { value: "WET", labelText: "Wet" }, + { value: "VERY_DRY", labelText: "Very Dry" }, + ], element: "select", }, { diff --git a/src/goats_tom/static/js/gpp/gpp.js b/src/goats_tom/static/js/gpp/gpp.js index dd356aca..1e9f71bb 100644 --- a/src/goats_tom/static/js/gpp/gpp.js +++ b/src/goats_tom/static/js/gpp/gpp.js @@ -60,6 +60,7 @@ class GPPModel { #gppUrl = "gpp/"; #gppProgramsUrl = `${this.#gppUrl}programs/`; #gppObservationsUrl = `${this.#gppUrl}observations/`; + #gppToosUrl = `${this.#gppUrl}toos/`; #gppPingUrl = `${this.#gppUrl}ping/`; #observationsUrl = `observations/`; @@ -111,6 +112,22 @@ class GPPModel { } } + /** + * Creates a new ToO observation. + * @param {*} formData The form data to submit. + * @returns {Promise<{status: number, data: Object}>} A response object with status code and + * response data. + */ + async createTooObservation(formData) { + try { + const response = await this.#api.post(this.#gppToosUrl, formData); + return { status: 200, data: response }; + } catch (error) { + const data = await error.json(); + return { status: data.status, data: data }; + } + } + /** * Fetches all programs from the server and refreshes the cache. * @@ -368,6 +385,19 @@ class GPPView { }); } + /** + * Get the data from the observation form. + * @return {Object|null} The form data, or null if no form is present. + * @private + */ + #getFormData() { + if (this.#form) { + const formData = this.#form.getData(); + return formData; + } + return null; + } + /** * Render hook called by the controller. * @param {String} viewCmd Command string. @@ -429,6 +459,12 @@ class GPPView { case "clearObservationForm": this.#clearObservationForm(); break; + case "getFormData": + return this.#getFormData(); + + default: + console.warn(`[GPPView] Unknown render command: ${viewCmd}`); + break; } } @@ -455,15 +491,9 @@ class GPPView { case "saveObservation": this.#poPanel.onSave(handler); break; - case "createNewObservation": + case "createAndSaveTooObservation": this.#poPanel.onCreateNew(handler); break; - case "createAndSaveObservation": - Utils.delegate(this.#formContainer, selector, "click", (e) => { - e.preventDefault(); - handler(); - }); - break; } } } @@ -509,21 +539,53 @@ class GPPController { this.#view.bindCallback("selectTooObservation", (item) => { this.#selectTooObservation(item.observationId); }); - // FIXME: - // Which one below do I keep in callbacks? - this.#view.bindCallback("createNewObservation", (item) => - this.#createNewObservation() - ); - this.#view.bindCallback("createAndSaveObservation", () => - this.#createAndSaveObservation() + this.#view.bindCallback("createAndSaveTooObservation", () => + this.#createAndSaveTooObservation() ); } - #createNewObservation() { - this.#view.render("clearObservationForm"); - this.#view.render("showCreateNewObservation"); - } - #createAndSaveObservation() { - console.log("Controller got the create and save"); + + /** + * Creates and saves a new ToO observation. Displays a toast notification + * based on the result of the operation. + * @returns {Promise} A promise that resolves when the operation is complete. + * @private + */ + async #createAndSaveTooObservation() { + const formData = this.#view.render("getFormData"); + + // If no form data, issue a warning toast and return. + if (formData == null) { + const notification = { + label: "No Form Data", + message: "No form data available to create a new ToO observation.", + color: "warning", + }; + this.#toast.show(notification); + return; + } + + const response = await this.#model.createTooObservation(formData); + + if (response.status === 200) { + const notification = { + label: "ToO Observation Sent Successfully", + message: `New ToO observation has been sent successfully.`, + color: "success", + }; + this.#toast.show(notification); + } else { + // Gracefully extract and format error messages. + const errorMessages = Object.values(response.data).flat().join(" "); + + const notification = { + label: "ToO Observation Not Created", + message: + errorMessages || + "An unknown error occurred while creating the ToO observation.", + color: "danger", + }; + this.#toast.show(notification); + } } /** @@ -531,7 +593,7 @@ class GPPController { * based on the result. Shows a warning if the observation has no reference, * a success toast if saved successfully, or an error toast with details if it fails. * @private - * @returns {Promise} + * @returns {Promise} A promise that resolves when the operation is complete. */ async #saveObservation() { const observation = this.#model.activeObservation; diff --git a/src/goats_tom/static/js/gpp/observation_form.js b/src/goats_tom/static/js/gpp/observation_form.js index 11ed2edc..e75d0d21 100644 --- a/src/goats_tom/static/js/gpp/observation_form.js +++ b/src/goats_tom/static/js/gpp/observation_form.js @@ -46,7 +46,7 @@ class ObservationForm { ); const angleField = this.#createFormField({ - id: `${meta.id}_angle`, + id: `${meta.id}Angle`, labelText: "\u00A0", // Non-breaking space to align. type: "number", suffix: meta.suffix, @@ -260,6 +260,9 @@ class ObservationForm { } else if (element === "input") { control = Utils.createElement("input", ["form-control"]); control.type = type; + if (control.type === "number") { + control.step = "any"; // Allow decimals. + } control.value = value; } else if (element === "select") { control = Utils.createElement("select", ["form-select"]); @@ -285,6 +288,7 @@ class ObservationForm { } control.id = elementId; + control.name = elementId; // Apply read-only state if applicable. // Use the global readOnly flag. // Otherwise, if readOnly is "both" or matches current mode, disable the control. @@ -345,4 +349,14 @@ class ObservationForm { return [...sharedFields, ...instrumentFields]; } + + /** + * Get the current form data as FormData. + * @returns {FormData|null} FormData object or null if form not initialized. + */ + getData() { + if (!this.#form) return null; + const formData = new FormData(this.#form); + return formData; + } } diff --git a/src/goats_tom/static/js/gpp/program_observations_panel.js b/src/goats_tom/static/js/gpp/program_observations_panel.js index 4d0234d1..5c03fd17 100644 --- a/src/goats_tom/static/js/gpp/program_observations_panel.js +++ b/src/goats_tom/static/js/gpp/program_observations_panel.js @@ -190,12 +190,20 @@ class ProgramObservationsPanel { console.log("[ProgramObservationsPanel] Normal buttons disabled:", disabled); } + /** + * Enable or disable the normal observation . + * @param {boolean} disabled + */ toggleTooSelect(disabled) { this.#tooSelect.disabled = disabled; if (this.#debug) diff --git a/src/goats_tom/static/js/gpp/source_profile_editor.js b/src/goats_tom/static/js/gpp/source_profile_editor.js index e1e59e9a..bd7038b8 100644 --- a/src/goats_tom/static/js/gpp/source_profile_editor.js +++ b/src/goats_tom/static/js/gpp/source_profile_editor.js @@ -55,11 +55,12 @@ class SourceProfileEditor { const col = Utils.createElement("div", "col-md-6"); const label = Utils.createElement("label", "form-label"); label.textContent = "Profile"; - label.htmlFor = "profileType"; + const profileId = "sedProfileTypeSelect"; + label.htmlFor = profileId; const select = Utils.createElement("select", "form-select"); - select.name = "profileType"; - select.id = "profileType"; + select.name = profileId; + select.id = profileId; select.disabled = this.#readOnly; // Hardcode options for now; will be dynamic later. @@ -93,11 +94,12 @@ class SourceProfileEditor { const col = Utils.createElement("div", "col-md-6"); const label = Utils.createElement("label", "form-label"); label.textContent = "SED"; - label.htmlFor = "sedType"; + const sedId = "sedTypeSelect"; + label.htmlFor = sedId; const select = Utils.createElement("select", "form-select"); - select.name = "sedType"; - select.id = "sedType"; + select.name = sedId; + select.id = sedId; select.disabled = this.#readOnly; // Hardcode options for now; will be dynamic later. @@ -130,15 +132,16 @@ class SourceProfileEditor { const label = Utils.createElement("label", "form-label"); label.textContent = "Temperature"; - label.htmlFor = "blackBodyTemperature"; + const inputId = "sedBlackBodyTemperature"; + label.htmlFor = inputId; const inputGroup = Utils.createElement("div", "input-group"); const input = Utils.createElement("input", "form-control"); input.type = "number"; - input.name = "temperature"; + input.name = inputId; input.value = data.temperature ?? "10000"; input.min = "0"; - input.id = "blackBodyTemperature"; + input.id = inputId; if (this.#readOnly) input.disabled = true; const suffix = Utils.createElement("span", "input-group-text"); @@ -149,9 +152,8 @@ class SourceProfileEditor { return col; }, extract: (formContainer) => { - const input = formContainer.querySelector("#blackBodyTemperature"); - const value = parseFloat(input?.value ?? ""); - return Number.isNaN(value) ? {} : { temperature: value }; + // FIXME: Don't need this anymore; can directly query the form data. + return {}; }, }; }