Adding Columns to Attachment Grid Via JS

Hi. I wanted to share a solution/proof of concept found for adding columns to the attachment slider grid as both the slider and grid are inaccessible via Application Studio controls. Users were requesting to see the attachment user and attachment date for each attachment record. Epicor suggested creating an idea for this, but I figured we could improvise a little (okay a lot).

This was a fun project overall and has opened some creative doors as far as Kinetic development goes as it demonstrates we can access the component registry at runtime (“Should we?” is the real question tho). Very useful for manipulating components we don’t have access to, normally. I hope anyone reading this may get something out of it, even if the solution itself isn’t the end goal.

This project consisted of creating BPMs, UD fields, a data directive and a single Application Studio event that injects JS during runtime. In short, the code creates a watcher object that detects when the attachment slider opens (there are no events tied to this panel/grid), locates the grid in the component tree, and adds the additional columns. The columns w/ data are automatically reapplied each time the framework rebuilds the grid. All details below. Created in version 2025.2.16, on-prem.

The Grid

Default:

Modified:

Project Flow

UD Fields

Epicor stores the attachment file reference in ice.XFileRef. Here is where I created two new UD fields that will be used in the grid.

  • ice.XFileRef_UD.Author_c
  • ice.XFileRef_UD.Created_c

Data Directive

Table: XFileRef
Type: In-Transaction
Description: When a new record is added to XFileRef, set the uploader and date of attachment

var row = ttXFileRef.Where(r => r.RowMod == “A”).FirstOrDefault();

if (row != null)
{    
     row.Author_c = Session.UserID;
     row.Created_c = DateTime.Now;
}

Methods

These append the above UD fields to the returned OrderHedAttch table from MasterUpdate & GetByID. The response data appends to the system-level data view OrderHedAttch, which the grid is linked to.

  • Note, depending on the form/dashboard you are working with, you will have to identify which service is being used to return the respective attachment table. This example used Order Entry.

Erp.BO.SalesOrder.MasterUpdate\Post-Processing

//using System.Reflection;

if (ds.OrderHedAttch.ExtendedColumns.All(x => x.ColumnName != "Created_c"))
{
  var columnList = new List<ExtendedColumn>(ds.OrderHedAttch.ExtendedColumns);
  columnList.Add(new ExtendedColumn("Created_c", typeof(DateTime)));
  typeof(IceTable<Erp.Tablesets.OrderHedAttchRow>).GetField("extendedColumns", BindingFlags.NonPublic | BindingFlags.Static).SetValue(null, columnList.ToArray(), BindingFlags.NonPublic | BindingFlags.Static, null, null);
}
if (ds.OrderHedAttch.ExtendedColumns.All(x => x.ColumnName != "Author_c"))
{
  var columnList = new List<ExtendedColumn>(ds.OrderHedAttch.ExtendedColumns);
  columnList.Add(new ExtendedColumn("Author_c", typeof(string)));
  typeof(IceTable<Erp.Tablesets.OrderHedAttchRow>).GetField("extendedColumns", BindingFlags.NonPublic | BindingFlags.Static).SetValue(null, columnList.ToArray(), BindingFlags.NonPublic | BindingFlags.Static, null, null);
}

foreach (var attchRow in ds.OrderHedAttch)
{
  var lookup = (from fr in Db.XFileRef
                where fr.Company == attchRow.Company
                   && fr.XFileRefNum == attchRow.XFileRefNum
                select new { fr.Created_c, fr.Author_c }).FirstOrDefault();

  if (lookup != null)
  {
    attchRow["Created_c"] = lookup.Created_c;
    attchRow["Author_c"] = lookup.Author_c;
  }
}

Erp.BO.SalesOrder.GetByID\Post-Processing

//using System.Reflection;

