Need to create a file uploader with grid preview

  1. My goal: Create a file uploader with grid preview of 20 slots that when the user clicks on any slot, they can select images from their PC and after selection, thumbnails would appear on the slots which the user can delete individual slots, delete all slots as well as reorder slots.
  2. Issue: Retool does not render any JS upon calling. I used the HTML component as well as the Reorderable component. both fail at deleting images when calling JS queries. No reordering happens at all with the HTML component. I don’t know why scripting is not accepted in retool natively which is a basic feature. I spent an entire week on this without able to solve it not knowing what the real issue is. I tried all sorts of different things. Whereas using pure HTML+CSS+JS in one single file when testing on my browser worked flawlessly. I have provided the full code below.
  3. Additional info: Cloud version used.
  4. I have an example of how this should look in this pure HTML+CSS+JS file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Advanced Image Grid Upload</title>
<style>
  body {
    background: #1a1a1a;
    color: #eee;
    font-family: Inter, sans-serif;
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
  }

  .upload-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, 100px);
    gap: 12px;
    width: 90%;
    max-width: 600px;
  }

  .slot {
    width: 100px;
    height: 100px;
    border-radius: 8px;
    border: 1px dashed #666;
    display: flex;
    justify-content: center;
    align-items: center;
    background: #2a2a2a;
    position: relative;
    overflow: hidden;
    transition: all 0.2s ease;
  }

  .slot img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .slot.empty:hover {
    background: #333;
    cursor: pointer;
  }

  .delete-btn {
    position: absolute;
    top: 4px;
    right: 4px;
    background: rgba(0,0,0,0.5);
    border: none;
    color: white;
    width: 22px;
    height: 22px;
    border-radius: 50%;
    font-size: 14px;
    cursor: pointer;
    opacity: 0;
    transition: 0.2s;
  }

  .slot:hover .delete-btn {
    opacity: 1;
  }

  /* Dragging effects */
  .slot.dragging {
    opacity: 0.8;
    transform: rotate(-5deg) scale(1.05);
    z-index: 100;
    box-shadow: 0 8px 18px rgba(0,0,0,0.5);
  }

  .slot.drag-over {
    border: 2px solid #00b4ff;
    background: rgba(0,180,255,0.1);
  }

  input[type="file"] {
    display: none;
  }

  .add-icon {
    width: 40px;
    height: 40px;
    fill: #777;
  }

</style>
</head>
<body>

<input type="file" id="fileInput" accept="image/*" multiple>

<div class="upload-grid" id="uploadGrid"></div>

<script>
  const grid = document.getElementById("uploadGrid");
  const fileInput = document.getElementById("fileInput");
  const maxSlots = 20;
  let files = [];

  // Initialize empty grid
  function renderGrid() {
    grid.innerHTML = "";

    for (let i = 0; i < maxSlots; i++) {
      const slot = document.createElement("div");
      slot.classList.add("slot");
      slot.dataset.index = i;

      const file = files[i];
      if (file) {
        const img = document.createElement("img");
        img.src = file.data;
        slot.appendChild(img);

        const delBtn = document.createElement("button");
        delBtn.innerText = "Γ—";
        delBtn.className = "delete-btn";
        delBtn.onclick = () => {
          files.splice(i, 1);
          renderGrid();
        };
        slot.appendChild(delBtn);
      } else {
        slot.classList.add("empty");
        slot.innerHTML = `
          <svg class="add-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
            <path d="M12 5v14m-7-7h14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/>
          </svg>
        `;
        slot.onclick = () => fileInput.click();
      }

      // Enable drag-and-drop
      slot.draggable = true;
      slot.addEventListener("dragstart", handleDragStart);
      slot.addEventListener("dragover", handleDragOver);
      slot.addEventListener("drop", handleDrop);
      slot.addEventListener("dragend", handleDragEnd);

      grid.appendChild(slot);
    }
  }

  // Handle upload
  fileInput.addEventListener("change", (e) => {
    const newFiles = Array.from(e.target.files);
    newFiles.forEach(file => {
      if (files.length < maxSlots) {
        const reader = new FileReader();
        reader.onload = function(event) {
          files.push({ name: file.name, data: event.target.result });
          renderGrid();
        };
        reader.readAsDataURL(file);
      }
    });
    fileInput.value = "";
  });

  // Drag logic
  let dragSrcIndex = null;

  function handleDragStart(e) {
    dragSrcIndex = +e.currentTarget.dataset.index;
    e.currentTarget.classList.add("dragging");
  }

  function handleDragOver(e) {
    e.preventDefault();
    e.currentTarget.classList.add("drag-over");
  }

  function handleDrop(e) {
    e.preventDefault();
    e.currentTarget.classList.remove("drag-over");
    const targetIndex = +e.currentTarget.dataset.index;
    if (dragSrcIndex !== targetIndex && files[dragSrcIndex]) {
      const moved = files.splice(dragSrcIndex, 1)[0];
      files.splice(targetIndex, 0, moved);
      renderGrid();
    }
  }

  function handleDragEnd(e) {
    e.currentTarget.classList.remove("dragging");
    grid.querySelectorAll(".slot").forEach(slot => slot.classList.remove("drag-over"));
  }

  renderGrid();
