Our pure JavaScript Scheduler component


Post by ewu »

We use nested events extensively in our project, where the child events have fixed durations and the parent event is calculated from the start/end of its child events. Drag-and-drop is broken for the nested events, as they do not calculate start times correctly with respect to non-working time.

I see that this feature request https://github.com/bryntum/support/issues/1959 got removed from the 4.X.X milestones. Can I request that it get added to the 5.X.X plans?

And in the meantime, do you have a suggested workaround?
We are currently calculating new start times for each child event in the scheduler.beforeEventDropFinalize listener, using calendar.skipNonWorkingTime() to compute the next available start date. However, this does not preserve gaps/overlaps between the child tasks.

beforeEventDropFinalize: async (event: any) => {
    // Prevent the user's drag from going through
    if (!event.context) return;
    event.context.async = true;
    const updateRequest: Partial<TaskFields>[] = [];
    const delta = DateHelper.diff(
      event.context.origStart,
      event.context.startDate,
      'hours'
    );
    const reassignedUnit: UnitModel | undefined =
      event.context.resourceRecord?.id !== event.context.newResource?.id
        ? event.context.newResource
        : undefined;
    const calendar: CalendarModel | undefined =
      getProject()?.calendarManagerStore.getById(
        NON_WORKING_DAYS
      ) as CalendarModel;
    const isForward = delta >= 0;
    schedulerRef.current?.instance.eventStore.beginBatch();
    for (const task of event.context.eventRecords || []) {
      // The tasks in the context already have modified start dates
      let newStart = task.startDate;
      // If the task is a slug, loop through its sidecar tasks and modify their start dates
      // with respect to the non-working time calendar
      // This is a manual workaround until Bryntum adds support for nested events:
      // https://github.com/bryntum/support/issues/1959
      if (task.isParent && Array.isArray(task.children)) {
        for (const sidecar of sortBy(task.children, 'startDate')) {
          newStart =
            calendar?.skipNonWorkingTime(newStart, isForward) || newStart;
          const payload: Partial<TaskFields> = {
            uuid: sidecar.getData('uuid'),
            startDate: DateHelper.format(newStart, DATE_FORMAT),
            sgStartTime: getStartTime(newStart),
          };
          // Round to quarter day
          newStart =
            getStartDateTime(
              payload.startDate as Date,
              payload.sgStartTime as number
            ) || newStart;
          await sidecar.setAsync('startDate', newStart);
          if (reassignedUnit) {
            sidecar.reassign(sidecar.resource, reassignedUnit);
            payload.sgUnit = (
              reassignedUnit as UnitModel
            ).convertToNestedEntity();
          }
          updateRequest.push(payload);
          newStart = sidecar.endDate;
        }
        if (reassignedUnit) {
          task.reassign(task.resource, reassignedUnit);
        }
      } else {
        // Nudge sidecar task directly
        newStart =
          calendar?.skipNonWorkingTime(newStart, isForward) || newStart;
        const payload: Partial<TaskFields> = {
          uuid: task.getData('uuid'),
          startDate: DateHelper.format(newStart, DATE_FORMAT),
          sgStartTime: getStartTime(newStart),
        };
        updateRequest.push(payload);
        await task.setAsync('startDate', newStart);
      }
    }
    schedulerRef.current?.instance.eventStore.endBatch();
    // We manually update the tasks above, so cancel the default behavior of this drop op.
    event.context.finalize(false);
    // Send update mutation to backend
    ...
  }

How do you recommend we calculate the nested event start dates with respect to non-working time?
The beforeEventDropFinalize event context doesn't keep track of the events' previous start dates (to my knowledge at least), so we are unable to properly compute the nested events' position relative to each other to preserve gaps and overlaps.


Post by johan.isaksson »

Hi,

The ticket was opened before Scheduler Pro even had support for nested events, there must have been some confusion. Guessing it was meant for the basic Scheduler demo. Anyhow, we can repurpose it to cover your issue.

Nested events do currently take non-working time into account when scheduling. Here for example I have added a calendar to the nested-events demo and removed the manuallyScheduled flag from the parent:

nested-non-working.gif
nested-non-working.gif (390.51 KiB) Viewed 452 times

But since they are constrained by their parent, it can be tricky to resize / drag in some cases. Could you please elaborate a bit on what it is that is not working for you, and how you would want it to work?

Best regards,
Johan Isaksson

Post by ewu »

The problem is that the nested events do not keep track of their position relative to each other.
You can this in my examples here:

Screen Recording 2023-05-16 at 5.06.51 PM.mov
video example
(7.06 MiB) Downloaded 38 times

Task 1. There are no gaps in working-time between the subtasks in this series, but dragging subevents across the weekend causes them to overlap on the next available working day. I would expect that the original order is preserved after moving the parent task (i.e. no gaps/overlaps after drop).
Task 2. Overlapping subtasks are not preserved. I would expect the same order & overlaps after drop.
Task 3. Gaps between tasks get larger/smaller depending on where you drop the parent task. I would expect the same gap of working time no matter where I drop it.