if (result.OrderHedAttch.ExtendedColumns.All(x => x.ColumnName != "Created_c"))
{
  var columnList = new List<ExtendedColumn>(result.OrderHedAttch.ExtendedColumns);
  columnList.Add(new ExtendedColumn("Created_c", typeof(DateTime)));
  typeof(IceTable<Erp.Tablesets.OrderHedAttchRow>).GetField("extendedColumns", BindingFlags.NonPublic | BindingFlags.Static).SetValue(null, columnList.ToArray(), BindingFlags.NonPublic | BindingFlags.Static, null, null);
}
if (result.OrderHedAttch.ExtendedColumns.All(x => x.ColumnName != "Author_c"))
{
  var columnList = new List<ExtendedColumn>(result.OrderHedAttch.ExtendedColumns);
  columnList.Add(new ExtendedColumn("Author_c", typeof(string)));
  typeof(IceTable<Erp.Tablesets.OrderHedAttchRow>).GetField("extendedColumns", BindingFlags.NonPublic | BindingFlags.Static).SetValue(null, columnList.ToArray(), BindingFlags.NonPublic | BindingFlags.Static, null, null);
}

foreach (var attchRow in result.OrderHedAttch)
{
  var lookup = (from fr in Db.XFileRef
                where fr.Company == attchRow.Company
                   && fr.XFileRefNum == attchRow.XFileRefNum
                select new { fr.Created_c, fr.Author_c }).FirstOrDefault();

  if (lookup != null)
  {
    attchRow["Created_c"] = lookup.Created_c;
    attchRow["Author_c"] = lookup.Author_c;
  }
}

Event

Trigger Type: Event
Hook: Before
Target: Form_OnLoad

Action: Row-Update
Binding: TransView.Watcher (field can be named anything)
Expression:

  • Not a complete copy-pasta! Will have to update it with your respective fields being added to the attachment grid
