Cord allows you to add inline comments to your charts and tables. This guide will walk you through building our Dashboard sample app.
This guide will show you how you can use Cord to build a dashboard on which users can leave comments. You can play with the final result here and find the entire code on GitHub. The dashboard was built with Highcharts and AG Grid libraries for charts and tables, but this guide will focus on Cord related code, providing simplified code examples with links to the actual implementation.
The dashboard demo app that this guide goes over consists of one chart and one table. Users can leave comments on chart's data points or on individual cells of the table. The core of this experience is the Thread component that is used to start and reply to conversations and the Pin component used to mark where these conversations are taking place. The code leverages the metadata
field on Thread to store additional custom information. In particular, this information identifies the point on a chart or cell of a table that each thread belongs. Pins can then be placed and clicked on by users to open up the corresponding thread.
The guide will first go through the code setup that is common for both the chart and the table implementation. Then it will talk about the code specific to the chart and then the table.
This guide assumes that you already know how to install and initialize Cord on your page. It uses React, Highcharts and AG Grid but the concepts explained here should be useful even if you use a different framework or libraries.
We create a context to make the React state related to commenting easily available to components that should support the commenting functionality. This context stores information such as what threads are visible on the current page, which thread is open or whether user can start new conversation threads.
// Context for storing all thread related information
type ThreadsContextType = {
// Map of all threads on current page, mapping from thread's ID to its
// metadata
threads: Map<string, ThreadMetadata>;
// Adds a thread to the threads map
addThread: (threadId: string, metadata: ThreadMetadata) => void;
// Removes a thread from the threads map
removeThread: (threadId: string) => void;
// The id of the thread open on this page (or null if none is open)
openThread: string | null;
setOpenThread: (arg: string | null) => void;
// The id of the thread that should be open after the page makes necessary
// adjustments to make the thread visible. Common adjustments are scrolling
// the page, updating chart/table filters, un-collapsing the right page
// section etc. This is useful for implementing ThreadList's onThreadClick
// callback or for implementing URL deep-linking. If page adjustments are not
// needed, then simply use `setOpenThread(threadId)` to open a thread.
//
// The standard usage pattern looks like this:
// useEffect(() => {
// if (requestToOpenThread) {
// ...scroll the page, adjust filters, etc.
// setOpenThread(requestToOpenThread);
// setRequestToOpenThread(null);
// }
// }, [requestToOpenThread, setRequestToOpenThread, setOpenThread]);
requestToOpenThread: string | null;
setRequestToOpenThread: (threadId: string | null) => void;
// True if user can leave threads at the moment
inThreadCreationMode: boolean;
setInThreadCreationMode: (
v: boolean | ((oldVal: boolean) => boolean),
) => void;
};
export const ThreadsContext = createContext<ThreadsContextType | undefined>(
undefined,
);
The corresponding React context provider has code here. It mostly consists of basic React useState
hooks. The main thing to pay attention to is Cord's thread.useLocationData(LOCATION)
which fetches threads that exist on LOCATION
(current page) and also updates in real time when new threads are created.
export function ThreadsProvider({ children }: PropsWithChildren) {
// ...
// Fetch existing threads associated with location
const {
threads: threadSummaries,
hasMore,
loading,
fetchMore,
} = thread.useLocationData(LOCATION, { includeResolved: false });
useEffect(() => {
if (loading) {
return;
}
if (hasMore) {
// NOTE: For this demo, fetch all threads on the page.
fetchMore(1000);
}
threadSummaries
.filter((t) => t.total > 0) // remove threads with no messages
.forEach((t) => addThread(t.id, t.metadata as ThreadMetadata));
}, [addThread, fetchMore, hasMore, loading, threadSummaries, threads]);
// ...
}
A ThreadList shows previews of all threads that exist on the page. It is useful for users to see all conversations in one place and to quickly navigate to them by clicking on the previews.
The ThreadList in the dashboard app highlights the preview of the currently open thread. Also, upon clicking one of the previews it updates ThreadsContext.requestToOpenThread
to ask the page to show the clicked thread.
const { openThread, setRequestToOpenThread } = useContext(ThreadsContext)!;
return (
<ThreadList
location={LOCATION}
highlightThreadId={openThread ?? undefined}
onThreadClick={setRequestToOpenThread}
style={{ maxHeight: '400px' }}
/>
);
In our dashboard app, clicking on a table cell or chart point should only start a conversation if the user is in comment mode. Let's add a button that will toggle whether clicks will leave comments or not.
const { setInThreadCreationMode } = useContext(ThreadsContext)!;
// ...
<button onClick={() => setInThreadCreationMode((val) => !val)}>
Add comment
</button>
It is common to press ESC
key when users are done leaving new comments on the page. If the user has a conversation open, the ESC
key press should also close the conversation. Here is a React effect that does both of these things.
// Effect to close open thread on ESCAPE key press and also stop thread
// creation mode
useEffect(() => {
const close = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpenThread(null);
setInThreadCreationMode(false);
}
};
document.addEventListener('keydown', close);
return () => document.removeEventListener('keydown', close);
}, [setInThreadCreationMode, setOpenThread]);
We want a conversation thread to close when the user clicks outside of it. This is achieved with a single useEffect
hook. You might not need or want this behavior so feel free to take it out here!
// Effect to close thread if user clicks anywhere but a Pin or Thread
useEffect(() => {
if (openThread) {
const close = (event: MouseEvent) => {
if (
!event.composedPath().some((e) => {
if (e instanceof Element) {
const elName = e.tagName.toLowerCase();
return elName === 'cord-pin' || elName === 'cord-thread';
}
return false;
})
) {
// user clicked somewhere that's not the pin nor thread
setOpenThread(null);
}
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}
return () => {};
}, [openThread, setOpenThread]);
Sometimes users click on a chart or a table cell to start a new conversation thread, only to immediately close it without sending a message. We remove such threads from the page since empty threads only clutter the user experience.
You can tell how many messages a thread has using the Thread SDK or Thread's onThreadInfoChange
prop.
// A wrapper over cord-thread that removes itself if empty when closed
export function ThreadWrapper({
forwardRef,
location,
threadId,
metadata,
style,
}: ThreadWrapperProps) {
const { openThread, removeThread, setOpenThread } =
useContext(ThreadsContext)!;
const [numberOfMessages, setNumberOfMessages] = useState<number | undefined>(
undefined,
);
// Effect that removes this thread if it has no messages at the time it is closed
useEffect(() => {
const isOpen = threadId === openThread;
if (!isOpen && numberOfMessages !== undefined && numberOfMessages <= 0) {
removeThread(threadId);
}
return () => {
if (numberOfMessages !== undefined && numberOfMessages <= 0) {
removeThread(threadId);
}
};
}, [numberOfMessages, openThread, removeThread, threadId]);
return (
<Thread
forwardRef={forwardRef}
showHeader={true}
location={location}
onThreadInfoChange={(info) => setNumberOfMessages(info.messageCount)}
onClose={() => setOpenThread(null)}
threadId={threadId}
metadata={metadata}
autofocus={openThread === threadId}
style={{
// Using css visibility: hidden instead of display: none to hide this
// thread if it is not the open one. display: none would remove the
// Thread from DOM and thus would lose the draft message.
visibility: openThread === threadId ? 'visible' : 'hidden',
width: '300px',
maxHeight: '400px',
...style,
}}
/>
);
}
In this section we go over the process of adding commenting functionality to a chart. We only show high-level code snippets here, but the full code can be found on Github. The high-level summary of the code is:
Every Cord Thread can store additional metadata
. We will use this to store information that will tell us where to place each each on the chart. Our dashboard app allows users to only have conversations on individual chart's points and so we store in the metadata:
// Metadata stored on threads left on charts
export type ChartThreadMetadata = {
type: 'chart';
chartId: string;
seriesId: string;
x: number;
y: number;
};
This of course requires that each chart and each line series have a unique and stable ID. It does not matter what the IDs are (they could be autogenerated UUIDs) as long as they don't change over time.
Every Thread has an ID that has to be unique across all your Cord Threads. In the dashboard app we want to allow only 1 conversation thread per chart point. This can be easily achieved by generating thread IDs based on the point they "belong" to. This way even if two users start a conversation on the same chart point at the same time, their comments will end up in the same thread.
const threadId = `${groupId}_${metadata.chartId}_${metadata.seriesId}_${metadata.x}_${metadata.y}`;
We included group ID
in the thread ID in case the same chart can be shown to users in different groups.
With the thread metadata and ID defined, we can finally place conversation threads over the chart. We use Pin as a marker where conversations have been started. From the ThreadsContext
we know the IDs and metadata for all threads on the page. Using the metadata and Highcharts API we can use CSS position: absolute
to position each pin over the chart.
Because the chart can change (e.g. axes ranges change) we re-render all pins by adding a handler for the chart's redraw event.
const { threads } = useContext(ThreadsContext)!;
return (
<div style={{ position: 'relative' }}>
<HighchartsReact />
{Array.from(threads)
.filter((keyVal) => {
const [_threadId, metadata] = keyVal;
// only render threads that belong to this chart
return metadata.type === 'chart' && metadata.chartId === chartId;
})
.map(([threadId, metadata]) => (
<Pin
key={threadId}
threadId={threadId}
location={LOCATION}
style={{
position: 'absolute',
top: /* compute from metadata.y */,
left: /* compute from metadata.x */,
}}
>
{/*...*/}
</Pin>
))}
</div>
);
Some pins need to be hidden because the chart point they correspond to is not visible at the moment. But because Highcharts API tells us what series are visible and what the axes' ranges are, it is not hard to check if a point is displayed.
Finally, when a user clicks on a pin, we show its thread below it with absolute positioning, but you can use a library such as floating-ui for more complex use cases.
<Pin style={{ position: absolute }}>
<ThreadWrapper
location={LOCATION}
threadId={threadId}
metadata={metadata}
style={{
position: 'absolute',
left: 0,
top: '100%',
}}
/>
</Pin>
To start a new thread, we need to register click handlers on the elements that users can leave comments on. In our case, when user is in commenting mode clicking on a point should start a thread (or open an existing thread) on that point. Highcharts API lets us register a handler for clicks on a point and all we need to do is update the ThreadsContext
when that happens. The code for this is here.
Sometimes users want to open a comment in a way other than by clicking directly on the pin. One of those ways is by clicking on thread previews in ThreadList. For these cases, the requestToOpenThread
variable in ThreadsContext
is used. When this variable is set, we need to adjust the page to make it possible to show the requested thread. The actual code is here, but below is what the code does on a high-level.
// Effect to update chart so that the requested thread can be displayed
useEffect(
() => {
if (requestToOpenThread === null) {
return;
}
const metadata = threads.get(requestToOpenThread);
if (metadata?.type !== 'chart' || metadata.chartId !== chartId) {
// request is not for this chart
return;
}
// 1. Make the requested chart series visible
// 2. Adjust the range of the chart axes
// 3. Scroll the page to the chart and open the thread
setOpenThread(requestToOpenThread);
setRequestToOpenThread(null);
},
[
/* ... */
],
);
In this section we show high-level code needed to add commenting functionality to a table. The full code can be found here, but the code boils down to:
Every Cord Thread can store additional metadata
. We will use this to store information that will tell us to which cell each thread belongs. Our dashboard app allows users to only have conversations on each table cell. When a user starts a new conversation on one of the cells it is enough to store:
// Metadata stored on threads left on table grids
export type GridThreadMetadata = {
type: 'grid';
gridId: string;
rowId: string;
colId: string;
};
This of course requires that each table row has a unique ID and that each column has a unique ID. For example, if your table shows a list of customers, then the ID of each row could be the ID of the customer and the ID of each column could be the name of the displayed customer field (assuming those cannot be renamed).
Every Thread has an ID that has to be unique across all your Cord Threads. In the dashboard app we want to allow only 1 conversation thread per each table cell. This can be easily achieved by generating thread IDs based on the cell they "belong" to. This way even if two users start a conversation on the same cell at the same time, their comments will end up in the same thread.
const threadId = `{groupId}_${metadata.gridId}_${metadata.rowId}_${metadata.colId}`;
We included group ID
in the thread ID in case the same table can be shown to users in different groups.
The table library we used in the dashboard, AG Grid, supports custom table cell renderers. We use that to add a visual indicator to cells that have conversations. We can easily tell which cell has a conversation because we know what the thread ID of the thread would be if the cell had a conversation.
function CellWithThreadAndPresence(
params: ICellRendererParams,
gridId: string,
setReference: (el: Element | null) => void,
) {
const { threads } = useContext(ThreadsContext)!;
// get rowId, colId from cell's params
const threadId = `${orgId}_${gridId}_${rowId}_${colId}`;
return (
<>
<div>{/* cell content */}</div>
{threads.has(threadId) && (/* visual indicator of conversation */)}
</>
);
}
The actual code also uses PresenceObserver and PresenceFacepile. Together they show in real time where each user is pointing their cursor which further improves the collaboration experience.
When user clicks on a cell with a conversation on it, the dashboard opens the corresponding thread. We position the thread next to the cell using the common floating-ui library. The only extra work we had to handle is hiding the thread when the cell is scrolled out of the view of the table.
To start a new thread, we need to register click handlers on the elements that users can leave comments on. In our case, when user is in commenting mode clicking on a table cell should start a thread (or open existing thread) on that cell. AG Grid API lets us register a handler for clicks on cells and all we need to do is update the ThreadsContext
when that happens. The code for this is here.
Sometimes users want to open a comment in a way other than by clicking directly on a table cell. One of those ways is by clicking on thread previews in ThreadList. For these cases, the requestToOpenThread
variable in ThreadsContext
is used. When this variable is set, we need to adjust the page to make it possible to show the requested thread. The actual code is here, but below is what the code does on a high-level.
// Effect to show the correct thread when the user requests to open a
// specific thread (e.g. by clicking a thread in ThreadList)
useEffect(
() => {
const metadata =
requestToOpenThread !== null ? threads.get(requestToOpenThread) : null;
if (metadata?.type === 'grid' && metadata.gridId === gridId) {
// this is a request for this grid, make the thread visible
const { rowId, colId } = metadata;
// 1. Remove table filters if needed
// 2. Scroll the page to the table
// 3. Scroll the table itself to the row rowId
// 4. Flash the cell for to guide user's eye
setRequestToOpenThread(null);
setOpenThread(requestToOpenThread);
}
},
[
/* ... */
],
);
Not finding the answer you need? Ask our Developer Community