</script>

</body>
</html>

Hey @Retooler

Something we often do when we want to trigger a component is use the Query JSON with SQL resource and do something like

select * from {{ filepicker1.value }}

and have a success handler that has the JS. When you have your query JSON with SQL referencing the filepicker.value && on run automatically, it should trigger a run whenever it sees a change and then the JS will run on success. you could then have that JS update a variable that contains your gridview data and a image component that hides/unhides when data is added

Let me know if that works and if it all makes sense :slight_smile:

1 Like

Hello @DavidD

No worries I will be creating a custom component for that in which I have full control over DOM events. I can customize every aspect of what I need. Thank you anyways.

Looks like you had already done most of the work in the post already. Always a good workaround!

1 Like

I was going to recommend a custom component, as well! Building one out can be a little intimidating, but it enables a ton of things that Retool doesn't support natively. Plus it looks like you've done a lot of the work already!

One thing to note is that you'll probably want to convert the raw HTML/CSS/JS you've written into React. ChatGPT is honestly pretty good at stuff like that, but let me know if you have any questions!

1 Like

Step 1: Create a Temporary State

Create:

tempImages

Initial value:

[]

This will store:

[
  { name: "image1.png", data: "base64…" },
  { name: "image2.png", data: "base64…" },
  ...
]


:check_mark: Step 2: Add 20 Slots Using a ListView

  1. Add a ListView component

  2. Set Number of rows = 20

  3. Inside ListView, add:

  • An Image component

  • A Button (Delete)

  • A Container that will act as the click area


:check_mark: Step 3: Make Each Slot Clickable to Upload

Add a hidden FilePicker inside the ListView.

Set FilePicker:

maxFiles = 1

When slot is clicked β†’ FilePicker opens:

Slot click handler:

{{ filepicker1.open() }}


:check_mark: Step 4: Handle File Upload

FilePicker β†’ on change β†’ run JS query:

JS Query: addImage

const file = filepicker1.value[0];

if (!file) return;

tempImages.setValue([
  ...tempImages.value,
  {
    name: file.name,
    data: file.data
  }
]);

Now thumbnails appear automatically.


:check_mark: Step 5: Show Image or Empty Slot

Image component β†’ "src":

{{ tempImages.value[i] ? tempImages.value[i].data : null }}

Show "Add Icon" when empty:

Use a conditional:

{{ !tempImages.value[i] }}


:check_mark: Step 6: Delete Button

Button β†’ on click β†’ JS query:

JS Query: deleteImage

let arr = [...tempImages.value];
arr.splice(i, 1);
tempImages.setValue(arr);


:check_mark: Step 7: Add "Delete All" Button

tempImages.setValue([]);


:check_mark: Step 8: Add Reordering with Retool's Reorderable List

Retool has a built-in Reorderable List.

  1. Add a Reorderable list

  2. Bind it to:

{{ tempImages.value }}

  1. Use event β†’ "onReorder" β†’ JS:
tempImages.setValue(newOrder);

This gives you perfect drag-to-reorder support.

Have you had a chance to build out that custom component, @Retooler? I'm curious to know if you have any feedback!

@Retooler I am also curious if you got this up and running as you expected via what @Darren was asking, as well.

If not, I was curious if you'd be okay with me essentially copy/pasting your OP that explains desired behavior, then seeing what my AI Agent would return that I trained on building out custom component libraries / adding and updating components in an existing library. I could send you the solution it provides to see if it sets you on the right path if interested? Let me know, and if not no biggie! :folded_hands: