Our pure JavaScript Scheduler component


Post by tagnm »

Hi,

We are working with the eventSelectionChange event.
We have two Schedulers that are partnered.
Each has Events on it, they share an ID. So (for example) Scheduler A has Event ID 1, and Scheduler B also has Event ID 1.

When an Event is selected, we do a few things.

  1. Select the corresponding Event on the partner Scheduler.
  2. Scroll both Events into view.
  3. Add a highlight time span to both Schedulers.

We also clear the selection when the Event is clicked on again.

The issue we are seeing is that sometimes, the corresponding Event just does not get selected. It seems to happen more during the action type of "update" inside the eventSelectionChange event.

But we get no errors, its just like the listeners function just exits early and no selection happens on the partner Scheduler.

I have explored using suspend/resume events, and awaiting functions such as scrollEventIntoView.

Any ideas what might be happening? I can provide sample code if needed.

Thanks,
NM


Post by mats »

Hi NM,

A test case would help a lot so we can dig in and see what's going on.


Post by paul.manole »

Hello mats,

tagnm is not in today so I can present some basic code similar to the real implementation. If the issue can't be spotted here and a full MRE is needed, let us know.

Thank you!

type DateRange = { startDate: Date; endDate: Date };

type TimelineDateRangeChangeEvent = {
  old: DateRange;
  new: DateRange;
};

type TimelineSelectEvent = {
  source: SchedulerPro;
  action: "select" | "deselect" | "update" | "clear";
  selection: EventModel[];
  selected: EventModel[];
  deselected: EventModel[];
};

const scrollOptions: ScrollOptions = {
  focus: true,
  animate: true,
  block: "start",
  x: false,
};

// we use the following hook and pass it the BryntumSchedulerPro refs on which
// it sets up all event listeners:

export function useTimelineEventListeners(
  sched1Ref: React.RefObject<BryntumSchedulerPro>,
  sched2Ref: React.RefObject<BryntumSchedulerPro>
) {
  const sched1 = sched1Ref.current?.instance;
  const sched2 = sched2Ref.current?.instance;

  const deselectSchedulers = (schedulers: SchedulerPro[]) => {
    schedulers.forEach((s) => s.clearEventSelection());
  };

  const unhighlightSchedulers = (schedulers: SchedulerPro[]) => {
    schedulers.forEach((s) => s.unhighlightTimeSpans());
  };

  const selectOneEvent = async (event: TimelineSelectEvent) => {
    if (!sched1 || !sched2) return;

    const { source, action, selection, selected, deselected } = event;

    const activeScheduler = source;
    const pairedScheduler = (source.partners as unknown as SchedulerPro[])[0];

    const performSelect = async () => {
      /** selected event on the current, active scheduler (event.source). */
      const selectedEvent = selected[0];
      const pairedEvent = pairedScheduler.eventStore.getById(
        selectedEvent.id
      ) as SchedulerEventModel;

      // if condition prevents a loop caused by each scheduler programatically
      // selecting the paired event on the other scheduler:

      if (!pairedScheduler.isEventSelected(pairedEvent)) {
        pairedScheduler.select(pairedEvent);

        activeScheduler.highlightTimeSpans([
          {
            startDate: selectedEvent.startDate,
            endDate: selectedEvent.endDate,
            clearExisting: true,
            cls: "highlight-timespan",
          },
        ]);

        pairedScheduler.highlightTimeSpans([
          {
            startDate: selectedEvent.startDate,
            endDate: selectedEvent.endDate,
            clearExisting: true,
            cls: "highlight-timespan",
          },
        ]);

        await activeScheduler.scrollEventIntoView(selectedEvent, scrollOptions);
        await pairedScheduler.scrollEventIntoView(pairedEvent, scrollOptions);
      } else {
        console.warn(
          "Doing nothing because the pairedScheduler already has a selection!"
        );
      }
    };

    const performDeselectOnPairedScheduler = async () => {
      const pairedScheduler = (source.partners as unknown as SchedulerPro[])[0];

      const selectedEvent = selected[0];
      const pairedEvent = pairedScheduler.eventStore.getById(
        selectedEvent.id
      ) as SchedulerEventModel;

      pairedScheduler.deselectEvent(pairedEvent);

      unhighlightSchedulers([activeScheduler, pairedScheduler]);
    };

    switch (action) {
      case "update": {
        const selectedEvent = selected[0];
        const deselectedEvent = deselected[0];

        // on deselection of any event on any timeline:
        if (deselectedEvent) await performDeselectOnPairedScheduler();

        if (selectedEvent) await performSelect();

        break;
      }
      case "select": {
        await performSelect();

        break;
      }
      case "deselect": {
        await performDeselectOnPairedScheduler();

        break;
      }
      case "clear": {
        for (const deselectedEvent of deselected) {
          const pairedEvent = pairedScheduler.eventStore.getById(
            deselectedEvent.id
          ) as SchedulerEventModel;

          pairedScheduler.deselectEvent(pairedEvent);
        }

        await activeScheduler.unhighlightTimeSpans();
        await pairedScheduler.unhighlightTimeSpans();

        break;
      }
      default:
        console.warn(
          `Unimplemented action type detected in useTimelineEventListeners! Non-exhaustive switch case handling!`
        );
        break;
    }
  };

  // set up date range change event listener on sched1:
  useEffect(() => {
    if (!sched1 || !sched2) return;

    const eventListener: BryntumListenerConfig = {
      buffer: 300,

      dateRangeChange: (event: TimelineDateRangeChangeEvent) => {
        // ...update React context with new visible date...

        // clear selection when loading new events from store:
        deselectSchedulers([sched1, sched2]);
        unhighlightSchedulers([sched1, sched2]);
      },
    };

    const removeListener = sched1.addListener(eventListener);

    return () => {
      if (removeListener) removeListener();
    };
  });

  // set up event selection listeners on the Schedulers:
  useEffect(() => {
    if (!sched1 || !sched2) return;

    const selectEventListener: BryntumListenerConfig = {
      eventSelectionChange: (event: TimelineSelectEvent) => {
        selectOneEvent(event);

        if (event.selected.length > 1) {
          console.log("Select Many");
        }
      },
    };

    const removeSched1Listeners = sched1.addListener(selectEventListener);
    const removeSched2Listeners = sched2.addListener(selectEventListener);

    return () => {
      if (removeSched1Listeners) removeSched1Listeners();
      if (removeSched2Listeners) removeSched2Listeners();
    };
  });
}

Post by marcio »

Hey paul.manole,

Could you please attach a runnable test case with that snippet inside and the configuration (that is missing on the snippet that you shared) of the two Schedulers?? You can get one of our demos and add the code to match your case, then zip it and attach it to this thread.

For more information please check our guidelines https://www.bryntum.com/forum/viewtopic.php?f=1&t=772

Best regards,
Márcio


Post by tagnm »

Hi Marcio,

Thank you for replying. The chance of putting it into a runnable test case is slim. The code Paul provided is but a small part of a complex Bryntum implementation we are going through.

Is there nothing in the code you see that stands out as a Bryntum anti-pattern? We are implementing in React.

Here is some config we are using for context:

export const timelineDefaultSettings: Partial<BryntumSchedulerProProps> = {
  rowHeight: 64,
  barMargin: 2,
  readOnly: false,
  zoomOnMouseWheel: false,
  multiEventSelect: true,
  deselectOnClick: true,
  createEventOnDblClick: false,
  scheduleMenuFeature: false,
};

export const timelineDefaultColumnSettings: Partial<GridColumnConfig> = {
  type: 'resourceInfo',
  cls: 'header-column',
  width: 256,
  editor: false,
  enableCellContextMenu: false,
  enableHeaderContextMenu: false,
};

/**
 * Shared Features config
 * As with a lot of Bryntum config, this is treated as a
 * generic object by {@link BryntumSchedulerProProps}
 */
export const timelineDefaultFeatureSettings: Partial<SchedulerProFeaturesConfigType> = {
  timeSpanHighlight: true,
  scheduleContext: false,
  eventMenu: false,
  eventDragCreate: false,
  eventDragSelect: true,
  dependencies: false,
  eventResize: false, // Disabled until we implement the resize functionality
  eventTooltip: false,
  scheduleTooltip: false,
  eventEdit: {
    editorConfig: {
      triggerEvent: null,
    },
  },
};

And that config is consumed in a custom hook:

/**
 * Custom hook that creates and returns the configuration props for the
 * {@link Timeline} schedulers.
 */
