Our pure JavaScript Scheduler component


Post by omnisip »

drag-and-drop-bug.gif
drag-and-drop-bug.gif (1.86 MiB) Viewed 596 times

Source code / test case example attached. Steps to reproduce:

  1. npm install
  2. npm run start
  3. Start dragging and holding an event element

We identified a problem on our scheduler board when an event element is dragged and held. The issue arises if the event is moved again while an update is in progress. The element continues to display incorrectly, even though it should not. The root cause is unclear, but resolving it is essential as dragging functionality within and outside the grid is necessary. It appears to be a race condition that results in an artifact when a server update coincides with the dragging action.

Attachments
inline-data example_drag-and-drop.zip
(7.66 MiB) Downloaded 9 times
Last edited by omnisip on Wed Jun 12, 2024 9:47 pm, edited 2 times in total.

Post by mats »

What version are you using?


Post by omnisip »

5.6.10


Post by saki »

Confirmed, it is a bug. I have created the issue here: https://github.com/bryntum/support/issues/9383


Post by ghulam.ghous »

Hi @omnisip,

I have debugged this issue and it turns out to be an issue with how you have configured the project on schedulerPro component. You were directly passing the project to the schedulerPro which is not a recommended way and is creating a new project instance on each re-render caused by the useGetEventsQuery hook. We recommend to use BryntumSchedulerProProjectModel or use useState hook to manage the project config and pass the project variable to schedulerPro component.

Approach 1:

Declare a ref for project:

const projectRef = useRef();

Now use BryntumSchedulerProProjectModel:

        <BryntumSchedulerProProjectModel 
            ref={projectRef}
            events={events}
            resources={resources}
        />

// do not pass the assignments as you have already used resourceId on events

and now pass the projectRef to the SchedulerProComponent:

        <BryntumSchedulerPro
            project={projectRef}
            ref={schedulerPro}
       />

Approach 2:

Place the project config inside a useState variable:

const [project, setProject] = useState({ events, resources});

Pass it to the schedulerPro component:

        <BryntumSchedulerPro
            project={project}
            ref={schedulerPro}
       />

And everything works great. We have a docs section here on how to configure things: https://bryntum.com/products/schedulerpro/docs/guide/SchedulerPro/integration/react/guide#best-practices-for-configuration-management

Updated Project:

Drag_Artifacts-Project.zip
(7.43 MiB) Downloaded 4 times

Clip:

Drag Artifacts-clip.zip
(11.37 MiB) Downloaded 4 times

Regards,
Ghous


Post by omnisip »

This issue still isn't resolved, and reproduces without redux. See attached source code and graphic below.

dnd issue no redux.gif
dnd issue no redux.gif (1.12 MiB) Viewed 441 times
Attachments
inline-data-sb-no-redux.zip
(3.94 MiB) Downloaded 11 times

Post by omnisip »

I've attached a further simplified example using setInterval instead of an async generator.

Attachments
inline-data-sb-no-redux-v2.zip
(3.94 MiB) Downloaded 9 times

Post by kronaemmanuel »

Hi omnisip,

The useEffect you have is updating the data every 500ms. So when we start drag on an event, before, it can be dropped, the project underneath has been loaded with fresh data, the event 'in hand' no longer can be dropped on the project you have, which is why it get's 'stuck' wherever you leave it. Normally, it would just disappear since it has nowhere to go, but you have turned on the setting: constrainDragToTimeline: false so it just gets dropped into the document wherever the mouse lets go of it.

Now, to fix your error, you need to decide what should happen when you have an event 'in hand', and the data underneath the event changes. The simplest solution would be to just not allow any updates as long as the event is being dragged. To implement this, i'm using a ref to keep track of whether we should update data or not:

