New community widget - Custom widget builder

Not to get too meta, but you can now use a custom widget to build custom widgets!

custom-widget-builder

Developed by @jarek (who does work at Grist Labs), who has been using this widget to build and test widgets on the fly. Think of it as a “widget fiddle”.

  • Build with HTML/JavaScript.
  • Documentation available within the widget itself (via “Open Configuration” > “Help”).
  • Remember to save your work, as there’s no autosave!

Now available as a community widget within Grist:

Some extra technical notes:

  • The widget itself is iframe inside an iframe, so some things (like locale) don’t get sent to your widget at the moment.
  • The “Open configuration” button won’t work for your widget, as the editor is intercepting it to open itself.
  • Since the editor intercepts certain messages, some development servers/browser extensions might cause issues if they are doing similar things.

Note: check out @jperon’s Pug/Python widget for a similar tool for building with Python.

11 Likes

Can the custom widgets created with this be shared, or even be copied to other documents you have youself?

I would love to know how far can we go with this Custom Widget Builder.

I suppose a lot of custom widgets already created depend on external libraries… for example… map widgets, the Advanced Charts widget, etc, right? And therefore those can´t be recreated with the custom widget builder??

This is great! Thanks for sharing, this’ll make quick prototyping a lot easier.

However, might I suggest the following for further improvement:

  • Store the HTML and JS in a record, via mapped columns, rather than in the widget settings. This would allow us to have a table dedicated to holding custom widgets, and open up easy ways of pulling in other data from the document.
  • Provide an option for iframeless operation, where ideally you’d have the <head> part of the HTML pre-configured (with the grist plugin api already included, of course) so that users would have to supply just the document body. This DOM could be dynamically appended rather than stuffed into an iframe.
1 Like

creating a widget through this, the configuration of where the data comes from… is it through the widget itself or the Creator Panel, like when creating a “normal” custom widget, as per instructions here?

You need to recreate it manually, just by copying JS and HTML content.

@Rogerio_Penna All limitation, that I can think of, were already mentioned, apart from that you can do whatever you want, in the end it just single HTML file rendered inside an iframe. Almost all custom widgets in the gallery can be recreated here (except this one and the one that inspects API, as this widget hijacks the Open configuration button).

The configuration is the same as in the instruction. This widget doesn’t change anything in the configuration.

Thanks for suggestion, but this way, you won’t be able to subscribe to onRecords event as the custom widget would need to be backed by this dedicated table. Alternatively, it could pull files from a different table, but then, it would require a full document access.

I’m not sure I understand it. In the HTML tab, you don’t need to add <html><head> tags at all. A widget with just a JS content (with all HTML removed) is perfectly fine, api library is added for you:

grist.ready({ requiredAccess: 'read table' });
grist.onRecords(table => {
  document.body.innerText = JSON.stringify(table);
});

ok, but I suppose it has several advantages over just using the HTML widget reading formulas from columns, right?

HTML viewer is only good for viewing “static” HTML files. Every time you change the cursor position the whole HTML code is changed. So you can’t hold a state in your HTML/JS widget and use it to build some kind of dynamic apps, as every-time row is changed, the whole “temporary” state, you app maintains, will be deleted.
This builder holds HTML/JS in the section’s metadata, so it starts only once, and can just react to events that come from Grist (via API).

Apart from that, the main benefit over a traditional widget is hosting. You don’t need a separate web server to host your custom HTML/JS files, as they are stored inside Grist document, and “hosted” (more or less) by Grist itself.

1 Like

Jarek… I created a widget that allows the user to create circle colored avatars with initials (like these angular - How to create round colored avatars with user initials? - Stack Overflow)

The code should get info from the company name, and output an html code to the HTMLLogo column.

So I set the widget as Custom Widget Builder. I paste the HTML and JS code. When I click preview, the Column Mapping settings appear.

As soon as I map the columns, I get an infinite loop where the configuration panel on the right keeps blinking between the initial configurations and the column mapping configuration that appears after I click to preview or save the code.

Any idea how to fix it?

Here is the Javascript Code

// Grist Integration: Declare required columns
grist.ready({
  columns: [
    { name: "Initials", title: "Initials Column", optional: false, type: "Text" },
    { name: "HTMLLogo", title: "Output HTML Column", optional: false, type: "Text" }
  ]
});

let currentRecord = {};
let isWritingToCell = false; // Prevent feedback loop during updates

// Listen for changes in the current record
grist.onRecord((record, mappings) => {
  if (isWritingToCell) return; // Prevent feedback loop

  const mapped = grist.mapColumnNames(record);

  if (mapped) {
    // Update widget state
    currentRecord = mapped;

    // Update widget with mapped data
    updateWidget(mapped);
  } else {
    console.error("Please map all required columns.");
  }
});

// Function to update the widget based on record data
function updateWidget(record) {
  const initials = record.Initials || "AB";

  // Update the initials preview
  const initialsPreview = document.getElementById("initialsPreview");
  initialsPreview.textContent = initials;

  // Update the avatar based on the new record data
  updateAvatar();
}

// Update the avatar preview dynamically
function updateAvatar() {
  const shape = document.getElementById("shape").value;
  const color1 = document.getElementById("color1").value;
  const color2 = document.getElementById("color2").value;
  const color3 = document.getElementById("color3").value;
  const gradientType = document.getElementById("gradientType").value;
  const gradientDirection = document.getElementById("gradientDirection").value;
  const fontFamily = document.getElementById("fontFamily").value;
  const isBold = document.getElementById("boldCheckbox").checked;
  const isItalic = document.getElementById("italicCheckbox").checked;
  const fontColor = document.getElementById("fontColor").value;
  const fontOutlineWidth = document.getElementById("fontOutlineWidth").value;
  const fontOutlineColor = document.getElementById("fontOutlineColor").value;
  const fontSize = document.getElementById("fontSize").value;

  const avatar = document.getElementById("avatarPreview");
  const initialsSpan = document.getElementById("initialsPreview");

  // Update shape
  avatar.style.borderRadius = shape === "circle" ? "50%" : "0";

  // Update gradient background
  if (gradientType === "linear") {
    avatar.style.background = `linear-gradient(${gradientDirection}, ${color1}, ${color2}, ${color3})`;
  } else if (gradientType === "radial") {
    avatar.style.background = `radial-gradient(circle, ${color1}, ${color2}, ${color3})`;
  }

  // Update font styles
  initialsSpan.style.fontFamily = fontFamily;
  initialsSpan.style.fontWeight = isBold ? "bold" : "normal";
  initialsSpan.style.fontStyle = isItalic ? "italic" : "normal";
  initialsSpan.style.color = fontColor;
  initialsSpan.style.fontSize = `${fontSize}%`;
  initialsSpan.style.textShadow = `
    -${fontOutlineWidth}px 0 ${fontOutlineColor},
    ${fontOutlineWidth}px 0 ${fontOutlineColor},
    0 -${fontOutlineWidth}px ${fontOutlineColor},
    0 ${fontOutlineWidth}px ${fontOutlineColor},
    -${fontOutlineWidth}px -${fontOutlineWidth}px ${fontOutlineColor},
    ${fontOutlineWidth}px -${fontOutlineWidth}px ${fontOutlineColor},
    -${fontOutlineWidth}px ${fontOutlineWidth}px ${fontOutlineColor},
    ${fontOutlineWidth}px ${fontOutlineWidth}px ${fontOutlineColor}
  `;
}

// Generate the HTML code and save it to the mapped HTMLLogo column
document.getElementById("generateHTML").addEventListener("click", function () {
  const avatarHTML = document.getElementById("avatarPreview").outerHTML;

  // Display the generated HTML in the textarea
  document.getElementById("htmlOutput").value = avatarHTML;

  // Save the HTML back to the mapped `HTMLLogo` cell (current row)
  if (currentRecord.id) {
    isWritingToCell = true; // Prevent feedback loop
    grist.docApi
      .updateCells({
        updates: [
          {
            id: currentRecord.id, // Use the current record's ID
            fields: {
              HTMLLogo: avatarHTML // Write to the `HTMLLogo` column for the current row
            }
          }
        ]
      })
      .finally(() => {
        isWritingToCell = false; // Allow updates again
      });
  } else {
    console.error("No record ID available to write to.");
  }
});

Hi @Rogerio_Penna,

I’m happy to fix that, can you share a sample document with that error?

yes, instruct me on how to share with you

Either by making the document public, or sharing it with me personally: jarek at getgrist.com

1 Like

I suppose as owner, to be able to mess with the code

image

First Tab… EMPRESA

I removed the code because it creates the loop, I will insert it again, just won´t map the columns, ok? The loop starts when I map the columns

ok, I created the code again without recreating the widget, and I got the loop… is it looping to you?

as I saved the code as preview, I guess the code is saved on the memory and thus it won´t loop to you…

Can you send me (DM) a link to this document?

DM = PM (Private Message)?

Hi Jarek. Were you able to access it?

I can reproduce it, thanks!

Is it a bug or something I did wrong?