Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React 18 support with vis-timeline #1779

Open
m-nathani opened this issue Mar 21, 2024 · 13 comments
Open

React 18 support with vis-timeline #1779

m-nathani opened this issue Mar 21, 2024 · 13 comments

Comments

@m-nathani
Copy link

m-nathani commented Mar 21, 2024

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
Copy link

annsch commented Mar 28, 2024

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
Copy link
Author

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
Copy link

annsch commented Mar 28, 2024

@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
Copy link
Author

m-nathani commented Mar 28, 2024

@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
Copy link

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

@m-nathani
Copy link
Author

m-nathani commented Apr 3, 2024

@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
Copy link

@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
Copy link

godmaster35 commented Apr 22, 2024

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
Copy link

andrewlimmer commented Jul 7, 2024

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

// Configuration for the Timeline
const options: TimelineOptions = {
  align:'center',
};

@NwosaEmeka
Copy link

@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
Copy link
Author

m-nathani commented Jul 20, 2024

@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
Copy link

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
Copy link

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;
      },
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants