visjs / vis-timeline

📅 Create a fully customizable, interactive timelines and 2d-graphs with items and ranges.
https://visjs.github.io/vis-timeline/
Other
1.85k stars 311 forks source link

React 18 support with vis-timeline #1779

Open m-nathani opened 6 months ago

m-nathani commented 6 months ago

I am using latest version of vis-timeline to create a view as shown in the screenshot

Moreover, using React 16, i was rendering items and groups using this ReactDOM approach, as recommended in the documentation: https://visjs.github.io/vis-timeline/docs/timeline/#Templates

Here is the working example for the code.

  useEffect(() => {
    if (isMobileDevice) {
      fullScreenAndRotateLandscape();
    }

    setTimeline(
      new VisTimeline(timelineRef.current, itemDataSet, groupDataSet, {
        ...defaultOptions,
        onInitialDrawComplete: () => {
          setLoading(false);
        },
        tooltipOnItemUpdateTime: {
          // NOTE: conflicting with key "itemsAlwaysDraggable" with "selected", to fix we need to use timeline.setSelection on move events
          template: (item) =>
            // eslint-disable-next-line react/jsx-props-no-spreading
            ReactDOMServer.renderToStaticMarkup(<ItemDraggingTooltip {...item} />),
        },
        template: (item, element, data) => {
          if (!item?.id || !data?.id) return null;

          if (item.reservationListType === TIMELINE_ITEM_TYPES.BACKGROUND) {
            return item;
          }

          return ReactDOM.createPortal(
            ReactDOM.render(
              item.reservationListType === TIMELINE_ITEM_TYPES.ROOM_SUMMARY ? (
                <GroupSummary
                  id={item.id}
                  reservationCount={item.reservationCount}
                  guestCount={item.guestCount}
                />
              ) : (
                <Item item={{ ...item, data }} />
              ),
              element
            ),
            element
          );
        },
        groupTemplate: (group, element) => {
          if (!group?.id) return null;
          // eslint-disable-next-line react/jsx-props-no-spreading
          return ReactDOM.createPortal(ReactDOM.render(<Group {...group} />, element), element);
        },
      })
    );
  }, []);

Now we want to move to React 18, and ReactDOM.render is deprecated, has anyone found a way too render using React way ?

Additionally i tried rendering using createPortal and createRoot but those approach were not successful.

annsch commented 6 months ago

Hey @m-nathani , we had the same problem because vis-timeline's support is kind of sleeping there's no official solution for this. We managed this with createRoot – so each timelineItemComponent is a separate react app. To get createRoot work with vis-timeline we have to check to call it only once and in other cases the timeline calls the template function again, just rerender the custom timelineItemComponent:

const createRootForTimelineItem = (
        item: TimelineItem,
        element: Element & { timelineItemRoot: Root },
        data: unknown
    ) => {
        if (!element.hasAttribute('data-is-rendered')) {
            element.setAttribute('data-is-rendered', 'true');
            const root = createRoot(element);
            root.render(React.createElement(timelineItemComponent, { item, data, setItemHeight }));
            element.timelineItemRoot = root;
        } else {
            element.timelineItemRoot.render(React.createElement(timelineItemComponent, { item, data, setItemHeight }));
        }
        return '';
    };

This function is called via timelineOptions:

template: createRootForTimelineItem
m-nathani commented 6 months ago

Hi @annsch, hope you are doing well... Thank you for your response—I really appreciate it you making time.

Your solution is quite similar to what we came up... however i like how you use data-attributes for checking the elements which are already rendered.

we wanted to first make use of createPortal but it isn't syncing with vis-timeline template properly and had many of edge cases...

Furthermore, i believe createRoot is a bit expensive for each item.. however couldn't found any correct alternative yet

Please find the approach below we for inline template function called via timelineOptions:

         const rootElements: Record<string, ReturnType<typeof createRoot>> = {};
         .
         .
         .

         {
           // other options...
           ...

           template: (item, element, data) => {
            if (!item?.id || !data?.id) return null;

            // Required because when adding a new item, the id will always be 'new-timeline-item',
            // Hence passing a unique id for AddItem popover, which gets destroyed on unmount using `destroyTooltipOnHide`
            const elemId = item.id === NEW_TIMELINE_ID ? uuidv4() : item.id;

            if (!rootElements[elemId]) {
              // If not, create a new root and store it
              rootElements[elemId] = createRoot(element);
            }

            // eslint-disable-next-line react/jsx-props-no-spreading
            rootElements[elemId].render(
                <Item item={{ ...item, data }} />
            );

            return rootElements[elemId];
          },
        }