(() => {
  const NS = '__attachColsInjector';
  if (window[NS]) { window[NS].arm(); return; }

  const PRUNE = new Set(['injector','_parentInjector','parent','componentRef','_view',
    'host','renderer','renderer2','nativeElement','hostView','_hostLView','elementRef','_lView']);
  const log = (...a) => console.log('[attachCols]', ...a);

  const isGrid = o => o && typeof o === 'object' && o.columns &&
    (o.columns.toArray || o.columns._results) && ('data' in o || 'view' in o);
  const rowsOf  = x => Array.isArray(x.data) ? x.data : x.data?.data || x.view?.data || [];
  const hostElOf = x => { for (const k in x) { try { const el = x[k]?.nativeElement;
    if (el?.nodeType === 1 && document.contains(el)) return el; } catch {} } return null; };
  const isTheGrid = x => { const r = rowsOf(x)[0];
    return r && 'Author_c' in r && hostElOf(x) && (x.columns.toArray?.().length || x.columns.length); };
  

  const headerLacksAuthor = g => { const host = hostElOf(g)?.closest('.k-grid');
    return host && ![...host.querySelectorAll('.k-grid-header th')].some(t => t.textContent.trim() === 'Author'); };

  function findGrids() {
    const roots = [...((window.epDebug?.loggerService?.epSysConfigService?.objManagerSvc
                     || window.__trans?.epSysConfigService?.objManagerSvc)?._components || [])];
    let hits = roots.filter(c => isGrid(c) && isTheGrid(c));              
    if (hits.length) return hits;
    for (const c of roots) { if (!c || typeof c !== 'object') continue;   
      for (const k of Object.keys(c)) { if (PRUNE.has(k)) continue;
        let v; try { v = c[k]; } catch { continue; }
        if (isGrid(v) && isTheGrid(v)) hits.push(v); } }
    if (hits.length) return hits;
    const seen = new Set(), q = [...roots]; let b = 50000;                 
    while (q.length && b-- > 0) { const o = q.shift();
      if (!o || typeof o !== 'object' || seen.has(o) || o.nodeType) continue; seen.add(o);
      if (isGrid(o) && isTheGrid(o)) hits.push(o);
      let ks; try { ks = Object.keys(o); } catch { continue; }
      for (const k of ks) { if (PRUNE.has(k)) continue;
        try { const v = o[k]; if (v && typeof v === 'object' && !seen.has(v)) q.push(v); } catch {} } }
    return hits;
  }

  function narrowDetectors(g) {
    const gridCdr = (typeof g.detectChanges === 'function' && typeof g.markForCheck === 'function')
                  ? g : (g.changeDetectorRef || g.cdr || g.cd || null);
    const set = new Set(), seen = new Set(), q = [g]; let b = 8000;
    while (q.length && b-- > 0) { const o = q.shift();
      if (!o || typeof o !== 'object' || seen.has(o) || o.nodeType) continue; seen.add(o);
      try { if (typeof o.columnsForLevel === 'function' && typeof o.detectChanges === 'function') set.add(o); } catch {}
      let ks; try { ks = Object.keys(o); } catch { continue; }
      for (const k of ks) { if (PRUNE.has(k)) continue;
        try { const v = o[k]; if (v && typeof v === 'object' && !seen.has(v)) q.push(v); } catch {} } }
    return [gridCdr, ...set].filter(Boolean);
  }
  const fire = (s, doResize) => { s.forEach(d => { try { d.markForCheck(); } catch {} });
                      s.forEach(d => { try { d.detectChanges(); } catch {} });
                      if (doResize) window.dispatchEvent(new Event('resize')); };

  function makeCols(g, cols) {
    const tmpl = cols.find(c => c.field === 'FileName') || cols.find(c => c.field === 'DrawDesc')
              || cols.find(c => c.field && !c.templateRef && !c.template);
    if (!tmpl) return [];
    const maxO = Math.max(0, ...cols.map(c => c.orderIndex || 0));
    const TP = ['template','templateRef','cellTemplate','headerTemplate','headerTemplateRef',
      'headerCellTemplate','_headerTemplate','groupHeaderTemplate','groupHeaderTemplateRef',
      'groupFooterTemplate','groupFooterTemplateRef','editTemplate','editTemplateRef',
      'footerTemplate','footerTemplateRef'];
    const mk = (field, title, n, format) => {
      const c = Object.create(Object.getPrototypeOf(tmpl));
      for (const p of Object.keys(tmpl)) {
        try { Object.defineProperty(c, p, Object.getOwnPropertyDescriptor(tmpl, p)); } catch {} }
      c.field = field;
      const setData = (k, val) => { try { Object.defineProperty(c, k,
        { value: val, writable: true, configurable: true, enumerable: true }); } catch { try { c[k] = val; } catch {} } };
      setData('title', title);
      setData('displayTitle', title);
      if ('_title' in tmpl) setData('_title', title);
      TP.forEach(p => { try { Object.defineProperty(c, p, { value: undefined, writable: true, configurable: true }); } catch {} });
      for (const p of Object.getOwnPropertyNames(c)) { let v; try { v = c[p]; } catch { continue; }
        const ctor = v && v.constructor && v.constructor.name;
        if (v && typeof v === 'object' && (('templateRef' in v) || /Template|Directive/.test(ctor || '')))
          { try { c[p] = undefined; } catch {} } }
      if ('headerClass' in c) c.headerClass = '';
      c.orderIndex = maxO + n; c.leafIndex = maxO + n; if ('_leafIndex' in c) c._leafIndex = maxO + n;
      c.width = 150; c.hidden = false; if ('_hidden' in c) c._hidden = false;
      c.parent = tmpl.parent ?? undefined; if ('level' in c) c.level = tmpl.level ?? 0;
      if ('isSpanColumn' in c) c.isSpanColumn = false; if ('isColumnGroup' in c) c.isColumnGroup = false;
      c.rowspan = (typeof tmpl.rowspan === 'function') ? tmpl.rowspan : () => 1;
      c.colspan = (typeof tmpl.colspan === 'function') ? tmpl.colspan : () => 1;
      if ('locked' in c) c.locked = tmpl.locked ?? false;
      if ('matchesMedia' in c && typeof tmpl.matchesMedia !== 'function') c.matchesMedia = true;
      if (format) c.format = format;
      return c;
    };
    return [mk('Author_c','Author',1), mk('Created_c','Created',2,'{0:g}')];
  }

  function injectAndResync(g, cols, extra) {
    g.columns.reset([...cols, ...extra]); g.columns.notifyOnChanges();
    try { g.updateColumnIndices?.(); }
    catch { try { const cl = g.columnList;
      if (cl?.reset) cl.reset(g.columns.toArray());
      else if (cl?.constructor && cl.constructor !== Object) g.columnList = new cl.constructor(g.columns);
    } catch (e2) { console.warn('[attachCols] columnList rebuild failed:', e2.message); } }
    try { g.columnsContainerChange?.(); } catch {}
    try { g.onColumnRangeChange?.(); } catch {}
  }

  function gridReady(g, cols) {
    let host = null; for (const k in g) { try { const el = g[k]?.nativeElement;
      if (el?.nodeType === 1 && document.contains(el)) { host = el.closest('.k-grid') || el; break; } } catch {} }
    if (!host) return false;
    const ths = host.querySelectorAll('.k-grid-header th[role="columnheader"]');
    return ths.length >= (cols || g.columns.toArray()).length;
  }

  function headerTitles(g) {
    let host = null; for (const k in g) { try { const el = g[k]?.nativeElement;
      if (el?.nodeType === 1 && document.contains(el)) { host = el.closest('.k-grid') || el; break; } } catch {} }
    return host ? [...host.querySelectorAll('.k-grid-header th[role="columnheader"]')].map(e => e.textContent.trim()) : [];
  }
  function assertAndHeal(g) {
    setTimeout(() => {
      let t = headerTitles(g);
      if (t.includes('Author') && t.includes('Created')) { log('applied via narrow CD OK', t); return; }
      log('narrow CD stale - self-heal full blast', t);
      const seen = new Set(), q = [g], all = new Set(); let b = 40000;
      while (q.length && b-- > 0) { const o = q.shift();
        if (!o || typeof o !== 'object' || seen.has(o) || o.nodeType) continue; seen.add(o);
        try { if (typeof o.markForCheck === 'function' && typeof o.detectChanges === 'function') all.add(o); } catch {}
        let ks; try { ks = Object.keys(o); } catch { continue; }
        for (const k of ks) { if (PRUNE.has(k)) continue;
          try { const v = o[k]; if (v && typeof v === 'object' && !seen.has(v)) q.push(v); } catch {} } }
      fire(all);
      setTimeout(() => { t = headerTitles(g);
        log((t.includes('Author') && t.includes('Created')) ? 'healed via full blast OK' : 'STILL STALE',
          t, '| detectors:', all.size); }, 50);
    }, 50);
  }

  function ensure() {
    const attachOpen = [...document.querySelectorAll('.k-grid-header')]
      .some(h => h.textContent.includes('Reference No'));
    if (!attachOpen) return 'idle';
    const grids = findGrids();
    if (!grids.length) return 'no-grid';
    let appliedAny = false, pendingReady = false;
    for (const g of grids) {
      const cols = g.columns.toArray();
      const hasField = cols.some(c => c.field === 'Author_c');
      const painted = !headerLacksAuthor(g);
      if (hasField && painted) continue;
      if (hasField && !painted) { fire(narrowDetectors(g)); assertAndHeal(g); continue; }
      if (!gridReady(g, cols)) { pendingReady = true; continue; }
      const extra = makeCols(g, cols);
      if (!extra.length) { pendingReady = true; continue; }
      try { injectAndResync(g, cols, extra); }
      catch (e) { console.warn('[attachCols] inject threw - will retry', e?.message || e); pendingReady = true; continue; }
      fire(narrowDetectors(g), true); assertAndHeal(g); appliedAny = true;                      
    }
    if (appliedAny) return 'applied';
    if (pendingReady) return 'no-grid';
    return 'noop';
  }
  
 

  let timer = null, obs = null, retryTimer = null, retriesLeft = 0;
  const RETRY_MAX = 8, RETRY_MS = 100;
   const attempt = () => { try { const r = ensure();
    if (r !== 'idle') console.log('[attachCols] attempt ->', r);
    if (r === 'no-grid' && retriesLeft > 0) { retriesLeft--; clearTimeout(retryTimer); retryTimer = setTimeout(attempt, RETRY_MS); }
    else { retriesLeft = 0; clearTimeout(retryTimer); }                  
  } catch (e) { console.warn('[attachCols] ensure error', e); } };
  const debounced = () => { clearTimeout(timer); retriesLeft = RETRY_MAX; timer = setTimeout(attempt, 120); };
  function arm() {
    if (window[NS]._armed) { attempt(); return; }
    window[NS]._armed = true;
    attempt();
    obs = new MutationObserver(debounced);
    obs.observe(document.documentElement, { childList: true, subtree: true });
    log('armed - watching for attachment grid');
  }
  function disarm() { try { obs?.disconnect(); } catch {} obs = null; clearTimeout(timer);
    window[NS]._armed = false; log('disarmed'); }

  window[NS] = { ensure, arm, disarm, findGrids, _armed: false };
  window[NS].arm();
})();

Working Example:

Solid write up sir. Thanks for sharing. In-framework, client-side extensibility points, is wanting.

Are you aware that Reflection may not be carried forward?

This is very neat, and I appreciate the write-up. I do want to put my old grandpa web engineer hat on for a minute, mostly to clarify a couple of possible side effects for others who may come across this pattern later.

Two things I would be careful with in general, not just with this specific example:

  1. As @Mark_Wonsil mentioned, reflection is likely going to be problematic or unavailable in SaaS.
  2. The observer is attached at the document level and remains active for the lifetime of the SPA session. As written, it is not restricted to the screen that launched it. If another attachment grid matching the same characteristics is opened later, the observer will evaluate it and may attempt to apply the same column injection there as well.

From what I can tell ( only looked at it briefly so I may be wrong), it first checks whether there is a grid header containing Reference No, then walks the component tree looking for grids whose row data includes Author_c. That may be perfectly fine for the intended attachment slider, but it does mean the code is not scoped to a specific DOM container, app, or unique panel instance.

So if another screen or attachment panel happens to match those same conditions, the same injection logic could run there too, even after you leave this page since the Mutation Observer is ttached at the Document level. Maybe that is intended, but I wanted to call it out because document/window-level JS in a SPA in Angular Live forever (essentially until you refresh the page, or open / navigate a new tab)