function App() {
  const shouldUpdateData = useRef(true);

  const workOrderOnDropHandlerCallback = useCallback(
    async (event: {
      externalDropTarget: HTMLElement | undefined
      eventRecords: SchedulerEventModel[]
    }) => {
      await workOrderOnDropHandler(event)
      shouldUpdateData.current = true;
    },
    []
  )

  //------------------------------------- useState ------------------------------------------
  const [events, setEvents] = useState<IWorkOrder[]>([])
  const [resources, setResources] = useState<IDriver[]>([])
  const [assignments, setAssignments] = useState<AssignmentModel[]>([])
  const [dateRange, setDateRange] = useState<DateRange>({
    startDate: (new Date()).toISOString(),
    endDate: (new Date()).toISOString(),
  })
  const [minimumExpectedDuration] = useState<number>(40)

  useEffect(() => {
    const workingHoursBegin = 5;
    const workingHoursEnd = 22;
    const dateRangeStartDate = new Date();
    const dateRangeEndDate = new Date();
    dateRangeStartDate.setHours(workingHoursBegin,0,0,0);
    dateRangeEndDate.setHours(workingHoursEnd,0,0,0);

const dateRange = { startDate: dateRangeStartDate.toISOString(), endDate: dateRangeEndDate.toISOString() };
setDateRange(dateRange);
  }, [])

  useEffect(() => {
    const dataGenerator = fetchWorkOrdersDataPeriodically(
      dateRange,
      minimumExpectedDuration,
      500
    );
    (async () => {
      for await (const result of dataGenerator) {
        if (result !== null && shouldUpdateData.current) {
            setEvents(getNotUnscheduledWorkOrders(result?.data as IWorkOrder[]))
            setAssignments(
                getAssignments(result?.data as IWorkOrder[]) as AssignmentModel[]
            )
        }
      }
    })()
  }, [dateRange, minimumExpectedDuration])

  useEffect(() => {
    getDrivers('entityId').then((result) => {
      setResources(
        getScheduleBoardDrivers(result.data as IDriver[], 'entityId')
      )
    })
  }, [])

  //-------------------------------------------------------------------------------------------

  const schedulerProRef = useRef<BryntumSchedulerPro>(null),
    projectRef = useRef<BryntumSchedulerProProjectModel>(null)

  return (
    <>
      <BryntumSchedulerProProjectModel
        ref={projectRef}
        events={events}
        resources={resources}
        assignments={assignments as unknown as AssignmentModel[]}
        eventModelClass={WorkOrderEventModel}
      />
      <BryntumSchedulerPro
        startDate={new Date(dateRange.startDate)}
        endDate={new Date(dateRange.endDate)}
        onEventDrop={workOrderOnDropHandlerCallback}
        onEventDragStart={(e) => {
          shouldUpdateData.current = false
        }}
        eventDragFeature={{
          // Allow dragging events outside of the Scheduler
          constrainDragToTimeline: false,
        }}
        ref={schedulerProRef}
        project={projectRef}
        {...schedulerProProps}
      />
    </>
  )
}

I'm using useRef and not useState to hold my shouldUpdateData variable because useRef doesn't re-render the component when it's value changes. Also, I'm using async await in workOrderOnDropHandlerCallback function. This is so the data doesn't update which that function is running. You might also want to turn back shouldUpdateData if the drag gets aborted etc. For that you can use the relevant functions like onDragAbort.

Lemme know if this suffices or if you want some other behavior when you're dragging the event.

Regards,
Krona


Post by omnisip »

I think there's a real bug here -- on the drop artifacts that's separate from what you're suggesting. Nothing is filtering or deduplicating by /id/ either events or assignments. If it was, we wouldn't see the duplicate drop artifact because immediately upon drop one would be taken out of circulation.

We're still working on testing your suggestions in our production code. useRef isn't really a practical option for us because that means we can't handle race conditions in redux. For instance, in our production code, we have a skip token that has been addressing the issue you mentioned above. However, there's a data race that can occur after drag when fetching is immediately re-enabled. In such case, it's possible for the list endpoint to return before the update endpoint does, creating another artifact on the screen. As a practical matter, we have to wait until the update operation is completed before we can restart the list operation. This means we need to be able to update the skipToken status in the state.

We're going to keep testing, and update you once we have a chance to test all the proposed changes in our redux code Monday.


Post by kronaemmanuel »

Hi omnisip,

Some thoughts after reading your reply:

  • In the code example you sent, what's happening under the hood is that on every data change, the project.eventStore and project.resourceStore gets a completely new dataset. You can think of it like we're doing store.data = [//new data]. It's not doing an update operation where it tries to keep the old entries and only update the new ones. So, basically we just re-render everything from scratch. That is why, upon drop, the event is not taken out of circulation, because the store has changed it's data. The event no longer belongs to that Store.
  • You mention that your update endpoint returns before the list endpoint, Wouldn't that be pretty easy to make sure that when update call is sent, it waits for it to finish before calling the list endpoint. Otherwise you'll be getting stale data. Well, I'm not sure how easy/difficult that is on your side.

A test case which can show your problem will of course be much better for us to understand where the problem lies, otherwise we're just throwing arrows in the dark. I hope we can get this resolved quickly for you.

Best Regards,
Krona


Post Reply