
let Crystal={
		
		diffractionTypes:[
			{ label:'OSC', value:'OSC' },
			{ label:'SAD', value:'SAD' },
			{ label:'MAD', value:'MAD' },
		],
		
		/**
		 * Crystal space groups, by category.
		 * Format: 
		 * Underscores are purely to indicate digit grouping boundaries. Some numbers are written as subscripts. Where 
		 * two digits are together, the second should be rendered as a subscript, e.g. P_42_2_2 rendered to the user as
		 * P4<sub>2</sub>22 and saved to the database as P4222.
		 */
		spaceGroups:[
			{
				'category':'Triclinic', 
				'groups':['Unknown','P_1']
			},
			{
				'category':'Monoclinic',
				'groups':[
					'P_2','P_21','C_2'
				]
			},
			{
				'category':'Orthorhombic',
				'groups':[
					'P_2_2_2','P_2_2_21','P_21_21_2','P_21_21_21','C_2_2_21','C_2_2_2','F_2_2_2','I_2_2_2','I_21_21_21'
				]
			},
			{
				'category':'Tetragonal',
				'groups':[
					'P_4','P_41','P_42','P_43','I_4','I_41','P_4_2_2','P_4_21_2','P_41_2_2',
					'P_41_21_2','P_42_2_2','P_42_21_2','P_43_2_2','P_43_21_2','I_4_2_2','I_41_2_2'
				]
			},
			{
				'category':'Trigonal',
				'groups':[
					'P_3','P_31','P_32','R_3','H_3','P_3_1_2','P_3_2_1','P_31_1_2','P_31_2_1',
					'P_32_1_2','P_32_2_1','R_3_2','H_3_2'
				]
			},
			{
				'category':'Hexagonal',
				'groups':[
					'P_6','P_61','P_65','P_62','P_64','P_63','P_6_2_2',
					'P_61_2_2','P_65_2_2','P_62_2_2','P_64_2_2','P_63_2_2'
				]
			},
			{
				'category':'Cubic',
				'groups':[
					'P_2_3','F_2_3','I_2_3','P_21_3','I_21_3','P_4_3_2','P_42_3_2','F_4_3_2',
					'F_41_3_2','I_4_3_2','P_43_3_2','P_41_3_2','I_41_3_2'
			 	]
			}
		],

		drawCrosshair:function (crystal, imageElement, suppressCrystalNumber){
			if(imageElement.tagName.toLowerCase()!=="img") {
				console.log("Tried to write crystal crosshair to non-image");
				return;
			}
			if(!imageElement.naturalHeight){
				//image hasn't loaded yet
				return window.setTimeout(Crystal.drawCrosshair,250, crystal, imageElement, suppressCrystalNumber);
			}
			let titleParts=[];
			let wrapper=imageElement.parentElement;
			let scale=imageElement.offsetHeight/imageElement.naturalHeight;
			let xh=document.createElement("div");
			let xr=document.createElement("div");
			xh.classList.add("xtal_crosshair");
			xr.classList.add("xtal_ring");
			xh.crystal=crystal;
			xh.appendChild(xr);
			if(1*(crystal["isdeposited"])){
				titleParts.push("Used in PDB deposition");
				xh.classList.add("xtal_deposited");
			} else if(1*(crystal["iscollected"])){
				titleParts.push("Data collected");
				xh.classList.add("xtal_fished");
			} else if(1*(crystal["isshipped"])){
				titleParts.push("Shipped to synchrotron");
				xh.classList.add("xtal_fished");
			} else if(1*(crystal["isfished"])){
				titleParts.push("Crystal has been fished");
				xh.classList.add("xtal_fished");
			} else {
				titleParts.push("Crystal has been marked but not fished");
			}
			if(parseInt(crystal["isdummycoordinate"])) {
				titleParts.push("dummy coordinate");
			} else {
				let xhc=document.createElement("div");
				let xhx=document.createElement("div");
				let xhy=document.createElement("div");
				xhc.classList.add("xtal_cross");
				xhx.classList.add("xtal_crossx");
				xhy.classList.add("xtal_crossy");
				xhc.appendChild(xhx);
				xhc.appendChild(xhy);
				xh.appendChild(xhc);
			}
			if(!suppressCrystalNumber){
				let xhn=document.createElement("div");
				xhn.classList.add("xtal_number");
				xhn.innerHTML=crystal.numberindrop;
				xh.appendChild(xhn);
			}
			wrapper.appendChild(xh);
			xh.style.left=((parseInt(crystal.pixelx)*scale)-(xh.offsetWidth/2))+"px";
			xh.style.top =((parseInt(crystal.pixely)*scale)-(xh.offsetHeight/2))+"px";
			xh.id="crystal"+crystal.id;
			if(imageElement.src.indexOf("/"+crystal.dropimageid+"/")<0){
				xh.classList.add("xtal_crosshairfromdifferentimage");
				titleParts.push("marked on different image");
			}
			xh.title=titleParts.join("; ");
			return xh;
		},
		
		getThumbnailLink: function(crystal){
			return '<a href="'+Crystal.getDropViewerUrl(crystal)+'"><img src="/dropimagethumb/'+crystal.dropimageid+'/" style="max-height:5em;margin:0.25em 0"  alt=""/></a>';
		},

		getTextLink: function(crystal){
			let dropName=Plate.getWellNameFromObject(crystal);
			dropName=dropName.replace("."," drop ");
			return '<a href="/crystal/'+crystal.id+'">'+dropName+' crystal '+crystal.numberindrop+'</a>';
		},
		getDropViewerUrl: function(crystal){
			let dropName=Plate.getWellNameFromObject(crystal);
			return '/imagingsession/'+crystal["imagingsessionid"]+'/#'+dropName+'c'+crystal.numberindrop;
		},

		spaceGroupField:function(crystal,parentElement) {
			let val = crystal.spacegroup;
			let displayValue = "Unknown";
			if ("" !== val) {
				Crystal.spaceGroups.forEach(function (cat) {
					cat.groups.forEach(function (grp) {
						if (val === grp.replace(/_/g, "")) {
							displayValue = grp.replace(/(\d)(\d)/g, "$1<sub>$2</sub>").replace(/_/g, '');
						}
					});
				});
			}
			let content = '<input data-apiurl="/api/crystal/' + crystal.id + '" type="hidden" name="spacegroup" value="' + val + '" /><span class="sgvalue">' + displayValue + '</span>';
			if(canUpdate){
				content += ' <button onclick="Crystal.showSpaceGroupPicker(this);return false">Change...</button>';
			}
			let ff=ui.formField({
				'label':'Space group',
				'content':content
			}, parentElement);
			ff.querySelector("input").dataset.oldvalue=val;
		},

		formatSpaceGroupTableCell: function(object, fieldName){
			return Crystal.spaceGroupAddSubscripts(object[fieldName]);
		},

		spaceGroupAddSubscripts:function (sg){
			if(!sg){ return sg; }
			sg=sg.replace(/_/g,"");
			let ret="";
			Crystal.spaceGroups.forEach(function (cat){
				cat.groups.forEach(function (grp){
					let clean=grp.replace(/_/g,"");
					if(clean===sg){
						ret=grp.replace(/(\d)(\d)/g,"$1<sub>$2</sub>").replace(/_/g,'');
					}
				});
			});
			if(""===ret){ ret=sg; }
			return ret;
		},

		showSpaceGroupPicker:function(btn){
			let box;
			let mb=document.getElementById("modalBox");
			if(mb){
				mb.oldTitle=mb.querySelector("h2").innerHTML;
				mb.querySelector("h2").innerHTML="Choose space group";
				let modalBody=mb.querySelector(".boxbody");
				box=modalBody.clone(false);
				box.id="sgoverlay";
				mb.querySelector("h2").after(box);
				modalBody.style.display="none";
				box.style.display="block";
			} else {
				box=ui.modalBox({ title:"Choose a space group", content:"" });
			}
			let lbl=btn.closest("label");
			lbl.querySelector("input").dataset.oldvalue=lbl.querySelector("input").value;
			box.field=lbl;
			Crystal.spaceGroups.forEach(function(cat){
				let buttons='<h4>'+cat.category+'</h4>';
				cat.groups.forEach(function(grp){
					let btnVal=grp.replace(/_/g,"");
					let btnLbl=grp.replace(/(\d)(\d)/g,"$1<sub>$2</sub>").replace(/_/g,'');
					buttons+='<button onclick="Crystal.setSpaceGroupField(this)" style="line-height:2em; margin:0.25em" value="'+btnVal+'">'+btnLbl+'</button>';
				});
				box.innerHTML+=buttons;
			});
		},
		
		setSpaceGroupField:function(btn){
			let mb=document.getElementById("modalBox");
			let overlay=document.getElementById("sgoverlay");
			let field=btn.closest(".boxbody").field;
			field.querySelector(".sgvalue").innerHTML=btn.innerHTML;
			let val=btn.value;
			if("Unknown"===val){ val=""; }
			field.querySelector("input").value=val;
			ui.updateFormField(field.querySelector("input"));
			if(overlay){
				let box=overlay.closest(".box");
				overlay.remove();
				box.querySelector(".boxbody").style.display="block";
				if(mb.oldTitle){ mb.querySelector("h2").innerHTML=mb.oldTitle; }
			} else {
				ui.closeModalBox();
			}
		},
		
		writeDetailsFormFields:function(xtal,canUpdate, parentElement){
			let xtalUrl='/api/crystal/'+xtal.id;
			let diffreq=null;
			xtal.diffractionrequests.forEach(function(dr){
				if(""===dr.shipmentid){
					diffreq=dr;
				}
			});

			let stars={
				readonly:!canUpdate,
				id:xtal["id"],
				objecttype:"crystal",
				name:"starrating",
				value:xtal["starrating"],
				apiUrl:xtalUrl,
				afterSuccess:function (div){
					let crosshair=document.querySelector(".xs_currentcrosshair");
					if(crosshair && crosshair.crystal){
						crosshair.crystal["starrating"]=div.querySelector("input").value;
					}
				}
			};
			ui.starRatingField(stars, parentElement);
			Crystal.spaceGroupField(xtal, parentElement);

			//Unit cell
			let tbl='<table style="width:100%">';
			tbl+='<tr><td id="uca">a: </td><td id="ucb">b: </td><td id="ucc">c: </td></tr>';
			tbl+='<tr><td id="ucalpha">&alpha;: </td><td id="ucbeta">&beta;: </td><td id="ucgamma">&gamma;: </td></tr>';
			tbl+='</table>';
			ui.formField({label:'Unit cell dimensions', content:tbl }, parentElement);

			let lbl=document.createElement("label");
			let h4=document.createElement("h4");
			h4.innerHTML="Information for next synchrotron trip";
			h4.style.textAlign="left";
			lbl.appendChild(h4);
			parentElement.appendChild(lbl);

			//Sample name
			ui.formField({
				label:'Sample name for synchrotron',
				content:'<span id="snamectrl" style="white-space:nowrap">'+xtal.prefix+ (""===xtal["suffix"] ? "" : "_"+xtal["suffix"]) +'</span>'
			}, parentElement);
			if(canUpdate){
				let sampleNameControl=document.getElementById("snamectrl");
				sampleNameControl.innerHTML=xtal.prefix+'<span id="nameseparator">_</span>';
				ui.textBox({ apiUrl:xtalUrl, name:'suffix', value:xtal["suffix"] }, sampleNameControl );
				document.getElementById("suffix").addEventListener("keyup",function(){
					if(''===document.getElementById("suffix").value){
						document.getElementById("nameseparator").style.visibility="hidden";
					} else {
						document.getElementById("nameseparator").style.visibility="visible";
					}
				});
			}

			if(!diffreq){ return; }
			let drUrl='/api/diffractionrequest/'+diffreq.id;

			//Shipping comment
			ui.formField({
				label:'Shipping comment',
				content:'<span id="commctrl">'+ (""===diffreq["comment"] ? "-" : diffreq["comment"]) +'</span>'
			}, parentElement);
			if(canUpdate){
				let ctrl=document.getElementById("commctrl");
				ctrl.innerHTML='';
				ui.textBox({ apiUrl:drUrl, name:'comment', value:diffreq["comment"] }, ctrl );
				ctrl.querySelector("input").style.width="95%";
			}

			//Diffraction type, resolutions
			ui.formField({ label:'Diffraction type', content:'<span id="dtype"></span>'}, parentElement);
			ui.formField({ label:'Observed resolution (&#8491;)', content:'<span id="obsres"></span>'}, parentElement);
			ui.formField({ label:'Required resolution: (&#8491;)', content:'<span id="reqres"></span>'}, parentElement);
			ui.formField({ label:'Minimum resolution: (&#8491;)', content:'<span id="minres"></span>'}, parentElement);
			ui.dropdownElement({ readonly:!canUpdate, apiUrl:drUrl, name:'diffractiontype', value:diffreq["diffractiontype"], options:Crystal.diffractionTypes }, document.getElementById("dtype") );

			let obs=document.getElementById("obsres");
			let req=document.getElementById("reqres");
			let min=document.getElementById("minres");

			if(canUpdate){
				ui.textBox({ apiUrl:drUrl, name:'observedresolution', value:diffreq["observedresolution"] }, obs );
				ui.textBox({ apiUrl:drUrl, name:'requiredresolution', value:diffreq["requiredresolution"] }, req );
				ui.textBox({ apiUrl:drUrl, name:'minimumresolution', value:diffreq["minimumresolution"] }, min );
				obs.querySelector("input").style.width="4em";
				req.querySelector("input").style.width="4em";
				min.querySelector("input").style.width="4em";
				ui.textBox({ apiUrl:xtalUrl, name:'unitcella',     value:xtal.unitcella },     document.getElementById("uca")     );
				ui.textBox({ apiUrl:xtalUrl, name:'unitcellb',     value:xtal.unitcellb },     document.getElementById("ucb")     );
				ui.textBox({ apiUrl:xtalUrl, name:'unitcellc',     value:xtal.unitcellc },     document.getElementById("ucc")     );
				ui.textBox({ apiUrl:xtalUrl, name:'unitcellalpha', value:xtal.unitcellalpha }, document.getElementById("ucalpha") );
				ui.textBox({ apiUrl:xtalUrl, name:'unitcellbeta',  value:xtal.unitcellbeta },  document.getElementById("ucbeta")  );
				ui.textBox({ apiUrl:xtalUrl, name:'unitcellgamma', value:xtal.unitcellgamma }, document.getElementById("ucgamma") );
			} else {
				obs.innerHTML=diffreq["observedresolution"]||"-";
				req.innerHTML=diffreq["requiredresolution"]||"-";
				min.innerHTML=diffreq["minimumresolution"]||"-";
				document.getElementById("uca").innerHTML+=xtal.unitcella||"-";
				document.getElementById("ucb").innerHTML+=xtal.unitcellb||"-";
				document.getElementById("ucc").innerHTML+=xtal.unitcellc||"-";
				document.getElementById("ucalpha").innerHTML+=xtal.unitcellalpha||"-";
				document.getElementById("ucbeta").innerHTML+=xtal.unitcellbeta||"-";
				document.getElementById("ucgamma").innerHTML+=xtal.unitcellgamma||"-";
			}

		},

		/**
		 * Deletes the crystal on the server. 
		 * @param {object} crystal The crystal to be deleted
		 * @param {function|null} afterSuccess A function to be called if deletion succeeds. The response JSON will be passed as a parameter.
		 * @param {function|null} afterFailure A function to be called if deletion fails.
		 */
		delete: function(crystal, afterSuccess, afterFailure){
			if(1*crystal.isFished){
				alert("Crystal cannot be deleted because it has been fished.");
				if(afterFailure){ return afterFailure(); }
				return false;
			}
			new AjaxUtils.Request('/api/crystal/'+crystal.id, {
				method:"delete",
				postBody:"csrfToken="+csrfToken,
				onSuccess:function(transport){ 
					if(afterSuccess){
						afterSuccess(transport.responseJSON);
					}
				},
				onFailure:function(transport){ 
					AjaxUtils.checkResponse(transport);
					if(afterFailure){
						afterFailure(transport);
					}
				},
			});
		},

		renderProteinTab:function(crystal, tabSet) {
			let proteinTab = tabSet.tab({"label": "Protein", "id": "protein"}).nextElementSibling;
			Crystal.refreshProteinTab(crystal, proteinTab);
		},

		refreshProteinTab:function(crystal, proteinTab){
			proteinTab.innerHTML="";
			if(!crystal["constructname"] || ""===crystal["constructname"]){
				proteinTab.innerHTML='Protein and construct not set';
			} else {
				let f=proteinTab.form({});
				f.formField({ readOnly:true, label:"Project", content:'<a href="/project/'+crystal["projectid"]+'">'+crystal["projectname"]+'</a>' });
				f.formField({ readOnly:true, label:"Protein", content:crystal["proteinname"]+' (acronym: '+crystal["proteinacronym"]+')' });
				f.formField({ readOnly:true, label:"Construct", content:crystal["constructname"] });
				Crystal.getPdbDepositionsForProteinTab(crystal, proteinTab);
			}
			return proteinTab;
		},

		getPdbDepositionsForProteinTab:function(crystal, proteinTab){
			new AjaxUtils.Request('/api/crystal/'+crystal.id+'/pdbdeposition',{
				method:'get',
				onSuccess:function (transport) {
					data["pdbdepositions"]=transport.responseJSON.rows;
					let frm=proteinTab.querySelector("form");
					Crystal.renderAssociatedPdbCodes(transport, frm);
				},
				onFailure:function (transport) {
					if(404===transport.status){
						data["pdbdepositions"]=[];
						let frm=proteinTab.querySelector("form");
						return Crystal.renderAssociatedPdbCodes(transport, frm);
					}
					AjaxUtils.checkResponse(transport);
				},

			});
		},

		renderAssociatedPdbCodes:function(transport, frm){
			frm.innerHTML+='<label><h3>Deposited structures</h3></label>';
			let content='&nbsp;';
			let wrote=false;
			if(canManagePdbDepositions){
				content='<img src="/images/'+'icons/btn_no.gif" alt="Dissociate" title="Unlink this structure from the crystal" onclick="Crystal.dissociatePdbCode(this)"/>';
			}
			if(transport.responseJSON && transport.responseJSON.rows){
				transport.responseJSON.rows.forEach(function (row) {
					let f=frm.formField({
						label:'<a href="/pdbdeposition/'+row.id+'">'+row.name+'</a>',
						content:content
					});
					f.dataset.pdbdepositioncrystalid=row.pdbdepositioncrystalid;
					f.dataset.pdbcode=row.name;
					wrote=true;
				});
			}
			if(canManagePdbDepositions){
				let f=frm.formField({
					label:'',
					content:'Link this crystal to a deposited structure: <input style="width:8em" type="text" name="pdbcode" placeholder="PDB code"/> <input type="button" value="Link"/>'
				});
				fieldValidations["pdbcode"]=["required","pdbcode"];
				f.querySelector('[name=pdbcode]').addEventListener("keydown", Crystal.handlePdbCodeFieldsEvent);
				f.querySelector('[type=button]').addEventListener("click", Crystal.handlePdbCodeFieldsEvent);
				wrote=true;
			}

			if(!wrote){
				frm.innerHTML+='<label><span class="label">(none)</span>&nbsp;</label>';
			}
		},

		handlePdbCodeFieldsEvent:function(evt){
			if('Enter'===evt.key || 'click'===evt.type){
				evt.preventDefault();
				let field=evt.target.closest('label').querySelector('[name=pdbcode]');
				if(validator.validate(field)){
					let pdbCode=field.value;
					new AjaxUtils.Request('/api/pdbdeposition/name/'+pdbCode,{
						method:'get',
						onSuccess:function (transport) {
							Crystal.associatePdbCodeWithCrystal(transport.responseJSON["id"], data.id, field);
						},
						onFailure:function(transport){
							if(404===transport.status){
								return Crystal.createAndAssociatePdbCode(pdbCode, data.id, field);
							}
							AjaxUtils.checkResponse(transport);
						}
					});
				}
			}
			return true;
		},

		associatePdbCodeWithCrystal:function(pdbDepositionId, crystalId, field){
			let lbl;
			if(field){
				lbl=field.closest("label");
				lbl.classList.add("updating");
			}
			new AjaxUtils.Request('/api/pdbdepositioncrystal/', {
				method:'post',
				parameters:{
					csrfToken:csrfToken,
					pdbdepositionid:pdbDepositionId,
					projectid:data.projectid,
					crystalid:crystalId
				},
				onSuccess:function () {
					Crystal.refreshProteinTab(data,lbl.closest(".tabbody"));
				},
				onFailure:function (transport) {
					if(lbl){ lbl.classList.remove("updating"); }
					if(400===transport.status && transport.responseText.indexOf("may already exist")!==false){
						alert("That PDB entry is already associated with this crystal");
					} else {
						AjaxUtils.checkResponse(transport);
					}
				}
			});
		},

		dissociatePdbCode:function(elem){
			let lbl=elem.closest("label");
			if(!confirm('Really unlink this crystal from PDB code '+lbl.dataset.pdbcode+'?\n\n(This won\'t delete IceBear\'s record of the PDB deposition.)')){
				return false;
			}
			lbl.classList.add("updating");
			new AjaxUtils.Request("/api/pdbdepositioncrystal/"+lbl.dataset.pdbdepositioncrystalid,{
				method:"delete",
				postBody:"csrfToken="+csrfToken,
				onSuccess:function(){
					Crystal.refreshProteinTab(data,lbl.closest(".tabbody"));
				},
				onFailure:function(transport){
					AjaxUtils.checkResponse(transport);
					lbl.classList.remove("updating");
				},
			})
		},

		createAndAssociatePdbCode:function(pdbCode, crystalId, field){
			if(field){
				if(pdbCode!==field.value){
					alert("PDB code does not match field value. This is a bug in IceBear.");
					return false;
				}
				field.closest("label").classList.add("updating");
			}
			new AjaxUtils.Request('/api/pdbdeposition',{
				method:'post',
				parameters:{
					csrfToken:csrfToken,
					projectid:data.projectid,
					name:pdbCode
				},
				onSuccess:function (transport) {
					let pdbId=transport.responseJSON['created']["id"];
					return Crystal.associatePdbCodeWithCrystal(pdbId, crystalId, field);
				},
				onFailure:AjaxUtils.checkResponse
			})
		}

};

let Dataset={

	types:["Original","Intermediate","Open-Access","Other"],

	/**
	 * Generates a tab with a list of datasets, filtered by the supplied property key and value.
	 * For example, for datasets pertaining to project 123.
	 *
	 * @param tabSet The parent tab set element
	 * @param propertyKey The property to filter on, e.g., "projectid"
	 * @param propertyValue The required value of the property, e.g., 123
	 */
	listTab:function (tabSet, propertyKey, propertyValue){
		let tab=tabSet.tab({
			'label':'Datasets',
			'id':'datasets',
			'contents':'',
		});
		Dataset.listTable(tab, propertyKey, propertyValue);
	},

	listTable:function (parentElement, propertyKey1, propertyValue1, propertyKey2, propertyValue2) {
		if (parentElement.nextElementSibling && parentElement.nextElementSibling.classList.contains("tabbody")) {
			parentElement = parentElement.nextElementSibling;
		}
		let apiUrl="/api/dataset/";
		if(parentElement.dataset.apiurl){
			apiUrl=parentElement.dataset.apiurl;
		} else {
			if(propertyKey1 && propertyValue1){
				apiUrl+=propertyKey1+"/"+propertyValue1+"/";
			}
			if(propertyKey2 && propertyValue2){
				apiUrl+=propertyKey2+"/"+propertyValue2+"/";
			}
		}
		parentElement.dataset.apiurl=apiUrl;
		parentElement.dataset.getAll="1";
		new AjaxUtils.request(apiUrl+"?all=1", {
			method: "get",
			onSuccess: function (transport) {
				if (!AjaxUtils.checkResponse(transport)) {
					return;
				}
				Dataset.renderListTable(transport.responseJSON, parentElement);
			},
			onFailure: function (transport) {
				if (404 === transport.status) {
					parentElement.innerHTML = 'No datasets found.';
					if(parentElement.closest(".treeitem")){
						let tabOrBox=parentElement.closest(".tabbody,.boxbody");
						parentElement.closest(".treeitem").remove();
						if(tabOrBox && tabOrBox.innerHTML===""){
							tabOrBox.innerHTML="<p>No datasets found.</p>";
							if("shipment"===data["objecttype"]){
								tabOrBox.innerHTML+="<p>Click \"Retrieve dataset metadata\" on the Shipment tab to retrieve them from "+data["shipmentdestinationname"]+".</p>";
							}
						}
					}
					parentElement.innerHTML+=Dataset.getAddButton();
				} else {
					parentElement.innerHTML = 'Error retrieving datasets';
				}
			}
		});
	},
	renderListTable:function (datasets, parentElement){
		let objectType=data["objecttype"];
		let headers=[];
		let cellTemplates=[];
		let sortOrders=[];
		let showFilters=[];
		let columns=[
			//header, cellTemplate, sortOrder
			["",'<input type="button" value="View..." onclick="Dataset.beginEdit(this)" />',"", false],
			[ "Rating", [ui.starRatingCellContents,"starrating"] , "-starrating", false ],
			[ "IceBear", [Dataset.getIceBearCellContents,'id'], "", false],
			[ "Synchrotron",[Dataset.getSynchrotronCellContents,'shipmentdestinationname'],"shipmentdestination.name", true],
			[ "Proposal","{{proposalname}}","shipment.proposalname", true],
			[ "PDB", '<a href="/pdbdeposition/{{pdbdepositionid}}">{{pdbcode}}</a>', "-pdbdeposition.name", true],
			[ "Resolution", [Dataset.getResolutionCellContents,'bestresolution'],"bestresolution", true],
			[ "Description", "{{description}}","",true]
		];

		if("pdbdeposition"===objectType){
			//Remove the "PDB" column
			columns.splice(5,1);
		} else if("shipment"===objectType){
			columns.splice(3,2);
		}

		columns.forEach(function (column) {
			headers.push(column[0]);
			cellTemplates.push(column[1]);
			sortOrders.push(column[2]);
			showFilters.push(column[3]);
		})
		ui.table({
			"headers": headers,
			"cellTemplates": cellTemplates,
			"sortOrders": sortOrders,
			"showFilters":showFilters,
			"contentBefore":Dataset.getAddButton()
		}, datasets, parentElement);
	},
	getAddButton(){
		if(!canEdit){ return ""; }
		if(data && data["objecttype"] && "crystal"!==data["objecttype"]){ return ""; }
		return ' <input type="button" value="Add a dataset..." style="margin-bottom:0.5em; float:left" onclick="Dataset.beginEdit(this)" />';
	},
	getResolutionCellContents:function (obj,field){
		if("bestresolution"!==field || ""===obj[field]){ return ""; }
		return obj[field]+" &Aring;";
	},
	getIceBearCellContents:function (obj){
		let objectType=data["objecttype"];
		let contents='<span style="white-space: nowrap">';
		if("project"!==objectType) {
			contents+='<a title="Project: {{projectname}}" href="/project/{{projectid}}"><img alt="" src="/ima' + 'ges/icons/ICON_THEME/header/bc_project.png" /></a>';
		}
		if("shipment"!==objectType && !!obj['shipmentid']) {
			contents+='&nbsp;<a title="Shipment: {{shipmentname}}" href="/shipment/{{shipmentid}}"><img alt="" src="/ima' + 'ges/icons/ICON_THEME/header/bc_shipment.png" /></a>'
		}
		if("crystal"!==objectType) {
			contents+='&nbsp;<a title="Crystal: {{crystalname}}" href="/crystal/{{crystalid}}"><img alt="" src="/ima'+'ges/icons/ICON_THEME/header/bc_crystal.png" /></a>';
			//contents+='&nbsp;<a title="Crystal: {{crystalname}}" href="/crystal/{{crystalid}}">{{crystalname}}</a>';
		}
		contents+='</span>';
		return contents.replaceAll("ICON_THEME", skin["bodyIconTheme"] + "icons");
	},
	getSynchrotronCellContents:function (obj){
		if(!obj["shipmentdestinationname"]){ return ""; }
		let contents= '<span style="white-space:nowrap">'+
			'<a target="_blank" title="Crystal at {{shipmentdestinationname}}: {{crystalname}}" href="{{crystalurlatremotefacility}}"><img alt="" src="/images/icons/ICON_THEME/header/bc_crystal.png" /></a>&nbsp;'+
			'<a target="_blank" title="Shipment at {{shipmentdestinationname}}: {{shipmentname}}" href="{{shipmenturlatremotefacility}}"><img alt="" src="/images/icons/ICON_THEME/header/bc_shipment.png" /></a>&nbsp;'+
			'<a href="/shipmentdestination/{{shipmentdestinationid}}">{{shipmentdestinationname}}</a>'+
			'</span>';
		return contents.replaceAll("ICON_THEME", skin["bodyIconTheme"] + "icons");
	},

	beginEdit:function(btn){
		let editButton=btn;
		if(undefined!==btn.target){
			//Got the event, not the button
			editButton=btn.target;
		}
		if("input"!==editButton.tagName.toLowerCase()){
			return false;
		}
		let tr=editButton.closest("tr");
		let diffractionRequestId=null;
		let ds=null;
		if(tr && tr.rowData) {
			ds=tr.rowData;
			diffractionRequestId=ds["diffractionrequestid"];
		}

		// If this isn't a crystal page it's read-only and there is no master list of PDB entries for the read-only
		// SELECT field to choose from. We need to bodge the single PDB entry associated with the record to be viewed
		// into data.pdbdepositions if there is one. This needs to work for multiple views of multiple datasets with
		// different PDB codes.
		if(ds && parseInt(ds.pdbdepositionid) && "crystal"!==data["objecttype"]){
			if (!data.pdbdepositions) {
				data.pdbdepositions = [];
			}
			let hasPdbAlready=false;
			data.pdbdepositions.forEach(function(pdb){
				if(pdb["name"]===ds["pdbcode"]){ hasPdbAlready=true; }
			});
			if(!hasPdbAlready){
				data.pdbdepositions.push({ "id":ds['pdbdepositionid'], "name":ds["pdbcode"]} );
			}
		}

		let ts=ui.modalTabSet({
			onclose:function(){ Dataset.listTable(btn.closest(".tabbody,.treebody,.boxbody")); }
		});
		if(tr){
			ts.dataRow=tr;
			ts.record=ts.dataRow.rowData;
		}
		let tabHeader="Dataset summary";
		if(!ds){ tabHeader="Add a dataset"; }
		let detailsTab=ts.tab({label:tabHeader, id:"dataset"}).nextElementSibling;
		let lbl=editButton.closest("label");
		let shipmentDestinationId=nullValue;
		if(lbl){
			shipmentDestinationId=lbl.dataset.shipmentDestinationId || nullValue;
		}
		Dataset.writeForm(detailsTab, diffractionRequestId, shipmentDestinationId, ds);

		if(ds){
			let shipmentDestination= {
				"name":ds["shipmentdestinationname"],
				"shipmenthandler":ds["shipmenthandler"]
			};
			Dataset.AutoProcessing.renderTab(ts, shipmentDestination);
			ts.notesTab(ds.id);
		}

	},

	/**
	 * Changes URLs and DOIs in the supplied dataset description into clickable links.
	 * @param text A string representing the location of a dataset location
	 * @returns string The dataset description
	 */
	makeDoiAndUrlClickable:function (text) {
		text=text.trim();
			text=text.replace(new RegExp(/^(https?:\/\/doi.org\/)/,"gi"), '');
 			text=text.replace(new RegExp(/^(https?:\/\/\S+)/,"gi"), '<a target="_blank"'+' href="$1">$1</a>'); //FIRST make URLs clickable
			text=text.replace(new RegExp(/^(?:doi:)?(10\.[0-9]{4,9}\/[^\s]+)/,"gi"), '<a target="_blank" href="https://www.doi.org/$1">$1</a>');
			return text;
	},

	writeForm:function(parentElement, diffractionRequestId, shipmentDestinationId, datasetObject) {
		parentElement.innerHTML="Just a moment...";
		let url="/api/beamline/";
		if(nullValue!==shipmentDestinationId){
			url+='shipmentdestinationid/'+shipmentDestinationId+'/';
		}
		url+="?all=1";
		new AjaxUtils.Request(url, {
			method: "get",
			onSuccess: function (transport) {
				Dataset._doWriteForm(parentElement, diffractionRequestId, datasetObject, transport.responseJSON);
			},
			onFailure:function (transport){
				if(404===transport.status){
					Dataset._doWriteForm(parentElement, diffractionRequestId, datasetObject, []);
					return;
				}
				AjaxUtils.checkResponse(transport);
			}
		});
	},
	_doWriteForm:function(parentElement, diffractionRequestId, datasetObject, beamlines){
		parentElement.innerHTML="";
		let isCreate=!datasetObject;

		let action = isCreate ? "/api/dataset/" : "/api/dataset/"+datasetObject['id'];
		let method = isCreate ? "post" : "patch";
		let wavelength = isCreate ? "" : datasetObject['wavelength'];
		let description = isCreate ? "" : datasetObject['description'];
		let starRating = isCreate ? 0 : datasetObject['starrating'];
		let bestResolution = isCreate ? "" : datasetObject['bestresolution'];
		let collectedAt=isCreate? "" : datasetObject["shipmentdestinationname"];
		let proposalName= isCreate ? "" : datasetObject["proposalname"];
		let sessionName= isCreate ? "" : datasetObject["sessionname"];
		let beamlineid = isCreate ? "" : datasetObject['beamlineid'];
		let detectormanufacturer = isCreate ? "" : datasetObject['detectormanufacturer'];
		let detectormodel = isCreate ? "" : datasetObject['detectormodel'];
		let detectortype = isCreate ? "" : datasetObject['detectortype'];
		let detectormode = isCreate ? "" : datasetObject['detectormode'];
		let pdbdepositionid = isCreate ? "" : datasetObject['pdbdepositionid'];
		let projectid = isCreate ? data["projectid"] : datasetObject['projectid']; //Create from crystal page
		let readOnly=!canEdit;
		if(!readOnly && typeof userUpdateProjects!=="undefined" && !isCreate){
			readOnly=!userUpdateProjects.includes(1*datasetObject["projectid"]);
		}

		let f=parentElement.form({
			action:action,
			method:method,
			autosubmit: !isCreate,
			readonly:readOnly
		});

		let beamlineOptions=[{ "label":"Choose...","value":nullValue, "id":"", "detectormanufacturer":"", "detectormodel":"", "detectortype":"" }];
		if(beamlines){
			if(beamlines["rows"]){
				beamlines=beamlines["rows"];
			}
			beamlines.forEach(function (bl){
				beamlineOptions.push({
					"label":bl["shipmentdestinationname"]+": "+bl.name,
					"value":bl["id"],
					"id":bl["id"],
					"detectormanufacturer":bl["detectormanufacturer"],
					"detectormodel":bl["detectormodel"],
					"detectortype":bl["detectortype"],
				})
			});
		}

		if(isCreate){
			f.hiddenField("crystalid",data.id);
			diffractionRequestId=nullValue;
		}
		f.hiddenField("diffractionrequestid",diffractionRequestId);
		f.hiddenField("projectid",projectid);

		let divLeft=document.createElement("div");
		divLeft.style.cssFloat="left";
		divLeft.style.width="49.5%";
		divLeft.style.margin="0.2em 0";
		f.appendChild(divLeft);
		let divRight=document.createElement("div");
		divRight.style.cssFloat="right";
		divRight.style.width="49.5%";
		divRight.style.margin="0.2em 0";
		f.appendChild(divRight);
		let shim=document.createElement("div");
		shim.classList.add("shim");
		f.appendChild(shim);

		if(""!==collectedAt){
			divLeft.appendChild(f.textField({
				readOnly:true,
				label:"Collected at",
				value:collectedAt
			}).closest("label"));

			let proposalAndSession=proposalName;
			if(sessionName.startsWith(proposalName)){
				proposalAndSession=sessionName;
			} else if(""!==sessionName){
				proposalAndSession+="-"+sessionName;
			}
			divRight.appendChild(f.textField({
				readOnly:true,
				label:"Proposal and session",
				value:proposalAndSession
			}).closest("label"));
		}


		let df=f.textField({
			name:'description',
			label:'Description',
			helpText:'Brief details of the dataset',
			value:description
		});
		if(!readOnly){
			df.querySelector("input").style.width="70%";
		}

		divLeft=divLeft.cloneNode();
		f.appendChild(divLeft);
		divRight=divRight.cloneNode();
		f.appendChild(divRight);
		shim=shim.cloneNode();
		f.appendChild(shim);

		let rf=f.starRatingField({
			readonly:readOnly,
			value:starRating
		});

		let pdbField;
		//won't work in PDB page. Use the PDB record ("data")
		if(data["pdbdepositions"] && 0!==data["pdbdepositions"].length){
			let pdbs=[{ "label":"(None)", "value":nullValue }];
			data["pdbdepositions"].forEach(function(pdb){
				pdbs.push({ "label":pdb["name"], "value":pdb["id"] });
			});
			pdbField=f.dropdown({
				name:"pdbdepositionid",
				label:"Associated PDB deposition",
				showOptionLabelOnReadOnly:true,
				value:pdbdepositionid,
				options:pdbs
			})
		} else {
			pdbField=f.formField({
				label:"Associated PDB deposition",
				content:"(Add a PDB deposition to the crystal first)"
			});
		}

		let br=f.textField({
			name:'bestresolution',
			label:'Best resolution (&Aring;)',
			value: bestResolution
		});
		let wl=f.textField({
			name:'wavelength',
			label:'Wavelength (&Aring;)',
			value: wavelength
		});

		let dt;
		if(isCreate){
			dt=f.dateTimeField({
				name:'collecteddatetime',
				label:'Date collected'
			});
		} else {
			let collectionDate=datasetObject["collecteddatetime"];
			dt=f.dateTimeField({
				name:'collecteddatetime',
				label:'Date collected',
				value:collectionDate
			})
		}

		divLeft.appendChild(br.closest("label"));
		divLeft.appendChild(wl.closest("label"));
		divLeft.appendChild(rf.closest("label"));
		divLeft.appendChild(pdbField.closest("label"));
		divLeft.appendChild(dt.closest("label"));

		/*
		 * Beamline dropdown. On choosing a beamline, we update the detector manufacturer, model and type from the
		 * IceBear record.
		 */
		let dd;
		if(1!==beamlineOptions.length){
			dd=f.dropdown({
				name:"beamlineid",
				label:"Beamline",
				value:beamlineid,
				options:beamlineOptions,
				showOptionLabelOnReadOnly:true
			})
			if(!readOnly){
				ui.addSuppliedHelpText(dd,"Choosing the beamline will automatically update the detector details");
				dd=dd.querySelector("select");
				beamlines.forEach(function (b){
					if(b.hasOwnProperty("id")){
						let opt=dd.querySelector('option[value="'+b.id+'"]');
						opt["beamline"]=b;
					}
				});
				dd.onchange=function (evt){
					let sel=evt.target;
					let opt=sel.options[sel.selectedIndex];
					let frm=sel.closest("form");
					let fields=["detectormanufacturer","detectormodel","detectortype"];
					fields.forEach(function (f){
						let formField=frm.querySelector("[name="+f+"]");
						formField.value= (opt.beamline) ? opt.beamline[f] : "";
						window.setTimeout(function() {
							formField.focus();
							formField.blur();
						},100);
					});
				}
			}
		}
		let manufacturer=f.textField({
			name:'detectormanufacturer',
			label:'Detector manufacturer',
			value:detectormanufacturer
		});
		let model=f.textField({
			name:'detectormodel',
			label:'Detector model',
			value:detectormodel
		});
		let type=f.textField({
			name:'detectortype',
			label:'Detector type',
			value:detectortype
		});
		let mode=f.textField({
			name:'detectormode',
			label:'Detector mode',
			value:detectormode
		});
		divRight.appendChild(dd.closest("label"));
		divRight.appendChild(manufacturer.closest("label"));
		divRight.appendChild(model.closest("label"));
		divRight.appendChild(type.closest("label"));
		divRight.appendChild(mode.closest("label"));

		//Match star rating field label height to beamline label
		rf=rf.closest("label");
		ui.addSuppliedHelpText(rf,"&nbsp;");
		rf.querySelector(".helptext").style.visibility="hidden";

		let lf;
		if(isCreate){
			lf=f.textField({
				name:'datalocation',
				label:'Dataset location',
				helpText: 'A DOI, URL, file path, or other location where the raw data is found',
				value: '',
				validations:"required"
			});
			lf.querySelector("input").style.width="70%";
		} else {
			lf=f.formField({
				label:"Dataset locations",
				helpText: 'A DOI, URL, file path, or other location where the raw data is found',
				content:""
			});
			lf.closest("label").dataset.datasetId=datasetObject["id"];
			let t=document.createElement("table");
			t.style.width="70%";
			t.style.cssFloat="right";
			t.style.textAlign="left";

			if(!readOnly || datasetObject["datasetlocations"].length>1){
				let tr=document.createElement("tr");
				let th=document.createElement("th");
				th.innerText="Type";
				tr.appendChild(th);
				th=document.createElement("th");
				th.innerText="Location";
				tr.appendChild(th);
				t.appendChild(tr);
			}

			datasetObject["datasetlocations"].forEach(function(loc){
				let tr=document.createElement("tr");
				tr.classList.add("datarow");
				tr.rowData=loc;
				tr.innerHTML='<td style="width: 10em">'+loc["type"]+'</td><td style="overflow:hidden">'+Dataset.makeDoiAndUrlClickable(loc["datalocation"])+'</td>';
				if(!readOnly){
					let out='<td style="width: 12em">';
					if(datasetObject["datasetlocations"].length>1){
						out+='<input type="button" value="Delete" style="margin-left:1em" onclick="Dataset.deleteDataLocation(this)"/>';
					}
					out+='<input type="button" value="Edit..." onclick="Dataset.startEditDataLocation(this)" /></td>';
					tr.innerHTML+=out;
				}
				t.appendChild(tr);
			})
			if(!readOnly){
				//Write "Add" row, and add icons to each tr
				let tr=document.createElement("tr");
				let td=document.createElement("td");
				let sel=document.createElement("select");
				Dataset.types.forEach(function(type){
					let opt=document.createElement("option");
					opt.value=type;
					opt.innerText=type;
					sel.appendChild(opt);
				});

				td.appendChild(sel);
				tr.appendChild(td);
				td=document.createElement("td");
				td.innerHTML = '<input type="text" name="loc" value="" placeholder="URL, DOI, filesystem path, or other location" style="width: 98%"/>';
				tr.appendChild(td);
				td=document.createElement("td");
				td.innerHTML = '<input type="button" value="Add location" onclick="Dataset.addDataLocation(this)" />';
				tr.appendChild(td);
				t.appendChild(tr);
			}
			t.querySelectorAll("td").forEach(function (cell){ cell.style.borderTop="1px solid #999"; });
			lf.appendChild(t);
			let shim=document.createElement("div");
			shim.classList.add("shim");
			lf.appendChild(shim);
		}

		if(isCreate){
			f.createButton({
				label:"Create dataset",
				afterSuccess:function(){
					ui.refreshTab(document.getElementById("datasets"));
					ui.closeModalBox();
				}
			})
		}
	},

	addDataLocation:function (btn){
		let tr=btn.closest("tr");
		let type=tr.querySelector("select").value;
		let datalocation=tr.querySelector("td+td>input").value.trim();
		if(""===datalocation){
			alert("Dataset location is required");
			return false;
		}
		tr.classList.add("updating");
		new AjaxUtils.Request('/api/datasetlocation',{
			method:"post",
			parameters:{
				csrfToken:csrfToken,
				type:type,
				datalocation:datalocation,
				datasetid:btn.closest("label").dataset.datasetId
			},
			onSuccess:function (xhr){
				tr.querySelector("select").value=Dataset.types[0];
				tr.querySelector("input").value="";
				tr.classList.remove("updating");
				let newTr=tr.clone(true);
				newTr.rowData=xhr.responseJSON["created"];
				newTr.querySelector("td").innerHTML=newTr.rowData["type"];
				newTr.querySelector("td+td").innerHTML=Dataset.makeDoiAndUrlClickable(newTr.rowData["datalocation"]);
				newTr.classList.add("datarow");
				let td=newTr.querySelector("td+td+td");
				td.innerHTML='<input type="button" value="Delete" onclick="Dataset.deleteDataLocation(this)" style="margin-left: 1em">';
				td.innerHTML+='<input type="button" value="Edit..." onclick="Dataset.startEditDataLocation(this)">';
				tr.insertAdjacentElement("beforebegin",newTr);
			},
			onFailure:function (xhr){
				AjaxUtils.checkResponse(xhr);
			},
		});
	},

	deleteDataLocation:function(btn){
		let tr=btn.closest("tr");
		if(tr.closest("table").querySelectorAll("tr.datarow").length<=1){
			alert("Dataset must have at least one location");
			return false;
		}
		if(!confirm("Really delete this dataset location?")){ return false; }
		tr.classList.add("updating");
		new AjaxUtils.Request('/api/datasetlocation/'+tr.rowData["id"],{
			method:"delete",
			parameters:{
				csrfToken:csrfToken,
			},
			onSuccess:function (){
				tr.remove();
			},
			onFailure:function (xhr){
				AjaxUtils.checkResponse(xhr);
			},
		});


	},

	startEditDataLocation:function (btn){
		let tr=btn.closest("tr");
		let td=tr.querySelector("td");
		let sel=document.createElement("select");
		Dataset.types.forEach(function(type){
			let opt=document.createElement("option");
			opt.value=type;
			opt.innerText=type;
			if(type===tr["rowData"]["type"]){
				opt.selected=true;
			}
			sel.appendChild(opt);
		});
		td.innerHTML="";
		td.appendChild(sel);
		td=tr.querySelector("td+td");
		td.innerHTML = '<input type="text" name="loc" value="'+tr.rowData["datalocation"]+'" placeholder="URL, DOI, filesystem path, or other location" style="width: 98%"/>';
		td=tr.querySelector("td+td+td");
		td.innerHTML='<input type="button" value="Cancel" onclick="Dataset.endEditDataLocation(this)" style="margin-left: 1em">';
		td.innerHTML+='<input type="button" value="Save" onclick="Dataset.saveEditDataLocation(this)">';
	},

	endEditDataLocation:function (btn){
		let tr=btn.closest("tr");
		tr.querySelector("td").innerHTML=tr.rowData["type"];
		tr.querySelector("td+td").innerHTML=Dataset.makeDoiAndUrlClickable(tr.rowData["datalocation"]);
		let td=tr.querySelector("td+td+td");
		td.innerHTML='<input type="button" value="Delete" onclick="Dataset.deleteDataLocation(this)" style="margin-left: 1em">';
		td.innerHTML+='<input type="button" value="Edit..." onclick="Dataset.startEditDataLocation(this)">';
	},

	saveEditDataLocation:function (btn){
		let tr=btn.closest("tr");
		tr.classList.add("updating");
		new AjaxUtils.Request('/api/datasetlocation/'+tr.rowData["id"],{
			method:"patch",
			parameters:{
				csrfToken:csrfToken,
				type:tr.querySelector("select").value,
				datalocation:tr.querySelector("td+td>input").value
			},
			onSuccess:function (xhr){
				tr.rowData=xhr.responseJSON["updated"];
				tr.classList.remove("updating");
				Dataset.endEditDataLocation(btn);
			},
			onFailure:function (xhr){
				AjaxUtils.checkResponse(xhr);
			},
		});
	},


	AutoProcessing:{

		tableHeaders:["Rating","Pipeline","Spacegroup","Resolution","Completeness","R<sub>pim</sub>","R<sub>merge</sub>","I/&#963;","cc<sub>&half;</sub>"],
		cellTemplates:[ [ui.starRatingCellContents,"starrating"],"{{pipelinename}}",[Crystal.formatSpaceGroupTableCell,"spacegroup"],"{{bestresolution}}","{{completeness}}","{{rpim}}","{{rmerge}}","{{ioversigma}}","{{cchalf}}"],
		sortOrders:["-starrating","pipelinename","","bestresolution","-completeness","rpim","rmerge","ioversigma","cchalf"],

		renderTab:function(tabSet,shipmentDestination) {
			let tab = tabSet.tab({"label": "Autoprocessing"}).nextElementSibling;
			let dataSet = tabSet.dataRow.rowData;
			let shipmentHandler=shipmentDestination["shipmenthandler"];
			if(shipmentHandler && !window[shipmentHandler]){
				let failureMessage="<p>Could not retrieve shipment handler for " + shipmentDestination['name'] + " (" + shipmentDestination["shipmenthandler"] + ")</p>";
				new AjaxUtils.Request('/js/model/shipping/handlers/' + shipmentHandler + '.js?t=' + Date.now(), {
					method: 'get',
					onSuccess: function(transport){
						//Not nice, but not much choice.
						eval(transport.responseText);
						if(window[shipmentHandler]){
							Dataset.AutoProcessing._doRenderTab(tab, dataSet, shipmentDestination);
						} else {
							tab.innerHTML=failureMessage;
						}
					},
					onFailure: function(){
						tab.innerHTML=failureMessage;
					},
				});
			} else {
				Dataset.AutoProcessing._doRenderTab(tab, dataSet, shipmentDestination);
			}
		},
		_doRenderTab:function (tab,dataSet,shipmentDestination){
			let shipmentHandler=shipmentDestination["shipmenthandler"];

			let uri='/api/dataset/'+dataSet["id"]+'/autoprocessingresult?all=1';
			tab.dataset.apiurl=uri;
			AjaxUtils.request(uri,{
				method:'get',
				onSuccess:function (transport){
					tab.table({
						contentBefore:'<p>Click on any table row to see the full autoprocessing results for that pipeline</p>',
						headers:Dataset.AutoProcessing.tableHeaders,
						cellTemplates:Dataset.AutoProcessing.cellTemplates,
						sortOrders:Dataset.AutoProcessing.sortOrders,
						dataRowCallback:Dataset.AutoProcessing.attachToggleEventsAndFetchParameters,
					},
					transport.responseJSON.rows);
				},
				onFailure:function (transport){
					let msg="Could not get autoprocessing data.";
					if(404===transport.status){
						msg="IceBear doesn't have any autoprocessing data for this dataset.";
					}
					tab.innerHTML="<p>"+msg+"</p>";
					if(shipmentHandler && shipmentHandler["getAutoProcessingResultsUrlAtFacility"]){
						let url=shipmentHandler.getAutoProcessingResultsUrlAtFacility(shipmentDestination, dataSet.remotedatasetid);
						if(url){
							tab.innerHTML='<p><a href="'+url+'">Open the dataset in ISPyB</a> to see its autoprocessing data</p>';
						}
					}
				}
			});
		},

		attachToggleEventsAndFetchParameters:function(tr){
			if("1"===tr.rowData["isanomalous"]){
				tr.querySelector("td").innerHTML+=" (anomalous)";
			}
			new AjaxUtils.request('/api/autoprocessingresult/'+tr.rowData['id']+'/autoprocessingresultparameter?all=1',{
				method:"get",
				onSuccess:function (transport){
					tr.rowData["parameters"]=[];
					transport.responseJSON.rows.forEach(function(parameter){
						tr.rowData["parameters"][parameter["parametername"]]=parameter["parametervalue"];
					});
				},
				onFailure:function (){
					tr.rowData["parameters"]=[];
				}
			});
			tr.addEventListener("click",Dataset.AutoProcessing.toggleFullAutoprocessingTable);
			tr.style.cursor="zoom-in";
			tr.querySelectorAll("th,td").forEach(function (cell){
				cell.style.cursor="zoom-in";
			});
		},

		toggleFullAutoprocessingTable:function(evt){
			let fullDataRowClassName="fullAutoProcessingData";
			let tr=evt.target.closest("tr");
			if(tr.classList.contains(fullDataRowClassName)){
				tr=tr.previousElementSibling;
			}
			if(tr.nextElementSibling && tr.nextElementSibling.classList.contains(fullDataRowClassName)){
				let oldBorderColor=tr.dataset.oldBorderColor;
				tr.nextElementSibling.remove();
				tr.style.cursor="zoom-in";
				tr.style.borderBottomColor=oldBorderColor;
				tr.querySelectorAll("th,td").forEach(function (cell){
					cell.style.borderBottomColor=oldBorderColor;
					cell.style.cursor="zoom-in";
				});
				return false;
			}
			tr.style.cursor="zoom-out";
			tr.querySelectorAll("th,td").forEach(function (cell){
				cell.style.cursor="zoom-out";
			});

			let tbl=tr.closest("table");
			if(tbl.closest("tr")){
				//this is a click on the sub-table, ignore
				return;
			}
			let firstTdStyle=window.getComputedStyle(tr.querySelector("td"));
			tr.dataset.oldBorderColor=firstTdStyle.borderBottomColor;
			tr.style.borderBottomColor="transparent";
			tr.querySelectorAll("th,td").forEach(function (cell){
				cell.style.borderBottomColor="transparent";
			});
			let newTr=tbl.insertRow(tr.rowIndex+1);
			newTr.classList.add(fullDataRowClassName);
			newTr.addEventListener("click",Dataset.AutoProcessing.toggleFullAutoprocessingTable);
			let newTd=newTr.insertCell();
			newTd.style.borderBottom="none";
			newTd.style.padding="1em 2em";
			newTd.style.fontSize="75%";
			newTd.colSpan=tr.querySelectorAll("th,td").length;

			let resultsTable=ui.table({
				headers:[
					"Space</br>group","a,b,c","&alpha;,&beta;,&gamma;",
					"Shell",
					"Resolution",
					"Multiplicity",
					"Completeness",
					"Anomalous<br/>multiplicity",
					"Anomalous<br/>completeness",
					"R<sub>meas</sub>","R<sub>pim</sub>","R<sub>merge</sub>",
					"I/&#963;",
					"cc<sub>1/2</sub>"
				],
				cellTemplates:[
					[Crystal.formatSpaceGroupTableCell,"spaceGroup"],"{{cellA}}<br/>{{cellB}}<br/>{{cellC}}","{{cellAlpha}}<br/>{{cellBeta}}<br/>{{cellGamma}}",
					"Overall<br/>Inner<br/>Outer",
					"{{resolutionLimitLow_overall}}&nbsp;-&nbsp;{{resolutionLimitHigh_overall}}<br/>{{resolutionLimitLow_inner}}-{{resolutionLimitHigh_inner}}<br/>{{resolutionLimitLow_outer}}-{{resolutionLimitHigh_outer}}",
					"{{multiplicity_overall}}<br/>{{multiplicity_inner}}<br/>{{multiplicity_outer}}",
					"{{completeness_overall}}<br/>{{completeness_inner}}<br/>{{completeness_outer}}",
					"{{anomalousMultiplicity_overall}}<br/>{{anomalousMultiplicity_inner}}<br/>{{anomalousMultiplicity_outer}}",
					"{{anomalousCompleteness_overall}}<br/>{{anomalousCompleteness_inner}}<br/>{{anomalousCompleteness_outer}}",
					"{{rMeasAllIPlusIMinus_overall}}<br/>{{rMeasAllIPlusIMinus_inner}}<br/>{{rMeasAllIPlusIMinus_outer}}",
					"{{rPimAllIPlusIMinus_overall}}<br/>{{rPimAllIPlusIMinus_inner}}<br/>{{rPimAllIPlusIMinus_outer}}",
					"{{rMerge_overall}}<br/>{{rMerge_inner}}<br/>{{rMerge_outer}}",
					"{{meanIOverSigma_overall}}<br/>{{meanIOverSigma_inner}}<br/>{{meanIOverSigma_outer}}",
					"{{ccHalf_overall}}<br/>{{ccHalf_inner}}<br/>{{ccHalf_outer}}",
				]
			},[tr.rowData["parameters"]],newTd);
			resultsTable.querySelector("tr.datarow").style.borderBottom="none";
			resultsTable.querySelectorAll("tr.datarow td").forEach(function(cell){
				cell.style.borderBottom="none";
				cell.innerHTML=cell.innerHTML.replaceAll("undefined","&nbsp;")
			})
			return false;
		},

	} //end Dataset.Autoprocessing

};

let PlateWidget={
		
		/**
		 * Renders the HTML for a plate widget and attaches it to the parent.
		 * @param details A JSON object containing at least the following:
		 * 					wellDrops An array of welldrop objects for this plate
		 * 					plateType A platetype object describing the plate geometry
		 *                Optional parameters:
		 *                  id The HTML ID of the widget element. Internal element IDs are prepended with this ID.
		 *                  dropPickerPosition:left Float the drop picker to the left, instead of on top.
		 *                  dropRenderer: A function taking the individual drop div, applied to all non-empty drop divs.
		 *                  dropOnMouseOver,
		 *                  dropOnMouseOut, 
		 *                  dropOnClick: Functions taking the EVENT (not the drop div element). event.target is the element.
		 *                               Applied to all non-empty drop divs.
		 * @param parent The parent HTML element
		 * @returns {Element|boolean} The plate widget element, or false if it cannot be rendered due to missing drops or plate type
		 */
		render:function(details, parent){
			
			let drops=details.wellDrops;
			let plateType=details.plateType;
			if(!drops || !plateType){
				alert("Cannot render plate widget - plate type and/or well drops not provided");
				return false;
			}
			//wrapper div
			let w=document.createElement("div");
			w.plateType=plateType;
			w.details=details;
			w.classList.add("platewidget");
			if(details.id){ w.id=details.id; }
			
			//drop mapping/picker
				let dp=document.createElement("table");
				dp.classList.add("pw_droppicker");
				let dm=plateType["dropmapping"];
				let rows=dm.split(",");
				rows.forEach(function(r){
					let tr=document.createElement("tr");
					for (let i = 0; i < r.length; i++) {
						let td = document.createElement("td");
						let cellContent = r[i];
						td.innerHTML = cellContent;
						if (parseInt(cellContent)) {
							td.classList.add("pw_drop","pw_empty","drop"+cellContent);
							td.dataset.dropnumber = cellContent;
						} else if ("R" === cellContent) {
							td.classList.add("pw_reservoir");
						}
						tr.appendChild(td);
					}
					dp.appendChild(tr);
				});
				w.appendChild(dp);
				if("left"===details.dropPickerPosition){ dp.style.float="left"; }
				if(plateType["subs"]<=1){
					dp.style.display="none";
				}

				//plate table
				let t=document.createElement("table");
				t.classList.add("pw_plate");
					//Header row
					let tr=document.createElement("tr");
					let th=document.createElement("th");
					tr.appendChild(th);
					for(let c=1;c<=plateType.cols;c++){
						th=document.createElement("th");
						th.innerHTML=c+"";
						tr.appendChild(th);
					}
					t.appendChild(tr);

					//One row per plate type row
					for(let r=1;r<=plateType.rows;r++){
						tr=document.createElement("tr");
						tr.classList.add("row"+r);
						th=document.createElement("th");
						th.innerHTML=Plate.rowLabels[r];
						tr.appendChild(th);
						for(let c=1;c<=plateType.cols;c++){
							let td=document.createElement("td");
							let wellNum=c+((r-1)*plateType.cols);
							td.classList.add("pw_well","pw_empty","row"+r,"col"+c,"well"+wellNum);
							td.dataset.row=""+r;
							td.dataset.col=""+c;
							td.dataset.wellNumber=""+wellNum;
							td.dataset.isInBottomHalf=(r>plateType["rows"]/2) ? "1" : "0";
							td.dataset.isInRightHalf=(c>plateType["cols"]/2) ? "1" : "0";
							if(details.id){ td.id=details.id+"_"+r+"_"+c; }
							for(let d=1; d<=plateType["subs"]; d++){
								let dd=document.createElement("div");
								dd.classList.add("pw_drop","pw_empty","row"+r,"col"+c,"drop"+d);
								dd.dataset.row=""+r;
								dd.dataset.col=""+c;
								dd.dataset.dropnumber=""+d;
								if(details.id){ dd.id=details.id+"_"+r+"_"+c+"_"+d; }
								td.appendChild(dd);
							}	
							tr.appendChild(td);
						}
						t.appendChild(tr);
					}
				w.appendChild(t);
			parent.appendChild(w);

			//iterate through drops and attach, removing class name "empty"
			drops.forEach(function(d){
				let cell=t.querySelector("tr.row"+d.row).querySelector("td.col"+d.col);
				let dropDiv=cell.querySelector(".drop"+d.dropnumber);
				dropDiv.wellDrop=d;
				dropDiv.dataset.welldropid=d.id;
				dropDiv.classList.remove("pw_empty");
				cell.classList.remove("pw_empty");
				if(dp){
					dp.querySelector("td.drop"+d.dropnumber).classList.remove("pw_empty");
				}
			});
			
			if(dp){
				dp.querySelectorAll("td.pw_drop").forEach(function(td){
					if(!td.classList.contains("pw_empty")){
						td.addEventListener("click",PlateWidget.dropPickerSetDrop);
					}
				});
			}
			
			w.setCurrentDrop=function(rowNumber,colNumber,dropNumber){
				return PlateWidget.setCurrentDrop(w,rowNumber,colNumber,dropNumber);
			};

			let currentRow=1;
			let currentCol=1;
			let currentDrop=1;
			if(details["currentDropIndex"] && details["currentDropIndex"]<=details.wellDrops.length){
				let wd=details.wellDrops[details["currentDropIndex"]];
				currentRow=wd.row;
				currentCol=wd.col;
				currentDrop=wd.dropnumber;
			}
			w.setCurrentDrop(currentRow,currentCol,currentDrop);

			window.setTimeout(function(){PlateWidget.afterRender(w)},50);
			return w;
		},
		/**
		 * called by PlateWidget.render. Do not call directly.
		 * @param plateWidget
		 */
		afterRender:function(plateWidget){
			let p=plateWidget.querySelector(".pw_plate");
			let offset=ui.positionedOffset(p);
			let newHeight=plateWidget.offsetHeight-offset.top;
			let newWidth=plateWidget.offsetWidth-offset.left;
			let cellHeight=newHeight/(1+(1*plateWidget.plateType.rows));
			let cellWidth=newWidth/(1+(1*plateWidget.plateType.cols));
			let cellSize=Math.min(cellHeight,cellWidth);
			let firstWell=p.querySelector("td");
			let cellBorder=parseInt(window.getComputedStyle(firstWell).borderLeftWidth);
			cellSize-=cellBorder;
			p.querySelectorAll("th").forEach(function(c){
				c.style.height=cellSize+"px";
				c.style.lineHeight=cellSize+"px";
				c.style.width=cellSize+"px";
				c.style.minWidth=cellSize+"px";
			});
			if(plateWidget.querySelector("table.pw_droppicker")){
				plateWidget.querySelector("table.pw_droppicker").style.marginTop=cellSize+"px";
			}
			window.setTimeout(function(){PlateWidget.attachEventsAndRenderDrops(plateWidget)},10);
		},
		/**
		 * called by PlateWidget.afterRender. Do not call directly.
		 * @param plateWidget
		 */
		attachEventsAndRenderDrops:function(plateWidget){
			let details=plateWidget.details;
			if(!details){ return; }
			plateWidget.querySelectorAll("div.pw_drop").forEach(function(d){
				if(d.classList.contains("pw_empty")){ return; /*from this iteration*/ }
				d.addEventListener("click",PlateWidget.dropDivSetDrop);
				if(details.dropRenderer){ details.dropRenderer(d); }
				if(details.dropOnClick){ d.addEventListener("click",details.dropOnClick); }
				if(details.dropOnMouseover){ d.addEventListener("mouseover",details.dropOnMouseover); }
				if(details.dropOnMouseout){ d.addEventListener("mouseout",details.dropOnMouseout); }
			});
		},
		
		
		dropPickerSetDrop:function(evt){
			let td=evt.target.closest("td");
			if(td.classList.contains("pw_current")){ return false; }
			let newDropNumber=td.dataset.dropnumber;
			let w=td.closest(".platewidget");
			w.setCurrentDrop(null,null,newDropNumber);
		},
		
		dropDivSetDrop:function(evt){
			let d=evt.target;
			if(d.closest(".pw_drop")){ d=d.closest(".pw_drop"); }
			d.dataset.foobar="hello";
			PlateWidget.setCurrentDrop(d.closest(".platewidget"),d.dataset.row, d.dataset.col, null);
		},
		
		setCurrentDrop:function(plateWidget,rowNumber,colNumber,dropNumber){
			if(null!=rowNumber){ plateWidget.currentRow=rowNumber; }
			if(null!=colNumber){ plateWidget.currentCol=colNumber; }
			if(null!=dropNumber){ 
				plateWidget.currentDrop=dropNumber; 
				if(plateWidget.querySelector(".pw_droppicker")){
					plateWidget.querySelector(".pw_droppicker").querySelectorAll("td").forEach(function(td){
						td.classList.remove("pw_current");
						if(td.classList.contains("drop"+dropNumber)){
							td.classList.add("pw_current");
						}
					});
					plateWidget.querySelector(".pw_plate").querySelectorAll("div.pw_drop").forEach(function(dd){
						dd.classList.remove("pw_current");
						if(1*dd.dataset.dropnumber===1*dropNumber){
							dd.classList.add("pw_current");
						}
					});
				}
			}
			//set current on plate cell
			let plateTable=plateWidget.querySelector(".pw_plate");
			let currentWell=plateTable.querySelector("td.pw_current");
			if(currentWell){ currentWell.classList.remove("pw_current"); }
			currentWell=plateTable.querySelector("tr.row"+plateWidget.currentRow).querySelector("td.col"+plateWidget.currentCol);
			currentWell.classList.add("pw_current");
			return currentWell.querySelector("div.drop"+plateWidget.currentDrop);
		}

};