Not trying to be overly critical. This is a clever approach. I just think it is worth noting that when injecting JS at the window/document level, especially with broad DOM selectors and component-tree scans, there can be unintended side effects outside the original screen unless the observer is scoped tightly or disconnected after use.

Spider man rules apply.

foreach (var attchRow in ds.OrderHedAttch)
{
  var lookup = (from fr in Db.XFileRef
                where fr.Company == attchRow.Company
                   && fr.XFileRefNum == attchRow.XFileRefNum
                select new { fr.Created_c, fr.Author_c }).FirstOrDefault();

  if (lookup != null)
  {
    attchRow["Created_c"] = lookup.Created_c;
    attchRow["Author_c"] = lookup.Author_c;
  }
}

Lol, not needed…

This whole piece is not even used.

//using System.Reflection;

if (ds.OrderHedAttch.ExtendedColumns.All(x => x.ColumnName != "Created_c"))
{
  var columnList = new List<ExtendedColumn>(ds.OrderHedAttch.ExtendedColumns);
  columnList.Add(new ExtendedColumn("Created_c", typeof(DateTime)));
  typeof(IceTable<Erp.Tablesets.OrderHedAttchRow>).GetField("extendedColumns", BindingFlags.NonPublic | BindingFlags.Static).SetValue(null, columnList.ToArray(), BindingFlags.NonPublic | BindingFlags.Static, null, null);
}
if (ds.OrderHedAttch.ExtendedColumns.All(x => x.ColumnName != "Author_c"))
{
  var columnList = new List<ExtendedColumn>(ds.OrderHedAttch.ExtendedColumns);
  columnList.Add(new ExtendedColumn("Author_c", typeof(string)));
  typeof(IceTable<Erp.Tablesets.OrderHedAttchRow>).GetField("extendedColumns", BindingFlags.NonPublic | BindingFlags.Static).SetValue(null, columnList.ToArray(), BindingFlags.NonPublic | BindingFlags.Static, null, null);
}

This is nice.
Works on SaaS as well, though I modified it for my own needs.

The Epicor Attachments through sharepoint is a mess, so I used it to add a clickable link to open the sharepoint file in sharepoint, rather than the goofy Epicor way of downloading a temp file.

Im sure itll break eventually with some update they do, but thats normal anyways.

Its a shame we have to resort to Javascript injection for basic stuff, but I have a feeling its going to be that way for a while considering the state that Application Studio is in.

My next big test/task for the js injection is getting an actual decent baq table dashboard setup and working instead of the nightmare tables they gave us.

Ideally they would just give us the option to write javascript pages entirely rather than being forced to use Application Studio. Im fairly sure I could train someone on javascript faster and easier than I could on how to use Application Studio.

Agree, mostly. Another approach is if they would provide supported extension points.
Like component.postRender(myFunc{...}(), componentInstance) and a bunch of others.