Compare commits

...

7 Commits

Author SHA1 Message Date
Emilia Allison 7defd8ff08
Bump versions
temp/pipeline/head Something is wrong with the build of this commit Details
Gitea Scan/plate-tool/pipeline/head This commit looks good Details
2024-08-03 18:49:57 -04:00
Emilia Allison be1ededc7e
fix: Styling bugs
- Drop down items not wide enough
- Dialog close button intersected modal text
2024-08-03 18:49:49 -04:00
Emilia Allison 0567e0a037
feature: Add CSV export type to plate-tool-web 2024-08-03 18:42:31 -04:00
Emilia Allison b78336def0
Clean export callbacks 2024-08-03 18:42:00 -04:00
Emilia Allison 194b78430c
Add Echo Client format support in lib 2024-08-03 17:55:13 -04:00
Emilia Allison 3fcd526010
fix: Remove debug call
This one call was spamming my logs and making them unreadable
2024-08-03 17:39:34 -04:00
Emilia Allison 108a2677e3
fix: Retain unknown columns when mangling headers on CSV import 2024-08-03 17:37:41 -04:00
15 changed files with 218 additions and 20 deletions

4
Cargo.lock generated
View File

@ -633,7 +633,7 @@ dependencies = [
[[package]] [[package]]
name = "plate-tool-lib" name = "plate-tool-lib"
version = "0.3.1" version = "0.4.0"
dependencies = [ dependencies = [
"csv", "csv",
"getrandom", "getrandom",
@ -648,7 +648,7 @@ dependencies = [
[[package]] [[package]]
name = "plate-tool-web" name = "plate-tool-web"
version = "0.3.1" version = "0.4.0"
dependencies = [ dependencies = [
"csv", "csv",
"getrandom", "getrandom",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "plate-tool-lib" name = "plate-tool-lib"
version = "0.3.1" version = "0.4.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@ -0,0 +1,26 @@
use serde::Serialize;
use super::TransferRecord;
// Format preferred by the Echo Client when using the Pick-List function.
// Note that this does not export plate info!
// Exports of this type should combine all transfers between the two actively selected plates.
#[derive(Serialize, Debug)]
pub struct EchoClientTransferRecord {
#[serde(rename = "SrcWell")]
pub source_well: String,
#[serde(rename = "DestWell")]
pub destination_well: String,
#[serde(rename = "XferVol")]
pub volume: f32,
}
impl From<TransferRecord> for EchoClientTransferRecord {
fn from(value: TransferRecord) -> Self {
EchoClientTransferRecord {
source_well: value.source_well,
destination_well: value.destination_well,
volume: value.volume,
}
}
}

View File

@ -1,10 +1,9 @@
use crate::transfer::Transfer; use crate::transfer::Transfer;
use crate::util::*; use crate::util::*;
use super::{TransferRecord, transfer_record::TransferRecordDeserializeIntermediate, mangle_headers::mangle_headers}; use super::{alternative_formats::EchoClientTransferRecord, mangle_headers::mangle_headers, transfer_record::TransferRecordDeserializeIntermediate, TransferRecord};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize};
use std::error::Error; use std::error::Error;
pub fn transfer_to_records( pub fn transfer_to_records(
@ -44,6 +43,15 @@ pub fn records_to_csv(trs: Vec<TransferRecord>) -> Result<String, Box<dyn Error>
Ok(data) Ok(data)
} }
pub fn records_to_echo_client_csv(trs: Vec<TransferRecord>) -> Result<String, Box<dyn Error>> {
let mut wtr = csv::WriterBuilder::new().from_writer(vec![]);
for record in trs {
wtr.serialize(Into::<EchoClientTransferRecord>::into(record))?
}
let data = String::from_utf8(wtr.into_inner()?)?;
Ok(data)
}
pub fn string_well_to_pt(input: &str) -> Option<(u8, u8)> { pub fn string_well_to_pt(input: &str) -> Option<(u8, u8)> {
lazy_static! { lazy_static! {
static ref REGEX: Regex = Regex::new(r"([A-Z,a-z]+)(\d+)").unwrap(); static ref REGEX: Regex = Regex::new(r"([A-Z,a-z]+)(\d+)").unwrap();

View File

@ -5,6 +5,8 @@ pub fn mangle_headers(data: &str) -> String {
for field in fields { for field in fields {
if let Some(f) = detect_field(field) { if let Some(f) = detect_field(field) {
modified_headers.push(f.to_string()); modified_headers.push(f.to_string());
} else {
log::debug!("Unk field: {:?}", field)
} }
} }
@ -12,22 +14,25 @@ pub fn mangle_headers(data: &str) -> String {
} }
fn detect_field(field: &str) -> Option<Field> { fn detect_field(field: &str) -> Option<Field> {
// NOTE: Don't return none! Consider refactor to -> Field later on.
// Returning none means a field will be dropped, which requires the user to manually drop
// columns from their picklist.
match field.trim().to_lowercase() { match field.trim().to_lowercase() {
x if x.contains("source") || x.contains("src") => match x { x if x.contains("source") || x.contains("src") => match x {
_ if x.contains("plate") => Some(Field::SourcePlate), _ if x.contains("plate") || x.contains("barcode") => Some(Field::SourcePlate),
_ if x.contains("well") => Some(Field::SourceWell), _ if x.contains("well") => Some(Field::SourceWell),
_ if x.contains("format") || x.contains("fmt") => Some(Field::SourceFormat), _ if x.contains("format") || x.contains("fmt") => Some(Field::SourceFormat),
_ => None, _ => Some(Field::Other(x)), // Retain unknown fields
}, },
x if x.contains("destination") || x.contains("dest") => match x { x if x.contains("destination") || x.contains("dest") => match x {
_ if x.contains("plate") => Some(Field::DestinationPlate), _ if x.contains("plate") || x.contains("barcode") => Some(Field::DestinationPlate),
_ if x.contains("well") => Some(Field::DestinationWell), _ if x.contains("well") => Some(Field::DestinationWell),
_ if x.contains("format") || x.contains("fmt") => Some(Field::DestinationFormat), _ if x.contains("format") || x.contains("fmt") => Some(Field::DestinationFormat),
_ => None, _ => Some(Field::Other(x)), // Retain unknown fields
}, },
x if x.contains("volume") => Some(Field::Volume), x if x.contains("volume") => Some(Field::Volume),
x if x.contains("concentration") => Some(Field::Concentration), x if x.contains("concentration") => Some(Field::Concentration),
_ => None, x => Some(Field::Other(x)), // Retain unknown fields
} }
} }
@ -40,6 +45,7 @@ enum Field {
DestinationFormat, DestinationFormat,
Volume, Volume,
Concentration, Concentration,
Other(String),
} }
impl ToString for Field { impl ToString for Field {
@ -53,6 +59,7 @@ impl ToString for Field {
Field::DestinationFormat => "destinationformat".to_string(), Field::DestinationFormat => "destinationformat".to_string(),
Field::Volume => "volume".to_string(), Field::Volume => "volume".to_string(),
Field::Concentration => "concentration".to_string(), Field::Concentration => "concentration".to_string(),
Field::Other(x) => x.to_string(),
} }
} }
} }

View File

@ -2,6 +2,7 @@ mod transfer_record;
mod conversion; mod conversion;
mod auto; mod auto;
mod mangle_headers; mod mangle_headers;
mod alternative_formats;
pub use transfer_record::volume_default; pub use transfer_record::volume_default;
pub use transfer_record::TransferRecord; pub use transfer_record::TransferRecord;

View File

@ -1,6 +1,6 @@
[package] [package]
name = "plate-tool-web" name = "plate-tool-web"
version = "0.3.1" version = "0.4.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@ -7,6 +7,8 @@ dialog {
color: $color-dark; color: $color-dark;
background: $color-white; background: $color-white;
padding-right: 5%;
} }
dialog > form[method="dialog"] { dialog > form[method="dialog"] {

View File

@ -30,6 +30,7 @@ div.upper_menu {
margin: 0; margin: 0;
cursor: pointer; cursor: pointer;
font-size: calc($menu-height*0.7); font-size: calc($menu-height*0.7);
width: 100%;
} }
* { * {

View File

@ -2,18 +2,18 @@
use js_sys::Array; use js_sys::Array;
use wasm_bindgen::{prelude::*, JsCast, JsValue}; use wasm_bindgen::{JsCast, JsValue};
use web_sys::{ use web_sys::{
Blob, HtmlAnchorElement, HtmlDialogElement, HtmlElement, HtmlFormElement, HtmlInputElement, Url, Blob, HtmlAnchorElement, Url,
}; };
use yew::prelude::*; use yew::prelude::*;
use yewdux::prelude::*;
use crate::components::states::{CurrentTransfer, MainState};
use crate::components::states::MainState;
use crate::state_to_csv; use crate::state_to_csv;
type NoParamsCallback = Box<dyn Fn(())>; // type NoParamsCallback = Box<dyn Fn(())>;
pub fn export_csv_button_callback(main_state: std::rc::Rc<MainState>) -> Callback<MouseEvent> { pub fn export_csv_button_callback(main_state: std::rc::Rc<MainState>) -> Callback<MouseEvent> {
Callback::from(move |_| { Callback::from(move |_| {
@ -24,7 +24,6 @@ pub fn export_csv_button_callback(main_state: std::rc::Rc<MainState>) -> Callbac
.unwrap(); .unwrap();
return; return;
} }
web_sys::window().unwrap().alert_with_message("CSV export is currently not importable. Export as JSON if you'd like to back up your work!").unwrap();
if let Ok(csv) = state_to_csv(&main_state) { if let Ok(csv) = state_to_csv(&main_state) {
save_str(&csv, "transfers.csv"); save_str(&csv, "transfers.csv");
} }

View File

@ -1,9 +1,18 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
use js_sys::Function;
use wasm_bindgen::{prelude::*, JsCast};
use web_sys::{
FileReader, HtmlButtonElement, HtmlDialogElement, HtmlElement, HtmlFormElement,
HtmlInputElement, HtmlOptionElement, HtmlSelectElement,
};
use yew::prelude::*; use yew::prelude::*;
use yewdux::prelude::*; use yewdux::prelude::*;
use crate::components::states::MainState; use crate::components::states::{CsvExportType, MainState};
use super::create_close_button;
type NoParamsCallback = Box<dyn Fn(())>; type NoParamsCallback = Box<dyn Fn(())>;
@ -28,3 +37,72 @@ pub fn toggle_volume_heatmap_callback(
}) })
}) })
} }
pub fn change_csv_export_type_callback(
main_dispatch: Dispatch<MainState>,
) -> Callback<web_sys::MouseEvent> {
let main_dispatch = main_dispatch.clone();
Callback::from(move |_| {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let body = document.body().unwrap();
let modal = document
.create_element("dialog")
.unwrap()
.dyn_into::<HtmlDialogElement>()
.unwrap();
let onclose_callback = {
let modal = modal.clone();
Closure::<dyn FnMut(_)>::new(move |_: Event| {
modal.remove();
})
};
modal.set_onclose(Some(onclose_callback.as_ref().unchecked_ref()));
modal.set_text_content(Some("CSV Export Type"));
let close_button = create_close_button(&onclose_callback);
onclose_callback.forget();
modal.append_child(&close_button).unwrap();
let form = document
.create_element("form")
.unwrap()
.dyn_into::<HtmlFormElement>()
.unwrap();
let export_type = document
.create_element("select")
.unwrap()
.dyn_into::<HtmlSelectElement>()
.unwrap();
for t in [CsvExportType::Normal, CsvExportType::EchoClient] {
let option = document
.create_element("option")
.unwrap()
.dyn_into::<HtmlOptionElement>()
.unwrap();
option.set_value(&t.to_string());
option.set_text(&t.to_string());
option.set_selected(t == main_dispatch.get().preferences.csv_export_type);
export_type.append_child(&option).unwrap();
}
form.append_child(&export_type).unwrap();
let form_change_callback = {
let export_type = export_type.clone();
let main_dispatch = main_dispatch.clone();
Closure::<dyn FnMut(_)>::new(move |_: Event| {
let current: CsvExportType = export_type.value().as_str().into();
main_dispatch.reduce_mut(|state| {
state.preferences.csv_export_type = current;
});
})};
form.set_onchange(Some(form_change_callback.as_ref().unchecked_ref()));
form_change_callback.forget();
modal.append_child(&form).unwrap();
body.append_child(&modal).unwrap();
modal.show_modal().unwrap();
})
}

View File

@ -48,6 +48,11 @@ pub fn MainWindow() -> Html {
main_window_callbacks::toggle_volume_heatmap_callback(main_dispatch) main_window_callbacks::toggle_volume_heatmap_callback(main_dispatch)
}; };
let change_csv_export_type_callback = {
let main_dispatch = main_dispatch.clone();
main_window_callbacks::change_csv_export_type_callback(main_dispatch)
};
let new_plate_dialog_is_open = use_state_eq(|| false); let new_plate_dialog_is_open = use_state_eq(|| false);
let new_plate_dialog_callback = let new_plate_dialog_callback =
main_window_callbacks::new_plate_dialog_callback(new_plate_dialog_is_open.clone()); main_window_callbacks::new_plate_dialog_callback(new_plate_dialog_is_open.clone());
@ -106,6 +111,12 @@ pub fn MainWindow() -> Html {
<button onclick={toggle_volume_heatmap_callback}>{"Toggle volume heatmap"}</button> <button onclick={toggle_volume_heatmap_callback}>{"Toggle volume heatmap"}</button>
</div> </div>
</div> </div>
<div class="dropdown-sub">
<button>{"Export"}</button>
<div>
<button onclick={change_csv_export_type_callback}>{"Change CSV export type"}</button>
</div>
</div>
</div> </div>
</div> </div>
<div class="main_container"> <div class="main_container">

View File

@ -65,7 +65,6 @@ pub fn Plate(props: &PlateProps) -> Html {
if let Some(val) = volume_map_temp.get_mut(&well) { if let Some(val) = volume_map_temp.get_mut(&well) {
*val += transfer.volume; *val += transfer.volume;
} else { } else {
log::info!("well: {:?}, vol: {:?}", well, transfer.volume);
volume_map_temp.insert(well, transfer.volume); volume_map_temp.insert(well, transfer.volume);
} }
volume_max_temp = f32::max(volume_max_temp, transfer.volume); volume_max_temp = f32::max(volume_max_temp, transfer.volume);

View File

@ -19,11 +19,40 @@ pub struct Preferences {
pub in_transfer_hashes: bool, pub in_transfer_hashes: bool,
#[serde(default)] #[serde(default)]
pub volume_heatmap: bool, pub volume_heatmap: bool,
#[serde(default)]
pub csv_export_type: CsvExportType,
} }
impl Default for Preferences { impl Default for Preferences {
fn default() -> Self { fn default() -> Self {
Self { in_transfer_hashes: true, volume_heatmap: false } Self { in_transfer_hashes: true, volume_heatmap: false, csv_export_type: CsvExportType::Normal }
}
}
#[derive(PartialEq, Clone, Copy, Serialize, Deserialize, Default)]
pub enum CsvExportType {
#[default]
Normal,
EchoClient,
}
impl std::fmt::Display for CsvExportType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CsvExportType::Normal => write!(f, "Normal"),
CsvExportType::EchoClient => write!(f, "Echo Client"),
}
}
}
impl From<&str> for CsvExportType {
fn from(value: &str) -> Self {
match value.trim().to_lowercase().as_str() {
"normal" => CsvExportType::Normal,
"echo client" => CsvExportType::EchoClient,
_ => CsvExportType::default(),
}
} }
} }