let Plate={

		/**
		 * Renders the HTML for a plate widget and attaches it to the parent.
		 * @see PlateWidget.render for documentation. This is an alias for that.
		 */
		plateWidget:function(details, parent){
			return PlateWidget.render(details, parent);
		},
		
		//array is zero-based, plate row numbers are 1-based, so empty item first.
		rowLabels:['','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T'],
		
		constructs:null,
		plateType:null,
		
		/*
		 * Selected coordinate ranges for setting a construct. These defaults do all drops in the entire plate.
		 * These are 1-based, ie A1 drop 1 is selectionDrop, selectionTop and selectionLeft all 1.
		 */
		selectionDrop:-1,
		selectionTop:-1,
		selectionBottom:10000,
		selectionLeft:-1,
		selectionRight:10000,

		getWellName:function(rowNumber, colNumber, dropNumber){
			let col=colNumber+"";
			if(colNumber<10){ col="0"+colNumber; }
			if(!dropNumber){ return Plate.rowLabels[rowNumber]+ col; }
			return Plate.rowLabels[rowNumber]+ col +"."+dropNumber;
		},
		
		getWellNameFromObject:function(obj){
			return Plate.getWellName(obj.row, obj.col, obj.dropnumber);
		},
		
		/*
		 * Update isn't destroyed. On success, reload the page.
		 * @return bool false if action was cancelled at confirm(), otherwise true. Note that true ONLY
		 *         means the request was fired, and does NOT imply that it was successful.
		 */
		setIsDestroyed:function(plateId, isDestroyed){
			let msg;
			let newValue;
			if(!isDestroyed){
				msg='Change plate status to "Not destroyed"?';
				newValue="0000-00-00";
			} else {
				msg='Mark plate as destroyed?';
				newValue=new Date().toISOString().split('T')[0];
			}
			if(!confirm(msg)){ return false; }
			new AjaxUtils.Request("/api/plate/"+plateId,{
				"method":"patch",
				"parameters":{ csrfToken:csrfToken, "datedestroyed":newValue },
				onSuccess:function(transport){
					if(304===transport.status){
						alert("Plate has already been destroyed");
					}
					ui.forceReload();
				},
				onFailure:AjaxUtils.checkResponse
			});
			return true; //Request was made. DOES NOT mean success.
		},
		
		
		/*
	 	 * PLATE INSPECTIONS tab
	 	 */
		
		getManualImagers: function(){
			if(!document.manualImagers){
				new AjaxUtils.Request('/api/imager/manualimaging/1?sortby=name&all=1',{
					method:'get',
					onFailure:function(){
						document.manualImagers=null;
					},
					onSuccess:function(transport){
						document.manualImagers=transport.responseJSON.rows;
					},
				});
			}
		},
		
		beginCreateInspection: function(){
			if(!canCreateInspections){
				alert("Either manual creation of plate inspections is not enabled in IceBear, or you do not have permission to create them on this plate.");
				return false;
			}
			if(!document.manualImagers){
				alert("No imagers suitable for manual imaging are defined in IceBear.");
				return false;
			}
			let mb=ui.modalBox({
				id:'inspectiondetails',
				title:"New plate inspection on plate "+data.name
			});
			Plate.renderSetInspectionDetailsForm(mb);
		},
		
		beginEditInspection: function(btn){
			if(!canCreateInspections){
				alert("Either manual editing of plate inspections is not enabled in IceBear, or you do not have permission to edit them on this plate.");
				return false;
			}
			let inspection=btn.closest("tr").rowData;
			if(!1*inspection["manualimaging"]){
				alert("Either manual editing of plate inspections is not enabled in IceBear, or this inspection was done on an automatic imager and cannot be edited.");
				return false;
			}
			Plate.editInspection(inspection);
		},
		
		editInspection:function(inspection){
			let ts=ui.modalTabSet();
			ts.imagingsession=inspection;
			ts.tab({
				id:'inspectiondetails',
				label:'Basic details'
			});
			Plate.renderSetInspectionDetailsForm(document.getElementById('inspectiondetails_body'),inspection);
			if(inspection && (1===1*inspection['manualimaging'])){
				let imagesTab=ts.tab({
					id:'inspectionimages',
					label:'Images'
				});
				new AjaxUtils.Request('/api/imagingsession/'+inspection.id+'/dropimage?all=1',{
					method:'get',
					onFailure:function(transport){
						if(404===transport.status){
							imagesTab.images=[];
						} else {
							return AjaxUtils.checkResponse(transport);
						}
					},
					onSuccess:function(transport){
						imagesTab.images=transport.responseJSON.rows;
					}
				});
				window.setTimeout(function(){
					Plate.renderSetInspectionImagesForm(inspection);
					document.getElementById("inspectionimages").click();
				},50);
			}
		},
		
		renderSetInspectionDetailsForm: function(parent,inspection){

			let isAutomaticImaging=(inspection && (0===1*inspection['manualimaging']) );

			let imagerId=0;
			let imagedTime='';
			let formAction='/api/imagingsession';
			let formMethod="post";
			if(inspection){
				imagerId=inspection["imagerid"];
				imagedTime=inspection["imageddatetime"];
				formAction+="/"+inspection.id;
				formMethod="patch";
			}
			let imagerOptions = [];
			if(isAutomaticImaging){
				imagerOptions.push({ label:inspection["imagername"]+" ("+inspection["temperature"]+"C)", value:inspection["imagerid"] });
			} else {
				document.manualImagers.forEach(function(im){
					if(im["manualimaging"]){
						imagerOptions.push({ label:im["name"]+" ("+im["temperature"]+"C)", value:im["id"] });
					}
				});
			}

			let f=parent.form({
				method:formMethod,
				action:formAction
			});


			f.hiddenField('plateid',data.id);
			f.hiddenField('name','');
			f.hiddenField('manufacturerdatabaseid',0);
			
			f.datePicker({
				readonly:isAutomaticImaging,
				label:'Inspection date and time',
				name:'imageddatetime',
				value:imagedTime,
				showTime:true,
				minuteStep:5
			});
			if(isAutomaticImaging) {
				f.formField({
					label:'Imaging device',
					content:inspection['imagername']
				});
			} else {
				f.dropdown({
					label:'Imaging device',
					name:'imagerid',
					value:imagerId,
					options:imagerOptions
				});
			}

			if(isAutomaticImaging){
				f.dropdown({
					label:'Light type',
					name:'lighttype',
					value:inspection['lighttype'],
					options:[
						{ "label":"Visible", "value":"visible" },
						{ "label":"UV", "value":"UV" }
					]
				});
			}

			f.formField({
				label:'Plate',
				content:data.name
			});
			
			if(!inspection){
				f.createButton({
					beforeSubmit:function(){
						f.querySelector("input[name=name]").value=data.name+" "+new Date().getTime();
					},
					afterSuccess:function(responseJSON){
						Plate.editInspection(responseJSON.created);
						document.getElementById("inspections").refresh();
					}
				});
			} else {
				f.querySelectorAll("label").forEach(function(lbl){
					lbl.afterUpdate=Plate.afterInspectionUpdate;
				});
			}
		},
		
		renderSetInspectionImagesForm: function(inspection){
			let ii=document.getElementById("inspectionimages");
			if(!inspection){
				alert('Inspection ID not provided');
				return false;
			}
			if(undefined===ii.images){
				window.setTimeout(function(){Plate.renderSetInspectionImagesForm(inspection)}, 50);
				return false;
			}
			let w=Plate.plateWidget({
				plateType:data.plateType,
				wellDrops:data.welldrops,
				dropPickerPosition:"left",
				dropRenderer:Plate.renderImageEditorDrop,
				dropOnClick:Plate.onImageEditorDropClick,
				dropOnMouseover:Plate.onImageEditorDropMouseover,
				dropOnMouseout:Plate.onImageEditorDropMouseout
			},document.getElementById("inspectionimages_body"));
			if(0!==ii.images.length){
				ii.images.forEach(function(im){
					let div=w.querySelector("tr.row"+im.row+">td.col"+im.col+">div.drop"+im.dropnumber);
					div.image=im;
				});
			}
			w.querySelector("td.pw_drop").addEventListener("click",Plate.closeContextMenu);
		},

		renderImageEditorDrop:function(dropDiv){
			dropDiv.innerHTML="";
			let im=dropDiv.image;
			if(im){
				let img=document.createElement("img");
				let imageAspectRatio=im["pixelwidth"]/im["pixelheight"];
				let containerAspectRatio=dropDiv.offsetWidth/dropDiv.offsetHeight;
				if(imageAspectRatio>=containerAspectRatio){
					img.style.width="100%";
				} else {
					dropDiv.style.textAlign="center";
					img.style.height="100%";
				}
				img.src="/dropimagethumb/"+im.id;
				dropDiv.appendChild(img);
			}
			let overlay=document.createElement("label");
			overlay.classList.add("pw_overlay");
            overlay.style.position="absolute";
            overlay.style.top="0";
            overlay.style.left="0";
            overlay.style.right="0";
            overlay.style.bottom="0";
            overlay.style.backgroundSize="50% 50%";
            overlay.style.backgroundPosition="center center";
            overlay.style.backgroundRepeat="no-repeat";
            overlay.style.textAlign="center";
            overlay.style.display="block";
            overlay.style.opacity="0";
			dropDiv.appendChild(overlay);
			dropDiv.addEventListener("contextmenu",Plate.onImageEditorContextMenu);
			if(im){
				overlay.style.backgroundImage='url(/images/icons/'+skin["bodyIconTheme"]+'/no.gif)';
				overlay.title="Click to remove this image. Right-click to set its scale.";
			} else {
				overlay.style.backgroundImage='url(/images/icons/'+skin["bodyIconTheme"]+'/btn_plus.gif)';
				overlay.title="Add an image";
				let f=document.createElement("input");
				f.name="well_r"+dropDiv.dataset.row+"c"+dropDiv.dataset.col;
				f.type="file";
				f.addEventListener("change",Plate.startDropImageAdd);
				overlay.appendChild(f);
				overlay.htmlFor=f.name;
				f.style.position="absolute";
				f.style.top="0";
				f.style.left="0";
				f.style.height="100%";
				f.style.width="100%";
				f.style.opacity="0";
			}
		},		

		onImageEditorContextMenu:function (evt){
			evt.preventDefault();
			Plate.closeContextMenu(evt);
			PlateWidget.dropPickerSetDrop(evt);
			let dropDiv=evt.target.closest(".pw_drop");
			let tb=document.getElementById("inspectionimages_body");
			let f=ui.form({"id":"dropcontext"});
			f.dropDiv=dropDiv;
			dropDiv.click();
			f.style.width="20em";
			f.style.zIndex="999999";
			f.style.padding="0.5em";
			f.style.border=getComputedStyle(tb)["border"];
			f.style.background=getComputedStyle(tb)["background"];
			if(dropDiv.image){
				f.dataset.imageId=dropDiv.image.id;
				let scale=parseFloat(dropDiv.image.micronsperpixelx);
				if(0>=scale){ scale=""; }
				f.textField({ "label":"Scale (&#181;m/pixel)","value":scale+"" })
				f.buttonField({ "label":"Delete image", "id":"deletebtn", onclick:Plate.handleContextMenuDelete }).style.textAlign="left";
				f.addEventListener("submit",Plate.handleContextMenuSubmit);
			} else {
				f.buttonField({ "label":"Add image", "id":"addbtn", onclick:Plate.handleContextMenuAdd }).style.textAlign="left";
			}
			tb.appendChild(f);
			f.style.position="absolute";
			if("1"===dropDiv.closest("td").dataset.isInBottomHalf){
				f.style.top=(ui.cumulativeOffset(dropDiv).top - ui.cumulativeOffset(tb).top - f.offsetHeight)+"px";
			} else {
				f.style.top=(ui.cumulativeOffset(dropDiv).top - ui.cumulativeOffset(tb).top + dropDiv.offsetHeight)+"px";
			}
			if("1"===dropDiv.closest("td").dataset.isInRightHalf){
				f.style.left=(dropDiv.offsetWidth+ui.cumulativeOffset(dropDiv).left- ui.cumulativeOffset(tb).left- f.offsetWidth)+"px";
			} else {
				f.style.left=(ui.cumulativeOffset(dropDiv).left- ui.cumulativeOffset(tb).left)+"px";
			}
			tb.addEventListener("click",Plate.closeContextMenu);
		},

		handleContextMenuSubmit:function(evt){
			evt.preventDefault();
			Plate.startDropImageSetScale(evt);
		},
		handleContextMenuDelete:function(evt){
			evt.preventDefault();
			Plate.startDropImageRemove(evt.target.closest("form").dropDiv);
		},
		handleContextMenuAdd:function(evt){
			evt.preventDefault();
			evt.target.closest("form").dropDiv.querySelector("input[type=file]").click();
		},

		closeContextMenu:function(evt){
			let clicked=(evt) ? evt.target : null;
			let tb=document.getElementById("inspectionimages_body");
			let dc=document.getElementById("dropcontext");
			if(dc && (!clicked || !clicked.closest("#dropcontext"))){ dc.remove(); }
			tb.removeEventListener("click",Plate.closeContextMenu);
		},

		onImageEditorDropClick:function(evt){
			let dropDiv=evt.target;
			if(!dropDiv.classList.contains(".pw_drop")){ dropDiv=dropDiv.closest(".pw_drop"); }
			if(dropDiv.image){
				Plate.startDropImageRemove(dropDiv);
			} else {
				//Handled by file input and its onchange event
			}
			return true;
		},
		onImageEditorDropMouseover:function(evt){
			let dropDiv=evt.target;
			if(dropDiv.tagName.toLowerCase()==="input"){ dropDiv=dropDiv.closest(".pw_drop"); }
			if(!dropDiv.classList.contains("pw_overlay")){ dropDiv=dropDiv.querySelector(".pw_overlay"); }
			dropDiv.style.opacity="0.6";
			if(dropDiv.image){
				//show delete icon
			} else {
				//show add icon
			}
		},
		onImageEditorDropMouseout:function(evt){
			let dropDiv=evt.target;
			if(dropDiv.tagName.toLowerCase()==="input"){ dropDiv=dropDiv.closest(".pw_drop"); }
			if(!dropDiv.classList.contains("pw_overlay")){ dropDiv=dropDiv.querySelector(".pw_overlay"); }
			dropDiv.style.opacity=0;
		},

		startDropImageSetScale:function (evt){
			let frm=evt.target;
			let field=frm.querySelector("input[type=text]");
			let newScale=field.value.trim();
			let lbl=field.closest("label");
			let dropDiv=frm.dropDiv;
			let imageId=frm.dataset.imageId;
			if(""===newScale){
				newScale=-1;
			} else if(!parseFloat(newScale)){
				lbl.classList.add("invalidfield");
				return false;
			} else if(0>=parseFloat(newScale)){
				newScale=-1;
			}
			lbl.classList.add("updating");
			new AjaxUtils.Request("/api/dropimage/"+imageId,{
				method:"patch",
				parameters:{
					csrfToken:csrfToken,
					micronsperpixelx:newScale,
					micronsperpixely:newScale,
				},
				onSuccess:function (transport){
					dropDiv.image=transport.responseJSON["updated"];
					Plate.closeContextMenu();
				},onFailure:function (){
					Plate.closeContextMenu();
					alert("Could not set scale information");
				}
			});
		},

		startDropImageAdd:function(evt){
			Plate.closeContextMenu();
			let fileField=evt.target; //the file input
			fileField.disabled="disabled";
			let dropDiv=fileField.closest(".pw_drop");
			dropDiv.classList.add("updating");
			dropDiv.querySelector("label").style.background="none"; //Don't show (+) on mouseover
			let formData=new FormData();
			formData.append('dropimage', fileField.files[0], fileField.files[0].name);
			formData.append('csrfToken', encodeURIComponent(csrfToken));
			formData.append('imagingsessionid', encodeURIComponent(document.getElementById("modalTabSet").imagingsession.id));
			formData.append('welldropid', encodeURIComponent(dropDiv.dataset.welldropid));
			let xhr=new XMLHttpRequest();
			let method="post";
	        xhr.open(method, "/api/dropimage", true);
	        xhr.onload=function(){
	        	try{
	        		xhr.responseJSON=JSON.parse(xhr.responseText);
	        	} catch(ex) {
	        		alert("Could not understand reply from server:\n\n"+xhr.responseText);
	        		return false;
	        	}
	            if(200===xhr.status || 201===xhr.status){
	                if(xhr.responseJSON.created){
	                	dropDiv.image=xhr.responseJSON.created;
	                }
	            } else if(401===xhr.status){
	            	ui.handleSessionExpired();
	            } else if(xhr.responseJSON.error){
	            	alert("Image upload failed. The server said:\n\n"+xhr.responseJSON.error);
	            } else {
	            	alert("Image upload failed. The server said:\n\n"+xhr.responseText);
	            }
	            dropDiv.classList.remove("updating");
	            Plate.renderImageEditorDrop(dropDiv);
	        };
			xhr.send(formData);
			
		},
		
		startDropImageRemove:function(dropDiv){
			if(!dropDiv.image){ alert("No image attached to that drop"); return false; }
			let dropName=Plate.rowLabels[dropDiv.dataset.row];
			if(10>(1*dropDiv.dataset.col)){ dropName+="0"; }
			dropName+=dropDiv.dataset.col+" drop "+dropDiv.dataset.dropnumber;
			if(!confirm("Really remove image from "+dropName+"?")){
				return false;
			}
			dropDiv.querySelector("label").style.background="none"; //Don't show (x) on mouseover
			dropDiv.classList.add("updating");
			new AjaxUtils.Request('/api/dropimage/'+dropDiv.image.id,{
				method:'delete',
				postBody:'csrfToken='+csrfToken,
				onSuccess:function(transport){ Plate.dropImageRemove_onSuccess(transport,dropDiv); },
				onFailure:function(transport){ Plate.dropImageRemove_onFailure(transport,dropDiv); },
			});
		},
		dropImageRemove_onSuccess:function(transport,dropDiv){
			AjaxUtils.checkResponse(transport);
			if(transport.responseJSON["deleted"]){ dropDiv.image=null; }
            Plate.renderImageEditorDrop(dropDiv);
			dropDiv.classList.remove("updating");
			Plate.closeContextMenu();
		},
		dropImageRemove_onFailure:function(transport,dropDiv){
			AjaxUtils.checkResponse(transport);
            Plate.renderImageEditorDrop(dropDiv);
			dropDiv.classList.remove("updating");
			Plate.closeContextMenu();
		},
		
		afterInspectionUpdate:function(){
			document.getElementById("inspections").refresh();
		},
		
		/*
	 	 * PROTEIN tab
	 	 */

		/**
		 * Renders the "Protein" tab.
		 * Generates a well map enabling different proteins to be set in different drop positions, i.e., one protein in ALL drop 1s,
		 * another protein in ALL drop 2s, etc. 
		 * ASSUMPTIONS: All plates will have the same protein in all drop 1s, the same protein in all drop 2s, etc. This assumption
		 * holds true as long as both (1) there is no UI for setting more complex arrangements, and (2) no API caller does it either.
		 */
		renderProteinTab:function(){
			let tb=document.getElementById("proteins_body");
			tb.innerHTML='';
			if(null==Plate.constructs || !data.plateType || !data.welldrops){
				window.setTimeout(Plate.renderProteinTab,50);
				return;
			}
			let dropMap=PlateType.getDropMapElement(data.plateType,tb);
			let drops=dropMap.querySelectorAll(".dropmapwell");
			drops.forEach(function(d){
				
				d.style.overflow="auto";
				let wellDrop, proteinAmount, proteinUnit, buffer;
				//Find first drop with this drop number
				for(let i=0;i<data.welldrops.length;i++){
					let wd=data.welldrops[i];
					if(0===wd.dropnumber-1*d.dataset.dropnumber){
						wellDrop=wd;
						proteinAmount=wd.proteinconcentrationamount;
						proteinUnit=wd.proteinconcentrationunit;
						buffer=wd.proteinbuffer;
						break;
					}
				}

				if(undefined===wellDrop){ return; }

				let f=ui.form({
					action:'/api/plate/'+data.id,
					method:'patch'
				},d);
				f.dataset.constructid=wellDrop.constructid;
				f.selectionTop=1;
				f.selectionLeft=1;
				f.selectionRight=1*data.plateType.cols;
				f.selectionBottom=1*data.plateType.rows;
				f.selectionDrop=1*d.dataset.dropnumber;
				Plate.renderDropConstructFormFields(f, wellDrop);
			});
			window.setTimeout(function(){
				let margins=20;
				dropMap.style.height="100%";
				dropMap.style.width="100%";
				if(1*DEFAULT_PROJECT_ID===1*data["projectid"]){
					let mb=ui.warningMessageBar('This plate is in the default project because its protein and construct have not been set. Set them here.',tb);
					let th=tb.previousElementSibling;
					th.classList.add("current"); //tab body has to be "visible" to calculate heights
					dropMap.style.height=(dropMap.scrollHeight-mb.scrollHeight-(margins/2))+"px";
					th.classList.remove("current");
					tb.appendChild(dropMap); //detach and re-append, so it's below the warning bar.
				}
				let screensTab=document.getElementById("screens");
				if(screensTab){
					screensTab.refresh();
				}
			},100);

			if(!canEdit){ return; }

			//"Copy from other drop"
			drops.forEach(function(drop){
				let toDrop=1*drop.dataset.dropnumber;
				if(0!==1*(drop.querySelector("form").dataset.constructid)){
					//Drops in this position already have a construct
					return; //from this iteration
				}
				let fromDrops=[];
				drops.forEach(function(otherDrop){
					let fromDrop=1*otherDrop.dataset.dropnumber;
					if(fromDrop===toDrop){
						//Can't copy onto ourselves
						return; //from this iteration
					} else if(0===1*(otherDrop.querySelector("form").dataset.constructid)){
						//other drop has no construct, nothing to copy from
						return; //from this iteration
					}
					fromDrops.push(fromDrop);
				});
				if(0<fromDrops.length){
					drop.querySelector("form").innerHTML+='<label class="radiohead"><span class="label">Copy from other drop positions:</span>&nbsp;</label>';
					fromDrops.forEach(function(fromDrop){
						let field=drop.querySelector("form").buttonField({
							label:'Copy from position '+fromDrop,
							
						});
						field.innerHTML+='&nbsp;';
						field.querySelector("input").onclick=function(){ Plate.copyProteinDetailsAcrossDropPositions(fromDrop, toDrop); };
					});
				}
			});
			
		},

		/**
		 * Renders the form for protein/construct choice and, if construct is set, protein buffer and concentration.
		 * 
		 * ASSUMPTION: This is used in the context of a well drop map, where we can pass in a single drop and assume that
		 * all drops being affected by this form have the same protein/construct, buffer, and concentration.
		 * 
		 * @param f The HTML form element
		 * @param wellDrop One of the well drops affected by this form. See assumption above.
		 */
		renderDropConstructFormFields:function(f, wellDrop){
			let proteinBuffer=wellDrop.proteinbuffer;
			let concAmount=wellDrop.proteinconcentrationamount;
			let concUnit=wellDrop.proteinconcentrationunit;
			f.innerHTML='';
			let out='';
			let constructId=1*f.dataset.constructid;
			f.dataset.proteinconcentrationamount=concAmount;
			f.dataset.proteinconcentrationunit=concUnit;
			f.dataset.proteinbuffer=proteinBuffer;
			if(!constructId){
				
				//"choose construct..."
				out+='<label style="text-align:left">'+
				'<strong>No construct chosen</strong>';
				if(canEdit){
					out+='<input type="button" value="Choose..." style="margin-top:.25em" onclick="Plate.beginSetProtein(this);" />';
				}
				out+='</label>';
				f.innerHTML=out;

			} else {
				
				let construct=null;
				Plate.constructs.forEach(function(c){
					if(1*c.id===constructId){ construct=c; }
				});
				if(!construct){ 
					alert("Drop "+wellDrop.dropnumber+" has a protein/construct not found in Plate.constructs. This shouldn't happen.");
					return; 
				}
				
				out+='<label style="text-align:left">'+
					'<strong>Protein:</strong> '+construct.proteinacronym+' - '+construct["proteinname"]+'<hr/>'+
					'<strong>Construct:</strong> '+construct.name+' - '+construct.description+'<br><a href="#" onclick="Plate.showSequencesForConstruct(this);return false;">Show sequence...</a>';
				if(canEdit){
					out+='<input type="button" value="Change..."  onclick="Plate.beginSetProtein(this);" />';
				}
				out+='<div class="shim">&nbsp;</div></label>';
				//protein buffer
				f.innerHTML=out;
				let bufferBox='<textarea name="proteinbuffer" id="proteinbuffer">'+proteinBuffer+'</textarea>';
				if(!canEdit){
					bufferBox=proteinBuffer;
				}
				let buff=f.formField({
					label:'Protein buffer',
					content:bufferBox,
					value:proteinBuffer,
				});
				if(canEdit){
					buff.querySelector("textarea").addEventListener("blur",Plate.updateProteinConcentrationOrBuffer);
					buff.querySelector("textarea").addEventListener("keyup",Plate.updateProteinConcentrationOrBuffer);
					buff.querySelector("textarea").addEventListener("change",Plate.updateProteinConcentrationOrBuffer);
				}
				//protein concentration
				let concContent=concAmount+concUnit.replace("u","&#181;");
				if(canEdit){
					concContent='<input type="text" id="proteinconcentrationamount" name="proteinconcentrationamount" value="'+concAmount+'" style="width:3em"/>';
					concContent+='<select id="proteinconcentrationunit" name="proteinconcentrationunit">';
					concContent+='<option value="mg/mL" '+( 'mg/mL'===concUnit ?  'selected="selected"' : '' )+'>mg/mL</option>';
					concContent+='<option value="ug/uL" '+( 'ug/uL'===concUnit ?  'selected="selected"' : '' )+'>&#181;g/&#181;L</option>';
					concContent+='</select>';
				}
				let concField=f.formField({
					label:'Protein concentration',
					content:concContent
				});
				if(canEdit){
					fieldValidations["proteinconcentrationamount"]=["required","float"];
					buff.addEventListener("keyup",Plate.updateProteinConcentrationOrBuffer);
					concField.querySelector('[name=proteinconcentrationamount]').addEventListener("blur",Plate.updateProteinConcentrationOrBuffer);
					concField.querySelector('[name=proteinconcentrationamount]').addEventListener("keyup",Plate.updateProteinConcentrationOrBuffer);
					concField.querySelector('[name=proteinconcentrationamount]').addEventListener("change",Plate.updateProteinConcentrationOrBuffer);
					concField.querySelector('[name=proteinconcentrationunit]').addEventListener("blur",Plate.updateProteinConcentrationOrBuffer);
					concField.querySelector('[name=proteinconcentrationunit]').addEventListener("keyup",Plate.updateProteinConcentrationOrBuffer);
					concField.querySelector('[name=proteinconcentrationunit]').addEventListener("change",Plate.updateProteinConcentrationOrBuffer);
				}
			}
			Plate.setProteinTabWarning(f);
		},

		setProteinTabWarning:function(frm){
			let tbl=frm.closest("table");
			let tb=tbl.closest(".tabbody");
			let hasAllConstructs=true;
			let hasAllBufferInfo=true;
			tbl.querySelectorAll("td.dropmapwell form").forEach(function (f) {
				if(!f.dataset.constructid){
					hasAllConstructs=false;
				} else if(""===f.dataset.proteinbuffer || ""===f.dataset.proteinconcentrationunit || ""===f.dataset.proteinconcentrationamount){
					hasAllBufferInfo=false;
				}
			});
			if(!hasAllConstructs){
				tb.warn("Protein and construct are not specified for all drops");
			} else if(!hasAllBufferInfo){
				tb.warn("Protein buffer and concentration are not specified for all drops");
			} else {
				tb.unwarn();
			}
		},

		showSequencesForConstruct:function(elem){
			let frm=elem.closest("form");
			if(!frm){ return false; }
			let constructId=frm.dataset.constructid;
			if(!constructId){ return false; }
			new AjaxUtils.Request("/api/construct/"+constructId+"/sequence",{
				method:"get",
				onSuccess:Plate.showSequencesForConstruct_onSuccess,
				onFailure:Plate.showSequencesForConstruct_onFailure,
			});
		},

		showSequencesForConstruct_onSuccess:function(transport){
			let mb=ui.modalBox({
				title:"Sequences"
			});
			transport.responseJSON.rows.forEach(function(seq){
				let f=mb.form({
					readonly:true
				});
				f.style.marginBottom="1em";
				f.append('<label><h3>'+seq.name+'</h3></label>');
				if(""!==seq.dnasequence){
					f.formField({
						label:"DNA sequence",
						content:'<div style="float:right;text-align:left;width:80%;max-width:80%">'+seq.dnasequence+'</div><div style="clear: both"></div>'
					})
				}
				if(""!==seq.proteinsequence){
					f.formField({
						label:"Protein sequence",
						content:'<div style="float:right;text-align:left;width:80%;max-width:80%">'+seq.proteinsequence+'</div><div style="clear: both"></div>'
					})
				}
			});
		},

		showSequencesForConstruct_onFailure:function(transport){
			if(404===transport.status){
				alert("No sequence information was found.");
			} else {
				AjaxUtils.checkResponse(transport);
			}
		},

	 	/**
	 	 * Begins the process of setting a protein.
	 	 * @param button The button pressed to start this process
	 	 */
		beginSetProtein: function(button){
			if("Default Project"===data["projectname"]){
				new AjaxUtils.Request('/api/project/?all=1',{
					method:'get',
					onSuccess:function(transport){
						if(!AjaxUtils.checkResponse(transport)){ return false; }
						Plate.showProjectList(transport.responseJSON.rows, button);
					},
					onFailure:function(transport){
						AjaxUtils.checkResponse(transport);
					},
				});
			} else {
				Plate.showProjectList([{ name:data["projectname"], id:data.projectid, description:'Plate is in project "'+data["projectname"]+'". You can choose from proteins in that project' }], button);
			}
		},
		
		/**
		 * Renders a list of projects as tree items in a modal box. Opening a project shows its proteins.
		 * @param projects The projects to render. Each must have name, id and description. The object must have a "rows" key, containing the array of projects.
	 	 * @param button The button pressed to start this process
		 */
		showProjectList: function(projects, button){
			let mb=ui.modalBox({
				'title':'Choose the construct',
				'content':''
			});
			mb.launcherForm=button.closest("form");
			Plate.doAfter=Plate["afterSetConstruct"];
			projects.forEach(function(p){
				if(1*p["isarchived"] || 1*p["issystem"]){ return; } //from this iteration
				mb.treeItem({
					record:p,
					id:"project"+p.id,
					header:p.name+": "+p.description+"",
					updater:Plate.showProteinsForProject
				});
			});
			if(1===mb.querySelectorAll(".treeitem").length){
				mb.querySelector(".treehead").click();
			}		
		},

		/**
		 * Within a tree list of projects, shows the proteins for the selected project.
		 * @param elem The clicked element (representing a project).
		 */
		showProteinsForProject: function(elem){
			if(elem.closest(".treeitem")){ elem=elem.closest(".treeitem"); }
			let proj=elem.record;
			new AjaxUtils.Request('/api/project/'+proj.id+'/protein?all=1',{
				method:'get',
				onSuccess:function(transport){
					if(!AjaxUtils.checkResponse(transport)){ return false; }
					elem.querySelector(".treebody").innerHTML='';
					transport.responseJSON.rows.forEach(function(p){
						elem.treeItem({
							record:p,
							id:"protein"+p.id,
							header:p.name+" ("+p.proteinacronym+"): "+p.description+"",
							updater:Plate.showConstructsForProtein
						});
					});
				},
				onFailure:function(transport){
					if(404===transport.status){
						elem.querySelector(".treebody").innerHTML='<label style="text-align:left">No proteins in this project</label>';
						return;
					}
					AjaxUtils.checkResponse(transport);
				},
			});
		},

		/**
		 * Within a tree list of projects and proteins, shows the constructs for the selected protein.
		 * @param elem The clicked element (representing a protein).
		 */
		showConstructsForProtein: function(elem){
			if(elem.closest(".treeitem")){ elem=elem.closest(".treeitem"); }
			let protein=elem.record;
			new AjaxUtils.Request('/api/protein/'+protein.id+'/construct?all=1',{
				method:'get',
				onSuccess:function(transport){
					if(!AjaxUtils.checkResponse(transport)){ return false; }
					elem.querySelector(".treebody").innerHTML='';
					transport.responseJSON.rows.forEach(function(c){
						let lbl=document.createElement("label");
						lbl.className="treeitem";
						lbl.record=c;
						lbl.innerHTML='<input type="button" value="Use this construct" onclick="Plate.setConstruct(this)" style="cursor:pointer" /> '+c.name+": "+c.description;
						lbl.style.textAlign="left";
						lbl.style.padding="0.5em 0.25em";
						elem.querySelector(".treebody").appendChild(lbl);
					});
				},
				onFailure:function(transport){
					if(404===transport.status){
						elem.querySelector(".treebody").innerHTML="No constructs for this protein";
						return;
					}
					AjaxUtils.checkResponse(transport);
				},
			});
		},

		/**
		 * Sets the chosen construct on the relevant protein drops within the plate.
		 * @param btn The clicked button.
		 */
		setConstruct: function(btn){
			let construct=btn.closest(".treeitem").record;
			let mb=document.getElementById("modalBox");
			let frm=mb.launcherForm || mb.querySelector(".boxbody").launcherForm;
			btn.closest(".treeitem").classList.add("updating");
			new AjaxUtils.Request("/api/plate/"+data.id, {
				method:'patch',
				parameters:{
					csrfToken:csrfToken,
					constructid:construct.id,
					selectionDrop:frm.selectionDrop,
					selectionTop:frm.selectionTop,
					selectionBottom:frm.selectionBottom,
					selectionLeft:frm.selectionLeft,
					selectionRight:frm.selectionRight,
				},
				onSuccess:Plate.reloadPageAfterUpdate,
				onFailure:function(transport){ 
					AjaxUtils.checkResponse(transport);
				},
			});
		},

		reloadPageAfterUpdate:function(transport){
			AjaxUtils.checkResponse(transport);
			//Reload the page with a unique timestamp, to bypass caching
			document.location.replace('/plate/'+data.id+'?ts=?'+Date.now()+"#"+document.getElementById("grid").querySelector(".tabset").querySelector(".current").id);
		},
		
		getWellDrops:function(){
			new AjaxUtils.Request("/api/plate/"+data.id+"/welldrop",{
				method:'get',
				onSuccess:Plate.getWellDrops_onSuccess,
				onFailure:Plate.getWellDrops_onFailure,
			});
		},
		getWellDrops_onSuccess:function(transport){
			if(!AjaxUtils.checkResponse(transport)){ return false; }
			data.welldrops=transport.responseJSON.rows;
		},
		getWellDrops_onFailure:function(transport){ 
			return AjaxUtils.checkResponse(transport);
		},

		/**
		 * Copies protein/construct ID, protein buffer, and protein concentration from one drop position to another.
		 * For example, ALL drops in position 1 (A01.1, A02.1, ...H12.1) have the same protein, etc.; after copying
		 * to drop 2, ALL drops in position 2 (A01.2, A02.2, ...H12.2) also have this protein, etc.
		 * @param fromDrop The drop position number to copy from
		 * @param toDrop The drop position number to copy to
		 */
		copyProteinDetailsAcrossDropPositions:function(fromDrop, toDrop){
			let fromForm=null;
			let toForm=null;
			document.getElementById("proteins_body").querySelectorAll("td.dropmapwell").forEach(function(d){
				if(1*d.dataset.dropnumber===1*fromDrop){
					fromForm=d.querySelector("form");
				} else if(1*d.dataset.dropnumber===1*toDrop){
					toForm=d.querySelector("form");
				} 
			});
			if(!fromForm || !toForm){
				alert("Could not find source and destination drops");
				return false;
			}
			let constructId=fromForm.dataset.constructid;
			let buffer=fromForm.querySelector("[name=proteinbuffer]").value.trim();
			let concAmount=fromForm.querySelector("[name=proteinconcentrationamount]").value.trim();
			let concUnit=fromForm.querySelector("[name=proteinconcentrationunit]").value.trim();
			toForm.closest("td").classList.add("updating");
			toForm.dataset.constructid=constructId;
			let postBody="csrfToken="+csrfToken+
				"&selectionTop="+toForm.selectionTop+
				"&selectionBottom="+toForm.selectionBottom+
				"&selectionLeft="+toForm.selectionLeft+
				"&selectionRight="+toForm.selectionRight+
				"&selectionDrop="+toForm.selectionDrop+
				"&constructid="+constructId;
			if(""!==buffer){ postBody+="&proteinbuffer="+buffer; }
			if(""!==concAmount){ postBody+="&proteinconcentrationamount="+concAmount; }
			if(""!==concUnit){ postBody+="&proteinconcentrationunit="+concUnit; }
			new AjaxUtils.Request('/api/plate/'+data.id, {
				method:'patch',
				postBody:postBody,
				onSuccess:Plate.reloadPageAfterUpdate,
				onFailure:function(transport){ Plate.copyProteinDetailsAcrossDropPositions_onFailure(transport, toForm); }
			});
		},
		copyProteinDetailsAcrossDropPositions_onFailure:function(transport,toForm){
			toForm.closest("td").classList.remove("updating");
			AjaxUtils.checkResponse(transport);
		},
		
		updateProteinConcentrationOrBuffer:function(evt){
			let field=evt.target;
			window.clearTimeout(submitTimer);
			if(!validator.validate(field)){ return false; }
			if(field.closest("label,td")){ field.closest("label,td").classList.remove("invalidfield"); }
			let delay=1500;
			if("blur"===evt.type){ delay=10; }
			window.submitTimer=setTimeout(function(){ Plate.doUpdateProteinConcentrationOrBuffer(field) },delay);
		},
		doUpdateProteinConcentrationOrBuffer:function(field){
			if(field.closest(".updating")){
				field.style.border="1px solid red";
				return false;
			}
			field.closest("label").classList.add("updating");
			let f=field.closest("form");
			new AjaxUtils.Request('/api/plate/'+data.id, {
				method:'patch',
				postBody:"csrfToken="+csrfToken+
					"&selectionTop="+f.selectionTop+
					"&selectionBottom="+f.selectionBottom+
					"&selectionLeft="+f.selectionLeft+
					"&selectionRight="+f.selectionRight+
					"&selectionDrop="+f.selectionDrop+
					"&"+field.name+"="+field.value, //Defaults on server to all drops in all wells
				onSuccess:function(transport){ Plate.updateProteinConcentrationOrBuffer_onSuccess(transport, f); },
				onFailure:function(transport){ Plate.updateProteinConcentrationOrBuffer_onFailure(transport, f); }
			});
		},
		updateProteinConcentrationOrBuffer_onSuccess:function(transport, frm){
			if(!AjaxUtils.checkResponse(transport)){ return false; }
			if(!transport.responseJSON || !transport.responseJSON["updated"] || !transport.responseJSON["updated"]["updateddrops"]){
				return AjaxUtils.checkResponse(transport);
			}
			let updated=transport.responseJSON["updated"]["updateddrops"][0];
			Plate.updateWellDropsByDropNumberAfterChange(updated.dropnumber,{
				'proteinbuffer':updated.proteinbuffer,
				'proteinconcentrationamount':updated.proteinconcentrationamount,
				'proteinconcentrationunit':updated.proteinconcentrationunit
			});
			frm.querySelector('[name=proteinbuffer]').closest("label").classList.remove("updating");
			frm.querySelector('[name=proteinconcentrationamount]').closest("label").classList.remove("updating");
			Plate.setProteinTabWarning(frm);
		},
		updateProteinConcentrationOrBuffer_onFailure:function(transport){
			return AjaxUtils.checkResponse(transport);
		},

		/**
		 * Updates any data.welldrops with the specified drop number, setting the new values supplied.
		 * @param dropNum The drop position number, or -1 for all drops
		 * @param newValues Key-value pairs to set on each of the chosen drops
		 */
		updateWellDropsByDropNumberAfterChange:function(dropNum, newValues){
			dropNum=1*dropNum;
			data.welldrops.forEach(function(drop){
				if(parseInt(drop.dropnumber)!==dropNum && -1!==dropNum){ return; }
				Object.keys(newValues).forEach(function(k){
					drop[k]=newValues[k];
				});
			});
		},
		
		getConstructs: function(){
			new AjaxUtils.Request("/api/plate/"+data.id+'/construct', {
				method:'get',
				onSuccess:function(transport){ 
					AjaxUtils.checkResponse(transport);
					Plate.constructs=transport.responseJSON.rows;
				},
				onFailure:function(transport){ 
					if(404===transport.status){
						Plate.constructs=[];
						return;
					}
					AjaxUtils.checkResponse(transport);
				},
			});
		},
		
	  	/*
	  	 * SCREEN tab
	  	 */
		renderScreenTab: function(){
			let tab=document.getElementById("screens_body");
			tab.unwarn();
			tab.dataset.apiurl='/api/screen/'+data.screenid+'/screencondition';
			if('Default Project'===data["projectname"]){
				if(canEdit){
					tab.innerHTML='No screen has been chosen for this plate. Set the protein first';
				} else {
					tab.innerHTML='No screen has been chosen for this plate.';
				}
				tab.warn("No screen information has been provided");
				return false;
			}
			if(""===data.screenid){
				tab.warn("No screen information has been provided");
				tab.innerHTML='';
				if(canEdit){
					let ss=document.createElement("div");
					ss.style.cssFloat="left";
					ss.style.width="49%";
					let sf=tab.form({ action:'/api/screen/', method:'post', id:'standardscreenform' });
					sf.hiddenField("name","Optimization "+data.name);
					sf.hiddenField("projectid", data.projectid);
					sf.hiddenField("rows", "");
					sf.hiddenField("cols", "");
					sf.formField({ label:'<h3>Choose a standard screen</h3>',content:'&nbsp;' });
					sf.formField({ label:'Standard screen',content:'<input type="button" value="Choose..." onclick="Plate.beginSetScreen(Plate.afterSetScreen)"/>' });
					ss.appendChild(sf);
					tab.appendChild(ss);

					let os=document.createElement("div");
					let of=tab.form({ action:'/api/screen/', method:'post', id:'optimizationscreenform' });
					of.style.cssFloat="right";
					of.style.width="49%";
					of.style.marginBottom="1.5em";
					of.formField({ label:'<h3>Upload an optimization screen file</h3>',content:'&nbsp;' });
					of.formField({ label:'',content:'<input type="file" name="file" onchange="Plate.uploadScreen()" id="createscreenbtn"/>' });
					of.hiddenField("name", "Optimization "+data.name);
					of.hiddenField("projectid", data.projectid);
					of.hiddenField("plateid", data.id);
					of.hiddenField("rows", "");
					of.hiddenField("cols", "");
					os.appendChild(of);
					tab.appendChild(os);

					tab.innerHTML+='<div class="tabhelp">'+
					'<p style="clear:both">You can either (i) use a standard screen or (ii) upload details of an optimization screen. In both cases, the screen will be attached to your plate as a file.</p>'+
					'<h3 style="">Standard screens:</h3>'+
					'<p>If your plate uses a standard screen, simply click the <strong>Choose...</strong> button above and choose your screen from the list.</p>'+
					'<p>To add more standard screens to IceBear, talk to your administrator.</p>'+
					'<h3 style="clear:both;margin-top:1.5em">Optimization screens:</h3>'+
					'<p>You can upload a file describing your optimization screen. Select your file in "Upload an optimization screen file" above, and it will be uploaded and attached to your plate automatically.</p>'+
					'<p>IceBear understands the following screen file formats:</p>'+
					'<ul style="padding-left:1.5em">'+
					'<li><strong>Two-column CSV:</strong> A simple file with well number and description. You can use this <a href="/resources/WellAndCondition.csv">Template CSV file</a>;</li>'+
					'<li><strong>Mimer:</strong> The output from Mimer*, saved as CSV (not Excel format);</li>'+
					'<li><strong>Rock Maker XML:</strong> A screen XML file in Rock Maker XML format.</li>'+
					'<li><strong>Tecan CrysScreen:</strong> The Microsoft Word document generated by the Tecan robot can be uploaded directly into IceBear; or</li>'+
					"<li><strong>Rigaku CrystalTrak:</strong> A plate or screen XML file generated by CrystalTrak. If IceBear is configured to import from your Rigaku imagers, these files should be detected automatically; you shouldn't need to upload them here.</li>"+
					'</ul>'+
					'<p>You can upload other files, but IceBear won\'t understand them. Instead, the file will be attached and each condition will read "See screen definition file."</p>'+
					'<hr style="clear:both;margin-top:1.5em;margin-bottom:1.5em"/>'+
					'<p><span style="font-size:80%">* Mimer: an automated spreadsheet-based crystallization screening system. Brodersen DE, Andersen GR, Andersen CB<br/>'+
						'<span style="color:transparent">* </span>Acta Cryst F, 2013 (69) pp815-820. <a target="_blank" href="https://www.ncbi.nlm.nih.gov/pubmed/?term=PMID%3A+23832216">PubMed PMID:23832216</a></span></p>'+
					'</div>';
				}
			} else {
				new AjaxUtils.Request('/api/screen/'+data.screenid, {
					method:'get',
					onSuccess:function(transport){
						if(!AjaxUtils.checkResponse(transport)){ return false; }
						new AjaxUtils.Request(tab.dataset.apiurl,{
							method:'get',
							onSuccess:function(transport){
								if(!AjaxUtils.checkResponse(transport)){ return false; }
								let screen=transport.responseJSON.screen;
								data.screen=screen;
								data.screen.conditions=transport.responseJSON.rows;
								let contentBefore='<div style="text-align:right">'+
									'<span style="float:left"><a href="/screen/'+screen.id+'">'+screen.name+'</a>';
									if(1*screen["isstandard"]){
										contentBefore+=' (Standard screen)';
									} else {
										contentBefore+=' (Optimization screen)';
									}
									contentBefore+='</span>';
									if(canEdit){
										contentBefore+='<input type="button" value="Remove screen from plate" onclick="Plate.unsetScreen()" />';
									}
									contentBefore+='</div>';
								let headers=['Well','Description'];
								let cellTemplates=[ [Plate.getWellNameFromObject, 'row'],'{{description}}'];
								document.getElementById("screens_body").table({
									'headers':headers,
									'cellTemplates':cellTemplates,
									'contentBefore':contentBefore
								}, transport.responseJSON);
							},
							onFailure:AjaxUtils.checkResponse
						});
					}, 
					onFailure:AjaxUtils.checkResponse
				});			
			}
			window.setTimeout(function(){
				let f=document.getElementById("files");
				if(f){
					f.refresh();
				}
			},100);
		},

		screenListUrl:"/api/screen/isstandard/1/isavailable/1",
		beginSetScreen: function(handler){
			new AjaxUtils.Request(Plate.screenListUrl+'?pagesize=25',{
				method:'get',
				onSuccess:function(transport){
					if(!AjaxUtils.checkResponse(transport)){ return false; }
					Plate.showScreenList(transport.responseJSON, handler);
				},
				onFailure:function(transport){
					AjaxUtils.checkResponse(transport);
				},
			});

		},
		showScreenList: function(response, handler){
			let mb=ui.modalBox({
				'title':'Choose the screen',
				'content':'Just a moment...'
			});
			Plate.doAfter=handler;
			mb.dataset.apiurl=Plate.screenListUrl;
			mb.dataset.pagenumber="1";
			mb.dataset.pagesize="25";
			mb.table({
				'headers':['Name','Manufacturer','Catalogue Number',''],
				'cellTemplates':['{{name}}','{{manufacturer}}','{{catalognumber}}','<input type="button" value="Choose" onclick="Plate.chooseStandardScreen(this)">']
			}, response);		
		},
		chooseStandardScreen: function(btn){
			btn.closest("tr").classList.add("updating");
			let screen=btn.closest("tr").rowData;
			Plate.setScreen(screen.id);
		},
		uploadScreen: function(){
			let frm=document.getElementById("optimizationscreenform");
			frm.rows.value=data.rows;
			frm.cols.value=data.cols;
			if(0===frm.file.files.length){ alert('Choose a screen description file to upload'); return false; }
			frm.file.options={ afterSuccess:Plate.setUploadedScreen };
			ui.submitForm(frm.file);
		},
		setUploadedScreen: function(responseJSON){
			let screen=responseJSON.created;
			Plate.setScreen(screen.id);
		},
		
		setScreen: function(screenId){
			new AjaxUtils.Request('/api/plate/'+data.id,{
				method:'patch',
				parameters:{
					csrfToken:csrfToken,
					screenid:screenId
				},
				onSuccess:function(transport){
					if(!AjaxUtils.checkResponse(transport)){ return false; }
					Plate.afterSetScreen(transport);
				},
				onFailure:function(transport){
					AjaxUtils.checkResponse(transport);
				},
			});
		},
		afterSetScreen: function(transport){
			let screen=transport.responseJSON["updated"];
			document.getElementById("files").relatedObjectIds=screen["screenid"];
			data["screenid"]=screen["screenid"];
			window.setTimeout(function(){
				Plate.renderScreenTab();
				document.getElementById("files_body").refresh();
			}, 50);
			ui.closeModalBox();
		},

		unsetScreen: function(){
			let msg;
			let isStandard=1*data.screen["isstandard"];
			if(isStandard){ 
				msg='Unset standard screen "'+data.screen.name+'"?\n\nThis will not delete the screen.'
			} else {
				//split because "Delete ... from" looks like SQL to IDE
				msg="Delete the optimization screen ";
				msg+="from this plate?\n\nThe screen and its definition files will be deleted."
			}
			if(!confirm(msg)){ return false; }
			new AjaxUtils.Request('/api/plate/'+data.id,{
				method:'patch',
				parameters:{
					csrfToken:csrfToken,
					screenid:"NULL"
				},
				onSuccess:function(transport){
					if(!AjaxUtils.checkResponse(transport)){ return false; }
					Plate.afterUnsetScreen(transport);
				},
				onFailure:function(transport){
					AjaxUtils.checkResponse(transport);
				},
			});
		},
		afterUnsetScreen: function(){
			let isStandard=1*data.screen["isstandard"];
			document.getElementById("files").relatedObjectIds=null;
			if(!isStandard){
				new AjaxUtils.Request('/api/screen/'+data.screen.id,{
					method:'delete',
					parameters:{
						csrfToken:csrfToken,
					},
					onFailure:function(transport){
						AjaxUtils.checkResponse(transport);
					},
				});
			}
			data.screenid="";
			data.screen=null;
			window.setTimeout(function(){
				Plate.renderScreenTab();
				document.getElementById("files_body").refresh();
			}, 50);
		},

	 	/*
	 	 * DROP CONDITIONS tab
	 	 */
		renderDropConditionsTab:function(){
			/*
			 * First implementation, assumes same across entire plate
			 */
			let db=document.getElementById("drops_body");
			if(!data.plateType || !data.welldrops){
				db.innerHTML="Just a moment...";
				window.setTimeout(Plate.renderDropConditionsTab,100);
				return;
			}
			let units=['uL','nL'];
			let unitsOptions='';
			units.forEach(function(u){
				unitsOptions+='<option value="'+u+'">'+u.replace("u","&#181;")+'</option>';
			});

			db.innerHTML="";
			let dropMap=PlateType.getDropMapElement(data.plateType, db);
			dropMap.style.height="100%";
			dropMap.style.width=dropMap.offsetHeight+"px";
			let drops=dropMap.querySelectorAll(".dropmapwell");
			drops.forEach(function(drop){
				drop.style.textAlign="center";
				let dropNumber=parseInt(drop.dataset.dropnumber);
				let proteinAmount="";
				let proteinUnit="uL";
				let wellAmount="";
				let wellUnit="uL";

				for(let i=0;i<data.welldrops.length;i++){
					let wd=data.welldrops[i];
					if(parseInt(wd.dropnumber)===dropNumber){
						proteinAmount=wd["proteinsolutionamount"];
						proteinUnit=wd["proteinsolutionunit"];
						wellAmount=wd["wellsolutionamount"];
						wellUnit=wd["wellsolutionunit"];
						break;
					}
				}
				
				if(canEdit){
					let out="<h3>Well solution:</h3>";
					out+='<div style="display:inline-block;width:6.25em;border:1px solid #666;border-radius:0.25em">';
					out+='<input type="text" style="width:2em;height:1.5em" name="wellsolutionamount" value="'+wellAmount+'" />';
					out+='<select style="border:none;width:3em;height:1.8em" name="wellsolutionunit">'+unitsOptions.replace('"'+wellUnit+'"','"'+wellUnit+'" selected="selected"')+'</select>';
					out+='</div>';
					out+='<hr style="margin:0.5em"/>';
					out+='<h3>Protein solution:</h3>';
					out+='<div style="display:inline-block;width:6.25em;border:1px solid #666;border-radius:0.25em">';
					out+='<input type="text" style="width:2em;height:1.5em" name="proteinsolutionamount" value="'+proteinAmount+'" />';
					out+='<select style="border:none;width:3em;height:1.8em" name="proteinsolutionunit">'+unitsOptions.replace('"'+proteinUnit+'"','"'+proteinUnit+'" selected="selected"')+'</select>';
					out+='</div>';
					drop.innerHTML+=out;
					drop.dataset.oldValue="";
					drop.querySelectorAll("input,select").forEach(function(elem){
						elem.addEventListener("keyup",Plate.updateWellSolutions);
						elem.addEventListener("change",Plate.updateWellSolutions);
						elem.addEventListener("blur",Plate.updateWellSolutions);
						drop.dataset.oldValue+=elem.value;
					});
				} else {
					drop.innerHTML+='<h3>Protein solution:</h3>';
					let proteinContent=proteinAmount+proteinUnit.replace("u","&#181;");
					if(""===proteinAmount || ""===proteinUnit){ proteinContent="(Not set)"; }
					drop.innerHTML+=proteinContent;
					drop.innerHTML+='<hr style="margin:0.5em"/>';
					drop.innerHTML+="<h3>Well solution:</h3>";
					let wellContent=wellAmount+wellUnit.replace("u","&#181;");
					if(""===wellAmount || ""===wellUnit){ wellContent="(Not set)"; }
					drop.innerHTML+=wellContent;
				}
			});
			Plate.setDropConditionsTabWarningStatus();
		},

		updateWellSolutions:function(evt){
			let drop=evt.target.closest("td");
			if(drop.classList.contains("updating")){
				window.setTimeout(Plate.updateWellSolutions, 250, evt);
				return;
			}
			let dropValue="";
			let fields=drop.querySelectorAll("input,select");
			let endsInDecimal=false;
			fields.forEach(function (field){
				dropValue+=""+field.value;
				if(field.tagName.toLowerCase()==="input"){
					field.value=field.value.replaceAll(/[^\d.]/g, '');
					if(field.value.endsWith(".")){
						endsInDecimal=true;
					}
				}
			});
			if(endsInDecimal || dropValue===drop.dataset.oldValue){ return false; }
			drop.classList.add("updating");
			drop.dataset.oldValue=dropValue;
			let delay=1500;
			if("blur"===evt.type){ delay=50; }
			setTimeout(function(){ Plate.doUpdateWellSolutions(drop) },delay);
		},

		doUpdateWellSolutions:function(drop){
			/*
			 * First implementation, assumes solutions are same for all drops in same position for whole plate
			 */
			let fields=drop.querySelectorAll("input,select");
			let parameters={ csrfToken:csrfToken};
			fields.forEach(function (field){
				parameters[field.name]=field.value;
			});

			parameters['selectionDrop']=drop.dataset.dropnumber;
			new AjaxUtils.Request("/api/plate/"+data.id,{
				method:"patch",
				parameters:parameters,
				onSuccess:function(transport){
					drop.classList.remove("updating");
					AjaxUtils.checkResponse(transport);
					Plate.setDropConditionsTabWarningStatus();
				},
				onFailure:function(transport){
					drop.classList.remove("updating");
					AjaxUtils.checkResponse(transport);
					Plate.setDropConditionsTabWarningStatus();
				}
			});
		},

		setDropConditionsTabWarningStatus:function (){
			let tab=document.getElementById("drops_body");
			if(!tab){ return false; }
			let inputs=tab.querySelectorAll("input[type=text]");
			tab.unwarn();
			inputs.forEach(function (inp){
				if(""===inp.value.trim()){
					tab.warn("Conditions not set for all drop positions");
				}
			});
		},

	renderSeedingTab:function(){
		let tb=document.getElementById("seeding_body");
		if(!data["plateType"] || !data["welldrops"]){
			tb.innerHTML="Just a moment...";
			window.setTimeout(Plate.renderSeedingTab,100);
			return;
		}
		tb.innerHTML="";
		let dropMap=PlateType.getDropMapElement(data["plateType"], tb);
		dropMap.style.height="100%";
		dropMap.style.width=dropMap.offsetHeight+"px";
		//let drops=dropMap.querySelectorAll(".dropmapwell");
		let drop1=dropMap.querySelector('[data-dropnumber="1"]');
		let drop2=dropMap.querySelector('[data-dropnumber="2"]');
		let drop3=dropMap.querySelector('[data-dropnumber="3"]');
		drop1.innerHTML+='<form>' +
			//TO DO Remove seeding
			'<label style="text-align:center">Seeded from <a href="#">9098</a><br/>well <a href="#">A03 drop 2</a><br/><input type="button" value="Change..." style="float:none;color:white;background-color:black"/></label>' +
			'<label style="text-align:center">Volume<input type="text" value="20" style="float:none;"/>nl</label>' +
			'<label style="text-align:center">Copy seeding from<br/><input type="button" value="Drop 2" style="float:none;color:white;background-color:black"/>&nbsp;<input type="button" value="Drop 3" style="float:none;color:white;background-color:black"/></label>' +
			'</form>';
		drop2.innerHTML+='<form>' +
			'<label style="text-align:center">Seeded from <a href="#">9098</a><br/>well <a href="#">A03 drop 2</a><br/><input type="button" value="Change..." style="float:none;color:white;background-color:black"/></label>' +
			'<label style="text-align:center">Volume<input type="text" value="20" style="float:none;"/>nl</label>' +
			'<label style="text-align:center">Copy seeding from<br/><input type="button" value="Drop 1" style="float:none;color:white;background-color:black"/>&nbsp;<input type="button" value="Drop 3" style="float:none;color:white;background-color:black"/></label>' +
			'</form>';
		drop3.innerHTML+='<form>' +
			'<label style="text-align:center">Not seeded<br/><input type="button" value="Choose seed plate and drop..." style="float:none;color:white;background-color:black"/></label>' +
			'<label style="text-align:center">Copy seeding from<br/><input type="button" value="Drop 1" style="float:none;color:white;background-color:black"/>&nbsp;<input type="button" value="Drop 2" style="float:none;color:white;background-color:black"/></label>' +
			'</form>';

	},

	getPlateType:function(){
			new AjaxUtils.Request('/api/platetype/'+data["platetypeid"],{
				method:"get",
				onSuccess:Plate.getPlateType_onSuccess,
				onFailure:Plate.getPlateType_onFailure,
			});
		},
		getPlateType_onSuccess:function(transport){
			if(!AjaxUtils.checkResponse(transport)){ return false; }
			data.plateType=transport.responseJSON;
		},
		getPlateType_onFailure:function(transport){
			AjaxUtils.checkResponse(transport);
		},

		
		/*
		 * INSPECTIONS tab of plate view
		 */
		writeInspectionLink: function(obj,field){
			return ImagingSession.getLink(obj,field);
		},
		
		/*
		 * Best score in, e.g., Project's plates tab
		 */
		renderPlateBestScoreCell:function(item){
			if(""===item["bestscorelabel"]){ return '-'; }
			return '<span style="float:right;width:20px;background-color:#'+item["bestscorecolor"]+'">&nbsp;</span>'+item["bestscorelabel"];
		},
		
};



let PlateType={
		
		getDropMapElement:function(plateType, parent){
			let t=document.createElement("table");
			t.style.position="relative";
			t.style.top="0";
			t.className="dropmap";
			let dropMap=plateType["dropmapping"].split(",");
			let rows=dropMap.length;
			let rowHeightPercent=100/rows;
			dropMap.forEach(function(row){
				let tr=document.createElement("tr");
				let cols=row.length;
				let colWidthPercent=100/cols;
				for(let c=0;c<row.length;c++){
					let td=document.createElement("td");
					td.style.height=rowHeightPercent+"%";
					td.style.maxHeight=rowHeightPercent+"%";
					td.style.width=colWidthPercent+"%";
					td.style.maxWidth=colWidthPercent+"%";
					td.style.overflowY="auto";
					let content=row[c];
					if(parseInt(content)){ 
						td.className="dropmapwell";
						td.dataset.dropnumber=content;
						let lbl=document.createElement("div");
						lbl.className="welllabel";
						lbl.innerHTML=content;
						td.appendChild(lbl);
					} else if("R"===content){
						//Reservoir
						td.className="dropmapreservoir";
					} else if("E"===content){
						//Empty well
						td.className="dropmapempty";
					} else if("X"===content){
						//Dead space on the plate
						td.className="dropmapdead";
					}
					tr.appendChild(td);
				}
				t.appendChild(tr);
			});
			if(null!=parent){
				parent.appendChild(t);
				window.setTimeout(function(){
					t.style.width=parent.offsetHeight+"px";
				},50);
			}
			return t;
		}
		
};


let ImagingSession={
	
		getLink: function(obj,field){
			let lighttype=obj["lighttype"];
			let label=obj[field];
			if('imageddatetime'===field){ label=ui.friendlyDate(label); }
			return ImagingSession.getLightPathIcon(lighttype)+'&nbsp;<a href="/imagingsession/{{id}}">'+label+'</a>';
		},
	
		getLightPathIcon: function(lightTypeName){
			let types=['Visible','UV'];
			let offset=60*Math.max(0, types.indexOf(lightTypeName));
			return '<span class="lightbulb" style="background-position:0 -'+offset+'px" title="'+lightTypeName+'">&nbsp;</span>';
		}
			
};


let CrystallizationScreen={ //don't call it Screen - almost guaranteed to conflict with some UI thing or other

	getConditionCell:function(obj,field){
		if("1"===data["isstandard"] && canEdit){
			let out='<form action="/api/screencondition/'+obj.id+'/" method="pa'; //split to suppress warning
			return out+'tch"><input type="text" name="description" value="'+ obj["description"] +'" /></form>';
		}
		return obj[field];
	},

	adjustConditionsTableForEdit:function(){
		let cbt=document.getElementById("conditions_body_table");
		if(!cbt){
			window.setTimeout(CrystallizationScreen.adjustConditionsTableForEdit,50);
			return;
		}
		if(cbt.adjustedForEdit){
			return;
		}
		let cells=document.querySelectorAll('#conditions_body_table td+td');
		cells.forEach(function(c){
			c.style.width="85%";
			let inp=c.querySelector("input");
			if(inp){
				inp.style.width="99%";
				inp.addEventListener("keyup", function(){ ui.updateFormField(inp)});
				inp.dataset.oldvalue=inp.value;
			}
		});
		cbt.adjustedForEdit=true;
	},

	delete:function (evt){
		if("screen"!==data["objecttype"] || !parseInt(data["isstandard"]) || (!isAdmin && !isTechnician)){
			return false;
		}
		if(!confirm("Really delete this screen? This action cannot be undone.")){
			return false;
		}
		evt.target.closest("label").classList.add("updating");
		new AjaxUtils.Request("/api/screen/"+data["id"],{
			method:"delete",
			parameters:{ csrfToken:csrfToken },
			onSuccess:CrystallizationScreen.delete_onSuccess,
			onFailure:CrystallizationScreen.delete_onFailure
		});
	},
	delete_onSuccess:function (transport){
		if(!transport.responseJSON || !transport.responseJSON["deleted"]){
			return CrystallizationScreen.delete_onFailure(transport);
		}
		document.location.href="/screen/";
	},
	delete_onFailure:function (){
		document.getElementById("actions").querySelectorAll(".updating").forEach(function(elem){
			elem.classList.remove("updating");
			alert("Could not delete the screen.");
		});
	},

	toggleAvailability:function (evt){
		if("screen"!==data["objecttype"] || !parseInt(data["isstandard"]) || (!isAdmin && !isTechnician)){
			return false;
		}
		evt.target.closest("label").classList.add("updating");
		new AjaxUtils.Request("/api/screen/"+data["id"],{
			method:"patch",
			parameters:{
				"csrfToken":csrfToken,
				"isavailable":1*!parseInt(data["isavailable"])
			},
			onSuccess:function (transport){
				data["isavailable"]=transport.responseJSON["updated"]["isavailable"];
				CrystallizationScreen.setUnavailableWarningVisibility();
				CrystallizationScreen.renderActionsBox();
			},
			onFailure:function (){
				document.getElementById("actions").querySelectorAll(".updating").forEach(function(elem){
					elem.classList.remove("updating");
					alert("Could not change the availability.");
				});
			}
		});
	},

	setUnavailableWarningVisibility:function (){
		let bar=document.getElementById("unavailablewarning");
		bar.style.display= (1===parseInt(data["isavailable"])) ? "none" : "";
	},

	renderActionsBox:function (){
		if((isAdmin || isTechnician) && parseInt(data["isstandard"])){
			if(document.getElementById("actions")) {
				document.getElementById("actions").remove();
			}
			let actionsBox=grid.box({
				id:"actions",
				classes:'r3 c1 w1 h1',
				title:'Actions'
			});
			let actionsForm=actionsBox.form({
				action:'/api/screen/'+data.id,
				method:'patch'
			});
			if(1===parseInt(data["isavailable"])){
				actionsForm.buttonField({
					label:"Withdraw from use",
					helpText:"Make this screen unavailable for use",
					onclick:CrystallizationScreen.toggleAvailability
				});
			} else {
				actionsForm.buttonField({
					label:"Make available for use",
					helpText:"Make this screen available for use",
					onclick:CrystallizationScreen.toggleAvailability
				});
			}
		}
	},

	getRockMakerLibraryScreens:function (parentElement){
		if(parentElement.querySelector(".boxbody")){
			parentElement=parentElement.querySelector(".boxbody");
		}
		new AjaxUtils.request('/api/screenfromrockmaker', {
			method: 'get',
			onSuccess: function (transport) {
				if(!transport.responseJSON || !transport.responseJSON['rows']){
					return CrystallizationScreen.handleRockMakerScreensFailure(transport, parentElement);
				}
				parentElement.innerHTML='<p>Formulatrix maintain a <a target="_blank" href="https://formulatrix.com/protein-crystallization-systems/rock-maker-crystallization-software/#pp-accord-5f1ef46879a47-1">library of standard screens</a>. IceBear can install many of ' +
					'these screens automatically.</p><p>IceBear needs the screen definition to have 96 conditions, and ' +
					'assumes that these are in a 12x8 layout.</p>';
				transport.responseJSON['rows'].forEach(function (manufacturer){
					let ti=parentElement.treeItem({
						"header":manufacturer["manufacturer"],
						"content":"Loading screens...",
					});
					let tb=ti.querySelector(".treebody");
					if(manufacturer["url"]) {
						tb.innerHTML='<p>You can find definitions in Rock Maker XML format for '+manufacturer["manufacturer"]+
							' screens from <a target=_blank" href="'+manufacturer["url"]+'">their website</a>.</p>'+
							'<p>Download the XML to your computer, and upload it into IceBear using the "Create '+
							'screen from file" form.</p>';
						return;
					}
					if(!manufacturer["screens"]) {
						CrystallizationScreen.handleRockMakerScreensFailure(parentElement);
					}
						let cellTemplates=[];
						if(manufacturer["screens"][0]["catalognumber"]){
							cellTemplates.push("{{catalognumber}}");
						}
						cellTemplates.push("{{name}}");
						cellTemplates.push([CrystallizationScreen.getRockMakerScreenInstallButton, "installed"]);

						tb.table({
							cellTemplates:cellTemplates
						}, manufacturer["screens"]);
				});
			},
			onFailure: function (transport) {
				CrystallizationScreen.handleRockMakerScreensFailure(transport, parentElement);
			}
		});
	},

	handleRockMakerScreensFailure:function (transport,parentElement){
		parentElement.innerHTML='';
		parentElement.innerHTML='<p>IceBear could not understand the screen information on the Formulatrix&reg; website. Perhaps the page has changed.</p>'+
			'<p>You can probably download the file from the <a target="_blank" href="https://formulatrix.com/protein-crystallization-systems/rock-maker-crystallization-software/#">Screen'+
			' Library</a> manually, unzip it, and upload the XML into IceBear using the "Create from file" option.</p>';
	},

	getRockMakerScreenInstallButton:function (obj){
		let isInstalled=1*obj["installed"];
		let action=CrystallizationScreen.installRockMakerScreen.name;
		let label="Install";
		if(isInstalled){
			return('<a href="/screen/'+obj['id']+'">Installed</a>');
		}
		return '<input type="button" onclick="CrystallizationScreen.'+action+'(this)" value="'+label+'" />';
	},

	installRockMakerScreen:function(btn) {
		let tr = btn.closest("tr");
		window.setTimeout(function() {
			tr.classList.add("updating");
		},10);
		btn.disabled=true;
		window.setTimeout(function(){
			CrystallizationScreen._doInstallRockMakerScreen(tr);
		},250);
	},
	_doInstallRockMakerScreen:function(tr){
		let screen = tr.rowData;
		screen.csrfToken=csrfToken;
		new AjaxUtils.Request("/api/screenfromrockmaker", {
			method: "post",
			parameters: screen,
			onSuccess: function (transport) {
				let screenId = transport.responseJSON["created"]["id"];
				tr.classList.remove("updating");
				tr.querySelector("input").closest("td").innerHTML = '<a href="/screen/' + screenId + '">Installed</a>';
			},
			onFailure: function (transport) {
				tr.classList.remove("updating");
				tr.querySelector("input").closest("td").innerHTML = 'Can\'t install.<br/><a href="'+tr.rowData["path"]+'">Download</a> instead.';
				if (transport.responseJSON && transport.responseJSON["error"]) {
					alert(transport.responseJSON["error"]);
				} else {
					alert("Something went wrong. HTTP status: ".transport.status);
				}

			},
		});
	},
};

let Container = {

	create: function (name, containerTypeId, successCallback) {
		if ("" === name.trim() || "" === containerTypeId.trim()) {
			alert("Name and container type ID are required");
			return false;
		} else if (!parseInt(containerTypeId)) {
			alert("Container type ID must be a number");
			return false;
		}
		let parameters = {
			'csrfToken': csrfToken,
			'name': name,
			'containertypeid': containerTypeId
		};
		new AjaxUtils.Request('/api/container', {
			method: 'post',
			parameters: parameters,
			onSuccess: function (transport) {
				if (!transport.responseJSON || !transport.responseJSON.created) {
					return AjaxUtils.checkResponse(transport);
				}
				if (successCallback) {
					successCallback(transport.responseJSON.created);
				}
			},
			onFailure: function (transport) {
				AjaxUtils.checkResponse(transport);
			},
		});

	},

	addChildById: function (childId, parentId, position, shipmentId, successCallback) {
		let parameters = {
			'csrfToken': csrfToken,
			'parent': parentId,
			'child': childId,
			'position': position,
		};
		if (null != shipmentId) {
			parameters['shipmentid'] = shipmentId;
		}
		new AjaxUtils.Request('/api/containercontent', {
			method: 'post',
			parameters: parameters,
			onSuccess: function (transport) {
				if (!transport.responseJSON || !transport.responseJSON.created) {
					return AjaxUtils.checkResponse(transport);
				}
				if (successCallback) {
					successCallback(transport.responseJSON.created);
				}
			},
			onFailure: function (transport) {
				AjaxUtils.checkResponse(transport);
			},
		});
	},

	/**
	 * Looks for a container with name/barcode "name". If found, sets the found object as
	 * the value of the supplied placeholder. If not found, errors and sets placeholder to "Not found".
	 */
	getByName: function (name, successCallback) {
		new AjaxUtils.Request('/api/container/name/' + name, {
			method: 'get',
			onSuccess: function (transport) {
				let found = transport.responseJSON;
				if (found.rows) {
					found = found.rows[0];
				}
				if (successCallback) {
					successCallback(found);
				}
			},
			onFailure: function (transport) {
				if (404 !== transport.status) {
					return AjaxUtils.checkResponse;
				}
				alert("No container with name " + name);
			},
		});
	},

	getAllContents: function (container, successCallback) {
		new AjaxUtils.Request('/api/container/' + container.id + '/content?recursive=yes', {
			method: 'get',
			onSuccess: function (transport) {
				let found = transport.responseJSON;
				container.childitems = found;
				if (successCallback) {
					successCallback(found);
				}
			},
			onFailure: function (transport) {
				if (404 !== transport.status) {
					return AjaxUtils.checkResponse;
				}
				alert("Container is empty");
			},
		});
	},

	showAllContents: function (container, elem, showEmptyingControls) {
		let category = container.containercategoryname.toLowerCase();
		if (!elem) {
			elem = ui.modalBox({title: "Contents of " + category + " " + container.name});
		}
		if (!container.childitems) {
			alert("Cannot show container contents - not set");
			return false;
		}
		if ("dewar" === category) {
			Container.renderDewarContents(container, elem, showEmptyingControls);
		} else if ("puck" === category) {
			Container.renderPuckContents(container, elem, showEmptyingControls);
		} else if ("pin" === category) {
			Container.renderPinContents(container, elem, showEmptyingControls);
		}
	},

	renderDewar: function (dewar, parentElement, showEmptyingControls) {
		let elem = ui.treeItem({
			header: "Dewar: " + dewar.name
		});
		Container.renderDewarContents(dewar, elem.querySelector(".treebody, .boxbody, .tabbody"), showEmptyingControls);
	},
	renderDewarContents: function (dewar, parentElement, showEmptyingControls) {
		let childitems = dewar.childitems;
		let isEmpty = true;
		parentElement.innerHTML="";
		for (let i = 1; i < childitems.length; i++) {
			if (childitems[i]) {
				isEmpty = false;
				let puckElement = Container.renderPuck(childitems[i], parentElement, showEmptyingControls);
				if (puckElement) {
					puckElement.closest(".treeitem").dewar = dewar;
				}
			}
		}
		if (isEmpty) {
			parentElement.innerHTML = "Dewar is empty.";
		} else {
			let buttons = parentElement.querySelectorAll(".removechild");
			if (buttons) {
				buttons.forEach(function (btn) {
					//btn.stopObserving("click");
					let newButton=btn.cloneNode(true);
					btn.after(newButton);
					btn.remove();
					newButton.addEventListener("click", function (evt) {
						Container.removeFromParent(evt.target);
						evt.stopPropagation();
					});
				});
			}
		}
		let pucks=parentElement.querySelectorAll('.treehead');
		if(pucks.length){
			pucks[0].click();
		}
	},

	renderPuck: function (puck, parentElement, showEmptyingControls) {
		if (!puck.containercategoryname || "puck" !== puck.containercategoryname.toLowerCase()) {
			parentElement.treeItem({header: puck.name + " is not a puck"});
			return false;
		}
		let controls = '';
		if (showEmptyingControls) {
			controls = 'Remove puck and <input value="Keep all contents" type="button" class="removechild" data-containercategoryname="puck" data-recursive="0" />&nbsp;<input value="Wash all pins" type="button" class="removechild" data-containercategoryname="puck" data-recursive="1" />';
		}
		let elem = parentElement.treeItem({
			record: puck,
			header: "Puck: " + puck.name + '<span style="float:right;line-height:2.5em">' + controls + '</span>'
		}).querySelector(".treebody");
		Container.renderPuckContents(puck, elem, showEmptyingControls);
		return elem;
	},
	renderPuckContents: function (puck, parentElement, showEmptyingControls) {
		let childitems = puck.childitems;
		let numChildItems = childitems.length;
		let fieldsToCopy = ['spacegroup', 'unitcella', 'unitcellb', 'unitcellc', 'unitcellalpha', 'unitcellbeta', 'unitcellgamma'];
		for (let i = 1; i < numChildItems; i++) {
			if (!childitems[i]) {
				childitems[i] = {
					position: i,
					name: '',
					rendername: '(empty)',
					samplename: '',
					spacegroup: '',
					unitcella: '',
					unitcellb: '',
					unitcellc: '',
					unitcellalpha: '',
					unitcellbeta: '',
					unitcellgamma: '',
				};
			}

			let pin = childitems[i];
			if (0 === pin.name.indexOf("dummypin")) {
				pin.rendername = "";
				pin.controls = 'Remove from puck and <input style="float:none" value="Wash pin" type="button" class="removechild" data-containercategoryname="pin" data-recursive="1" />';
			} else {
				pin.rendername = pin.name;
				pin.controls = 'Remove from puck and <input style="float:none" value="Keep crystal" type="button" class="removechild" data-containercategoryname="pin" data-recursive="0" />&nbsp;<input style="float:none" value="Wash pin" type="button"  class="removechild" data-containercategoryname="pin" data-recursive="1"/>';
			}
			pin.position = i;
			pin.isEmpty = 0;
			pin.puckid = puck.id;
			pin.puckname = puck.name;
			pin.puckpositions = puck.positions;
			if (childitems[i].childitems && childitems[i].childitems[1]) {
				let xtal = childitems[i].childitems[1];
				if (xtal.name) {
					pin.proteinacronym = xtal.proteinacronym ? xtal.proteinacronym : '';
					pin.samplename = xtal.name;
					pin.shippingcomment = xtal.shippingcomment ? xtal.shippingcomment : '';
					fieldsToCopy.forEach(function (f) {
						pin[f] = xtal[f];
					});
					pin.crystalid = xtal.id;
					pin.crystallocalurl = '/crystal/' + xtal.id;
					pin.crystalremoteurl = xtal.urlatremotefacility;
				} else {
					//User can't see contents
					if(!window["isShipper"]){
						pin.proteinacronym = '(hidden)';
						pin.samplename = '(hidden)';
						pin.shippingcomment = '(hidden)';
					} else {
						pin.proteinacronym = xtal.proteinacronym ? xtal.proteinacronym : '';
						pin.samplename = xtal.name;
						pin.shippingcomment = xtal.shippingcomment ? xtal.shippingcomment : '';
					}
				}
			} else {
				//position is empty
				pin.proteinacronym = '';
				pin.samplename = '';
				pin.shippingcomment = '';
				pin.controls = ''; //nothing to remove
				pin.isEmpty = 1;
			}
		}
		let headers = ['Slot', 'Pin', 'Sample name',
			'Protein acronym', 'Spacegroup', 'a', 'b', 'c', '&alpha;', '&beta;', '&gamma;', 'Remarks'
		];
		let cellTemplates = ['{{position}}', '{{rendername}}',/*'{{samplename}}'*/[Container.getCrystalLinkForPin, 'samplename'], '{{proteinacronym}}',
			'{{spacegroup}}', '{{unitcella}}', '{{unitcellb}}', '{{unitcellc}}', '{{unitcellalpha}}', '{{unitcellbeta}}', '{{unitcellgamma}}','{{shippingcomment}}'
		];
		if (showEmptyingControls) {
			headers = ['Slot', 'Pin', 'Sample name', 'Protein acronym', '&nbsp;'];
			cellTemplates = ['{{position}}', '{{rendername}}', '{{samplename}}', '{{proteinacronym}}', '{{controls}}'];
		}
		ui.table({
			stickyColumns:4,
			headers: headers,
			cellTemplates: cellTemplates
		}, {rows: childitems/*.splice(1)*/}, parentElement);
		let buttons = parentElement.querySelectorAll(".removechild");
		if (buttons) {
			buttons.forEach(function (btn) {
				btn.closest("td").style.textAlign = "right";
				let newButton=btn.cloneNode(true);
				btn.after(newButton);
				btn.remove();
				newButton.addEventListener("click", function (evt) {
					Container.removeFromParent(evt.target);
					evt.stopPropagation();
				});
			});
		}
	},

	getCrystalLinkForPin: function (pin) {
		if (!pin.crystallocalurl || "" === pin.crystallocalurl) {
			return pin.samplename;
		}
		return '<a href="' + pin.crystallocalurl + '">' + pin.samplename + '</a>';
	},

	/**
	 * Use this only for a loose pin. renderPuckContents will handle pins in a puck.
	 * @param pin An object representing the pin
	 * @param parentElement The element to render into
	 * @param showEmptyingControls if true, render a Remove button
	 */
	renderPinContents: function (pin, parentElement, showEmptyingControls) {
		let childitems = pin.childitems;
		let out = '';
		if (2 === childitems.length && !childitems[1]['name'] && !childitems[1]['crystalhidden']) {
			out = "Pin is empty.";
		}
		parentElement.pin = pin;
		for (let i = 1; i < childitems.length; i++) {
			if (childitems[i]) {
				let xtal = childitems[i];
				let xtalName = xtal["name"] || "(hidden)";
				let construct = xtal["constructname"] || "(hidden)";
				let protein = "(hidden)";
				if (xtal["proteinname"] && xtal["proteinacronym"]) {
					protein = xtal["proteinname"] + " (" + xtal["proteinacronym"] + ")";
				}
				out += "<h3>Crystal " + i + "</h3>";
				out += "Sample name: " + xtalName;
				out += "<br/>Protein: " + protein;
				out += "<br/>Construct: " + construct;
				out += '<br/><br/><span id="pincontent_datefished"></span><br/>';
				if (showEmptyingControls) {
					//Assumes only one crystal per pin.
					out += '<label><input value="Wash pin" type="button" class="removechild" data-recursive="1" data-containercontentid="' + xtal.containercontentid + '"/></label>';
				}
				new AjaxUtils.Request("/api/baseobject/" + xtal['containercontentid'], {
					method: "get",
					onSuccess: function (transport) {
						document.getElementById("pincontent_datefished").innerHTML = "Date fished: " + ui.friendlyDate(transport.responseJSON["createtime"]);
					},
					onFailure: function () {
					}
				});

			}
		}
		parentElement.innerHTML = out;
		let buttons = parentElement.querySelectorAll(".removechild");
		if (buttons) {
			buttons.forEach(function (btn) {
				//btn.stopObserving("click");
				btn.closest("label").style.textAlign = "left";
				let newButton=btn.cloneNode(true);
				btn.after(newButton);
				btn.remove();
				newButton.addEventListener("click", function (evt) {
					Container.removeFromParent(evt.target);
					evt.stopPropagation();
				});
			});
		}
	},

	removeFromParent: function (removeButton) {
		if("puck"===removeButton.dataset.containercategoryname && !confirm("Really remove puck from dewar?")){
			return false;
		}
		let childItemId = 0;
		let recursive = 1 * removeButton.dataset.recursive;
		if (undefined !== removeButton.dataset.containercontentid) {
			childItemId = removeButton.dataset.containercontentid;
			if (removeButton.closest("label")) {
				removeButton.closest("label").classList.add("updating");
			}
		} else if (removeButton.closest("tr.datarow")) {
			let tr = removeButton.closest("tr.datarow");
			childItemId = tr.rowData.containercontentid;
			tr.classList.add("updating");
		} else if (removeButton.closest("div.treeitem")) {
			let ti = removeButton.closest("div.treeitem");
			childItemId = ti.record.containercontentid;
			ti.querySelector("h3").classList.add("updating");
		}
		if (!childItemId) {
			return false;
		}
		let uri = '/api/containercontent/' + childItemId;
		let postBody = "csrfToken=" + csrfToken;
		if (recursive) {
			postBody += "&recursive=1";
		}
		new AjaxUtils.Request(uri, {
			method: "delete",
			postBody: postBody,
			onSuccess: function (transport) {
				Container.removeFromParent_onSuccess(removeButton, transport);
			},
			onFailure: function (transport) {
				Container.removeFromParent_onFailure(removeButton, transport);
			},
		})
	},
	removeFromParent_onSuccess: function (removeButton) {
		removeButton.closest(".updating").classList.remove("updating");
		if (removeButton.closest("tr")) {
			removeButton.closest("tr").querySelectorAll("td+td").forEach(function (td) {
				td.innerHTML = "";
			});
		} else if (removeButton.closest(".treeitem")) {
			removeButton.closest(".treeitem").remove();
		} else if (removeButton.closest(".pincontent")) {
			removeButton.closest(".pincontent").remove();
			ui.closeModalBox();
		}
	},
	removeFromParent_onFailure: function (removeButton, transport) {
		removeButton.closest(".updating").classList.remove("updating");
		AjaxUtils.checkResponse(transport);
	},


};

let Protein = {

	codonAsAmino: {
		"ATT": "I", "ATC": "I", "ATA": "I", "CTT": "L", "CTC": "L", "CTA": "L", "CTG": "L", "TTA": "L",
		"TTG": "L", "GTT": "V", "GTC": "V", "GTA": "V", "GTG": "V", "TTT": "F", "TTC": "F", "ATG": "M",
		"TGT": "C", "TGC": "C", "GCT": "A", "GCC": "A", "GCA": "A", "GCG": "A", "GGT": "G", "GGC": "G",
		"GGA": "G", "GGG": "G", "CCT": "P", "CCC": "P", "CCA": "P", "CCG": "P", "ACT": "T", "ACC": "T",
		"ACA": "T", "ACG": "T", "TCT": "S", "TCC": "S", "TCA": "S", "TCG": "S", "AGT": "S", "AGC": "S",
		"TAT": "Y", "TAC": "Y", "TGG": "W", "CAA": "Q", "CAG": "Q", "AAT": "N", "AAC": "N", "CAT": "H",
		"CAC": "H", "GAA": "E", "GAG": "E", "GAT": "D", "GAC": "D", "AAA": "K", "AAG": "K", "CGT": "R",
		"CGC": "R", "CGA": "R", "CGG": "R", "AGA": "R", "AGG": "R", "TAA": "*", "TAG": "*", "TGA": "*",
	},

	dnaToProtein: function (dna) {
		dna = dna.replace(/\s/g, "");
		let protein = "";
		let dnaParts = dna.match(/.{1,3}/g);
		dnaParts.forEach(function (codon) {
			let amino = Protein.codonAsAmino[codon];
			if (!amino) {
				return false;
			}
			protein += amino;
		});
		return protein;
	},

	formatDnaSequence: function (seq) {
		if (!seq || "" === seq) {
			return false;
		}
		let seqParts = seq.match(/.{1,3}/g);
		seq = seqParts.join(" ").toUpperCase();
		return seq;
	},

	formatProteinSequence: function (seq) {
		if (!seq || "" === seq) {
			return false;
		}
		let seqParts = seq.match(/.{1,10}/g);
		seq = seqParts.join(" ").toUpperCase();
		return seq;
	},

};

