top of page

Another Way to Handle File Uploads in Power Pages

  • Writer: Sofia Ng
    Sofia Ng
  • Aug 22
  • 7 min read

If you’ve worked with Power Pages forms, you’ll know the default flow can be a bit clunky. You hit submit to create the record, and then you have to go back in to add your files. That might be fine for testing, but in production it feels like asking the user to do twice the work.

In my case, I wanted a single button that would:


  1. Create the main record in Dataverse.

  2. Upload one or more files.

  3. Attach those files to the record immediately.

No second pass, no jumping between screens.

A cheerful cartoon platypus presses a large red Submit button while files and a folder float nearby, symbolising a one-step upload process.

What we’re working with

To make the example easy to follow, I’m using these three pieces:

  • Main table: orders

  • Attachment table: orderattachments with a File column called filecontent

  • Relationship: orders_orderattachments (one order can have many attachments)

That’s it. You could adapt the idea to any other tables in your own environment.


The problem with the stock approach

The out-of-the-box submit button in Power Pages:

  • Fires once to create the record.

  • Leaves attachments to be uploaded after the fact.

  • Doesn’t give you much feedback while things are happening.

If your users are working with invoices, contracts or claims, this feels clumsy. They expect a single action to get the job done.


Another approach

The custom script I’m sharing takes over the submit process. It does three things in order:

  1. Creates the parent order record through the Dataverse Web API.

  2. Loops through each selected file, creates an attachment row, uploads the binary, and binds it to the order.

  3. Shows a progress overlay so the user knows exactly what’s happening.

Here’s a quick sketch of the flow:


User clicks Submit → Create Order →

For each file:

Create attachment row

Upload file

Link to parent


Once it’s done, the overlay closes and the user sees a clear “Submitted” message.


The user experience

I also hid the default submit button and the “form assistant” panel so there’s only one way to complete the form. The page adds its own simple upload box with a file picker, a list of chosen files, and a single Submit button.

  • Users can pick multiple files at once.

  • They can remove a file before submitting.

  • While the files are uploading, a dark overlay appears with a spinner and a counter.

It feels much more natural than waiting, wondering, and then having to reopen the record.


How to use this

  1. Paste the script in Power Pages > your form > Advanced > Custom JavaScript.

  2. Publish the page.

  3. Pick a couple of small test files and click Submit.


You should see:

  • a dashed “Attachments” box with a file picker

  • a file list with remove buttons

  • a busy overlay while the record is created and files upload

  • a simple “Submitted” alert at the end


What to change

  • PARENT_SET → your parent table set name.

  • CHILD_SET → your attachment table set name.

  • FILE_COL → your file column logical name.

  • REL_NAME → the relationship name from parent to child.

  • buildParentPayload() → swap the example fields for the inputs on your form.

  • ALLOWED_EXT and MAX_BYTES → your file policy.

  • REDIRECT_URL → set a path if you want a thank‑you page.


Tip: if your form uses different input IDs, open the page, inspect the inputs, and copy their id values into buildParentPayload().


Permissions you need

Give your portal user (or Web Role) the right access:

  • Create on the parent table

  • Create on the attachment table

  • Append on the attachment table

  • Append To on the parent table

  • Write on the file column

If you miss any of these you will usually get a 403 or 401 from the Web API calls.


Troubleshooting quick hits

  • “Missing anti‑forgery token”

    Make sure the page includes the standard token input. If you are using a very custom layout, add the hidden __RequestVerificationToken to the form or rely on shell.getTokenDeferred().

  • 400 on upload

    Check the file column name in FILE_COL. It must be the logical name of the File column on the child table.

  • 404 on $ref bind

    Confirm REL_NAME is the exact relationship name from the parent to the child. Open the relationship in Dataverse to copy it.

  • Nothing happens on click

    Ensure the default submit is hidden and you are clicking the new Submit button inside the “Attachments” box.

  • Files rejected

    Either the extension is not in ALLOWED_EXT or the file is bigger than MAX_BYTES. Adjust those constants to fit your policy.


Why this helps

  • One action for users. No second pass to add files.

  • Clear feedback with a small overlay and a simple counter.

  • Safer uploads with an allow‑list and a size cap.

  • Parallel uploads for speed, with a sensible limit.


Optional tweaks

  • Change <input type="file" multiple> to single file if that suits the process.

  • Replace the alert with a toast or redirect to a summary page.

  • Add a small note above the picker with your file policy, for example “PDF, JPG, DOCX. Max 25 MB.”


What’s next in Part 2

In the next post I’ll take this from a demo to a pattern you can reuse:

  • Token handling the right way, including fallbacks

  • Building the parent payload from real form fields

  • Adding lookup columns with @odata.bind

  • A safe retry when lookups are fussy

  • Cleaner error messages that make sense to non‑developers


Script


