Repository: marimo-team/marimo
Pull request #4298: improvement: format using popover for long strings
Author: login: Light2Dark
Created: 2025-03-28T16:11:02Z | Merged: 2025-03-29T17:41:44Z

## 📝 Summary

<!--
Provide a concise summary of what this pull request is addressing.

If this PR fixes any issues, list them here by number (e.g., Fixes #123).
-->
Some refactors and display long strings in a popup. Setting an artificial limit of 50 to decide when to use popup (else might be quite complex with useEffects and useRef).

![image](https://github.com/user-attachments/assets/d29f6352-2d0d-4d61-8653-4253d714a589)

The code feels messy, I will try to refactor.

## 🔍 Description of Changes

<!--
Detail the specific changes made in this pull request. Explain the problem addressed and how it was resolved. If applicable, provide before and after comparisons, screenshots, or any relevant details to help reviewers understand the changes easily.
-->

## 📋 Checklist

- [X] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md).
- [ ] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable).
- [ ] I have added tests for the changes made.
- [X] I have run the code and verified that it works as expected.

## 📜 Reviewers

<!--
Tag potential reviewers from the community or maintainers who might be interested in reviewing this pull request.

Your PR will be reviewed more quickly if you can figure out the right person to tag with @ -->

@akshayka OR @mscolnick



Diff:
diff --git a/frontend/src/components/data-table/columns.tsx b/frontend/src/components/data-table/columns.tsx
index a5096409f0e..5bf83fc9dfb 100644
--- a/frontend/src/components/data-table/columns.tsx
+++ b/frontend/src/components/data-table/columns.tsx
@@ -31,6 +31,9 @@ import { PopoverClose } from "@radix-ui/react-popover";
 import { Button } from "../ui/button";
 import type { ColumnChartSpecModel } from "./chart-spec-model";
 