let Shipment = {

	keepingAlive:false,
	SHOW_CSV_EXPORT: "shipment_show_csv_export",

    synchrotronApiDescription:null,

	synchrotronApiCall:function (endpointName, parameters, onSuccess, onFailure, parentRecord){
		if(!Shipment.synchrotronApiDescription){
			alert("Shipment.synchrotronApiDescription not set.")
			return false;
		} else if(!Shipment.synchrotronApiDescription["apiRoot"]){
			alert("Shipment.synchrotronApiDescription['apiRoot'] not set.")
			return false;
		} else if(!Shipment.synchrotronApiDescription["token"]){
			alert("Shipment.synchrotronApiDescription['token'] not set.")
			return false;
		} else if(!Shipment.synchrotronApiDescription[endpointName]){
			alert("Shipment.synchrotronApiDescription['"+endpointName+"'] not set.")
			return false;
		}
		let apiRoot=Shipment.synchrotronApiDescription["apiRoot"].replace("{{TOKEN}}", Shipment.synchrotronApiDescription["token"]);
		if(!apiRoot.endsWith("/")){ apiRoot+="/"; }
		let endpoint=Shipment.synchrotronApiDescription[endpointName];
		let uri=endpoint["uri"];
		if(!uri.startsWith('http')){
			if(uri.startsWith('/')) {
				uri=uri.substring(1);
			}
			uri=apiRoot+uri;
		}
		if(parentRecord){
			Object.keys(parentRecord).forEach(function(key){
				uri=uri.replaceAll("{{"+key+"}}", parentRecord[key]);
			});
		}
		let headers=[];
		if(Shipment.synchrotronApiDescription["authHeader"]){
			let authHeader=Shipment.synchrotronApiDescription["authHeader"].replace("{{TOKEN}}",Shipment.synchrotronApiDescription["token"]);
			let parts=authHeader.split(": ");
			headers[parts[0]]=parts[1];
		}
		AjaxUtils.remoteAjax(
			uri,
			endpoint["method"],
			parameters,
			function (xhr){
				if(!xhr.responseJSON){
					onFailure(xhr);
				} else if(xhr.responseJSON["error"]){
					if(xhr.responseJSON["error"].toLowerCase().includes("token") && window[window.shipmentDestination.shipmenthandler]["attemptRecoveryFromInvalidToken"]){
						//Synchweb returns 200 OK with this error, so it has to go in the success handler.
						Shipment.synchrotronApiCall_attemptRecoveryFromInvalidToken(endpointName, parameters, onSuccess, onFailure, parentRecord);
					} else {
						onFailure(xhr);
					}
				} else {
					let result=Shipment.synchrotronApiCall_mapProperties(xhr.responseJSON, endpoint);
					onSuccess(result);
				}
			},
			function (xhr){
				if(404===xhr.status) {
					onSuccess([]);
				} else {
					onFailure(xhr);
				}
			},
			headers
		);
	},

	synchrotronApiCall_mapProperties:function (result, endpoint){
		if(typeof endpoint==="string"){
			endpoint=Shipment.synchrotronApiDescription[endpoint];
		}
		//Navigate down to the array of results
		if(endpoint["path"]){
			let parts=JSON.parse(JSON.stringify(endpoint["path"]));
			while(parts.length){
				result=result[parts[0]];
				parts=parts.slice(1);
			}
		}
		//Iterate through results, adding properties with known names from synchrotron-specific ones
		result.forEach(function (item){
			Shipment.synchrotronApiCall_mapPropertiesForIndividualRecord(item, endpoint);
		});
		return result;
	},

	synchrotronApiCall_mapPropertiesForIndividualRecord(record, endpoint){
		if(typeof endpoint==="string"){
			endpoint=Shipment.synchrotronApiDescription[endpoint];
		}
		Object.keys(endpoint["properties"]).forEach(function(key){
			let path=endpoint["properties"][key];
			if(!Array.isArray(path)){
				path=JSON.parse(JSON.stringify([ endpoint["properties"][key] ]));
			}
			let val=JSON.parse(JSON.stringify(record));
			let parts=JSON.parse(JSON.stringify(path));
			while(parts.length){
				val=val[parts[0]];
				parts=parts.slice(1);
			}
			record[key]=val;
		});
		return record;
	},

	synchrotronApiCall_attemptRecoveryFromInvalidToken(endpointName, parameters, onSuccess, onFailure, parentRecord){
		window[window.shipmentDestination.shipmenthandler]["attemptRecoveryFromInvalidToken"](
			function(){
				Shipment.synchrotronApiCall(endpointName, parameters, onSuccess, onFailure, parentRecord);
			},
			onFailure
		)
	},

	submission:{

		getProposals:function (){
			ui.setModalBoxTitle("Getting proposals....");
			Shipment.synchrotronApiCall(
				"getProposals",	[],
				Shipment.submission.getProposals_onSuccess,
				Shipment.submission.getProposals_onFailure
			);
		},
		getProposals_onSuccess:function (result){
			result=result.sort(function(a,b){
				if(a["id"] < b["id"]){ return 1; }
				if(a["id"] > b["id"]){ return -1; }
				return 0;
			})
			let mb=document.getElementById("modalBox").querySelector(".boxbody");
			ui.setModalBoxTitle("Choose the proposal and session");
			mb.innerHTML="";
			result.forEach(function(p){
				let prop=mb.treeItem({
					record:p,
					header:p['type']+p['number']+": "+p["title"],
					updater:function (tb){ Shipment.submission.getProteinsForProposal(tb); }
				});
				prop.dataset.filtertext=(p['type']+p['number']+' '+p['title']).toLowerCase();
			});
			Shipment.writeProposalsFilterBox();
		},
		getProposals_onFailure:function (xhr){
			let mb=document.getElementById("modalBox");
			if(404===xhr.status){
				ui.setModalBoxTitle("No proposals");
				mb.querySelector(".boxbody").innerHTML="You don't have any proposals, so the shipment can't be sent.";
			} else {
				ui.setModalBoxTitle("Could not get list of proposals");
				let msg="You don't have any proposals, so the shipment can't be sent";
				if(xhr.responseJSON && xhr.responseJSON["error"]){
					msg+="<br/><br/>"+xhr.responseJSON["error"];
				}
				mb.querySelector(".boxbody").innerHTML=msg;
			}
		},

		getProteinsForProposal:function(proposalTreeItem){
			proposalTreeItem=proposalTreeItem.closest(".treeitem");
			Shipment.synchrotronApiCall(
				"getProteins", [],
				function(result){ Shipment.submission.getProteinsForProposal_onSuccess(result, proposalTreeItem) },
				function(xhr){ Shipment.submission.getProteinsForProposal_onFailure(xhr, proposalTreeItem) },
				proposalTreeItem.record
			);
		},
		getProteinsForProposal_onSuccess:function (result, proposalTreeItem){
			let tb=proposalTreeItem.querySelector(".treebody");
			if(!result.length){
				tb.innerHTML="";
				ui.errorMessageBar("Proposal has no proteins. Add your proteins to the proposal and try again.",tb);
				return;
			}
			let proposalAcronyms=[];
			let acronymsNotOnProposal=[];
			let noAcronymCount=0;
			result.forEach(function(protein){
				if(-1===proposalAcronyms.indexOf(protein["acronym"])){
					proposalAcronyms.push(protein["acronym"]);
				}
			});
			document.querySelectorAll(".containertab").forEach(function(dewar){
				dewar.nextElementSibling.querySelectorAll(".treeitem").forEach(function (puck){
					puck.querySelectorAll("tr.datarow").forEach(function(slot){
						if(slot["rowData"]["proteinacronym"]){
							let pinAcronym=slot["rowData"]["proteinacronym"];
							if(""===pinAcronym){
								noAcronymCount++;
							} else if(-1===proposalAcronyms.indexOf(pinAcronym) && -1===acronymsNotOnProposal.indexOf(pinAcronym)){
								acronymsNotOnProposal.push(pinAcronym);
							}
						}
					});
				});
			});
			let hasProteinErrors=false;
			if(acronymsNotOnProposal.length){
				hasProteinErrors=true;
				tb.innerHTML="";
				ui.errorMessageBar("Shipment contains proteins ["+acronymsNotOnProposal.join(",")+"] not on this proposal. Add them to the proposal or choose another proposal.",tb);
			}
			if(noAcronymCount){
				hasProteinErrors=true;
				tb.innerHTML="";
				ui.errorMessageBar(noAcronymCount+" samples in the shipment have no protein acronym. Set a protein acronym in the relevant project.",tb);
			}
			if(!hasProteinErrors){
				Shipment.submission.getSessionsForProposal(proposalTreeItem);
			}
		},
		getProteinsForProposal_onFailure:function (xhr, proposalTreeItem){
			let tb=proposalTreeItem.querySelector(".boxbody");
			tb.innerHTML="";
			if(404===xhr.status){
				ui.errorMessageBar("Proposal has no protein acronyms",tb);
			} else {
				ui.errorMessageBar("Could not protein acronyms for proposal",tb);
			}
		},

		getSessionsForProposal:function (proposalTreeItem){
			proposalTreeItem=proposalTreeItem.closest(".treeitem");
			Shipment.synchrotronApiCall(
				"getSessions", [],
				function(result){ Shipment.submission.getSessionsForProposal_onSuccess(result,proposalTreeItem); },
				function(xhr){ Shipment.submission.getSessionsForProposal_onFailure(xhr, proposalTreeItem); },
				proposalTreeItem.record
			);
		},
		getSessionsForProposal_onSuccess:function (result, proposalTreeItem){
			let treeBody=proposalTreeItem.querySelector(".treebody");
			let shipmentHandler=window[shipmentDestination["shipmenthandler"]];
			let unattendedSession=shipmentHandler.getUnattendedSession();
			if(!result.length && !unattendedSession){
				ui.errorMessageBar("No sessions on this proposal",treeBody);
				return;
			}
			result.forEach(function(item){
				let endDate=item["endDate"];
				if(endDate.match(/\d\d:\d\d \d\d-\d\d-\d\d\d\d/)){ //Diamond
					let parts=endDate.split(" ");
					endDate=parts[0];
					parts=parts[1].split("-");
					endDate=parts[2]+"-"+parts[1]+"-"+parts[0]+" "+endDate;
				}
				endDate=new Date(endDate);
				item["sortDate"]=endDate.toISOString();
			});
			let now=new Date().toISOString();
			result=result.filter((item)=>item.sortDate>now);
			result=result.sort(function(a,b){
				if(a["sortDate"] < b["sortDate"]){ return 1; }
				if(a["sortDate"] > b["sortDate"]){ return -1; }
				return 0;
			})
			let headers=["Session","Beamline","Start","End",""];
			let cellTemplates=["{{session}}", "{{beamline}}", "{{startDate}}", "{{endDate}}", '<input type="button" value="Use this session" onclick="Shipment.submission.chooseSession(this)" />' ];
			if(result[0] && !result[0]["session"]){
				headers=headers.slice(1);
				cellTemplates=cellTemplates.slice(1);
			}
			if(unattendedSession){
				unattendedSession=Shipment.synchrotronApiCall_mapPropertiesForIndividualRecord(unattendedSession, "getSessions");
				result.unshift(unattendedSession);
			}
			if(result[0]){
				treeBody.table({
						headers:headers,
						cellTemplates:cellTemplates,
					},
					result
				);
			} else {
				treeBody.innerHTML="";
				let nullSession=shipmentHandler.getNullSession();
				if(nullSession){
					let btn='<input type="button" value="Proceed without a session" onclick="Shipment.submission.chooseSession(this)" />';
					let bar=ui.warningMessageBar("There are no valid sessions on this proposal. "+btn, treeBody);
					bar.rowData=nullSession;
				} else {
					ui.errorMessageBar("There are no active sessions on this proposal.",treeBody);
				}
			}
		},
		getSessionsForProposal_onFailure:function (xhr, proposalTreeItem){
			let treeBody=proposalTreeItem.querySelector(".treebody");
			if(404===xhr.status){
				treeBody.innerHTML="No sessions on this proposal";
			} else {
				treeBody.innerHTML="Couldn't get sessions for proposal.";
				if(xhr.responseJSON && xhr.responseJSON.error){
					treeBody.innerHTML="<br/><br/>"+xhr.responseJSON.error;
				}
			}
		},
		chooseSession:function (btn){
			let shipmentHandler=window[shipmentDestination["shipmenthandler"]];
			let proposal=btn.closest(".treeitem").record;
			shipmentHandler.session=btn.closest("tr,.msgbar").rowData;
			shipmentHandler.proposal=proposal;
			shipmentHandler.proposalName=proposal["type"]+proposal["number"];
			Shipment.submission.getLabContactsForProposal(btn.closest(".treeitem"));
		},
		getLabContactsForProposal:function (proposalTreeItem){
			Shipment.synchrotronApiCall(
				"getLabContacts", [],
				function(result){ Shipment.submission.getLabContactsForProposal_onSuccess(result,proposalTreeItem); },
				function(xhr){ Shipment.submission.getLabContactsForProposal_onFailure(xhr, proposalTreeItem); },
				proposalTreeItem.record
			);
		},
		getLabContactsForProposal_onSuccess:function (result, proposalTreeItem){
			let tb=proposalTreeItem.querySelector(".treebody");
			if(0===result.length){
				tb.innerHTML="There are no lab contacts on this proposal. Add one in the synchrotron's systems and try again.";
				return;
			}
			ui.setModalBoxTitle("Choose the lab contact for this shipment");
			let mb=document.getElementById("modalBox").querySelector(".boxbody");
			mb.innerHTML="";
			mb.table({
				"cellTemplates":["{{familyName}}, {{givenName}}", "{{labName}}",'<input type="button" value="Choose" onclick="Shipment.submission.chooseLabContact(this)" />']
			}, result);
		},
		getLabContactsForProposal_onFailure:function (xhr, proposalTreeItem){
			let tb=proposalTreeItem.querySelector(".treebody");
			if(404===xhr.status){
				tb.innerHTML="There are no lab contacts on this proposal. Add one in the synchrotron's systems and try again.";
				return;
			}
			tb.innerHTML="Could not retrieve the list of lab contacts from the synchrotron.";
			if(xhr.responseJSON && xhr.responseJSON.error){
				tb.innerHTML+="<br/><br/>"+xhr.responseJSON.error;
			}
		},

		chooseLabContact:function(btn){
			let shipmentHandler=window[shipmentDestination["shipmenthandler"]];
			shipmentHandler.labContact=btn.closest("tr").rowData;
			Shipment.submission.showSummary();
		},

		showSummary:function(){
			let shipmentHandler=window[shipmentDestination["shipmenthandler"]];
			let mb=document.getElementById("modalBox");
			let box=mb.querySelector(".boxbody");
			let proposal=shipmentHandler.proposal;
			let session=shipmentHandler.session;
			let labContact=shipmentHandler.labContact;
			box.innerHTML='';
			ui.setModalBoxTitle("Check the details before sending the shipment");
			let f=box.form({ action:'#', method:'post' });
			f.style.position="absolute";
			f.style.top="2%";
			f.style.left="1%";
			f.style.width="48%";
			f.textField({ readonly:true, label:'Destination', value:window.shipmentDestination.name });
			f.textField({ readonly:true, label:'Proposal', value:proposal["type"]+proposal["number"] });
			if(session["session"]){
				f.textField({ readonly:true, label:'Session', value:session["session"] });
			}
			f.textField({ readonly:true, label:'Home lab contact', value:labContact["familyName"]+", "+labContact["givenName"] });

			let f2=box.form({ action:'#', method:'post' });
			f2.style.marginTop="0";
			f2.style.position="absolute";
			f2.style.top="2%";
			f2.style.right="1%";
			f2.style.width="48%";
			let b=f2.formField({ readonly:true, label:'Review this information to make sure that it is correct, then click "Send shipment".', content:'<input type="button" value="Send shipment" onclick="window[shipmentDestination.shipmenthandler].sendShipment()" />' });
			b.style.height=f.offsetHeight+"px";
			b.querySelector("input").style.position="absolute";
			b.querySelector("input").style.bottom="0.5em";
			b.querySelector("input").style.right="0.5em";

			box.innerHTML+='<div style="position:absolute;bottom:10%; left:1%; width:98%; text-align:center"><a href="#" onclick="ui.closeModalBox();return false">Cancel - don\'t send this shipment</a></div>';
		}
	},

	togglePageConfig:function(){
		let mb=ui.modalBox({ "title":"Configure the shipment page" });
		let f=mb.form({});
		let showCsvExport=UserConfig.get(Shipment.SHOW_CSV_EXPORT,false);
		f.checkbox({
			"label":"Show synchrotron CSV export option when available",
			"handler":Shipment.setCsvExportVisibilityPreference,
			"name":Shipment.SHOW_CSV_EXPORT, "value":showCsvExport
		});
	},

	setCsvExportVisibilityPreference:function (field) {
			UserConfig.set(Shipment.SHOW_CSV_EXPORT,field.value);
		window.setTimeout(Shipment.showOrHideCsvExportButton, 50);
	},
	showOrHideCsvExportButton:function () {
		let btn=document.getElementById("csvExport");
		if(!btn){ return false; }
		let lbl=btn.closest("label");
		let show=UserConfig.get(Shipment.SHOW_CSV_EXPORT, false);
		if(parseInt(show)){
			lbl.style.display="block";
		} else {
			lbl.style.display="none";
		}
	},

	getShipmentStatus:function(shipment) {
		let text="?";
		let tooltip="Shipment has returned date but no shipped date. This should not happen.";
		if (!shipment.dateshipped && !shipment.datereturned) {
			text="Pending";
			tooltip="The shipment has not been submitted yet.";
		} else if (shipment.dateshipped && !shipment.datereturned) {
			text="Shipped";
			tooltip="The shipment has been submitted.";
		} else if (shipment.dateshipped && shipment.datereturned) {
			text="Returned";
			tooltip="The shipment has been returned.";
		}
		return '<span style="cursor:help" title="'+tooltip+'">'+text+'</span>';
	},

	cleanOldDewarTabs: function (currentContainers) {
		document.querySelectorAll(".containertab").forEach(function (head) {
			let dewarName = head.dataset.containername;
			let inShipment = false;
			currentContainers.forEach(function (cont) {
				if (cont.name === dewarName) {
					inShipment = true;
				}
			});
			if (!inShipment) {
				ui.nextElementSiblingMatchingSelector(head,".tabbody").remove();
				head.remove();
			}
		});
		if (!document.getElementById("shiptabs").querySelector("h2.current")) {
			let t = document.getElementById("shipmentdetails");
			t.classList.add("current");
			window.document.location.hash = t.id;
		}
	},

	getShipmentDestination: function () {
		new AjaxUtils.Request('/api/shipmentdestination/' + data["shipmentdestinationid"], {
			method: 'get',
			onSuccess: function (transport) {
				window.shipmentDestination = transport.responseJSON;
				Shipment.getShipmentSubmissionHandler();
			},
			onFailure: function (transport) {
				AjaxUtils.checkResponse(transport);
			},
		});
	},

	getShipmentSubmissionHandler: function () {
		if ("" === shipmentDestination["shipmenthandler"]) {
			return false;
		}
		new AjaxUtils.Request('/js/model/shipping/handlers/' + shipmentDestination["shipmenthandler"] + '.js?t=' + Date.now(), {
			method: 'get',
			onSuccess: function (transport) {
				//Not nice, but not much choice.
				eval(transport.responseText);
			},
			onFailure: function () {
				alert("Could not retrieve shipment submission handler for " + shipmentDestination['name'] + " (" + shipmentDestination["shipmenthandler"] + ")");
			},
		});
	},

	getAndRenderContainers: function () {
		new AjaxUtils.Request('/api/shipment/' + data.id + '/container', {
			method: 'get',
			onSuccess: function (transport) {
				if (transport.responseJSON.rows) {
					//First remove any tabs for dewars that are NOT in the shipment
					Shipment.cleanOldDewarTabs(transport.responseJSON.rows);
					//Then render all dewars that ARE in the shipment, reusing any existing tabs
					transport.responseJSON.rows.forEach(function (cont) {
						if (!cont.containercategoryname) {
							Shipment.renderPlate(cont);
						} else if ('dewar' === cont.containercategoryname.toLowerCase()) {
							Shipment.renderDewar(cont);
						} else {
							alert(cont.name + " is directly inside the shipment but is not a plate or dewar.");
						}
					});
				}
			},
			onFailure: function (transport) {
				if (404 !== transport.status) {
					return AjaxUtils.checkResponse(transport);
				}
				document.querySelectorAll(".containertab").forEach(function (head) {
					ui.nextElementSiblingMatchingSelector(head,".tabbody").remove();
					head.remove();
				});
				Shipment.cleanOldDewarTabs();
			},
		});
	},
	toggleDewarTabIcons:function () {
		window.setTimeout(function (){
			document.querySelectorAll(".containertab").forEach(function (tab){
				let img=tab.querySelector("img");
				if(tab.classList.contains("current")){
					img.src=img.src.replace("darkicons","lighticons");
				} else {

					img.src=img.src.replace("lighticons","darkicons");
				}
			});
		},50);
	},
	renderDewar: function (dewar) {
		let t = document.getElementById(dewar.name);
		if (!t) {
			t = document.getElementById("shiptabs").tab({
				label: dewar.name,
				id: dewar.name,
				classes: 'containertab',
				content: ''
			});
			let i=document.createElement("img");
			i.src="/images/icons/darkicons/containers.png";
			i.style.height="1.8em";
			i.style.width="1.8em";
			i.style.objectFit="cover";
			i.style.objectPosition="0 -115px";
			i.style.verticalAlign="top";
			t.insertAdjacentElement("afterbegin", i);
			t.addEventListener("click", Shipment.toggleDewarTabIcons);
		}
		t.dataset.containercontentid = dewar.containercontentid;
		t.dataset.containername = dewar.name;
		t.dataset.containerid = dewar.id;
		let tb = t.nextElementSibling;
		tb.innerHTML = "";
		Container.renderDewarContents(dewar, tb);
		if ("" !== data.dateshipped) {
			tb.prepend(ui.infoMessageBar("This is the data sent to the synchrotron. Some information may have changed since then."));
		} else if (userCanShip) {
			let lbl=document.createElement("label");
			lbl.style.textAlign="left";
			lbl.innerHTML='<input style="float: none;margin-right:5em;cursor:pointer" type="button" class="noprint" title="Remove from shipment (keeping all contents)" onclick="Shipment.removeContainer(this)" value="Remove dewar from shipment" /> Add a puck: <form class="noprint" style="display:inline" onsubmit="Shipment.addPuckByBarcode(this);return false"><input class="noprint" type="text" name="barcode" style="width:12em" value="" placeholder="Scan barcode"/></form>';
			tb.prepend(lbl);

			let treeItems=tb.querySelectorAll(".treeitem");
			if(treeItems){
				treeItems.forEach(function (ti) {
					//Remove Puck control
					ti.querySelector(".treehead").innerHTML+='<input type="button" class="noprint" title="Remove from dewar (keeping all contents)" style="cursor:pointer;float:none;margin-left:5em;" onclick="return Shipment.removeContainer(this)" value="Remove puck" /> ';
				});
			}
			let tableRows=tb.querySelectorAll("tr.datarow");
			if(!tableRows){ return; }
			tableRows.forEach(function (tr) {
				let barcodeCell = tr.querySelector("td").nextElementSibling;
				if (tr.rowData.containercontentid) {
					//Insert "Remove Pin" control before barcode (or, if no barcode, loose)
					let inp=document.createElement("input");
					inp.type="button";
					inp.classList.add("noprint");
					inp.title="Remove from puck (keeping crystal in pin)";
					inp.style.cursor="pointer";
					inp.style.cssFloat="none";
					inp.addEventListener("click", Shipment.removeContainer);
					inp.value="X";
					barcodeCell.prepend(inp);
				} else {
					//Nothing here. Add pin by scanning barcode.
					barcodeCell.innerHTML = '<form class="noprint" style="display:inline" onsubmit="return Shipment.addPinByBarcode(this)"><input class="noprint" style="width:8em" type="text" name="barcode" value="" placeholder="Scan barcode"/></form>';
				}
			});
		}

	},
	renderPlate: function (plate) {

	},

	addDewarByBarcode: function () {
		let b = document.getElementById("addtoplevelbybarcode");
		let barcode = b.value.trim();
		if ("" === barcode) {
			return false;
		}
		let alreadyIn = false;
		let numContainers = 0;
		document.querySelectorAll('.tab').forEach(function (t) {
			if (t.dataset.containername) {
				numContainers++;
				if (barcode === t.dataset.containername) {
					alreadyIn = true;
				}
			}
		});
		if (alreadyIn) {
			b.value = "";
			alert(barcode + " is already in this shipment");
			return false;
		}
		let bar = b.closest("label");
		bar.classList.add("updating");
		let shipmentId = data.id;
		let position = numContainers + 1;
		Container.getByName(barcode, function (found) {
			if ("dewar" !== found.containercategoryname.toLowerCase()) {
				alert(barcode + " is not a dewar.");
				bar.classList.remove("updating");
				return false;
			}
			Container.addChildById(found.id, shipmentId, position, shipmentId, Shipment.getAndRenderContainers);
			bar.classList.remove("updating");
		});
		b.value = "";
		return false; //stop form submission and page reload
	},

	addPuckByBarcode: function (frm) {
		let barcode = frm.querySelector("input").value.trim();
		if ("" === barcode) {
			alert("Puck barcode cannot be empty");
			return false;
		}
		let tb = frm.closest(".tabbody");
		let bar = frm.closest(".infobar,label");
		bar.classList.add("updating");
		let dewarId = ui.previousElementSiblingMatchingSelector(tb,".tab").dataset.containerid;
		let position = document.querySelectorAll(".treeitem").length + 1;
		Container.getByName(barcode, function (found) {
			if ("puck" !== found.containercategoryname.toLowerCase()) {
				alert(barcode + " is not a puck.");
				bar.classList.remove("updating");
				return false;
			}
			Container.addChildById(found.id, dewarId, position, null, Shipment.getAndRenderContainers);
		});
		frm.querySelector("input").value = "";
		return false; //stop form submission and page reload
	},

	addPinByBarcode: function (frm) {
		let field = frm.querySelector("input");
		let barcode = field.value.trim();
		if ("" === barcode) {
			alert("Pin barcode cannot be empty");
			return false;
		}
		let tr = frm.closest("tr");
		let puckId = frm.closest(".treeitem").record.id;
		let position = tr.rowData.position;
		tr.classList.add("updating");
		Container.getByName(barcode, function (found) {
			if ("pin" !== found.containercategoryname.toLowerCase()) {
				alert(barcode + " is not a pin.");
				tr.classList.remove("updating");
				return false;
			}
			//Get the contents; will 404 if no crystal in pin.
			new AjaxUtils.Request('/api/container/' + found.id + '/content', {
				method: 'get',
				onSuccess: function (transport) {
					if (!transport.responseJSON) {
						return AjaxUtils.checkResponse(transport);
					}
					Container.addChildById(found.id, puckId, position, null, Shipment.getAndRenderContainers);
				},
				onFailure: function (transport) {
					if (404 !== transport.status) {
						return AjaxUtils.checkResponse(transport);
					}
					alert(barcode + " has no crystal. Cannot add it to the shipment.");
					tr.classList.remove("updating");
					field.value = "";
				}
			});
		});
		return false; //stop form submission and page reload
	},

	removeContainer: function (btn) {
		if(btn.target){
			//Got an event
			btn=btn.target;
		}
		let deletedTopLevel = false;
		let containerContentId = null;
		if (btn.closest("tr")) {
			//Pin
			containerContentId = btn.closest("tr").rowData.containercontentid;
			btn.closest("tr").classList.add("updating");
		} else if (btn.closest(".treeitem")) {
			//Puck
			containerContentId = btn.closest(".treeitem").record.containercontentid;
			btn.closest(".treeitem").classList.add("updating");
		} else if (btn.closest(".tabbody")) {
			//Dewar
			containerContentId = btn.closest(".tabbody").previousElementSibling.dataset.containercontentid;
			btn.closest("label,div.msgbar").classList.add("updating");
			deletedTopLevel = true;
		}
		if (!containerContentId) {
			alert("Cannot determine container type for removal.");
			return false;
		}
		new AjaxUtils.Request('/api/containercontent/' + containerContentId, {
			method: 'delete',
			postBody: 'csrfToken=' + csrfToken,
			onSuccess: function (transport) {
				if (!transport.responseJSON) {
					return AjaxUtils.checkResponse(transport);
				}
				Shipment.getAndRenderContainers();
				if (deletedTopLevel) {
					window.setTimeout(Shipment.renumberTopLevelContainers, 50);
				}
			},
			onFailure: AjaxUtils.checkResponse
		});

	},

	/**
	 * After deleting a top level container, iterate through tabs and set the "position" number
	 * to consecutive values starting from 1. Otherwise, adding another may fail due to clashing
	 * position numbers. Strictly speaking, we don't care what position a dewar occupies within a
	 * shipment - but the server does.
	 */
	renumberTopLevelContainers: function () {
		let pos = 1;
		document.querySelectorAll(".containertab").forEach(function (head) {
			new AjaxUtils.Request('/api/containercontent/' + head.dataset.containercontentid, {
				method: 'patch',
				parameters: {
					'csrfToken': csrfToken,
					'position': pos
				},
			});
			pos++;
		});
	},

	validate: function (proteinAcronyms) {
		let errors = Shipment.getShipmentErrors(proteinAcronyms);
		if (!errors || 0 === errors.length) {
			return true;
		}
		alert("You must fix these errors before the shipment can be sent:\n\n* " + errors.join("\n* "));
		return false;
	},
	getShipmentErrors: function (proteinAcronyms) {
		let errors = [];
		let dewars = document.querySelectorAll(".containertab");
		if (0 === dewars.length) {
			errors.push("Shipment must contain at least one dewar.");
		}
		dewars.forEach(function (d) {
			Shipment.unhighlightValidationFailure(d);
			let dewarName = d.dataset.containername;
			let pucks = d.nextElementSibling.querySelectorAll(".treeitem");
			if (0 === pucks.length) {
				errors.push("Dewar " + dewarName + " is empty. Remove it or add a puck.");
				Shipment.highlightValidationFailure(d);
			}
			pucks.forEach(function (p) {
				Shipment.unhighlightValidationFailure(p);
				let puckName = p.record.name;
				let slots = p.querySelectorAll("tr.datarow");
				let numPins = 0;
				slots.forEach(function (tr) {
					Shipment.unhighlightValidationFailure(tr);
					if (!tr.rowData.childitems || 2 < tr.rowData.childitems.length) {
						//position is empty
					} else {
						numPins++;
						if (1 !== 1*tr.rowData.childitems[1]["hasacronym"]) {
							errors.push("Dewar " + dewarName + ", puck " + puckName + ", position " + tr.rowData.position + ": Crystal has no protein acronym. Set the parent plate's protein.");
							Shipment.highlightValidationFailure(tr);
						} else if (proteinAcronyms) {
							let matched = false;
							proteinAcronyms.forEach(function (pa) {
								if (pa === tr.rowData.childitems[1].proteinacronym) {
									matched = true;
								}
							});
							if (!matched) {
								errors.push("Dewar " + dewarName + ", puck " + puckName + ", position " + tr.rowData.position + ": Crystal has a protein acronym (" + tr.rowData.childitems[1].proteinacronym + ") that is not in the approved list for this proposal.");
								Shipment.highlightValidationFailure(tr);
							}
						}
					}
				});
				if (0 === numPins) {
					errors.push("Puck " + puckName + " in dewar " + dewarName + " is empty. Remove it or add a pin.");
					Shipment.highlightValidationFailure(p);
				}
			});
		});
		return errors;
	},

	highlightValidationFailure: function (elem) {
		elem.classList.add("haserror");
		//find a parent puck and highlight it
		if (elem.closest(".treeitem")) {
			elem.closest(".treeitem").querySelector(".treehead").classList.add("haserror");
		}
		//find a parent dewar and highlight it
		if (elem.closest(".tabbody")) {
			ui.previousElementSiblingMatchingSelector(elem.closest(".tabbody"),"h2").classList.add("haserror");
		}
	},

	unhighlightValidationFailure: function (elem) {
		elem.classList.remove("haserror");
		//Don't do parent elements. We call this on the parent then validate its
		//children, so a good child would unhighlight its parent after a bad sibling.
	},

	/**
	 * Submits the shipment contents to the synchrotron
	 */
	send: function () {
		let shipmentHandler;
		if ("" !== shipmentDestination["shipmenthandler"] && window[shipmentDestination["shipmenthandler"]]) {
			shipmentHandler=window[shipmentDestination["shipmenthandler"]];
			if(OIDC.exists()){
				shipmentHandler.token=OIDC.getToken("sendShipment"); //OIDC authenticated, continue. No OIDC, continue. Otherwise redirects to synchrotron OIDC.
				if(!shipmentHandler.token){ return false; }
				OIDC.deleteToken(data['shipmentdestinationid']);
			}
		} else {
			alert("No shipment submission handler defined for " + window.shipmentDestination.name);
			return false;
		}
		if ("" !== data.dateshipped) {
			alert("Shipment has already shipped");
			return false;
		}
		if(!shipmentHandler.allowCreatePlaceholderShipment){
			if(!Shipment.validate()){ return false; }
			shipmentHandler.begin();
			return;
		}

		//Placeholder shipment is allowed
		if(""!==data["idatremotefacility"]){
			//Placeholder already exists
			if(!Shipment.validate()){ return false; }
			Shipment.creatingPlaceholder=false;
			shipmentHandler.begin();
		} else {
			//Placeholder does not exist
			let containerTab=document.querySelector(".containertab");
			if(!containerTab){
				alert("Shipment must contain at least one dewar");
				return false;
			} else {
				let errors=Shipment.getShipmentErrors();
				if(errors.length) {
					Shipment.showErrorsAndAttemptPlaceholderShipment(shipmentHandler, errors);
					return false;
				} else {
					Shipment.choosePlaceholderOrFullShipment(shipmentHandler);
				}
			}
		}
	},

	showErrorsAndAttemptPlaceholderShipment:function (shipmentHandler, errors){
		let mb=ui.modalBox({
			"title":"Problems were found"
		});
		errors.forEach(function(err){
			ui.warningMessageBar(err,mb);
		});
		let	out="<p>IceBear can create a placeholder shipment at "+window.shipmentDestination.name+". This may" +
			"be useful if, for example, they need advance notice of the shipment in order to create an air waybill. You " +
			"will need to fix these problems before IceBear can submit the whole shipment to "+window.shipmentDestination.name+".</p>" +
			"<p>Note that you must add <strong>all dewars</strong> to this IceBear shipment before creating the placeholder shipment.</p>";
		let containerTabs=document.querySelectorAll(".containertab");
		if(containerTabs.length){
			mb.innerHTML+=out;
			let frm=mb.form({});
			frm.buttonField({ "label":"Yes, create a placeholder shipment", "onclick":function(){
					Shipment.creatingPlaceholder=true;
					ui.closeModalBox();
					shipmentHandler.begin();
				} });
		} else {
			out+="<p>There are no dewars in your shipment. IceBear can't create a placeholder shipment until you add all the dewars to your shipment.</p>";
			mb.innerHTML+=out;
		}
	},

	choosePlaceholderOrFullShipment:function (shipmentHandler){
		let mb=ui.modalBox({
			"title":"Choose placeholder or full shipment"
		});
		let shipmentDestinationName=data["shipmentdestinationname"];

		let placeholderForm=mb.form({});
		Object.assign(placeholderForm.style, {
			"position":"absolute",
			"top":"1%", "left":"1%", "height":"98%", "width":"48%"
		});
		placeholderForm=placeholderForm.appendChild(document.createElement("label"));
		placeholderForm.appendChild(document.createElement("h3")).innerHTML="Placeholder shipment";
		placeholderForm.innerHTML+='<p>You can create a placeholder shipment at '+shipmentDestinationName+', even if you still have crystals to add. ' +
			'You may need to do this if '+shipmentDestinationName+' needs a shipment to create an air waybill. Click &quot;Send placeholder shipment&quot; below.</p>' +
			'<p><strong>Ensure that all your dewars are in the shipment</strong>. You can add the pucks and crystals later.</p><input type="button" id="startPlaceholderShipment" value="Send placeholder shipment" />';
		document.getElementById("startPlaceholderShipment").addEventListener("click",function(){
			Shipment.creatingPlaceholder=true;
			shipmentHandler.begin();
		});

		let fullForm=mb.form({});
		Object.assign(fullForm.style, {
			"position":"absolute", "marginTop":"0",
			"top":"1%", "right":"1%", "height":"98%", "width":"48%"
		});
		fullForm=fullForm.appendChild(document.createElement("label"));
		fullForm.appendChild(document.createElement("h3")).innerHTML="Full shipment";
		fullForm.innerHTML+='<p>If you have added all your crystals to the shipment and are ready to send the details to '+shipmentDestinationName+', ' +
			'click &quot;Send full shipment&quot; below.</p><p>You <strong>cannot change the shipment</strong> once it has been sent, so make sure that all your ' +
			'dewars, pucks, and crystals are in the shipment.</p><input id="startFullShipment" type="button" value="Send full shipment" />';
		document.getElementById("startFullShipment").addEventListener("click",function(){
			Shipment.creatingPlaceholder=false;
			shipmentHandler.begin();
		});

		mb.querySelectorAll("label").forEach(function (lbl){
			Object.assign(lbl.style,{
				"position":"absolute", "textAlign":"left",
				"top":"0", "bottom":"0", "left":"0", "right":"0"
			});
		});

	},

	writeProposalsFilterBox:function (){
		let mb=document.getElementById("modalBox").querySelector(".boxbody");
		let proposals=mb.querySelectorAll(".treeitem");
		if(!proposals || proposals.length<10 || !proposals[0].dataset.filtertext){
			return false;
		}
		let filterBox=document.createElement('input');
		let filter=ui.formField({"label":"Filter on proposal number or title:&nbsp;"}, null);
		filter.appendChild(filterBox);
		filter.style.textAlign="left";
		filterBox.addEventListener("keyup",Shipment.filterProposals);
		mb.prepend(filter);

	},
	filterProposals:function (evt){
		let box=evt.target;
		let text=box.value.toLowerCase();
		let mb=box.closest(".boxbody");
		let proposals=mb.querySelectorAll('.treeitem');
		proposals.forEach(function(ti){
			if(ti.dataset.filtertext && -1<ti.dataset.filtertext.indexOf(text)){
				ti.style.display="block";
			} else {
				ti.style.display="none";
			}
		});
	},

	markReturned: function (evt) {
		if (!data["objecttype"] || "shipment" !== data["objecttype"]) {
			//Not on a shipment page!
			return false;
		}
		let btn = evt.target;
		if (!confirm("Really mark this shipment as returned?")) {
			return false;
		}
		btn.closest("label").classList.add("updating");
		new AjaxUtils.Request("/api/shipment/" + data.id, {
			method: "patch",
			parameters: {
				csrfToken: csrfToken,
				datereturned: (new Date().toISOString().split("T"))[0]
			},
			onSuccess: function () {
				ui.forceReload();
			},
			onFailure: function (transport) {
				btn.closest("label").classList.remove("updating");
				AjaxUtils.checkResponse(transport);
			}
		});
	},

	hasDatasetRetrieval:function(){
		return !!window[shipmentDestination["shipmenthandler"]] && !!window[shipmentDestination["shipmenthandler"]].DatasetRetrieval;
	},

	getCollectedDatasets: function () {
		if ("" === data.dateshipped) {
			alert("Shipment has not shipped");
			return false;
		}
		if(Shipment.hasDatasetRetrieval()){
			window[shipmentDestination["shipmenthandler"]].DatasetRetrieval.begin();
		} else {
			if ("" === shipmentDestination["shipmenthandler"] || !window[shipmentDestination["shipmenthandler"]]) {
				alert("No shipment submission handler defined for " + window.shipmentDestination.name);
			} else {
				alert("No dataset retrieval handler defined for " + window.shipmentDestination.name);
			}
		}
	},



	/**
	 * Handles the recording of information during the synchrotron data collection process.
	 */
	DataCollection:{

		quickNotes:[
			{
				"label":"Ice rings",
				"note":"Ice rings"
			},
			{
				"label":"Empty pin",
				"note":"Pin was empty"
			},
			{
				"label":"Not visible",
				"note":"Crystal was not visible"
			},
		],

		renderTab: function () {
			let tb=document.getElementById("shiptabs");
			if (!tb || !data["manifest"]) {
				return;
			}
			let t = tb.tab({
				'id': 'shipment_datacollection',
				'label': 'Data collection'
			});
			t.addEventListener("click", function () {
				window.setTimeout(Shipment.DataCollection.setHeightsInTab, 50);
			});
			t = t.nextElementSibling; //body, not the header
			let puckDiv = document.createElement("div");
			puckDiv.id = "dcpucks";
			puckDiv.style.position="absolute";
			puckDiv.style.top="0";
			puckDiv.style.left="0";
			puckDiv.style.right="0";
			puckDiv.innerHTML = "";
			let pinDiv = document.createElement("div");
			pinDiv.id = "dcpins";
			pinDiv.style.position="absolute";
			pinDiv.style.top="0";
			pinDiv.style.left="0";
			pinDiv.style.right="0";
			pinDiv.style.bottom="0";

			data["manifest"].rows.forEach(function (dewar) {
				if (!dewar.childitems) {
					return;
				}
				dewar.childitems.forEach(function (puck) {
					if (!puck || !puck.childitems) {
						return;
					}
					let puckButton = document.createElement("div");
					puckButton.classList.add("ship_containertype");
					puckButton.id = "dcpuck" + puck.id;
					let puckName = document.createElement("div");
					puckName.classList.add("ship_containertypename","ship_puck");
					puckName.innerHTML = puck.name;
					puckButton.appendChild(puckName);
					puckButton.style.cursor = "pointer";
					puckButton.pins=puck.childitems;
					puckDiv.appendChild(puckButton);
				});
			});
			puckDiv.innerHTML += '<hr style="clear:both;margin:0 1em"/>';
			t.appendChild(puckDiv);
			t.appendChild(pinDiv);
			data["manifest"].rows.forEach(function (dewar) {
				if (!dewar.childitems) {
					return;
				}
				dewar.childitems.forEach(function (puck) {
					if (!puck || !puck.childitems) {
						return;
					}
					let puckButton = document.getElementById("dcpuck" + puck.id);
					puckButton.pins = puck.childitems;
					puckButton.pins.forEach(function(pin){
						if(!pin.childitems){ return; }
						let diffractionRequestId=pin.childitems[1].diffractionrequestid;
						pin.actionupdated=false;
						new AjaxUtils.Request("/api/diffractionrequest/"+diffractionRequestId,{
							method:"get",
							onFailure:function(){
								pin.actionupdated=true;
							},
							onSuccess:function(transport){
								pin.childitems[1]["actiononreturn"]=transport.responseJSON.actiononreturn;
								pin.actionupdated=true;
							}
						});
					});
				});
			});
			window.setTimeout(function () {
				Shipment.DataCollection.setHeightsInTab();
				puckDiv.querySelectorAll(".ship_containertype").forEach(function (p) {
					p.onclick = Shipment.DataCollection.renderPuck;
					window.setTimeout(function(){ Shipment.DataCollection.updatePuckCompletedStatus(p) },1000);
				});
			}, 10);
		},

		setHeightsInTab: function () {
			document.getElementById("dcpins").style.top = document.getElementById("dcpucks").offsetHeight + "px";
		},

		renderPuck: function (evt) {
			if(!Shipment.keepingAlive){
				ui.keepAlive();
				Shipment.keepingAlive=true;
			}
			let puckButton = evt.target;
			if (puckButton.closest(".ship_containertype")) {
				puckButton = puckButton.closest(".ship_containertype");
			}
			document.getElementById("dcpucks").querySelectorAll(".ship_containertype").forEach(function (p) {
				p.classList.remove("dccurrentpuck");
			});
			puckButton.classList.add("dccurrentpuck");

			let dcPins=document.getElementById("dcpins");
			dcPins.innerHTML = "";
			let ts = ui.tabSet({}, dcPins);
			ts.style.left = "1em";
			ts.style.right = "1em";
			ts.style.top = "1em";
			ts.style.bottom = ".5em";
			for (let i = 1; i < puckButton.pins.length; i++) {
				let pin = puckButton.pins[i];
				let content = "Puck position is empty.";
				if (!pin.isEmpty) {
					content = "Puck " + pin.puckname + " position " + i;
				}
				let t = ts.tab({
					label: "Pin " + i,
					content: content,
					disabled: pin.isEmpty
				});
				if(pin.childitems && ""!==pin.childitems[1].actiononreturn){
					t.classList.add("complete");
				}
				t.pin = pin;
			}
			ts.querySelectorAll(".enabledtab").forEach(function (t) {
				Shipment.DataCollection.renderPin(t.nextElementSibling);
			});
			dcPins.querySelector("h2.enabledtab").click();
		},

		renderPin: function (tb) { //gets tab body
			/** @namespace isAdmin **//* Defined in page header, IDE warnings hack */
			/** @namespace isShipper **//* Defined in page header, IDE warnings hack */
			let t = tb.previousElementSibling; //gets tab header
			tb.innerHTML = '';
			let pin = t.pin;
			let f = tb.form("#", {});

			let pos = f.formField({
				'label': pin.position,
				'content': ''
			});
			let lbl = '<a target="_blank" href="' + pin.crystallocalurl + '">View crystal in IceBear</a>';
			if ("" !== pin.childitems[1].crystalurlatremotefacility) {
				lbl += '<br/>' +
					'<a target="_blank" href="' + pin.childitems[1].crystalurlatremotefacility + '">View crystal at ' + shipmentDestination.name + '</a>';
			}

			let det = f.formField({
				'label': lbl,
				'content': pin.samplename + ' (Protein: ' + pin.proteinacronym + ')<br/>Puck ' + pin.puckname + ' position ' + pin.position
			});
			pos.title = "Position in puck";
			pos.classList.add("pinnumber");
			det.classList.add("xtaldetails");

			if (!isAdmin && !isShipper && -1 === userUpdateProjects.indexOf(parseInt(pin.childitems[1].projectid))) {
				Shipment.DataCollection._renderPinControlsReadOnly(pin, f);
			} else {
				Shipment.DataCollection._renderPinControls(pin, f);
			}

		},

		_renderPinControlsReadOnly: function (pin, frm) {
			frm.formField({
				label: "Not your crystal",
				content: "You do not have permission to add notes to this crystal, or to decide whether to keep it."
			});
			let btn = frm.buttonField({
				label: "Go to next pin",
			});
			btn.id = "pin" + pin.position + "save";
			let tb = frm.closest(".tabbody");
			btn = btn.querySelector("input");
			if (!ui.nextElementSiblingMatchingSelector(tb,".enabledtab")) {
				btn.value = "Done";
			}
			btn.onclick = function () {
				Shipment.DataCollection.advanceToNextPin(btn);
			};

		},

		_renderPinControls: function (pin, frm) {
			let isBarcodedPin = true;
			if (!pin.name || 0 === pin.name.indexOf("dummypin")) {
				isBarcodedPin = false;
			}
			//Notes box, with any previous note (since this page was loaded)
			let notes = frm.textArea({
				'id':'dcnote'+pin.position,
				'label':'Data collection notes'
			});
			notes.querySelector("textarea").rows=5;
			notes.querySelector("textarea").insertAdjacentHTML("beforebegin",'<div id="dcnote'+pin.position+'_buttons" style="text-align:right">Quick notes:</div>');
			Note.writeQuickNoteButtons(
				document.getElementById("dcnote"+pin.position+"_buttons"),
				"dcnote"+pin.position,
				Shipment.DataCollection.quickNotes,
				"margin:0.5em"
			);

			if (pin.dcnote && "" !== pin.dcnote) {
				notes.querySelector("textarea").insertAdjacentHTML("beforebegin",pin.dcnote + "<br/><br/>");
			}
			if (isBarcodedPin) {
				let defaultAction = Crystal.WASH;
				let pinAction=pin.childitems[1].actiononreturn;
				if (pinAction && "" !== pinAction) {
					defaultAction = pinAction;
				}
				frm.radioButtons({
					name: 'pin' + pin.position + 'action',
					cssClasses: 'pinaction',
					label: 'When the shipment is returned...',
					defaultValue: defaultAction,
					options: [
						{
							'value': Crystal.WASH,
							'label': '<span class="shipment_crystalaction_wash">Wash the pin.</span> I don\'t want to keep the crystal.'
						},
						{
							'value': Crystal.KEEP,
							'label': '<span class="shipment_crystalaction_keep">Keep the crystal</span> in the pin. I want to send it again.'
						}
					]
				})
			} else {
				frm.formField({
					name: "pin" + pin.position + "action",
					label: "When the shipment is returned...",
					content: "Pin has no barcode, so it will be washed on return."
				});
			}
			let save = frm.buttonField({
				label: "Save",
			});
			save.querySelector("input").onclick = Shipment.DataCollection.savePin;
			save.id = "pin" + pin.position + "save";
			let tb = frm.closest(".tabbody");
			if (ui.nextElementSiblingMatchingSelector(tb,".enabledtab")) {
				save.querySelector("input").after(" and go to next");
			} else {
				save.querySelector("input").after(" and finish this puck");
			}
		},

		savePin: function (evt) {
			let clicked = evt.target;
			let lbl = clicked.closest("label");
			let f = clicked.closest("form");
			let tb = f.closest(".tabbody");
			let t = tb.previousElementSibling;
			let note = f.querySelector("textarea").value;
			let returnAction = "wash"; //default for non-barcoded pins
			if (f.querySelector("label.selected")) {
				//barcoded pins have options
				returnAction = f.querySelector("label.selected").querySelector("input").value;
			}
			let diffractionRequestId = t.pin.childitems[1]["diffractionrequestid"];
			lbl.classList.add("updating");

			//save note
			if ("" !== note.trim()) {
				new AjaxUtils.Request("/api/note", {
					method: "post",
					parameters: {
						"csrfToken": csrfToken,
						"parentid": t.pin.crystalid,
						"text": note.trim()
					},
					onSuccess: function () {
						clicked.noteSaved = true;
					},
					onFailure: function (transport) {
						AjaxUtils.checkResponse(transport);
					},
				});
			} else {
				//set success flag, no note to add.
				clicked.noteSaved = true;
			}

			//save return action
			new AjaxUtils.Request("/api/diffractionrequest/" + diffractionRequestId, {
				method: "patch",
				parameters: {
					"csrfToken": csrfToken,
					"actiononreturn": returnAction
				},
				onSuccess: function () {
					clicked.actionSaved = true;
				},
				onFailure: function (transport) {
					AjaxUtils.checkResponse(transport);
				},
			});
			clicked.dcnote = note.trim();
			clicked.actiononreturn = returnAction;
			window.setTimeout(function () {
				Shipment.DataCollection.checkPinSaved(clicked);
			}, 100);
		},

		checkPinSaved: function (saveButton) {
			if (!saveButton || !saveButton.actionSaved) {
				window.setTimeout(function () {
					Shipment.DataCollection.checkPinSaved(saveButton);
				}, 100);
				return false;
			}
			let t = saveButton.closest(".tabbody").previousElementSibling;
			t.classList.add("complete");
			let puckDiv = document.getElementById("dcpuck" + t.pin.puckid);
			puckDiv["pins"][t.pin.position].dcnote = saveButton.dcnote;
			puckDiv["pins"][t.pin.position].childitems[1]["actiononreturn"] = saveButton.actiononreturn;
			Shipment.DataCollection.advanceToNextPin(saveButton);
		},

		advanceToNextPin: function (saveButton) {
			let nextPinHeader = ui.nextElementSiblingMatchingSelector(saveButton.closest(".tabbody"),".enabledtab");
			if (nextPinHeader) {
				saveButton.noteSaved = false;
				saveButton.actionSaved = false;
				nextPinHeader.click();
				saveButton.closest("label").classList.remove("updating");
				window.setTimeout(function () {
					nextPinHeader.nextElementSibling.querySelector(".pinnumber span").classList.add("flash");
				}, 100);
				window.setTimeout(function () {
					nextPinHeader.nextElementSibling.querySelector(".pinnumber span").classList.remove("flash");
				}, 5000);
			} else {
				Shipment.DataCollection.closePuck(saveButton);
			}
		},

		closePuck: function (saveButton) {
			let header = saveButton.closest(".tabbody").previousElementSibling;
			let pin = header.pin;
			let puckDiv = document.getElementById("dcpuck" + pin.puckid);
			let ts = header.closest(".tabset");
			puckDiv.classList.remove("dccurrentpuck");
			ts.remove();
		},

		updatePuckCompletedStatus:function(puckButton){
			let completed=true;
			puckButton.pins.forEach(function(pin){
				if(!pin.childitems){ return; } //from this iteration
				if(!pin.actionupdated || ""===pin.childitems[1].actiononreturn){
					completed=false;
				}
			});
			if(completed){
				puckButton.classList.add("dccompletedpuck");
			} else {
				window.setTimeout(function () {
					Shipment.DataCollection.updatePuckCompletedStatus(puckButton);
				}, 1000);
			}
		},

	}, //end DataCollection

	/**
	 * Handles the return of pins, pucks and dewars.
	 */
	DewarReturn: {

		renderTab: function () {
			if (!data["objecttype"] || "shipment" !== data["objecttype"]) {
				//Not on a shipment page!
				return false;
			} else if ("" === data.datereturned) {
				//Shipment has not been returned
				return false;
			} else if (!data["manifest"]) {
				//no idea what was in the shipment
				return false;
			}
			document.getElementById("shiptabs").tab({
				label: "Shipment return",
				id: "shipmentreturn",
				content: ""
			});
			let dewars = data["manifest"].rows;
			dewars.forEach(function (dewar) {
				Shipment.DewarReturn.renderDewar(dewar);
			});
		},

		renderDewar: function (dewar) {
			//Ensure that the dewar was removed from the shipment when shipment was marked returned.
			//If the containercontent for the shipment-dewar relationship exists, delete it.
			new AjaxUtils.Request("/api/containercontent/" + dewar.containercontentid, {
				"method": "get",
				onSuccess: function () {
					new AjaxUtils.Request("/api/containercontent/" + dewar.containercontentid, {
						"method": "delete",
						"parameters": {"csrfToken": csrfToken},
						onSuccess: function () {
						},
						onFailure: function () {
							//alert("Dewar was not removed from shipment when shipment was returned. Could not remove it now.")
						}
					});
				},
				onFailure: function (transport) {
					if (404 !== transport.status) {
						return AjaxUtils.checkResponse(transport);
					}
				}
			});
			//Render a tree item for this dewar
			let ti = document.getElementById("shipmentreturn_body").treeItem({
				header: "Dewar " + dewar.name + ': <span class="returndetails" style="margin-right:1em">...Checking...</span>'
			});
			let th = ti.querySelector(".treehead");
			let tb = ti.querySelector(".treebody");
			th.classList.add("updating");
			//And render each puck within the dewar, after checking whether the dewar-puck relationship still exists
			dewar.childitems.forEach(function (puck) {
				if ("" === puck || "dummy" === puck) {
					return;
				}
				new AjaxUtils.Request("/api/containercontent/" + puck["containercontentid"], {
					method: "get",
					onSuccess: function () {
						Shipment.DewarReturn.renderPuck(puck, tb, true);
					},
					onFailure: function (transport) {
						if (404 !== transport.status) {
							AjaxUtils.checkResponse(transport);
						} else {
							Shipment.DewarReturn.renderPuck(puck, tb, false);
						}
					},
				});
			});
			window.setTimeout(function () {
				Shipment.DewarReturn.updateDewarHeader(th);
			}, 1000);
		},

		updateDewarHeader: function (dewarHeader) {
			let treeItem = dewarHeader.closest(".treeitem");
			window.setTimeout(function () {
				Shipment.DewarReturn.updateDewarHeader(dewarHeader);
			}, 1000);
			// if (treeItem.querySelector(".treebody .updating")) {
			// 	return false;
			// }
			let pucksStillInDewar = 0;
			let crystalsStoredElsewhere=0;
			let pucks = treeItem.querySelectorAll(".treebody .treeitem");
			pucks.forEach(function (puck) {
				if (1 * puck.dataset.puckindewar || "true"===puck.dataset.puckindewar) {
					pucksStillInDewar++;
				}
				crystalsStoredElsewhere+=1*(puck.dataset.crystalsStoredElsewhere);
			});

			let out = "";
			if (!pucksStillInDewar) {
				out += "All pucks have been removed.";
			} else if (1 === pucksStillInDewar) {
				out += "1 puck is still in this dewar.";
			} else {
				out += pucksStillInDewar + " pucks are still in this dewar.";
			}
			if(crystalsStoredElsewhere){
				out+='<span style="color:#aaf">&nbsp;&nbsp;&nbsp;&nbsp;Crystals stored elsewhere: ' + crystalsStoredElsewhere+'</span>';
			}
			dewarHeader.querySelector(".returndetails").innerHTML = out;
			dewarHeader.classList.remove("updating"); //Only use this for initial render, then auto-update.
		},

		renderPuck: function (puck, dewarBody, puckStillInDewar) {
			let ti = dewarBody.treeItem({
				header: '<span class="removebuttons" style="float:right;margin:.25em 1em">...Checking...</span> Puck ' + puck.name + ': <span class="returndetails">...Checking...</span>'
			});
			let th = ti.querySelector(".treehead");
			let tb = ti.querySelector(".treebody");
			th.classList.add("updating");
			ti.dataset.puckindewar = puckStillInDewar;
			ti.dataset.ccPuckInDewar = puck.containercontentid;

			let positions = {"rows": puck.childitems.slice(1)};
			let t = tb.table({
				headers: ["Pos", "Pin", "Crystal", "Protein", "Action on return", ""],
				cellTemplates: ["{{position}}", "", "", "", "", ""] //Pin renderer will populate
			}, positions);

			t.querySelectorAll("tr.datarow").forEach(function (row) {
				let pin = row.rowData;
				if (pin.isEmpty) {
					let cells = row.querySelectorAll("td");
					cells[5].innerHTML = "";
					row.dataset.crystalinpin = "";
					row.dataset.pininpuck = "";
					row.dataset.actiononreturn = "";
				} else {
					row.querySelector("td").classList.add("updating");
					//is pin still in puck?
					new AjaxUtils.Request("/api/containercontent/" + pin.containercontentid, {
						method: "get",
						onSuccess: function () {
							row.dataset.pininpuck = "1";
						},
						onFailure: function () {
							row.dataset.pininpuck = "0";
						}
					});
					//is crystal still in pin?
					new AjaxUtils.Request("/api/containercontent/" + pin.childitems[1].containercontentid, {
						method: "get",
						onSuccess: function () {
							row.dataset.crystalinpin = "1";
						},
						onFailure: function () {
							row.dataset.crystalinpin = "0";
						}
					});
					//What should be done with the pin?
					new AjaxUtils.Request("/api/diffractionrequest/" + pin.childitems[1]["diffractionrequestid"], {
						method: "get",
						onSuccess: function (transport) {
							row.dataset.actiononreturn = transport.responseJSON.actiononreturn;
						},
						onFailure: function () {
							row.dataset.actiononreturn = "fail";
						}
					});
					//Render the pin.
					Shipment.DewarReturn.renderPin(row);
				}
			});
			window.setTimeout(function () {
				Shipment.DewarReturn.updatePuckHeader(th);
			}, 500);
		},

		updatePuckHeader: function (puckHeader) {
			window.setTimeout(function () {
				Shipment.DewarReturn.updatePuckHeader(puckHeader);
			}, 1000);
			let treeItem = puckHeader.closest(".treeitem");
			// if (treeItem.querySelector(".updating")) {
			// 	return false;
			// }

			let rows = treeItem.querySelectorAll("tr.datarow");
			let crystalsToCheckWithOwner = 0;
			let crystalsToKeepInPuck = 0;
			let crystalsToKeepElsewhere = 0;
			let emptyPinsInPuck = 0;
			let crystalsToWash = 0;

			//Do pins still need to be washed? y/n
			//Have crystals been kept, and still in the puck? y/n
			rows.forEach(function (row) {
				let actionOnReturn=row.dataset.actiononreturn;
				let crystalInPin=1*row.dataset.crystalinpin;
				let pinInPuck=1*row.dataset.pininpuck;
				if (1 * row.rowData.isEmpty || (!crystalInPin && !pinInPuck)) {
					//crystal not in pin, pin not in puck, job done
				} else if (crystalInPin && pinInPuck) {
					//crystal in pin, pin in puck, nothing has been done yet
					if("keep"===actionOnReturn){
						crystalsToKeepInPuck++;
					} else if("wash"===actionOnReturn){
						crystalsToWash++;
					} else {
						crystalsToCheckWithOwner++;
					}
				} else if (!crystalInPin && pinInPuck) {
					//Pin is empty but still in puck
					emptyPinsInPuck++;
				} else if (crystalInPin && !(pinInPuck)) {
					//Crystal is in pin, but removed from puck
					crystalsToKeepElsewhere++;
				} else {
					//Something weird happened
				}
			});

			let parts = [];
			if (crystalsToKeepInPuck) {
				parts.push('<span class="shipment_crystalaction_keep">'+"Crystals to keep, in puck: " + crystalsToKeepInPuck+"</span>");
			}
			if (crystalsToKeepElsewhere) {
				parts.push('<span class="shipment_crystalaction_kept">' +"Crystals stored elsewhere: " + crystalsToKeepElsewhere+"</span>");
				treeItem.dataset.crystalsStoredElsewhere=crystalsToKeepElsewhere+"";
			}
			if (crystalsToWash) {
				parts.push('<span class="shipment_crystalaction_wash">' +"Pins to wash: " + crystalsToWash+"</span>");
			}
			if (emptyPinsInPuck) {
				parts.push('<span class="shipment_crystalaction_wash">' +"Empty pins in puck: " + emptyPinsInPuck+"</span>");
			}
			if (crystalsToCheckWithOwner) {
				parts.push('<span class="shipment_crystalaction_ask">' +"Check with pin owner: " + crystalsToCheckWithOwner+"</span>");
			}
			let out = parts.join("&nbsp;&nbsp;&nbsp;&nbsp;");
			if ("" === out) {
				out = "All pins have been removed.";
			} else if(crystalsToKeepElsewhere){
				out = "All pins have been removed.&nbsp;&nbsp;&nbsp;&nbsp;"+out;
			}
			puckHeader.querySelector(".returndetails").innerHTML = out;

			//Do I need a "remove puck" button? y/n
			if ("1"===treeItem.dataset.puckindewar || "true"===treeItem.dataset.puckindewar) {
				treeItem.querySelector(".removebuttons").innerHTML = '<input onclick="Shipment.DewarReturn.removePuckFromDewar(this);return false" value="Remove puck from dewar" type="button" style="float:right" />';
			} else {
				treeItem.querySelector(".removebuttons").innerHTML = "";
			}
			puckHeader.classList.remove("updating"); //Only use this for initial render, then auto-update.
		},

		renderPin: function (row) {
			//Wait for checks on current status to complete
			if (undefined===row.dataset.crystalinpin || undefined===row.dataset.pininpuck || ""===row.dataset.crystalinpin || ""===row.dataset.pininpuck || undefined===row.dataset.actiononreturn) {
				window.setTimeout(function () {
					Shipment.DewarReturn.renderPin(row);
				}, 250);
				return false;
			}

			let cells = row.querySelectorAll("td");
			let pin = row.rowData;

			row.dataset.ccPinInPuck = pin.containercontentid;
			row.dataset.ccCrystalInPin = pin.childitems[1].containercontentid;

			if (0 === pin.name.indexOf("dummypin")) {
				cells[1].innerHTML = "(no barcode)";
			} else {
				cells[1].innerHTML = '<a href="/container/' + pin.id + '">' + pin.name + '</a>';
			}

			cells[2].innerHTML = '<a href="' + pin.crystallocalurl + '">' + pin.childitems[1].name + '</a>';

			cells[3].innerHTML = pin.proteinacronym;

			let keepCrystal = true;
			let action = '<span class="shipment_crystalaction_ask">Check with owner</span>';
			if ("keep" === row.dataset.actiononreturn) {
				keepCrystal = true;
				action = '<span class="shipment_crystalaction_keep">Keep crystal</span>';
			} else if ("wash" === row.dataset.actiononreturn) {
				keepCrystal = false;
				action = '<span class="shipment_crystalaction_wash">Wash pin</span>';
			}
			cells[4].innerHTML = action;

			let pinInPuck = 1 * row.dataset.pininpuck;
			let crystalInPin = 1 * row.dataset.crystalinpin;

			let controls="";
			if (pinInPuck) {
				if (crystalInPin) {
					//pin still in puck, with crystal
					if (pin.name.indexOf("dummypin")) {
						controls += 'Remove and ' +
							'<input onclick="Shipment.DewarReturn.removePinFromPuck(this)" value="Keep crystal" type="button" style="float:none" /> ' +
							'<input onclick="Shipment.DewarReturn.removePinAndWashCrystal(this)" value="Wash pin" type="button" style="float:none" />';
					} else {
						controls += 'Non-barcoded pin. ' +
							'<input onclick="Shipment.DewarReturn.removePinAndWashCrystal(this)" value="Remove and wash" type="button" style="float:none" />';
					}
				} else {
					//pin still in puck, crystal washed
					//Shouldn't happen. Remove the pin.
					controls += 'Pin washed, but still in puck. <input onclick="Shipment.DewarReturn.removePinFromPuck(this)" value="Remove" type="button" style="float:none" />';
				}
			} else {
				if (crystalInPin) {
					//pin removed, crystal kept
					controls += 'Pin moved to storage, crystal kept. <input onclick="Shipment.DewarReturn.removeCrystalFromPin(this)" value="Wash pin" type="button" style="float:none" />';
					if (keepCrystal) {
						row.classList.remove("outstanding");
					}
				} else {
					//pin removed and washed
					controls += "Pin removed and washed.";
					row.classList.remove("outstanding");
				}
			}
			cells[5].innerHTML = controls;

			row.querySelector("td").classList.remove("updating");
		},

		removePuckFromDewar: function (btn) {

			let puckHeader = btn.closest(".treehead");
			let treeItem = puckHeader.closest(".treeitem");
			puckHeader.classList.add("updating");
			if ("true"===treeItem.dataset.puckindewar) {
				new AjaxUtils.Request("/api/containercontent/" + treeItem.dataset.ccPuckInDewar, {
					method: "delete",
					parameters: {csrfToken: csrfToken},
					onSuccess: function () {
						treeItem.dataset.puckindewar = "false";
					},
					onFailure: function (transport) {
						AjaxUtils.checkResponse(transport);
					},
				});
			}
			return false;
		},

		removePinFromPuck: function (btn) {
			let pinTr = btn.closest("tr");
			Shipment.DewarReturn.waitForPinStateAndReRender(pinTr, false, pinTr.dataset.crystalinpin);
			Shipment.DewarReturn._doRemovePinFromPuck(pinTr);
		},

		removeCrystalFromPin: function (btn) {
			let pinTr = btn.closest("tr");
			if ("keep" === pinTr.dataset.actiononreturn && !confirm("This crystal is to be kept. Really wash the pin?")) {
				return false;
			}
			Shipment.DewarReturn.waitForPinStateAndReRender(pinTr, pinTr.dataset.pininpuck, false);
			Shipment.DewarReturn._doRemoveCrystalFromPin(pinTr);
		},

		removePinAndWashCrystal: function (btn) {
			let pinTr = btn.closest("tr");
			if ("keep" === pinTr.dataset.actiononreturn && !confirm("This crystal is to be kept. Really wash the pin?")) {
				return false;
			}
			Shipment.DewarReturn.waitForPinStateAndReRender(pinTr, false, false);
			Shipment.DewarReturn._doRemoveCrystalFromPin(pinTr);
			Shipment.DewarReturn._doRemovePinFromPuck(pinTr);
		},

		_doRemovePinFromPuck: function (pinTr) {
			if (1 * pinTr.dataset.pininpuck) {
				new AjaxUtils.Request("/api/containercontent/" + pinTr.dataset.ccPinInPuck, {
					method: "delete",
					parameters: {csrfToken: csrfToken},
					onSuccess: function () {
						pinTr.dataset.pininpuck = "0";
						if("1"===pinTr.dataset.crystalinpin){
							let treeItem=pinTr.closest(".treeitem");
							if(treeItem.dataset.crystalsStoredElsewhere){
								treeItem.dataset.crystalsStoredElsewhere=""+(1+parseInt(treeItem.dataset.crystalsStoredElsewhere));
							} else {
								treeItem.dataset.crystalsStoredElsewhere="1";
							}
						}
					},
					onFailure: function (transport) {
						AjaxUtils.checkResponse(transport);
					},
				});
			}
		},

		_doRemoveCrystalFromPin: function (pinTr) {
			if (1 * pinTr.dataset.crystalinpin) {
				new AjaxUtils.Request("/api/containercontent/" + pinTr.dataset.ccCrystalInPin, {
					method: "delete",
					parameters: {csrfToken: csrfToken},
					onSuccess: function () {
						pinTr.dataset.crystalinpin = "0";
					},
					onFailure: function (transport) {
						AjaxUtils.checkResponse(transport);
					},
				});
			}
		},

		waitForPinStateAndReRender: function (pinTr, pinInPuck, crystalInPin) {
			if (1 * pinInPuck !== 1 * pinTr.dataset.pininpuck || 1 * crystalInPin !== 1*pinTr.dataset.crystalinpin) {
				window.setTimeout(function () {
					Shipment.DewarReturn.waitForPinStateAndReRender(pinTr, pinInPuck, crystalInPin);
				}, 250);
				return false;
			}
			Shipment.DewarReturn.renderPin(pinTr); //Puck and dewar above will update on their regular schedule.
		},

	}, //end DewarReturn

	DatasetRetrieval:{

		datasets:[],

		beamlines:[],
		beamlineNameToId:{},

		renderProgressNumbers:function (){
			let mb=document.getElementById("modalBox").querySelector(".boxbody");
			if(!document.getElementById("progressnumbers")){
				let pn=document.createElement("div");
				pn.id="progressnumbers";
				let counts=["Found","Processed","Succeeded","Failed"];
				counts.forEach(function (c){
					let dv=document.createElement("div");
					dv.style.display="inline-block";
					dv.style.margin="1em";
					dv.style.textAlign="center";
					dv.style.width="8em";
					let num=document.createElement("div");
					num.id="num"+c;
					num.classList.add("bignumbers");
					dv.appendChild(num);
					num.innerHTML="0";
					num.dataset.numDatasets="0";
					dv.innerHTML+=c;
					pn.appendChild(dv);
				});
				mb.appendChild(pn);
			}
			Shipment.DatasetRetrieval.setFoundCount();
		},
		setFoundCount:function(){
			document.getElementById("numFound").innerHTML=Shipment.DatasetRetrieval.datasets.length+"";
		},
		incrementFailedCount:function (message){
			ui.logToDialog(message,"error");
			Shipment.DatasetRetrieval.incrementProgressCount("Processed");
			Shipment.DatasetRetrieval.incrementProgressCount("Failed");
		},
		incrementSucceededCount:function (message){
			ui.logToDialog(message,"success");
			Shipment.DatasetRetrieval.incrementProgressCount("Processed");
			Shipment.DatasetRetrieval.incrementProgressCount("Succeeded");
		},
		incrementProgressCount:function (name){
			let elem=document.getElementById("num"+name);
			elem.innerHTML=(1+parseInt(elem.innerHTML))+"";
			if("Processed"===name && parseInt(document.getElementById("numFound").innerHTML)===parseInt(document.getElementById("numProcessed").innerHTML)){
				ui.logToDialog("All datasets processed");
			}
		},

		/**
		 * Creates or updates IceBear beamline records matching the supplied objects.
		 * @param beamlines an array of beamline objects, containing at minimum "name" and "shipmentdestinationid"
		 * 					properties. "detectormanufacturer", "detectormodel" and "detectortype" are optional.
		 */
		updateIceBearBeamlines:function (beamlines){
			Shipment.DatasetRetrieval.beamlines=beamlines;
			beamlines.forEach(function(bl){
				new AjaxUtils.Request("/api/beamline/shipmentdestinationid/"+data["shipmentdestinationid"]+"/name/"+encodeURIComponent(bl["name"]), {
					method:'get',
					onSuccess:function(transport){
						let found=transport.responseJSON;
						if(found.rows){
							if(1!==found.rows.length){
								ui.logToDialog("Found multiple beamlines at "+data["shipmentdestinationname"]+" called "+bl.name+", this should not happen","error");
							} else {
								found=found.rows[0];
							}
						}
						if(found.id){
							ui.logToDialog("Found IceBear beamline with name "+bl.name+", updating");
							Shipment.DatasetRetrieval.createOrUpdateIceBearBeamline(bl, found.id);
						} else {
							ui.logToDialog("Error on checking for IceBear beamline","error");
						}
					},
					onFailure:function(transport){
						if(404===transport.status){
							ui.logToDialog("No IceBear beamline for this synchrotron with name "+bl.name+", creating");
							Shipment.DatasetRetrieval.createOrUpdateIceBearBeamline(bl, null);
						} else {
							ui.logToDialog("Error on checking for IceBear beamline, HTTP "+transport.status, "error");
						}
					}
				});
			});
		},

		createOrUpdateIceBearBeamline:function(beamline, localId){
			let uri="/api/beamline/";
			let method="post";
			if(localId){
				uri+=localId;
				method="patch";
				ui.logToDialog("Updating IceBear beamline "+beamline["name"]);
			} else {
				ui.logToDialog("Creating IceBear beamline "+beamline["name"]);
			}
			beamline["csrfToken"]=csrfToken;
			new AjaxUtils.Request(uri,{
				method:method,
				parameters:beamline,
				onSuccess:function (transport){
					ui.logToDialog("Created/updated IceBear beamline OK","success");
					let beamlineId=0;
					if(transport.responseJSON["created"]){ beamlineId=transport.responseJSON["created"].id; }
					if(transport.responseJSON["updated"]){ beamlineId=transport.responseJSON["updated"].id; }
					Shipment.DatasetRetrieval.beamlineNameToId[beamline["name"]]=beamlineId;
				},
				onFailure:function (transport){
					ui.logToDialog("Error on creating/updating IceBear beamline, HTTP "+transport.status, "error");
				}
			});
		},

		getRemoteCrystalIdsFromManifest:function(manifest) {
			let ids=[];
			if (manifest["rows"]) { manifest = manifest["rows"]; }
			manifest.forEach(function(item){
				if(item["childitems"]){
					ids=ids.concat(Shipment.DatasetRetrieval.getRemoteCrystalIdsFromManifest(item["childitems"]));
				} else if(item["crystalidatremotefacility"]){
					ids.push(item["crystalidatremotefacility"]);
				}
			});
			return ids;
		},


	}, //end DatasetRetrieval

	Results:{

		getProteinAcronymsInManifestItem(item,acronyms){
			if(!acronyms){ acronyms=[]; }
			if(item.proteinacronym){
				acronyms.push(item.proteinacronym);
			} else if(item.rows){
				item.rows.forEach(function(row){
					acronyms=Shipment.Results.getProteinAcronymsInManifestItem(row, acronyms);
				});
			} else if(item.childitems){
				item.childitems.forEach(function (childItem){
					acronyms=Shipment.Results.getProteinAcronymsInManifestItem(childItem, acronyms);
				});
			}
			return [...new Set(acronyms)]; //https://stackoverflow.com/questions/1960473/get-all-unique-values-in-a-javascript-array-remove-duplicates
		},

		renderTab:function(){
			if(!data["manifest"]){ return; }
			let ts=document.getElementById("shiptabs");
			if (!ts || !data["manifest"]) {
				return;
			}
			let t=document.getElementById("shipment_results_body");
			if(!t){
				ts.tab({
					"id": "shipment_results",
					"label": "Datasets"
				});
				t=document.getElementById("shipment_results_body");
			}
			t.innerHTML="";
			let acronyms=Shipment.Results.getProteinAcronymsInManifestItem(data["manifest"]);
			acronyms.sort(function (a, b) {
				return a.toLowerCase().localeCompare(b.toLowerCase());
			});
			acronyms.forEach(function (acronym){
				let acronymTreeItem=t.treeItem({
					"header":"Protein: "+acronym,
					"content":""
				}).querySelector(".treebody");
				Dataset.listTable(acronymTreeItem, "shipmentid",data["id"],"proteinacronym",acronym);
				if(1===acronyms.length){
					acronymTreeItem.closest(".treeitem").classList.remove("closed");
				}
			});
		}
	} //end Results

}; //end Shipment