P.S: The only confusion here is we don't know what should we return on template function.. also in the documentation is not clear.. however returning rootElement itself is not breaking anything too...

annsch commented 6 months ago

@m-nathani yes, this return is very confusing :D this is why we had a look at the template function's implementation: https://github.com/visjs/vis-timeline/blob/master/lib/timeline/component/item/Item.js#L432 We did not completely understand what is done here but our intention with our custom template function is to omit all vis stuff when using our custom component because we'd like to have full control of the timelineItem's content. So in fact when the template function is called we only want to call our function and nothing vis specific ... did I make my point clear enough to understand? best regards, anna

m-nathani commented 6 months ago

@m-nathani yes, this return is very confusing :D this is why we had a look at the template function's implementation: https://github.com/visjs/vis-timeline/blob/master/lib/timeline/component/item/Item.js#L432 We did not completely understand what is done here but our intention with our custom template function is to omit all vis stuff when using our custom component because we'd like to have full control of the timelineItem's content. So in fact when the template function is called we only want to call our function and nothing vis specific ... did I make my point clear enough to understand? best regards, anna

@annsch , Ahhh I see what you mean... That's why you have retuned empty string in your template function.

Vis timeline is using the content to check thing's further which is strange...

      content = templateFunction(itemData, element, this.data);

Will test retuning return'' and see how it works.

Jonas-PRF commented 6 months ago

@m-nathani Are you able to implement the collapse functionality using this custom group template?

m-nathani commented 6 months ago

@m-nathani Are you able to implement the collapse functionality using this custom group template?

@Jonas-PRF you need to create a structure of items with group and showNested for example:

in this way it will create a group and inside this group you can have multiple items Group:

  {
    id: 'group-id',
    order: 0,
    showNested: true,
    className: `group-class`,
    name: 'group name',
    nestedGroups: ['item'],
  },

items inside the group:

  {
    id: 'item',
    name: 'item',
    className: `item-classname`,
    order: 0,
  },
Jonas-PRF commented 6 months ago

@m-nathani

We're reusing (or atleast trying) the template function for the groups and the items.

export const renderItemTemplate = <T extends CommonTimelineProperties>({ item, element, itemType, Component }: RenderItemParams<T>) => {
  if (!element || !item) {
    return ''
  }

  const DATA_IS_RENDERED = 'data-is-rendered'

  const itemIsRendered = element.hasAttribute(DATA_IS_RENDERED)

  if (!itemIsRendered) {
    element.setAttribute(DATA_IS_RENDERED, 'true')
    const root = createRoot(element)
    root.render(
        <Component key={`${itemType}-${item.id}`} {...item} />,
    )

    element.timelineItemRoot = root
  } else {
    element.timelineItemRoot.render(
        <Component key={`${itemType}-${item.id}`} {...item} />,
    )
  }

  return ''
}

Using this function the groups are being rendered but once I click the collapse button the component is not rendered

const TimelineGroupComponent = ({ content, treeLevel, ...props }: TimelineGroup) => {
  return (
    <>
      {treeLevel === 1 ? (
        <div className="ml-2 pb-1 pt-1">
          <P type="small">{content}</P>
        </div>
      ) : null}
      {treeLevel === 2 ? (
        <div className="parent-subgroup-label border-r-grey-300 flex flex-row items-center gap-1 border-r pb-2.5 pt-2.5">
          <FontAwesomeIcon fontSize="16px" icon={faBolt} className="mr-1 text-blue-100" />
          <div className="flex flex-col items-start">
            <P type="small" className="text-grey-300">
              {content}
            </P>
            <P type="strong" className="pr-2">
              Montage tafel 1
            </P>
          </div>
        </div>
      ) : null}
    </>
  )
}
  const defaultTimelineOptions: InternalTimelineOptions = {
    editable: {
      add: false,
      remove: false,
      updateGroup: true,
      updateTime: true,
    },
    orientation: 'top',
    zoomKey: 'ctrlKey',
    zoomMin: timeIntervals.ONE_HOUR,
    zoomMax: timeIntervals[p.options.range ?? 'ONE_WEEK'],
    verticalScroll: true,
    horizontalScroll: true,
    snap: null,
    stack: false,
    showCurrentTime: true,
    // margin: {
    //   item: {
    //     horizontal: -1,
    //   },
    // },
    groupHeightMode: 'fitItems',
    start: defaultStart,
    end: defaultEnd,
    initialDate: new Date(),
    range: 'ONE_WEEK',
    groupTemplate: (item?: TimelineGroup, element?: TimelineElement) => {
      return renderItemTemplate({ item, element, itemType: 'group', Component: p.options.groupComponent })
    },
    // template: (item?: TimelineItem, element?: TimelineElement) => {
    //   return renderItemTemplate({ item, element, itemType: 'item', Component: p.options.itemComponent })
    // },
    skipAmount: 'ONE_DAY',
    largeSkipAmount: 'ONE_WEEK',
    ...p.options,
  }
godmaster35 commented 5 months ago

Hi all,

it seems you got this working with react 18. Im actually in charge of porting this from a plain js app. I have this simply code here for my component. My issue is and therefor asking for help is if i zoom in/out in the timeline view the item position is not redrawn properly so the start/end time changes when scrolling - i guess a scaling issue. Event the item content appears depending on the zoom level and hides if i drag the current visible time left or right. Can anyone point me in the right direction plz?

zooming out... image image

package.json

  "dependencies": {
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "@types/jest": "^27.5.2",
    "@types/node": "^16.18.96",
    "@types/react": "^18.2.75",
    "@types/react-dom": "^18.2.24",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1",
    "typescript": "^4.9.5",
    "vis-data": "^7.1.9",
    "vis-timeline": "7.7.3",
    "web-vitals": "^2.1.4"
  },

the component

import { useEffect, useRef } from "react";
import { Timeline as Vis } from 'vis-timeline/standalone';
import { DataSet } from 'vis-data';
import "vis-timeline/styles/vis-timeline-graph2d.css"
// import "./customtimeline.css"

const VisTimeline2 = () => {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const timelineRef = useRef<Vis | null>(null);

  useEffect(() => {
    if (!timelineRef.current) initTimeline();
  }, [containerRef]);

  const initTimeline = () => {
    if (!containerRef.current) return;

    // var visgroups = new DataSet([
    //   {id: 1, content: "testgroup"}
    // ]);

    var items2 = new DataSet([
      {id: 1, title: "Title item 1", content: 'Content item 1', start: '2024-04-19 10:00', end: '2024-04-19 14:00'}
      // {id: 2, content: 'item 2', start: '2024-04-24'},
      // {id: 3, content: 'item 3', start: '2024-04-18'},
      // {id: 4, content: 'item 4', start: '2024-04-16 10:00', end: '2024-04-16 16:00', group: 1},
      // {id: 5, content: 'item 5', start: '2024-04-25'},
      // {id: 6, content: 'item 6', start: '2024-04-27', type: 'point'}
    ]);

    timelineRef.current = new Vis(containerRef.current, items2);
  }

  return (
     <div ref={containerRef} />
  )
}

export default VisTimeline2;
andrewlimmer commented 2 months ago

To fix the zoom error add the 'align' option.

// Configuration for the Timeline
const options: TimelineOptions = {
  align:'center',
};
NwosaEmeka commented 2 months ago

@m-nathani

We're reusing (or atleast trying) the template function for the groups and the items.

export const renderItemTemplate = <T extends CommonTimelineProperties>({ item, element, itemType, Component }: RenderItemParams<T>) => {
  if (!element || !item) {
    return ''
  }

  const DATA_IS_RENDERED = 'data-is-rendered'

  const itemIsRendered = element.hasAttribute(DATA_IS_RENDERED)

  if (!itemIsRendered) {
    element.setAttribute(DATA_IS_RENDERED, 'true')
    const root = createRoot(element)
    root.render(
        <Component key={`${itemType}-${item.id}`} {...item} />,
    )

    element.timelineItemRoot = root
  } else {
    element.timelineItemRoot.render(
        <Component key={`${itemType}-${item.id}`} {...item} />,
    )
  }

  return ''
}

Using this function the groups are being rendered but once I click the collapse button the component is not rendered

const TimelineGroupComponent = ({ content, treeLevel, ...props }: TimelineGroup) => {
  return (
    <>
      {treeLevel === 1 ? (
        <div className="ml-2 pb-1 pt-1">
          <P type="small">{content}</P>
        </div>
      ) : null}
      {treeLevel === 2 ? (
        <div className="parent-subgroup-label border-r-grey-300 flex flex-row items-center gap-1 border-r pb-2.5 pt-2.5">
          <FontAwesomeIcon fontSize="16px" icon={faBolt} className="mr-1 text-blue-100" />
          <div className="flex flex-col items-start">
            <P type="small" className="text-grey-300">
              {content}
            </P>
            <P type="strong" className="pr-2">
              Montage tafel 1
            </P>
          </div>
        </div>
      ) : null}
    </>
  )
}
  const defaultTimelineOptions: InternalTimelineOptions = {
    editable: {
      add: false,
      remove: false,
      updateGroup: true,
      updateTime: true,
    },
    orientation: 'top',
    zoomKey: 'ctrlKey',
    zoomMin: timeIntervals.ONE_HOUR,
    zoomMax: timeIntervals[p.options.range ?? 'ONE_WEEK'],
    verticalScroll: true,
    horizontalScroll: true,
    snap: null,
    stack: false,
    showCurrentTime: true,
    // margin: {
    //   item: {
    //     horizontal: -1,
    //   },
    // },
    groupHeightMode: 'fitItems',
    start: defaultStart,
    end: defaultEnd,
    initialDate: new Date(),
    range: 'ONE_WEEK',
    groupTemplate: (item?: TimelineGroup, element?: TimelineElement) => {
      return renderItemTemplate({ item, element, itemType: 'group', Component: p.options.groupComponent })
    },
    // template: (item?: TimelineItem, element?: TimelineElement) => {
    //   return renderItemTemplate({ item, element, itemType: 'item', Component: p.options.itemComponent })
    // },
    skipAmount: 'ONE_DAY',
    largeSkipAmount: 'ONE_WEEK',
    ...p.options,
  }

Hey @Jonas-PRF were you able to figure a workaround regarding the Group disappearing on button collapse? having the same issue and seems not to be able to figure it out yet

m-nathani commented 2 months ago

@m-nathani We're reusing (or atleast trying) the template function for the groups and the items.

export const renderItemTemplate = <T extends CommonTimelineProperties>({ item, element, itemType, Component }: RenderItemParams<T>) => {
  if (!element || !item) {
    return ''
  }

  const DATA_IS_RENDERED = 'data-is-rendered'

  const itemIsRendered = element.hasAttribute(DATA_IS_RENDERED)

  if (!itemIsRendered) {
    element.setAttribute(DATA_IS_RENDERED, 'true')
    const root = createRoot(element)
    root.render(
        <Component key={`${itemType}-${item.id}`} {...item} />,
    )

    element.timelineItemRoot = root
  } else {
    element.timelineItemRoot.render(
        <Component key={`${itemType}-${item.id}`} {...item} />,
    )
  }

  return ''
}

Using this function the groups are being rendered but once I click the collapse button the component is not rendered

const TimelineGroupComponent = ({ content, treeLevel, ...props }: TimelineGroup) => {
  return (
    <>
      {treeLevel === 1 ? (
        <div className="ml-2 pb-1 pt-1">
          <P type="small">{content}</P>
        </div>
      ) : null}
      {treeLevel === 2 ? (
        <div className="parent-subgroup-label border-r-grey-300 flex flex-row items-center gap-1 border-r pb-2.5 pt-2.5">
          <FontAwesomeIcon fontSize="16px" icon={faBolt} className="mr-1 text-blue-100" />
          <div className="flex flex-col items-start">
            <P type="small" className="text-grey-300">
              {content}
            </P>
            <P type="strong" className="pr-2">
              Montage tafel 1
            </P>
          </div>
        </div>
      ) : null}
    </>
  )
}
  const defaultTimelineOptions: InternalTimelineOptions = {
    editable: {
      add: false,
      remove: false,
      updateGroup: true,
      updateTime: true,
    },
    orientation: 'top',
    zoomKey: 'ctrlKey',
    zoomMin: timeIntervals.ONE_HOUR,
    zoomMax: timeIntervals[p.options.range ?? 'ONE_WEEK'],
    verticalScroll: true,
    horizontalScroll: true,
    snap: null,
    stack: false,
    showCurrentTime: true,
    // margin: {
    //   item: {
    //     horizontal: -1,
    //   },
    // },
    groupHeightMode: 'fitItems',
    start: defaultStart,
    end: defaultEnd,
    initialDate: new Date(),
    range: 'ONE_WEEK',
    groupTemplate: (item?: TimelineGroup, element?: TimelineElement) => {
      return renderItemTemplate({ item, element, itemType: 'group', Component: p.options.groupComponent })
    },
    // template: (item?: TimelineItem, element?: TimelineElement) => {
    //   return renderItemTemplate({ item, element, itemType: 'item', Component: p.options.itemComponent })
    // },
    skipAmount: 'ONE_DAY',
    largeSkipAmount: 'ONE_WEEK',
    ...p.options,
  }

Hey @Jonas-PRF were you able to figure a workaround regarding the Group disappearing on button collapse? having the same issue and seems not to be able to figure it out yet

Hi @NwosaEmeka @Jonas-PRF ,

while having a high level look into the code, it appears when collapse and expand the groupTemplate function is not able to restore the group...

A quick fix that made it work was to create and render on every attempt inside groupTemplate, rather checking for the rootElements[group.id]) and reuse it. This works and shows groups on collapse and expand.. however if you have a better solution then please share.

        groupTemplate: (group, element) => {
          if (!group?.id) return null;

           // if (!rootElements[group.id]) {
          //   // If not, create a new root and store it
          rootElements[group.id] = createRoot(element);
          // }

          // eslint-disable-next-line react/jsx-props-no-spreading
          rootElements[group.id].render(<Group {...group} />);

          return rootElements[group.id];
        },
pgross41 commented 2 months ago

This thread helped me come up with the following which allows me to use JSX for my item/group content. Very similar to examples above but it allows each item to use its own component and the extra container div fixed DOM issues others might be seeing. Another caveat is make sure your group IDs don't overlap with your item IDs! (Note: I'm using React 17)

const options = {
    // ...options...
    template: renderReactTemplate,
    groupTemplate: renderReactTemplate
}

const elementMap: { [id: string]: HTMLElement } = {};
function renderReactTemplate<T extends TimelineItem | TimelineGroup>(
    itemOrGroup: T | null, // This is `null` on .destroy
    element: HTMLElement
) {
    // Do nothing if it's null
    if (!itemOrGroup) return '';

    // Do nothing special if content isn't a ReactElement
    if (!isValidElement(itemOrGroup.content)) return itemOrGroup.content;

    // Return the element reference if we've already rendered this
    const mapId = itemOrGroup?.id;
    if (elementMap[mapId]) return elementMap[mapId];

    // Create a container for the react element (prevents DOM node errors)
    const container = document.createElement('div');
    element.appendChild(container);
    ReactDOM.render(itemOrGroup?.content, container);

    // Store the rendered element container to reference later
    elementMap[mapId] = container;

    // Return the new container
    return container;
}
tranphuongthao2405 commented 2 months ago

This thread helped me come up with the following which allows me to use JSX for my item/group content. Very similar to examples above but it allows each item to use its own component and the extra container div fixed DOM issues others might be seeing. Another caveat is make sure your group IDs don't overlap with your item IDs! (Note: I'm using React 17)

const options = {
    // ...options...
    template: renderReactTemplate,
    groupTemplate: renderReactTemplate
}
const elementMap: { [id: string]: HTMLElement } = {};
function renderReactTemplate<T extends TimelineItem | TimelineGroup>(
    itemOrGroup: T | null, // This is `null` on .destroy
    element: HTMLElement
) {
    // Do nothing if it's null
    if (!itemOrGroup) return '';

    // Do nothing special if content isn't a ReactElement
    if (!isValidElement(itemOrGroup.content)) return itemOrGroup.content;

    // Return the element reference if we've already rendered this
    const mapId = itemOrGroup?.id;
    if (elementMap[mapId]) return elementMap[mapId];

    // Create a container for the react element (prevents DOM node errors)
    const container = document.createElement('div');
    element.appendChild(container);
    ReactDOM.render(itemOrGroup?.content, container);

    // Store the rendered element container to reference later
    elementMap[mapId] = container;

    // Return the new container
    return container;
}

For someone is using React 18 or higher:

const elementMapRef = useRef({});

const timelineOptions = useMemo(() => {
    const options = {
      ..., // some timeline options
      template: function (item, element) {
        if (!item) return;

        const mapId = item?.id;
        if (elementMapRef.current?.[mapId]) return elementMapRef.current[mapId];

        // Create a container for the react element (prevents DOM node errors)
        const container = document.createElement('div');
        element.appendChild(container);
        createRoot(container).render(<TimelineItem item={item} rangeType={rangeType} />);

        // Store the rendered element container to reference later
        elementMapRef.current[mapId] = container;

        // Return the new container
        return container;
      },
    }