+// Artificial limit to display long strings
+const MAX_STRING_LENGTH = 50;
+
 function inferDataType(value: unknown): [type: DataType, displayType: string] {
   if (typeof value === "string") {
     return ["string", "string"];
@@ -200,8 +203,6 @@ export function generateColumns<T>({
         }
 
         const value = getValue();
-        const justify = textJustifyColumns?.[key];
-        const wrapped = wrappedColumns?.includes(key);
 
         function selectCell() {
           if (selection !== "single-cell" && selection !== "multi-cell") {
@@ -211,23 +212,51 @@ export function generateColumns<T>({
           cell.toggleSelected?.();
         }
 
+        const justify = textJustifyColumns?.[key];
+        const wrapped = wrappedColumns?.includes(key);
         const isCellSelected = cell?.getIsSelected?.() || false;
         const canSelectCell =
           (selection === "single-cell" || selection === "multi-cell") &&
           !isCellSelected;
 
+        const cellStyles = getCellStyleClass(
+          justify,
+          wrapped,
+          canSelectCell,
+          isCellSelected,
+        );
+
         const format = column.getColumnFormatting?.();
+
+        if (typeof value === "string") {
+          const stringValue = format
+            ? String(column.applyColumnFormatting(value))
+            : String(renderValue());
+
+          if (stringValue.length > MAX_STRING_LENGTH) {
+            return (
+              <PopoutColumn
+                cellStyles={cellStyles}
+                selectCell={selectCell}
+                rawStringValue={stringValue}
+                contentClassName="max-h-64 overflow-auto whitespace-pre-wrap break-words text-sm"
+                buttonText="X"
+              >
+                <UrlDetector text={stringValue} />
+              </PopoutColumn>
+            );
+          }
+
+          return (
+            <div onClick={selectCell} className={cellStyles}>
+              <UrlDetector text={stringValue} />
+            </div>
+          );
+        }
+
         if (format) {
           return (
-            <div
-              onClick={selectCell}
-              className={getCellStyleClass(
-                justify,
-                wrapped,
-                canSelectCell,
-                isCellSelected,
-              )}
-            >
+            <div onClick={selectCell} className={cellStyles}>
               {column.applyColumnFormatting(value)}
             </div>
           );
@@ -236,22 +265,8 @@ export function generateColumns<T>({
         if (isPrimitiveOrNullish(value)) {
           const rendered = renderValue();
           return (
-            <div
-              onClick={selectCell}
-              className={getCellStyleClass(
-                justify,
-                wrapped,
-                canSelectCell,
-                isCellSelected,
-              )}
-            >
-              {rendered == null ? (
-                ""
-              ) : typeof rendered === "string" ? (
-                <UrlDetector text={rendered} />
-              ) : (
-                String(rendered)
-              )}
+            <div onClick={selectCell} className={cellStyles}>
+              {rendered == null ? "" : String(rendered)}
             </div>
           );
         }
@@ -262,15 +277,7 @@ export function generateColumns<T>({
             column.columnDef.meta?.dataType === "date" ? "date" : "datetime";
           const timezone = extractTimezone(column.columnDef.meta?.dtype);
           return (
-            <div
-              onClick={selectCell}
-              className={getCellStyleClass(
-                justify,
-                wrapped,
-                canSelectCell,
-                isCellSelected,
-              )}
-            >
+            <div onClick={selectCell} className={cellStyles}>
               <DatePopover date={value} type={type}>
                 {exactDateTime(value, timezone)}
               </DatePopover>
@@ -281,15 +288,7 @@ export function generateColumns<T>({
         const mimeValues = getMimeValues(value);
         if (mimeValues) {
           return (
-            <div
-              onClick={selectCell}
-              className={getCellStyleClass(
-                justify,
-                wrapped,
-                canSelectCell,
-                isCellSelected,
-              )}
-            >
+            <div onClick={selectCell} className={cellStyles}>
               {mimeValues.map((mimeValue, idx) => (
                 <MimeCell key={idx} value={mimeValue} />
               ))}
@@ -300,47 +299,18 @@ export function generateColumns<T>({
         if (Array.isArray(value) || typeof value === "object") {
           const rawStringValue = renderAny(value);
           return (
-            <EmotionCacheProvider container={null}>
-              <Popover>
-                <PopoverTrigger
-                  className={getCellStyleClass(
-                    justify,
-                    wrapped,
-                    canSelectCell,
-                    isCellSelected,
-                  )}
-                  onClick={selectCell}
-                >
-                  <span
-                    className="cursor-pointer hover:text-link"
-                    title={rawStringValue}
-                  >
-                    {rawStringValue}
-                  </span>
-                </PopoverTrigger>
-                <PopoverContent>
-                  <PopoverClose className="absolute top-2 right-2">
-                    <Button variant="link" size="xs">
-                      Close
-                    </Button>
-                  </PopoverClose>
-                  <JsonOutput data={value} format="tree" className="max-h-64" />
-                </PopoverContent>
-              </Popover>
-            </EmotionCacheProvider>
+            <PopoutColumn
+              cellStyles={cellStyles}
+              selectCell={selectCell}
+              rawStringValue={rawStringValue}
+            >
+              <JsonOutput data={value} format="tree" className="max-h-64" />
+            </PopoutColumn>
           );
         }
 
         return (
-          <div
-            onClick={selectCell}
-            className={getCellStyleClass(
-              justify,
-              wrapped,
-              canSelectCell,
-              isCellSelected,
-            )}
-          >
+          <div onClick={selectCell} className={cellStyles}>
             {renderAny(getValue())}
           </div>
         );
@@ -387,6 +357,45 @@ export function generateColumns<T>({
   return columns;
 }
 
+const PopoutColumn = ({
+  cellStyles,
+  selectCell,
+  rawStringValue,
+  contentClassName,
+  buttonText,
+  children,
+}: {
+  cellStyles: string;
+  selectCell: () => void;
+  rawStringValue: string;
+  contentClassName?: string;
+  buttonText?: string;
+  children: React.ReactNode;
+}) => {
+  return (
+    <EmotionCacheProvider container={null}>
+      <Popover>
+        <PopoverTrigger className={cellStyles} onClick={selectCell}>
+          <span
+            className="cursor-pointer hover:text-link"
+            title={rawStringValue}
+          >
+            {rawStringValue}
+          </span>
+        </PopoverTrigger>
+        <PopoverContent className={contentClassName}>
+          <PopoverClose className="absolute top-2 right-2">
+            <Button variant="link" size="xs">
+              {buttonText ?? "Close"}
+            </Button>
+          </PopoverClose>
+          {children}
+        </PopoverContent>
+      </Popover>
+    </EmotionCacheProvider>
+  );
+};
+
 function isPrimitiveOrNullish(value: unknown): boolean {
   if (value == null) {
     return true;
diff --git a/marimo/_smoke_tests/tables/rich_elements.py b/marimo/_smoke_tests/tables/rich_elements.py
index cf0ffa69823..e6182f40420 100644
--- a/marimo/_smoke_tests/tables/rich_elements.py
+++ b/marimo/_smoke_tests/tables/rich_elements.py
@@ -1,6 +1,6 @@
 import marimo
 
-__generated_with = "0.11.26"
+__generated_with = "0.11.30"
 app = marimo.App(width="medium")
 
 
@@ -104,6 +104,10 @@ def simple_echo_model(messages, config):
             ["fruits", "trees", "animals"],
             ["humans", "aliens", "life"],
         ],
+        "long_text": [
+            "lorem_ipsum_dollar_sit" * 20,
+            "lorem_ipsum_dollar_sit" * 20,
+        ],
         "images": [img, html_img],
         "batch": user_info,
         "dropdowns": [


Reviews:
user: login: mscolnick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:18:38Z
user: login: mscolnick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:19:23Z
user: login: mscolnick
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-29T17:13:33Z

Review comments:
diff_hunk: @@ -217,6 +220,72 @@ export function generateColumns<T>({
           !isCellSelected;
 
         const format = column.getColumnFormatting?.();
+
+        if (typeof value === "string" && value.length > MAX_STRING_LENGTH) {
+          if (format) {
+            const formattedValue = column.applyColumnFormatting(value);
+            return (
+              <PopoutColumn
path: frontend/src/components/data-table/columns.tsx
user: login: mscolnick
body: could this be refactored a bit to:

const getValue() = {
  // handles formatting
  // handle renderValue
}

to that we don't need to have `value.length > MAX_STRING_LENGTH` and `<PopoutColumn` twice
created_at: 2025-03-28T16:18:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -217,6 +220,72 @@ export function generateColumns<T>({
           !isCellSelected;
 
         const format = column.getColumnFormatting?.();
+
+        if (typeof value === "string" && value.length > MAX_STRING_LENGTH) {
+          if (format) {
+            const formattedValue = column.applyColumnFormatting(value);
+            return (
+              <PopoutColumn
+                cellStyles={getCellStyleClass(
+                  justify,
+                  wrapped,
+                  canSelectCell,
+                  isCellSelected,
+                )}
+                selectCell={selectCell}
+                rawStringValue={String(formattedValue)}
+                contentClassName="max-h-64 overflow-auto whitespace-pre-wrap break-words text-sm"
+                buttonText="X"
+              >
+                {formattedValue}
+              </PopoutColumn>
+            );
+          }
+
+          // Handle unformatted string values
+          const rendered = renderValue();
+          let valueToDisplay: React.ReactNode;
+
+          if (rendered === null) {
+            valueToDisplay = "";
+          } else if (typeof rendered !== "string") {
+            valueToDisplay = String(rendered);
+          } else if (rendered.length > MAX_STRING_LENGTH) {
+            return (
+              <PopoutColumn
+                cellStyles={getCellStyleClass(
path: frontend/src/components/data-table/columns.tsx
user: login: mscolnick
body: at think we can pull out `cellStyles` to the root at this point.
created_at: 2025-03-28T16:19:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-28T16:11:07Z
body: [vc]: #eFHbsyz/mrhbfHxdUDPe17QCv61BvK0uoDzJlQfgfgo=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJtYXJpbW8tZG9jcyIsInJvb3REaXJlY3RvcnkiOiJkb2NzIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL21hcmltby9tYXJpbW8tZG9jcy9ubnNacHk0VFA1N1NtdUZ1NTNlaEp2OGNvaGc1IiwicHJldmlld1VybCI6Im1hcmltby1kb2NzLWdpdC1mb3JrLWxpZ2h0MmRhcmstZm9ybWF0LWxvbmctc3RyaW5ncy1tYXJpbW8udmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJtYXJpbW8tZG9jcy1naXQtZm9yay1saWdodDJkYXJrLWZvcm1hdC1sb25nLXN0cmluZ3MtbWFyaW1vLnZlcmNlbC5hcHAifX0seyJuYW1lIjoibWFyaW1vLXN0b3J5Ym9vayIsInJvb3REaXJlY3RvcnkiOiJmcm9udGVuZCIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9tYXJpbW8vbWFyaW1vLXN0b3J5Ym9vay82SFA1SGF2ajlTVEJGNnZWS2lrdmk2NWdxM0U3IiwicHJldmlld1VybCI6Im1hcmltby1zdG9yeWJvb2stZ2l0LWZvcmstbGlnaHQyZGFyay1mb3JtYXQtbG9uZy1zdHJpbmdzLW1hcmltby52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6Im1hcmltby1zdG9yeWJvb2stZ2l0LWZvcmstbGlnaHQyZGFyay1mb3JtYXQtbG9uZy1zdHJpbmdzLW1hcmltby52ZXJjZWwuYXBwIn19XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **marimo-docs** | ✅ Ready ([Inspect](https://vercel.com/marimo/marimo-docs/nnsZpy4TP57SmuFu53ehJv8cohg5)) | [Visit Preview](https://marimo-docs-git-fork-light2dark-format-long-strings-marimo.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/marimo-docs-git-fork-light2dark-format-long-strings-marimo.vercel.app?via=pr-comment-feedback-link) | Mar 29, 2025 4:45am |
| **marimo-storybook** | ✅ Ready ([Inspect](https://vercel.com/marimo/marimo-storybook/6HP5Havj9STBF6vVKikvi65gq3E7)) | [Visit Preview](https://marimo-storybook-git-fork-light2dark-format-long-strings-marimo.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/marimo-storybook-git-fork-light2dark-format-long-strings-marimo.vercel.app?via=pr-comment-feedback-link) | Mar 29, 2025 4:45am |



author_association: NONE
user: login: Light2Dark
created_at: 2025-03-28T16:13:16Z
body: oops, missed some cases.
author_association: COLLABORATOR
user: login: akshayka
created_at: 2025-03-29T17:40:23Z
body: This is great! :)
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-29T17:49:39Z
body: 🚀 Development release published. You may be able to view the changes at https://marimo.app?v=0.11.32-dev5
author_association: NONE