let Report= {

	builder: null,
	resultFormat: null,
	objectTypes: null,
	report: null,

	init: function(){
		let grid=document.getElementById("grid");
		if(!grid){ grid=ui.grid(); }
		let box=grid.box({
			classes:"r1 c1 w1 h3",
			id:"builder",
			content:""
		});
		Report.builder=box.treeItem({
			"isOpen":true,
			"header":"Selection criteria",
			"content":""
		}).querySelector(".treebody");
		Report.builder.onsubmit="return false";
		Report.resultFormat=box.treeItem({
			"isOpen":true,
			"header":"Result format",
			"content":""
		}).querySelector(".treebody");
		Report.resultFormat.onsubmit="return false";
		Report.actionButtons();
		Report.render();
	},

	actionButtons:function(){
		let buttons=ui.formField({},document.getElementById("builder").querySelector(".boxbody"));
		buttons.style.textAlign="center";
		buttons.style.marginTop="0.25em";
		buttons.innerHTML='<input type="button" value="Run report" onclick="Report.run()"/>&nbsp;<input type="button" value="Save report..." onclick="Report.save()" />';
	},

	render: function () {
		if (!Report.objectTypes) {
			return alert("set Report.objectTypes");
		}
		if (!Report.report) {
			return alert("set Report.report");
		}
		Report.builder.innerHTML = "";
		Report.builder.addEventListener("click", Report.hideRoles);
		if (Report.report.selectFrom && "object" !== typeof Report.objectTypes[Report.report.selectFrom].properties) {
			Report.getPropertiesForObjectType(Report.report.selectFrom);
			return window.setTimeout(Report.render, 250);
		}
		if(!Report.report["groupAndCountBy"] && !Report.report["showColumns"]){
			Report.report["groupAndCountBy"]=Object.keys(Report.objectTypes[Report.report.selectFrom]["properties"])[0];
		} else if(Report.report["groupAndCountBy"] && Report.report["showColumns"]){
			alert("Report contains both groupAndCountBy and showColumns. This is incorrect.");
			return false;
		}
		Report.renderTypeSelector();
		Report.renderResultFormat();
		if (!Report.report.selectFrom) {
			return;
		}
		Report.renderConditions();
	},

	renderTypeSelector: function () {
		let options = [{"label": "-", "value": ""}];
		Object.keys(Report.objectTypes).forEach(function (className) {
			options.push({
				"label": Report.objectTypes[className]['label'],
				"value": Report.objectTypes[className]['name']
			});
		});
		let sel = ui.dropdown({
			options: options,
			name:"selectFrom",
			label: "Show..."
		}, Report.builder).querySelector("select");
		let sel2 = sel.cloneNode(true);
		sel.parentElement.replaceChild(sel2, sel);
		sel2.value = Report.report["selectFrom"] || "";
		sel2.insertAdjacentHTML("afterend", '<span style="font-weight:bold">&nbsp;where...</span>');
		sel2.addEventListener("change", Report.handleObjectTypeChange);
	},

	handleObjectTypeChange: function () {
		let newType = Report.builder.querySelector("select").value;
		if(!newType){ return false; }
		Report.report = {"selectFrom": newType};
		Report.getPropertiesForObjectType(newType);
		Report.render();
	},

	getPropertiesForObjectType: function (objectType) {
		if (Report.objectTypes[objectType]["properties"]) {
			return false;
		}
		Report.objectTypes[objectType]["properties"] = "loading";
		new AjaxUtils.Request("/api/" + objectType + "/fielddetails", {
			method: "get",
			onFailure: function () {
				Report.report={};
				Report.render();
				Report.resultFormat.innerHTML='';
				window.setTimeout(function (){ alert("Could not get properties for " + objectType)}, 50);
			},
			onSuccess: function (xhr) {
				Report.objectTypes[objectType]["properties"] = xhr.responseJSON["rows"];
				Report.renderResultFormat();
			}
		});
	},

	renderConditions: function () {
		if (Report.report.selectFrom && "object" !== typeof Report.objectTypes[Report.report.selectFrom]["properties"]) {
			return window.setTimeout(Report.renderConditions, 250);
		}
		if (Report.report["and"]) {
			Report.renderConditionsBlock(Report.builder, Report.report["and"], "and");
		} else if (Report.report["or"]) {
			Report.renderConditionsBlock(Report.builder, Report.report["or"], "or");
		} else {
			Report.renderConditionsBlock(Report.builder);
			let block = Report.builder.querySelector(".conditionsBlock");
			Report.renderCondition(block, {"property": "", "condition": "", "value": ""});
			block.appendChild(block.querySelector("label").remove());
		}
	},

	renderConditionsBlock(parent, conditions, andOr) {
		if (parent.dataset.andOr) {
			let ff = ui.formField({label: parent.dataset.andOr, content: "&nbsp;"}, parent);
			let childBlockType=("or"===parent.dataset.andOr ? "AND" : "OR");
			Report.renderConditionDeleteIcon(ff, "Delete entire "+childBlockType.toUpperCase()+" block");
		}
		let block = document.createElement("div");
		block.classList.add("conditionsBlock");
		block.dataset.andOr = andOr;
		block.style.borderLeft = "1px solid black";
		block.style.margin = "0.25em 0 0.25em 1.5em";
		block.style.padding = "0 0.5em 0 1.5em";
		block.style.position = "relative";
		parent.appendChild(block);
		if (conditions) {
			if ("and" !== andOr && "or" !== andOr) {
				alert("Report.renderConditionsBlock: bad andOr '" + andOr + "'");
				return false;
			}
			conditions.forEach(function (condition) {
				if (condition["and"]) {
					Report.renderConditionsBlock(block, condition["and"], "and");
				} else if (condition["or"]) {
					Report.renderConditionsBlock(block, condition["or"], "or");
				} else {
					Report.renderCondition(block, condition);
				}
			});
		}
		Report.renderAddBar(block);
	},

	renderAddBar: function (block) {
		let conditions = block.querySelectorAll(".condition");
		let ff = ui.formField({label: ""}, block);
		ff.style.textAlign = "center";
		if ("and" === block.dataset.andOr) {
			ff.innerHTML = '<input type="button" value="Add AND" onclick="Report.addCondition(this,\'and\')" /> <input type="button" value="Add OR block" onclick="Report.addBlock(this)" />';
		} else if ("or" === block.dataset.andOr) {
			ff.innerHTML = '<input type="button" value="Add OR" onclick="Report.addCondition(this,\'or\')" /> <input type="button" value="Add AND block" onclick="Report.addBlock(this)" />';
		} else {
			ff.innerHTML = '<input type="button" value="Add AND" onclick="Report.addCondition(this,\'and\')" /> <input type="button" value="Add OR" onclick="Report.addCondition(this,\'or\')" />';
		}
	},

	addBlock: function (btn) {
		let ff = btn.closest("label");
		let block = ff.closest(".conditionsBlock");
		let conditions = [
			{"property": "", "condition": "", "value": ""},
			{"property": "", "condition": "", "value": ""}
		];
		Report.renderConditionsBlock(block, conditions, "or" === block.dataset.andOr ? "and" : "or");
		block.appendChild(ff.remove());
	},

	addCondition: function (btn, andOr) {
		let ff = btn.closest("label");
		let block = ff.closest(".conditionsBlock");
		let condition = {"property": "", "condition": "", "value": ""};
		block.dataset.andOr = andOr;
		ff.remove();
		Report.renderCondition(block, condition);
		Report.renderAddBar(block);
	},

	renderCondition: function (block, condition) {
		let label = "";
		if (block.querySelector(".condition")) {
			label = block.dataset.andOr;
		}

		let options = [{"label": "-", "value": ""}];
		Object.keys(this.objectTypes[Report.report.selectFrom]["properties"]).forEach(function (key) {
			let prop = Report.objectTypes[Report.report.selectFrom]["properties"][key];
			options.push({"label": prop["label"], "value": key});
		})
		let ff = ui.dropdown({
			label: label,
			name: "property",
			options: options,
			value: condition["property"]
		}, block);
		ff.classList.add("condition");
		let sel = ff.querySelector("select");
		let sel2 = sel.cloneNode(true);
		sel2.value = sel.value;
		sel.parentElement.replaceChild(sel2, sel);
		sel2.addEventListener("change", Report.handleConditionPropertyChange);
		Report.renderConditionFields(ff, condition);
		Report.renderConditionDeleteIcon(ff, "Delete condition");
	},

	handleConditionPropertyChange: function (evt) {
		Report.renderConditionFields(evt.target);
	},

	renderConditionFields: function (lbl, condition) {
		lbl = lbl.closest("label");
		let frm = lbl.querySelector(".conditionFields");
		if (!frm) {
			frm = document.createElement("div");
			frm.style.display = "inline";
			frm.classList.add("conditionFields");
			lbl.appendChild(frm);
		}
		frm.innerHTML = "";
		if (!condition) {
			condition = {
				"property": lbl.querySelector("select").value,
				"condition": "",
				"value": ""
			};
		}
		if (!condition.property) {
			return Report.renderNullConditionFields(lbl);
		}
		lbl.property=Report.objectTypes[Report.report.selectFrom]["properties"][condition.property];
		let validations = Report.objectTypes[Report.report.selectFrom]["properties"][condition.property]["validations"];
		if (Report.objectTypes[Report.report.selectFrom]["properties"][condition.property]["otherTable"]){
			Report.renderRoleConditionFields(lbl, condition);
		} else if (validations.includes("float") || validations.includes("int")) {
			Report.renderNumberConditionFields(lbl, condition);
		} else if (validations.includes("boolean")) {
			Report.renderBooleanConditionFields(lbl, condition);
		} else if (validations.includes("datetime")) {
			Report.renderDateTimeConditionFields(lbl, condition, true);
		} else if (validations.includes("date")) {
			Report.renderDateTimeConditionFields(lbl, condition, false);
		} else {
			Report.renderTextConditionFields(lbl, condition);
		}
	},

	renderNullConditionFields: function (lbl) {
		lbl.querySelector(".conditionFields").innerHTML = "";
	},

	renderTextConditionFields: function (lbl, condition) {
		let verbs = {
			"eq": "is",
			"ne": "is not",
			"sw": "starts with",
			"ew": "ends with",
			"ct": "contains",
			"nsw": "does not start with",
			"new": "does not end with",
			"nct": "does not contain",
		}
		Report.renderConditionVerbs(lbl, condition, verbs);
		Report.renderConditionValueBox(lbl, condition);
	},

	renderRoleConditionFields: function (lbl, condition) {
		let verbs = {
			"in": "is",
			"nin": "is not"
		}
		Report.renderConditionVerbs(lbl, condition, verbs);
		lbl.querySelector(".conditionFields").innerHTML+='<input type="button" value="Choose..." onclick="Report.showRoles(this)"/>';
		let rolesDiv=lbl.querySelector(".roles");
		if(!rolesDiv){
			rolesDiv=document.createElement("div");
			rolesDiv.classList.add("roles");
			lbl.appendChild(rolesDiv);
		}
		if(!condition["value"]){
			condition["value"]=[];
		} else if(!Array.isArray(condition["value"])) {
			condition["value"] = condition["value"].split(",");
		}
		let otherTable=lbl["property"]["otherTable"];
		condition["value"].forEach(function (id) {
			AjaxUtils.Request("/api/" + otherTable+"/"+id, {
				method: "get",
				onFailure: function (xhr) {
					if (404 !== xhr.status) {
						alert("Could not get list of " + otherTable);
					}
				}, onSuccess: function (xhr){
					Report.addRole(xhr.responseJSON, lbl);
				}
			});
		});
	},

	renderDateTimeConditionFields: function (lbl, condition, showTime) {
		let verbs = {
			"eq": "is",
			"ne": "is not",
			"gt": "is after",
			"gte": "is on or after",
			"lt": "is before",
			"lte": "is on or before",
		}
		Report.renderConditionVerbs(lbl, condition, verbs);
		if(showTime){
			ui.dateTimePicker({ "value":condition["value"] }, lbl.querySelector(".conditionFields"));
		} else {
			ui.datePicker({ "value":condition["value"] }, lbl.querySelector(".conditionFields"));
		}
	},

	renderNumberConditionFields:function(lbl, condition){
		let verbs= {
			"eq": "is",
			"ne": "is not",
			"lt": "&lt;",
			"lte": "&lt;=",
			"gt": "&gt;",
			"gte": "&gt;="
		}
		if(!condition.value){ condition.value="0"; }
		Report.renderConditionVerbs(lbl, condition, verbs);
		Report.renderConditionValueBox(lbl, condition);
	},

	renderBooleanConditionFields:function (lbl, condition){
		let div=lbl.querySelector(".conditionFields");
		div.innerHTML='<span style="font-weight:bold">&nbsp;is&nbsp;</span><input type="hidden" name="condition" value="eq" />';
		let sel=document.createElement("select");
		sel.name="value";
		let values= {
			"1": "true",
			"0": "false"
		}
		Object.keys(values).forEach(function(val){
			let opt=document.createElement("option");
			opt.value=val;
			opt.innerHTML=values[val];
			if(1*condition["condition"]===1*val){
				opt.selected=true; //TODO Doesn't work
			}
			sel.appendChild(opt);
		});
		div.appendChild(sel);
	},

	renderConditionVerbs:function(lbl,condition,verbs){
		let div=lbl.querySelector(".conditionFields");
		div.innerHTML="";
		let sel=document.createElement("select");
		sel.name="condition";
		let current;
		Object.keys(verbs).forEach(function(verb){
			sel.innerHTML+='<option value="'+verb+'"'+(condition["condition"]===verb?' selected="selected"':'')+'>'+verbs[verb]+'</option>';
		});
		div.appendChild(sel);
	},

	renderConditionValueBox:function(lbl,condition,verbs){
		let div=lbl.querySelector(".conditionFields");
		let validations=Report.objectTypes[Report.report.selectFrom]["properties"][condition.property]["validations"];
		let box=document.createElement("input");
		box.name="value";
		box.type="text";
		box.value=condition["value"];
		box.style.width="7em";
		box.validations=validations;
		div.appendChild(box);
	},

	hideRoles:function(){
		let roleBox=document.getElementById("roleBox");
			if(roleBox){ roleBox.remove(); }
	},

	showRoles: function (btn){
		let lbl=btn.closest("label");
		let otherTable=lbl["property"]["otherTable"];
		AjaxUtils.Request("/api/"+otherTable, {
			method:"get",
			onFailure:function (xhr){
				if(404===xhr.status){
					alert("No "+otherTable+" found");
				} else {
					alert("Could not get list of "+otherTable);
				}
			}, onSuccess: function (xhr){
				let cellTemplates=['<input type="button" value="&lt;&lt; Add" onclick="Report.addRoleFromSearch(this)" />',"{{name}}"];
				if(xhr.responseJSON.rows[0]["label"]){
					let cellTemplates=["button","{{label}}"];
				}
				let box=document.getElementById("grid").box({
					"title":"Add "+lbl["property"]["label"].toLowerCase(),
					"id":"roleBox",
					"classes":"r1 c2 w2 h3",
					"url":"/api/"+otherTable,
					"showFilters":[false,true],
					"cellTemplates":cellTemplates
				});
				box.closest(".box").roleField=lbl;
				Report.waitForRoleTableAndSetWidths(box);
			}
		});
	},

	waitForRoleTableAndSetWidths:function(box){
		if(!box.querySelector("th,td")){
			return window.setTimeout(Report.waitForRoleTableAndSetWidths, 100, box);
		}
		box.querySelector("th,td").style.width="6em";

	},

	addRoleFromSearch: function(btn){
		Report.addRole(btn.closest("tr").rowData);
		btn.closest("tr").remove();
	},

	addRole: function(roleObject, formField){
		if(!formField){
			formField=document.getElementById("roleBox").roleField;
		}
		let rolesDiv=formField.querySelector(".roles");
		if(!rolesDiv){
			rolesDiv=document.createElement("div");
			rolesDiv.classList.add("roles");
			formField.appendChild(rolesDiv);
		}
		if(rolesDiv.querySelector('[data-role-id="'+roleObject.id+'"]')){
			return;
		}

		let link=document.createElement("span");
		link.addEventListener("click", Report.removeRole);
		link.title="Click to remove";
		link.dataset.roleId=roleObject["id"];
		link.style.cursor="pointer";
		link.style.display="inline-block";
		link.style.border="1px solid #888";
		link.style.margin="0 0 0.25em 0.5em";
		link.style.lineHeight="1.25em";
		link.style.padding="0.25em 1.5em 0.25em 0.25em";
		link.style.backgroundImage="url(/images/icons/no.gif)";
		link.style.backgroundRepeat="no-repeat";
		link.style.backgroundPosition="right 50%";
		link.style.backgroundSize="1.25em";
		link.innerText=(roleObject.label) ? roleObject.label : roleObject.name;
		rolesDiv.appendChild(link);
	},

	removeRole: function(evt){
		evt.stopPropagation();
		evt.target.remove();
		return false;
	},

	renderConditionDeleteIcon: function (parent, title){
		if(!title){ title="Delete"; }
		let img=document.createElement("img");
		img.src="/images/icons/no.gif";
		img.style.cursor="pointer";
		img.style.borderLeft="1px solid #888";
		img.style.marginLeft="0.25em";
		img.style.cssFloat="right";
		img.style.position="relative";
		img.style.left="0.25em";
		img.title=title;
		img.addEventListener("click", Report.deleteCondition);
		parent.insertAdjacentElement("afterbegin", img);
	},

	deleteCondition:function (evt){
		let ff=evt.target.closest("label");
		let next=ff.nextElementSibling;
		if(next && next.classList.contains("conditionsBlock")){
			next.remove();
		}
		ff.remove();
	},

	renderResultFormat: function(){
		let elem=Report.resultFormat;
		if(!Report.report.selectFrom){
			elem.innerHTML="Choose an object type first";
			return;
		} else if(!this.objectTypes[Report.report.selectFrom]["properties"]){
			elem.innerHTML="";
			return window.setTimeout(Report.renderResultFormat, 250);
		}
		elem.innerHTML="";
		elem.innerHTML+='<label id="groupAndCountLabel"><span class="label"><input onclick="Report.setResultFormatElementsVisibility()" type="radio" name="groupAndCount" value="1"/> Group and count</span>&nbsp;<span id="groupSelect">Group by </span></label>';
		elem.innerHTML+='<label id="showAsTableLabel"><span class="label"><input onclick="Report.setResultFormatElementsVisibility()" type="radio" name="groupAndCount" value="0"/> List in table <span id="withColumns">with columns:</span></span>&nbsp;<div style="display:inline-block; text-align: left;" id="columnsList"></div></label>';
		let groupOptions=[];
		let columnOptions=[];
		let groupBy=Object.keys(Report.objectTypes[Report.report.selectFrom]["properties"])[0];
		if(Report.report["groupAndCountBy"]){
			groupBy=Report.report["groupAndCountBy"];
		}
		Object.keys(Report.objectTypes[Report.report.selectFrom]["properties"]).forEach(function(prop){
			let label=Report.objectTypes[Report.report.selectFrom]["properties"][prop]["label"];
			let selected='';
			let checked='';
			if(groupBy===prop){
				selected=' selected="selected" ';
			}
			if(Report.report["showColumns"]){
				if(Report.report["showColumns"].includes(prop)) {
					checked = ' checked="checked" ';
				}
			}
			columnOptions.push('<span onclick="this.querySelector(\'input\').click()"><input type="checkbox" '+checked+' name="prop" value="'+prop+'"/>&nbsp;'+label+"</span>");
			groupOptions.push('<option '+selected+' value="'+prop+'">'+label+"</option>");
		});
		document.getElementById("columnsList").innerHTML=columnOptions.join('<br/>');
		document.getElementById("groupSelect").innerHTML+='<select id="groupBy" name="groupBy">'+groupOptions.join("")+"</select>";
		elem.innerHTML+='<label id="sortByLabel"><span class="label">Sort by</span><select id="sortBy" name="sortBy">'+groupOptions.join("")+'</select><select id="sortAscending" name="sortAscending"></select></label>';
		document.getElementById("sortAscending").innerHTML='<option value="1" selected="selected">Ascending</option><option value="0">Descending</option>';
		if(Report.report["groupAndCountBy"]) {
			document.getElementById("groupBy").value=groupBy;
			document.getElementById("groupAndCountLabel").querySelector("input[type=radio]").click();
		} else {
			document.getElementById("showAsTableLabel").querySelector("input[type=radio]").click();
		}
		Report.setResultFormatElementsVisibility();
	},

	setResultFormatElementsVisibility:function(){
		if(document.getElementById("groupAndCountLabel").querySelector("input[type=radio]").checked){
			document.getElementById("groupSelect").style.display="inline";
			document.getElementById("withColumns").style.display="none";
			document.getElementById("columnsList").style.display="none";
			document.getElementById("sortByLabel").style.display="none";
		} else {
			document.getElementById("groupSelect").style.display="none";
			document.getElementById("withColumns").style.display="inline";
			document.getElementById("columnsList").style.display="inline-block";
			document.getElementById("sortByLabel").style.display="block";
		}
	},

	save:function(){
		let json=Report.parseFormToJson();
		if(!json){ return false; }
		alert("not implemented");
	},

	run:function(){
		let json=Report.parseFormToJson();
		if(!json){ return false; }
		alert("not implemented");
	},

	parseFormToJson:function(){
		if(!Report.validateForm()){ return false; }

		let formReport={
			"selectFrom":document.getElementById("selectFrom").value
		};
		let conditionBlock=Report.builder.querySelector(".conditionsBlock");
		let andOr=conditionBlock.dataset.andOr;
		formReport[andOr]=Report.parseFormConditionsAndBlocks(conditionBlock, []);
		let groupAndCountLabel=document.getElementById("groupAndCountLabel");
		if(groupAndCountLabel.querySelector("input[name=groupAndCount]").checked){
			formReport["groupAndCountBy"]=groupAndCountLabel.querySelector("select").value;
		} else {
			let columns=[];
			Report.builder.querySelectorAll("#showAsTableLabel input[name=prop]").forEach(function(cb){
				if(cb.checked){
					columns.push(cb.value);
				}
			})
			formReport["showColumns"]=columns;
			formReport["sortBy"]=document.getElementById("sortBy").value;
			formReport["sortAscending"]=document.getElementById("sortAscending").value;
		}
		Report.report=formReport;
		return JSON.stringify(formReport);
	},

	parseFormConditionsAndBlocks:function (block, reportElement){
		let subBlocks=block.querySelectorAll(":scope > .conditionsBlock");
		let conditions=block.querySelectorAll(":scope > .condition");
		subBlocks.forEach(function (subBlock){
			let andOr=subBlock.dataset.andOr;
			let obj={};
			obj[andOr]=Report.parseFormConditionsAndBlocks(subBlock, []);;
			reportElement.push(obj);
		});
		conditions.forEach(function(condition){
			let roles=condition.querySelectorAll("[data-role-id]");
			let val;
			if(roles.length){
				val=[];
				roles.forEach(function(role){
					val.push(role.dataset.roleId);
				});
			} else {
				val=condition.querySelector("[name=value]").value;
			}
			reportElement.push({
				"property":condition.querySelector("[name=property]").value,
				"condition":condition.querySelector("[name=condition]").value,
				"value":val
			});
		});
		return reportElement;
	},

	validateForm:function(){
		//Undo any previous validation error highlighting
		Report.builder.querySelectorAll(".invalidfield .helptext").forEach(function(txt){
			txt.remove();
		});
		Report.builder.querySelectorAll(".invalidfield").forEach(function(field){
			field.classList.remove("invalidfield");
		});
		//Do we have an object type?
		Report.checkFormField(document.getElementById("selectFrom"), document.getElementById("selectFrom").value, "Object type is required");
		Report.builder.querySelectorAll(".condition").forEach(function(condition){
			let prop=condition["property"];
			if(prop["otherTable"]){
				Report.checkFormField(condition, condition.querySelector("span[data-role-id]"), "Choose at least one "+prop["label"].toLowerCase());
			} else {
				let val=condition.querySelector("[name=value]").value;
				let validations=condition["property"]["validations"];
				if(!Array.isArray(validations)){ validations=[validations]; }
				if(validations.includes("integer")){
					Report.checkFormField(condition, /^-?\d+$/.test(val), "Value must be a whole number");
				} else if(validations.includes("float")){
					Report.checkFormField(condition, !isNaN(val), "Value must be a number");
				} else {
					//catchall - make sure we have property, condition and value
					Report.checkFormField(condition, (condition.querySelector("select[name=property]").value && condition.querySelector("[name=condition]").value && condition.querySelector("[name=value]").value), "Property, condition, and value are required");
				}
			}
		})

		if(document.getElementById("showAsTableLabel").querySelector("input[type=radio]").checked){
			Report.checkFormField(document.getElementById("showAsTableLabel"), Report.builder.querySelector("#columnsList input[checked=checked]"), 'At least one column must be selected');
		}
		return !Report.builder.querySelector(".invalidfield");
	},

	checkFormField(formField, condition, message){
		if(condition){ return true; }
		formField=formField.closest("label");
		ui.addSuppliedHelpText(formField, message);
		formField.classList.add("invalidfield");
		return false;
	}

} //end Report

