Open m-nathani opened 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
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...
@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 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.
@m-nathani Are you able to implement the collapse functionality using this custom group template?
@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,
},
@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,
}
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...
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;
To fix the zoom error add the 'align' option.
// Configuration for the Timeline
const options: TimelineOptions = {
align:'center',
};
@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 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];
},
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;
}
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 extracontainer
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;
},
}
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 thisReactDOM
approach, as recommended in the documentation: https://visjs.github.io/vis-timeline/docs/timeline/#TemplatesHere is the working example for the code.
Now we want to move to
React 18
, andReactDOM.render
is deprecated, has anyone found a way too render using React way ?Additionally i tried rendering using
createPortal
andcreateRoot
but those approach were not successful.