export function useTimelineSchedulerProps() {
  const { serviceLocationTerms, hrTerms } = useTerminology();

  const serviceLocationSchedulerProps: BryntumSchedulerProProps = {
    ...timelineDefaultSettings,
    id: SERVICE_LOCATIONS_TIMELINE_ID,
    dependenciesFeature: { disabled: true },
    resourceTimeRangesFeature: { disabled: true },
    nonWorkingTimeFeature: { disabled: true },
    eventNonWorkingTimeFeature: { disabled: true },
    eventDragFeature: { constrainDragToTimeline: true },
    timeRangesFeature: { disabled: true, enableResizinging: true },
    infiniteScroll: true,
    viewPreset: HOUR_AND_DAY_VIEWPRESET_ID,
    columns: [
      // 1st column displays service locations:
      {
        ...timelineDefaultColumnSettings,
        id: SERVICE_LOCATIONS_COLUMN_ID,
        text: serviceLocationTerms.serviceLocations,
        renderer: (args: unknown) => <TimelineServiceLocationResourceCard rendererArgs={args} />,
      },
    ],
    features: {
      ...timelineDefaultFeatureSettings,
    },
  };

  const employeeSchedulerProps: BryntumSchedulerProProps = {
    ...timelineDefaultSettings,
    id: EMPLOYEES_TIMELINE_ID,
    columns: [
      // 1st column displays employees:
      {
        ...timelineDefaultColumnSettings,
        id: EMPLOYEES_COLUMN_ID,
        text: hrTerms.employees,
        renderer: (args: unknown) => <TimelineEmployeeResourceCard rendererArgs={args} />,
      },
    ],
    features: {
      ...timelineDefaultFeatureSettings,
    },
  };

  return {
    serviceLocationSchedulerProps,
    employeeSchedulerProps,
  };
}

Post by paul.manole »

Hello everyone,

I think we can add the JSX to the code example above:

<TimelineLayout>
  <TimelineServiceLocationToolbar />
  <BryntumSchedulerPro
    {...serviceLocationSchedulerProps}
    ref={slocSchedulerRef}
    onVisibleDateRangeChange={onVisibleDateRangeChange}
  />
</TimelineLayout>
<TimelineLayout>
  <TimelineEmployeeToolbar />
  <TimelineEmployee {...employeeSchedulerProps} ref={emplSchedulerRef} />
</TimelineLayout>

emplSchedulerRef is a forwarded ref to another BryntumSchedulerPro instance.
And part of the configuration is another hook that partners the two timelines using these two refs:

// partner the two timelines to sync up their horizontal scrolling and zoom
// levels:
useEffect(() => {
  const slocScheduler = serviceLocationScheduler.current?.instance;
  const emplScheduler = employeeScheduler.current?.instance;

  if (!slocScheduler || !emplScheduler) return;

  emplScheduler.addPartner(slocScheduler);

  return () => {
    if (!emplScheduler.isDestroyed) {
      emplScheduler.removePartner(slocScheduler);
    }
  };
}, [employeeScheduler, serviceLocationScheduler]);

Post by tagnm »

Hi everyone,

Creating a runnable demo from all this could take us a lot of time, but if possible, we could set up a virtual meeting and show you the full implementation.

Is that something we can do?

Kind regards,
NM


Post by tagnm »

Forgot to add this in my initial comment.

Bryntum version we are using:

"@bryntum/schedulerpro": "5.3.5",
"@bryntum/schedulerpro-react": "5.3.5",


Post by alex.l »

Hi tagnm and paul.manole,

Visually it all look consistent. I am afraid, we cannot give you any useful information by checking the code visually in this case. If it happened from time to time, it is not some typo in the code or wrong method. We need to debug this to know better what's going on.
You could try to use our demo as a base and only apply your logic of selecting/deselecting events, that should be enough to replicate this bug, isn't it?

All the best,
Alex


Post by tagnm »

I have put together a (very reduced) test case, based on your "drag-between-schedulers" React demo. I chose this one as it already had a partnered timeline. But I have heavily modified it. All the code I have added to generate events is unique to this demo, and not part of our proper implementation.

https://bryntum.com/products/schedulerpro/examples-scheduler/frameworks/react/javascript/drag-between-schedulers/build/

The bug is present. After a clicking on different events for a while, the "matched" event stops becoming visually selected. Of course it could be an error in our implementation, but I am happy for your expert opinion.

I have attached the modified demo in a zip.

Kind regards,
NM

Attachments
drag-between-schedulers.zip
(2.39 MiB) Downloaded 21 times

Post Reply