let OIDC={

	token:null,

	exists:function(){
		return window["remoteServiceOidcConfigFileExists"];
	},

	/**
	 * Calls server-side function to get the OIDC token, if not already retrieved. If not found, calls sendUserToRemoteAuthService.
	 * @param actionOnReturnIfUnauthenticated A short string identifying the action (not a function), to be checked on
	 *                      returning to the page after OIDC auth. The intent is to resume automatically the action the user was
	 *                      carrying out before being bounced to OIDC. "sendShipment" for example.
	 * @returns {*}
	 */
	getToken: function (actionOnReturnIfUnauthenticated){
		if(!OIDC.exists()){ return null; }
		if(!actionOnReturnIfUnauthenticated){
			alert("OIDC::getToken: actionOnReturnIfUnauthenticated is required");
			return null;
		}
		if(!OIDC.token && window["oidcToken"]){ OIDC.token=""+window["oidcToken"]; }
		if(OIDC.token){ return OIDC.token; }
		let callbackUrl=document.location.href;
		let frm=ui.form({ "action":"/api/oidc", "method":"post" });
		frm.hiddenField("csrfToken", csrfToken);
		frm.hiddenField("callbackUrl", callbackUrl);
		frm.hiddenField("actionOnReturn", actionOnReturnIfUnauthenticated);
		frm.method="post";
		document.body.appendChild(frm);
		frm.submit();
	},

	deleteToken: function(objectId){
		OIDC.token=null;
		window["oidcToken"]=null;
		new AjaxUtils.Request('/api/oidc/'+objectId, {
			method:"delete",
			postBody:"csrfToken="+csrfToken,
			onSuccess:function(){
				OIDC.token=null;
			},
			onFailure:function(xhr){
				AjaxUtils.checkResponse(xhr);
			}
		})
	}

};
