Perspective Scheduler - Drag and Drop From Table

I'm trying something I'm not 100% is possible to do but wanted to throw the question in case others have attempted it. We are experimenting with the Perspective Scheduler. A big potential GUI goal we have can be broken into a few steps:

  1. Have a table with rows of items that need to be put into the schedule. This is assumed to be working.

  2. Be able to click and drag a row from the table and fire an event when this occurs. This forum post by @victordcq below does enough to fulfill my goal. I am able to fire an event script and interact with the scheduler when a row is dropped.
    Dragging rows in Perspective Table - Ignition - Inductive Automation Forum

  3. Create a new scheduledEvent in the scheduler depending on where the user dropped the row of data. This is the part I'm struggling on. There are a few JS extension scripts tacked onto the perspective scheduler, but these are only triggerable by clicking into a square. Is it possible to, on an onClick event, to know where the mouse is located inside the scheduler? If I could somehow figure out where the mouse is located, perhaps I could locate the start and end date or the itemID needed to create a new scheduledEvent.

Thanks for any ideas.

1 Like

I'm assuming when you say Perspective Scheduler, you're talking about the Equipment Schedule component...

There probably is a way to do this pretty easily. I would think the hardest part of accomplishing what you want is the dragging and dropping. The Equipment Schedule component has a props.scheduleEvents property (if I remember correctly) and you'll need to persist that data across sessions. The easiest way to do this is to use a database, of course, which is how I've done it in the past. I suppose you could also create a Dataset or Document tag to hold those schedule events, or use a session custom property. I would think a database would be most useful, since you'll be able to do reporting on current and historical events easier.

Your task 3 becomes trivial - as long as you can trigger an action (ie. when you "drop" the table row onto the schedule), all you'd need to do is to insert a database record and refresh the binding on props.scheduleEvents and your event will appear in the schedule.

Last thing: something that tripped me up when I began using this component are the concepts of scheduleEvents and items - schedule events are your events on the schedule but items are the Lines or Areas you display (the rows on the left side of the Equipment Schedule component).

Apologies for the confusion, yes, I meant the equipment scheduler. These are good pointers, especially with the items. That was confusing me slightly.

I have two issues with the drag and drop capabilities so far. I can't seem to find a good way to trigger an event if a row is dropped into the scheduler. I also want to take into WHERE the user 'drops' the row. This way you could utilize the scheduler's structure. This means somehow getting knowledge of where the cursor is inside the scheduler or some way to trigger the add extension function. Not sure if this is possible.

similar to the script i provided here, you should also be able to add on drop events on the scheduler;

the cells have a attribute data-column and data-row, which you could use to determine where it was dropped (column 0 is start date of the scheduler, row 0 is the first item)

you can probably lso trigger an onclick event on these cells, but it might be tricky to combine this drag and drop and on click event handelrs if you do, probably best to just write the new scheduled event directly into the component. So that perspective can handle the moving and editing.
I have not checked if its easy to trigger an add event. ill have to take a deeper look next week

(not working just what you have to change: )
the selector to appy these 2 event too: .schedule-grid-cell.ia_equipmentScheduleComponent__gridSpace__gridCell

function ondrop(ev) {
	ev.preventDefault(); 
	const draggedId = ev.dataTransfer.getData('text');
	const droppedId = this.getAttribute('data-row-id'); 
	view.custom.write('"""+propName+"""',{'draggedId':draggedId,'droppedId':droppedId});
};
function ondragover(ev){
	ev.preventDefault();
};	
	