I attached the example I modified (nested events example with a working day calendar with the modifications you suggested).

Attachments
nested-events-nwd.zip
test case
(238.59 KiB) Downloaded 22 times

Post by alex.l »

Hi ewu,

Thanks for clear demo and video! This is expected behaviour now in current implementation of this feature. Unfortunately the way you expecting that is not supported now. I've opened a feature request https://github.com/bryntum/support/issues/6821
We did not plan to implement this in nearest future, if you need that fast, you could contact our Sales about sponsoring option using the form here https://bryntum.com/services

All the best,
Alex


Post by ewu »

Thank you.

Below is our workaround that suits our needs in the meantime.
We make use of CalendarModel.calculateStartDate & calculateEndDate in our workaround, but these are not exposed in your typedefs or public API.

Could you make these methods public?

  beforeEventDropFinalize: async (event: any) => {
    // Prevent the user's drag from going through
    if (!event.context) return;
    // We manually update the tasks below, so cancel the default behavior of this drop op.
    event.context.finalize(false);
    const updateRequest: Partial<TaskFields>[] = [];
    const delta = event.context.timeDiff;
    const isForward = delta >= 0;
    const reassignedUnit: UnitModel | undefined =
      event.context.resourceRecord?.id !== event.context.newResource?.id
        ? event.context.newResource
        : undefined;
    const calendar: CalendarModel | undefined =
      getProject()?.calendarManagerStore.getById(
        NON_WORKING_DAYS
      ) as CalendarModel;
    schedulerRef.current?.instance.eventStore.beginBatch();
    for (const task of event.context.eventRecords || []) {
      let prevEnd: Date | undefined = undefined;
      // The tasks in the context already have modified start dates
      let newStart = task.startDate;
      // If the task is a parent, loop through its child tasks and modify their start dates
      // with respect to the non-working time calendar
      // FIXME: This is a manual workaround until Bryntum adds support for nested events:
      // https://github.com/bryntum/support/issues/6821
      if (task.isParent && Array.isArray(task.children)) {
        const taskSeries = sortBy(task.children, 'startDate');
        for (const subtask of taskSeries) {
          newStart =
            calendar?.skipNonWorkingTime(newStart, isForward) || newStart;
          // Since the tasks in the context are already modified, we read/write to an additional field
          // to access the original start date
          const originalStart = subtask.getData('originalStartDate') as Date;
          prevEnd = prevEnd || originalStart;
          // Calculate gap between `prevEnd` and `originalStart`
          const gap = DateHelper.isAfter(originalStart, prevEnd)
            ? calendar?.calculateDurationMs(prevEnd, originalStart)
            : -calendar?.calculateDurationMs(originalStart, prevEnd);
          // Compute `newStart` as `prevEnd` + `gap` with respect to non-working time
          // @ts-ignore `calculateEndDate` exists on CalendarModel
          newStart = calendar?.calculateEndDate(newStart, gap) || newStart;
          // Calculate task end with respect to non-working time
          const durationMs = subtask.duration * 3600000;
          prevEnd =
            // @ts-ignore `calculateEndDate` exists on CalendarModel
            calendar?.calculateEndDate(originalStart, durationMs) ||
            DateHelper.add(originalStart, subtask.duration, 'hours');
          await subtask.setAsync('startDate', newStart);
          // We're relying on Bryntum's scheduling engine to round/adjust the start time
          // before we build our SG update payload
          const payload: Partial<TaskFields> = {
            uuid: subtask.getData('uuid'),
            startDate: subtask.startDate,
          };
          await subtask.setAsync('originalStartDate', subtask.startDate);
          if (reassignedUnit) {
            subtask.reassign(subtask.resource, reassignedUnit);
            payload.sgUnit = (
              reassignedUnit as UnitModel
            ).convertToNestedEntity();
          }
          updateRequest.push(payload);
          newStart = subtask.endDate;
        }
        if (reassignedUnit) {
          task.reassign(task.resource, reassignedUnit);
        }
      } else {
        // Nudge child task directly
        newStart =
          calendar?.skipNonWorkingTime(newStart, isForward) || newStart;
        await task.setAsync({
          startDate: newStart,
          originalStartDate: newStart,
        });
        const payload: Partial<TaskFields> = {
          uuid: task.getData('uuid'),
          startDate: task.startDate,
        };
        updateRequest.push(payload);
      }
  }

Post by arcady »

Hello the methods are public on the Engine level: https://www.bryntum.com/products/schedulerpro/docs/engine/classes/_lib_engine_quark_model_scheduler_basic_basecalendarmixin_.basecalendarmixin.html#calculatestartdate
So feel free to use them. Our docs browser is just not able to display Engine level API automatically yet. We'll add docs for the methods.


Post Reply