<!-- Add this into your form's Advanced > Custom JavaScript -->
<script>
/*
==============================================
 One-step record + file upload for Power Pages
==============================================
This script replaces the stock form submit with a single flow:
 1. Creates a parent record (orders table).
 2. Uploads one or more files to the orderattachments table.
 3. Binds the files to the parent through a relationship.
It also:
 - Hides the default submit button and AI assistant.
 - Provides a file picker and list with remove buttons.
 - Shows a simple busy overlay while work is in progress.
 - Adds "good citizen" checks for file type, size, and duplicates.

Update the config section below with your own table and column names.
*/

const PARENT_SET        = "orders";                 // parent table set name
const CHILD_SET         = "orderattachments";       // child table set name
const FILE_COL          = "filecontent";            // file column logical name
const REL_NAME          = "orders_orderattachments";// parent->child relationship name
const PARALLEL_LIMIT    = 3;                        // how many files to upload at once
const REDIRECT_URL      = "";                       // optional redirect after submit

/* ===== token + fetch helpers ===== */
let _token = null;
async function getPortalToken() {
  if (_token) return _token;
  if (window.shell?.getTokenDeferred) _token = await shell.getTokenDeferred();
  if (!_token) {
    _token = document.querySelector("input[name='__RequestVerificationToken']")?.value
          || (document.cookie.match(/(?:^|;\\s*)__RequestVerificationToken=([^;]+)/)?.[1] || "");
 }
  if (!_token) throw new Error("Missing anti-forgery token");
  return _token;
}
async function apiFetch(url, opts = {}) {
  const t = await getPortalToken();
  const h = new Headers(opts.headers || {});
  h.set("__RequestVerificationToken", t);
  h.set("OData-Version", "4.0");
  h.set("OData-MaxVersion", "4.0");
  return fetch(url, { ...opts, headers: h, credentials: "same-origin", cache: "no-store" });
}
async function apiJson(url, method, body) {
  const r = await apiFetch(url, {
    method,
    headers: { "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined
  });
  if (!r.ok) throw await r.json().catch(() => ({ error:{message:r.statusText, status:r.status} }));
  return r;
}
const errText = e => e?.error?.message || e?.message || e?.statusText || "Request failed";

/* ===== tiny busy overlay ===== */
function ensureBusy() {
  if (document.getElementById("busy")) return;
  const s = document.createElement("style");
  s.textContent = `
    #busy{position:fixed;inset:0;background:rgba(0,0,0,.35);display:none;
      align-items:center;justify-content:center;z-index:99999;font:14px system-ui}
    #busy .w{background:#111;color:#fff;padding:14px 18px;border-radius:10px;display:flex;gap:10px;align-items:center}
    #busy .s{width:18px;height:18px;border-radius:50%;border:3px solid #777;border-top-color:#fff;animation:spin .9s linear infinite}
    @keyframes spin{to{transform:rotate(360deg)}}
  `;
  document.head.appendChild(s);
  const d = document.createElement("div");
  d.id = "busy";
  d.innerHTML = `<div class="w"><div class="s"></div><div id="busymsg">Working...</div></div>`;
  document.body.appendChild(d);
}

function showBusy(msg){ ensureBusy(); document.getElementById("busy").style.display="flex"; setBusy(msg||"Working..."); }

function setBusy(msg){ const m=document.getElementById("busymsg"); if(m) m.textContent=msg||""; }

function hideBusy(){ const el=document.getElementById("busy"); if(el) el.style.display="none"; }


/* ===== minimal parent payload: replace with your own fields ===== */function buildParentPayload(){
  const name  = document.getElementById("order_name")?.value?.trim();
  const email = document.getElementById("order_contactemail")?.value?.trim();
  const desc  = document.getElementById("order_description")?.value?.trim();
  return {
    "order_name": name || ("Web submit " + new Date().toISOString()),
    "order_contactemail": email || null,
    "order_description": desc || null
  };
}
/* ===== dataverse calls ===== */

async function createParent(){
  const r = await apiJson(`/_api/${PARENT_SET}`, "POST", buildParentPayload());
  const id = (r.headers.get("entityid")||"").replace(/[{}]/g,"");
  if (!id) throw new Error("No entityid returned for parent");
  return id;
}

async function createChildRow(filename){
  const r = await apiJson(`/_api/${CHILD_SET}`, "POST", { "name": filename || "file" });
  const id = (r.headers.get("entityid")||"").replace(/[{}]/g,"");
  if (!id) throw new Error("No entityid returned for child");
  return id;
}

async function uploadFile(childId, file){
  const r = await apiFetch(`/_api/${CHILD_SET}(${childId})/${FILE_COL}`, {
    method: "PATCH",
    headers: {
      "Content-Type": "application/octet-stream",
      "If-None-Match": "null",
      "x-ms-file-name": file.name
    },
    body: file
  });
  if (!r.ok) throw await r.json().catch(() => ({ error:{message:r.statusText, status:r.status} }));
}

async function bindChildToParent(childId, parentId){
  const base = window.location.origin + "/_api";
  const url  = `/_api/${PARENT_SET}(${parentId})/${REL_NAME}/$ref`;
  const body = { "@odata.id": `${base}/${CHILD_SET}(${childId})` };
  const r = await apiFetch(url, { method:"POST", headers:{ "Content-Type": "application/json" }, body: JSON.stringify(body) });
  if (!r.ok){
    let j=null,msg=r.statusText; try{ j=await r.json(); msg=j?.error?.message||msg; }catch{}
    const err=new Error(`Link failed: ${msg}`); err.httpStatus=r.status; throw err;
  }
}

/* ===== small queue for parallel uploads ===== */
async function runQueue(tasks, limit = PARALLEL_LIMIT){
  let i = 0;
  const workers = Array(Math.min(limit, tasks.length)).fill(0).map(async ()=>{
    while(i < tasks.length){ const idx = i++; await tasks[idx](); }
  });
  await Promise.all(workers);
}
/* ===== file allow-list and size cap (good citizen) ===== */
const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
const ALLOWED_EXT = [".pdf",".docx",".xlsx",".jpg",".png"];

function isAllowed(file){
  const ext = (file.name.match(/\.[^.]+$/)?.[0] || "").toLowerCase();
  if (!ALLOWED_EXT.includes(ext)) {
    alert(`File type not allowed: ${file.name}`);
    return false;
  }
  if (file.size > MAX_BYTES) {
    alert(`File too large (${(file.size/1024/1024).toFixed(1)} MB). Max is 25 MB: ${file.name}`);
    return false;
  }
  return true;
}

/* ===== UI: hide stock submit and mount our picker ===== */
const picked = [];

function renderList(){
  const ul = document.getElementById("file-list");
  if (!ul) return;
  ul.innerHTML = picked.length
    ? picked.map((f,i)=>`<li style="display:flex;gap:8px;align-items:center">
         <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${f.name} (${f.size.toLocaleString()} bytes)</span>
         <button type="button" class="rm" data-i="${i}" aria-label="Remove ${f.name}">✕</button>
       </li>`).join("")
    : `<li style="opacity:.7">No files yet</li>`;
}
document.addEventListener("click", e=>{
  const b = e.target.closest("button.rm"); if(!b) return;
  const i = Number(b.dataset.i); if(Number.isFinite(i)){ picked.splice(i,1); renderList(); }
});

function hideStockSubmit(){
  document.querySelectorAll("#InsertButton, .entity-form input[type=submit][name=Insert], .crmEntityFormView input[type=submit][name=Insert]")
    .forEach(b => { b.style.display="none"; b.disabled=true; b.setAttribute("aria-hidden","true"); });
}

function mountBox(){
  if (document.getElementById("upload-box")) return;
  hideStockSubmit();
  const anchor = document.querySelector(".entity-form") ||
                 document.querySelector(".crmEntityFormView") ||
                 document.querySelector("main") ||
                 document.getElementById("content") ||
                 document.body;
  const box = document.createElement("div");
  box.id = "upload-box";
  box.style.cssText = "border:1px dashed #888;padding:12px;margin-top:16px";
  box.innerHTML = `
    <div style="font-weight:600;margin-bottom:8px">Attachments</div>
    <input id="picker" type="file" multiple />
    <ul id="file-list" style="margin:8px 0 0; padding-left:18px; list-style:disc"></ul>
    <button id="btn-submit" type="button" style="margin-top:8px">Submit</button>
  `;
  anchor.appendChild(box);
  const picker = document.getElementById("picker");
  picker.addEventListener("change", e => {
    for (const f of Array.from(e.target.files||[])) {
      if (isAllowed(f)) {
        if (!picked.some(x => x.name===f.name && x.size===f.size)) {
          picked.push(f);
        }
      }
    }
    e.target.value = "";
    renderList();
  });
  renderList();
  document.getElementById("btn-submit").addEventListener("click", submitAll);
}
mountBox();

/* ===== main submit ===== */

async function submitAll(){
  const btn = document.getElementById("btn-submit");
  try{
    btn.disabled = true;
    showBusy("Creating record...");
    // 1) create parent
    const parentId = await createParent();
    // 2) upload files (optional)
    const files = picked.slice();
    if (files.length){
      let done = 0;
      const tasks = files.map(f => async ()=>{
        setBusy(`Uploading ${done}/${files.length}: ${f.name}`);
        const childId = await createChildRow(f.name);
        await uploadFile(childId, f);
        await bindChildToParent(childId, parentId);
        done++; setBusy(`Uploading ${done}/${files.length}...`);
      });
      await runQueue(tasks);
    }
    hideBusy();
    alert("Submitted");
    picked.length = 0; renderList();
    if (REDIRECT_URL) window.location.href = REDIRECT_URL;
  }catch(e){
    hideBusy();
    console.error(e);
    alert(errText(e));
  }finally{
    btn.disabled = false;
  }
}
</script>


We can help you implement smoother, smarter Power Pages solutions. Contact us today and let’s build it right the first time.

Comments

Rated 0 out of 5 stars.
No ratings yet

Add a rating

Contact Us

QUESTIONS?

WE'RE HERE TO HELP

  • LinkedIn

© 2023 by Ava Technology Solutions. Proudly created with Wix.com

bottom of page