Build a dashboard with comments

Cord allows you to add inline comments to your charts and tables. This guide will walk you through building our Dashboard sample app.


Overview #

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.

Prerequisites #

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.


General setup #

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.

React:
// 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,
);

// 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,
);

Copy

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.

React:
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]);

  // ...
}

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]);

  // ...
}

Copy

Put a Thread List on the page #

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.

React:
const { openThread, setRequestToOpenThread } = useContext(ThreadsContext)!;

return (
  <ThreadList
    location={LOCATION}
    highlightThreadId={openThread ?? undefined}
    onThreadClick={setRequestToOpenThread}
    style={{ maxHeight: '400px' }}
  />
);
const { openThread, setRequestToOpenThread } = useContext(ThreadsContext)!;

return (
  <ThreadList
    location={LOCATION}
    highlightThreadId={openThread ?? undefined}
    onThreadClick={setRequestToOpenThread}
    style={{ maxHeight: '400px' }}
  />
);
Copy

Let user choose when they want to leave comments #

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.

React:
const { setInThreadCreationMode } = useContext(ThreadsContext)!;
// ...
<button onClick={() => setInThreadCreationMode((val) => !val)}>
  Add comment
</button>
const { setInThreadCreationMode } = useContext(ThreadsContext)!;
// ...
<button onClick={() => setInThreadCreationMode((val) => !val)}>
  Add comment
</button>
Copy

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.

React:
// 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]);
// 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]);
Copy

Close conversation thread when user clicks elsewhere #

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!

React:
// 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]);
// 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]);
Copy

Remove empty conversation threads #

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.

React:
// 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,
      }}
    />
  );
}
// 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,
      }}
    />
  );
}
Copy

Adding comments to a chart #

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:

  • start new (or open an existing) conversations when user clicks on a point on a chart
  • remember the point's x and y coordinates in Thread's metadata
  • position Pin elements over the chart based on each Thread's metadata

 

Decide what metadata you need per conversation thread #

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:

  • ID of the chart
  • ID of the line series
  • x-coordinate of the point
  • y-coordinate of the point

 

Typescript:
// Metadata stored on threads left on charts
export type ChartThreadMetadata = {
  type: 'chart';
  chartId: string;
  seriesId: string;
  x: number;
  y: number;
};
// Metadata stored on threads left on charts
export type ChartThreadMetadata = {
  type: 'chart';
  chartId: string;
  seriesId: string;
  x: number;
  y: number;
};
Copy

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.

Choose how you generate Thread IDs #

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.

Vanilla JavaScript:
const threadId = `${groupId}_${metadata.chartId}_${metadata.seriesId}_${metadata.x}_${metadata.y}`;
const threadId = `${groupId}_${metadata.chartId}_${metadata.seriesId}_${metadata.x}_${metadata.y}`;
Copy

We included group ID in the thread ID in case the same chart can be shown to users in different groups.

Place conversation Pins over the chart #

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.

React:
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>
);
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>
);
Copy

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.

React:
<Pin style={{ position: absolute }}>
  <ThreadWrapper
    location={LOCATION}
    threadId={threadId}
    metadata={metadata}
    style={{
      position: 'absolute',
      left: 0,
      top: '100%',
    }}
  />
</Pin>
<Pin style={{ position: absolute }}>
  <ThreadWrapper
    location={LOCATION}
    threadId={threadId}
    metadata={metadata}
    style={{
      position: 'absolute',
      left: 0,
      top: '100%',
    }}
  />
</Pin>
Copy

Adding new threads #

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.

Handle requests to open a comment #

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.

React:
// 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);
  },
  [
    /* ... */
  ],
);
// 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);
  },
  [
    /* ... */
  ],
);
Copy

Adding comments to a table #

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:

  • start new (or open an existing) conversation when user clicks on a table cell
  • store the cell's row and column IDs in the Thread's metadata
  • add a visual indicator to cells with row and column IDs matching the metadata of one of the existing threads

 

Decide what metadata you need per conversation thread #

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:

  • ID of the table
  • ID of the cell's row
  • ID of the cell's column

 

Typescript:
// Metadata stored on threads left on table grids
export type GridThreadMetadata = {
  type: 'grid';
  gridId: string;
  rowId: string;
  colId: string;
};
// Metadata stored on threads left on table grids
export type GridThreadMetadata = {
  type: 'grid';
  gridId: string;
  rowId: string;
  colId: string;
};
Copy

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).

Choose how you generate Thread IDs #

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.

Vanilla JavaScript:
const threadId = `{groupId}_${metadata.gridId}_${metadata.rowId}_${metadata.colId}`;
const threadId = `{groupId}_${metadata.gridId}_${metadata.rowId}_${metadata.colId}`;
Copy

We included group ID in the thread ID in case the same table can be shown to users in different groups.

Add UI to indicate conversations #

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.

React:
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 */)}
    </>
  );
}
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 */)}
    </>
  );
}
Copy

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.

Adding new threads #

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.

Handle requests to open a comment #

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.

React:
// 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);
    }
  },
  [
    /* ... */
  ],
);
// 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);
    }
  },
  [
    /* ... */
  ],
);
Copy

Ask Cordy