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();
})();