View File

@ -40,6 +40,13 @@ pub fn plate_test() {
} }
pub fn state_to_csv(state: &MainState) -> Result<String, Box<dyn Error>> { pub fn state_to_csv(state: &MainState) -> Result<String, Box<dyn Error>> {
match state.preferences.csv_export_type {
components::states::CsvExportType::Normal => state_to_csv_regular(state),
components::states::CsvExportType::EchoClient => state_to_csv_echo_client(state)
}
}
fn state_to_csv_regular(state: &MainState) -> Result<String, Box<dyn Error>> {
let mut records: Vec<TransferRecord> = Vec::new(); let mut records: Vec<TransferRecord> = Vec::new();
for transfer in &state.transfers { for transfer in &state.transfers {
let src_barcode = state let src_barcode = state
@ -60,3 +67,33 @@ pub fn state_to_csv(state: &MainState) -> Result<String, Box<dyn Error>> {
} }
records_to_csv(records) records_to_csv(records)
} }
fn state_to_csv_echo_client(state: &MainState) -> Result<String, Box<dyn Error>> {
let current_src = state.selected_source_plate;
let current_dst = state.selected_dest_plate;
let mut records: Vec<TransferRecord> = Vec::new();
for transfer in &state.transfers {
let src_barcode = state
.source_plates
.iter()
.find(|spi| spi.get_uuid() == transfer.source_id)
.ok_or("Found unpurged transfer")?;
let dest_barcode = state
.destination_plates
.iter()
.find(|dpi| dpi.get_uuid() == transfer.dest_id)
.ok_or("Found unpurged transfer")?;
if src_barcode.get_uuid() == current_src && dest_barcode.get_uuid() == current_dst {
records.append(&mut transfer_to_records(
transfer,
&src_barcode.name,
&dest_barcode.name,
))
}
}
records_to_echo_client_csv(records)
}