document.querySelectorAll('.schedule-grid-cell.ia_equipmentScheduleComponent__gridSpace__gridCell').forEach(e => {

i can make a working test project next week if i have the time

Thanks for the information @victordcq. I'm still fairly green on perspective development, let alone getting fancy with js injection. Would be really happy to see what you could throw together for this if you have the time.

As for an event to tie into, whenever I attempt to drag a row of data into the scheduler component, I cannot get an onClick event to trigger. Or even any event involving the mouse moving. I might just not have a great understanding of what I need to do for the drag to trigger properly. Just to make note, we are on 8.1.21 if we are missing some updates. Thanks for the help.

You can trigger an event using componentEvents. fireComponentEvent
It takes a bit of knowledge to find where to use it...

The problem is you need to fill in the event yourself (you can add whatever) So you will need to calculate the start date using the column id

might need some more insight on how ignition gets the start date, only way i see this to be possible using only info of the frontend is when dateRange prop is filled in, so you can calculate it... you might also have to grab the zoom level i suppsoe

(this is not complete and only half tested)

    const view = [...window.__client.page.views._data.values()].find(view => view.value.mountPath == this.parentNode.parentNode.parentNode.getAttributeNode('data-component-path').value.split('.')[0]).value; 
function ondrop(ev) {
  ev.preventDefault(); 
  const draggedId = ev.dataTransfer.getData('text');

  #the zoom interval (Month, Day, 6 hours ...) use to calcualte the date, 
  const interval = this.parentNode.parentNode.parentNode.parentNode.parentNode.parentNode.parentNode.querySelector(".ia_equipmentScheduleComponent__actionBar__dropdown .ia_dropdown__valueSingle").getAttribute("data-label")
  
  #it will be easier to calculute the start/end date in python using the dateRange together with the found interval and column
  const addEvent = {'end':'?','start':'?', 'itemId':this.getAttribute('data-row'), 'draggedId ':draggedId, 'interval':interval,'droppedColumn':this.getAttribute('data-column') }

  #assuming the schedule is child directly under the root 
  const scheduleComponent =  view.root.childComponents.find((component)=> component.def.meta.name=='EquipmentSchedule')
  scheduleComponent.componentEvents.fireComponentEvent('onAddEvent',{'event':addEvent})
};

Hmm not all the cells are loaded on page load, i'll need to modify it to apply the on drop events on dom changes.

But i made a proof on concept that is working so far, ill get back to it when i have some time

2 Likes

I actually got the drag and drop feature working, but I did not resolve your issue with the start and end dates completely. I just assumed each cell was one hour. I have been trying to do what you were doing and drill down to the selected interval with this line:

const interval = this.parentNode.parentNode.parentNode.parentNode.parentNode.parentNode.parentNode.querySelector(".ia_equipmentScheduleComponent__actionBar__dropdown .ia_dropdown__valueSingle").getAttribute("data-label")

However, I cannot seem to get the markdown to function when adding this. I've been trying to do some JS reading to figure out why that line isn't working, but no luck. If someone finds a way to get access to that selected scale, I will have everything I need to get this project up and running.

i'll find some time to finish this tmw.

the js injection is senstive to ";" at the end of each line, which i did not add everywhere in the snipet above as i wasnt testing that in ignition

I was able to grab the interval and am able to set the start time dynamic to the zoom level. Figured out the issue. However, as you mentioned earlier, the javascript is only applied to the cells that are loaded on the page. Since not all cells are loaded initially, once you scroll around, the drag and drop no longer functions. Is there a way to constantly apply the component binding to trigger whenever a new cell is loaded?

Update: This was something I was trying with my rudimentary JS skills. I was trying to use the testTwo function to print out to the console whenever the scroll bar is used, but the onchange is not working. I just don't know how to latch until a change in the grid object:

propName = "rowDroppedData"
		code =  """<img style='display:none' src='/favicon.ico' onload=\"
			const view = [...window.__client.page.views._data.values()].find(view => view.value.mountPath == this.parentNode.parentNode.parentNode.getAttributeNode('data-component-path').value.split('.')[0]).value; 
			function ondrop(ev) {
				ev.preventDefault();
				const draggedId = ev.dataTransfer.getData('text');
				const droppedRow = this.getAttribute('data-row');
				const droppedColumn = this.getAttribute('data-column')
				const droppedId = this.getAttribute('data-row-id');
				const interval = this.parentNode.parentNode.parentNode.parentNode.parentNode.parentNode.parentNode.querySelector('.ia_equipmentScheduleComponent__actionBar__dropdown .ia_dropdown__valueSingle').getAttribute('data-label');
				const addEvent = {'draggedId':draggedId,'itemId':droppedRow,'droppedColumn':droppedColumn,'interval':interval};
				view.custom.write('"""+propName+"""',addEvent);
				const scheduleComponent =  view.root.childComponents.find((component)=> component.def.meta.name=='EquipmentSchedule');
				scheduleComponent.componentEvents.fireComponentEvent('onAddEvent',{'event':addEvent});
			};
			function ondragstart(ev){
				ev.dataTransfer.setData('text', ev.target.getAttribute('data-row-id'));
			};
			function ondragover(ev){
				ev.preventDefault();
			};
			function test(){
			document.querySelectorAll('.schedule-grid-cell.ia_equipmentScheduleComponent__gridSpace__gridCell').forEach(e => {
				e.setAttribute('draggable',true);
				e.ondragstart = ondragstart;
				e.ondragover = ondragover;
				e.ondrop = ondrop;
			});
			}
			test();
			function testTwo(){
				view.custom.write('"""+propName+"""',{'test':'helloWorld'});
			};
			document.querySelectorAll('.ReactVirtualized__Grid__innerScrollContainer').onchange = function(){testTwo()};
		\"></img>""".replace("\n", "").replace("\t", "")
		return code

yes you can setup a mutation observer that triggers on domchanges
ive done something similar here:

i'll fix up the script i have in a bit

edit: here you go!

a table , a schedule and the markdown are in here, with the dragdrop in de markdown and an (example of) AddEvent in de schedule.
tabledropschedule.zip (12.6 KB)

4 Likes

(linking it with my js topic)

1 Like

Man, you are a lifesaver. This would've taken me an absurd amount of time to figure out how to build that mutation observer. Thanks!

1 Like

hehe yeah its not something people usually use, its basicly only used to hack your way in other components xD

Thanks for this work, it helps me a lot. I was trying to understand the markdown source binding with no sucess...

def transform(self, value, quality, timestamp):
#make the propName the key to write too in the view.custom 
#const callback = (mutationList, observer) => {let textLabels = document.querySelectorAll('#"+chartDomId+" .ia_powerChartComponent__labelText.ia_powerChartComponent__xTrace__box__label > tspan'); textLabels.forEach(textLabel => {if(textLabel.textContent in mappingStatus){textLabel.textContent = mappingStatus[textLabel.textContent]; }})}; const observer = new MutationObserver(callback); observer.observe(chart, { attributes: false, childList: true, subtree: true })
	scheduleName = "EquipmentSchedule"
	code =  """<img style='display:none' src='/favicon.ico' onload=\"
		const view = [...window.__client.page.views._data.values()].find(view => view.value.mountPath == this.parentNode.parentNode.parentNode.getAttributeNode('data-component-path').value.split('.')[0]).value; 
		function ondrop(ev) {
		  ev.preventDefault(); 
		  const draggedId = ev.dataTransfer.getData('text');
		  const interval = this.parentNode.parentNode.parentNode.parentNode.parentNode.parentNode.parentNode.querySelector('.ia_equipmentScheduleComponent__actionBar__dropdown .ia_dropdown__valueSingle').getAttribute('data-label');		  		  
		  const addEvent = {'end':'?','start':'?', 'itemId':parseInt(this.getAttribute('data-row')), 'draggedId':parseInt(draggedId), 'interval':interval,'droppedColumn':parseInt(this.getAttribute('data-column')) };
		  const scheduleComponent =  view.root.childComponents.find((component)=> component.def.meta.name=='"""+scheduleName+"""');
		  scheduleComponent.componentEvents.fireComponentEvent('onAddEvent',addEvent);
		};
		function ondragstart(ev){			
			ev.dataTransfer.setData('text', ev.target.getAttribute('data-row-id'));
		};
		function ondragover(ev){
			ev.preventDefault();
		};
		const callbackTable = (mutationList, observer) => {
		document.querySelectorAll('.ia_tableComponent .tr-group.ia_table__rowGroup').forEach(e => {
					e.setAttribute('draggable',true);
					e.ondragstart = ondragstart;			
				});
		};
		
		const callbackSchedule = (mutationList, observer) =>{
			document.querySelectorAll('.ia_equipmentScheduleComponent .schedule-grid-cell.ia_equipmentScheduleComponent__gridSpace__gridCell').forEach(e => {			
						e.ondragover = ondragover;
						e.ondrop = ondrop;
					});	
		};
							
		const observerTable = new MutationObserver(callbackTable); 
		const tables = document.querySelectorAll('.ia_tableComponent .tb.ia_table__body');
		tables.forEach(table => observerTable.observe(table, { attributes: false, childList: true, subtree: true }));		
		const observerSchedule = new MutationObserver(callbackSchedule); 
		const schedules = document.querySelectorAll('.ia_equipmentScheduleComponent .ReactVirtualized__Grid');
		schedules.forEach(schedule => observerSchedule.observe(schedule, { attributes: false, childList: true, subtree: true }));
		document.querySelectorAll('.schedule-grid-cell.ia_equipmentScheduleComponent__gridSpace__gridCell').forEach(e => {			
												e.ondragover = ondragover;
												e.ondrop = ondrop;
											});
		document.querySelectorAll('.ia_tableComponent .tr-group.ia_table__rowGroup').forEach(e => {
													e.setAttribute('draggable',true);
													e.ondragstart = ondragstart;			
												});			
	\"></img>""".replace("\n", "").replace("\t", "")
	return code
	

For example, I want to place the table and the equipment schedule insde another flex container. I understand that I should change the const view adding another .parentNode, but I guess that's not it. Could you give me any advice?

I have actually updated this code to be this recently, though it does exactly the same.

const view = [...window.__client.page.views._data.values()].find(view => view.value.mountPath == this.closest('[data-component-path]').getAttributeNode('data-component-path').value.split('.')[0]).value; 

The this is refering to the generated img html inside the markdown component. So you never really need to change it.
It is looking for the markdowns path attribute, something perspective gives all its components. With part of this this path it is possible to find the view the markdown is located it through the windows.__client.page.views.

Perspective generates these random letters and numbers based on the position a component is in.

You can open a console in the chrome browser console and poke around in the windows.client a bit to find this, here the mount path is on the 3th view "C" coming from the markdown path "C.0:0"

For elements inside an embeded view this might look a little different (more complex) than just the one letter, but should still work the same

You might change somethiing to find the views properties here, you probably will have to first find the container in the root view, and than in there its childeren will be the schedule

 const scheduleComponent =  view.root.childComponents.find((component)=> component.def.meta.name=='"""+scheduleName+"""');

1 Like

Nice, thank you!!

		  const flexContainer = view.root.childComponents.find((component) => component.def.meta.name === 'FlexContainer');
		  const scheduleComponent = flexContainer.childComponents.find((component) => component.def.meta.name === '"""+scheduleName+"""');
		  scheduleComponent.componentEvents.fireComponentEvent('onAddEvent',addEvent);
1 Like