Add Dynamics userscripts
This commit is contained in:
1 parent
86f7fdcb30
commit
6ff0b5c96c
1 file changed
+310
@@ -0,0 +1,310 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name CRM User Enhancements
|
||||||
|
// @namespace https://git.home-brightview.com/matt.rb
|
||||||
|
// @version 1.0.0
|
||||||
|
// @author matt.rb
|
||||||
|
// @match https://*.dynamics.com/*
|
||||||
|
// @run-at document-idle
|
||||||
|
// @grant none
|
||||||
|
// @noframes
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
// Data comes from the Dataverse Web API with the signed-in user's own session,
|
||||||
|
// same approach as GAMS_Change_Tracking (src/api/dataverse.js) — no DOM scraping.
|
||||||
|
//
|
||||||
|
// Tags are the N:N "Tags" subgrid on the Application form:
|
||||||
|
// msnfp_request <-> cgms_ref_tag (label in cgms_name), verified on PRD.
|
||||||
|
// If navProperties is emptied, the script falls back to auto-discovery:
|
||||||
|
// 1. every field on the Application record (lookups, choices, multi-select), then
|
||||||
|
// 2. every 1:N / N:N relationship from msnfp_request to a table whose logical
|
||||||
|
// name contains "tag".
|
||||||
|
// Check the console for [NCA] lines to see what it found.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
entity: 'msnfp_request',
|
||||||
|
entitySet: 'msnfp_requests',
|
||||||
|
tagValue: 'Non-Compliant',
|
||||||
|
// Where the tag lives. Empty both to fall back to auto-discovery.
|
||||||
|
fields: [], // logical names on msnfp_request to check as well
|
||||||
|
navProperties: ['cgms_msnfp_request_cgms_ref_Tag_cgms_ref_Tag'],
|
||||||
|
tagNameAttribute: 'cgms_name', // label column on cgms_ref_tag
|
||||||
|
pollMs: 30000, // re-check while the record stays open (catches saves)
|
||||||
|
debug: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const API = '/api/data/v9.2';
|
||||||
|
const FORMATTED = 'OData.Community.Display.V1.FormattedValue';
|
||||||
|
const HEADERS = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'OData-MaxVersion': '4.0',
|
||||||
|
'OData-Version': '4.0',
|
||||||
|
Prefer: 'odata.include-annotations="*"',
|
||||||
|
};
|
||||||
|
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
const log = (...a) => CONFIG.debug && console.log('[NCA]', ...a);
|
||||||
|
const warn = (...a) => console.warn('[NCA]', ...a);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- API
|
||||||
|
|
||||||
|
async function get(path) {
|
||||||
|
const res = await fetch(`${location.origin}${API}${path}`, {
|
||||||
|
headers: HEADERS,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try { detail = (await res.json())?.error?.message || ''; } catch { /* non-JSON */ }
|
||||||
|
throw new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ''} — ${path}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- matching
|
||||||
|
|
||||||
|
// "Non-Compliant", "non compliant", "Non–Compliant" all compare equal.
|
||||||
|
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
const TARGET = norm(CONFIG.tagValue);
|
||||||
|
|
||||||
|
// Whole-value match only, so a description that merely mentions
|
||||||
|
// "non-compliant" in a sentence doesn't trigger the alert. Multi-select
|
||||||
|
// choices format as "A; B", so values are split on ; and , first.
|
||||||
|
function valueMatches(v) {
|
||||||
|
if (typeof v !== 'string' || !v || GUID_RE.test(v)) return false;
|
||||||
|
return v.split(/[;,]/).some((part) => norm(part) === TARGET);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the attribute keys on a record whose value (raw or formatted) matches.
|
||||||
|
function matchingKeys(record, onlyFields) {
|
||||||
|
const hits = [];
|
||||||
|
for (const [key, val] of Object.entries(record)) {
|
||||||
|
const base = key.split('@')[0];
|
||||||
|
if (base.startsWith('@')) continue;
|
||||||
|
if (key.includes('@') && !key.endsWith(`@${FORMATTED}`)) continue;
|
||||||
|
if (onlyFields && !onlyFields.some((f) => base === f || base === `_${f}_value`)) continue;
|
||||||
|
if (valueMatches(val)) hits.push(base);
|
||||||
|
}
|
||||||
|
return [...new Set(hits)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inactive link rows (statecode 1) are treated as removed tags.
|
||||||
|
const isActive = (row) => row.statecode === undefined || row.statecode === 0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- discovery
|
||||||
|
|
||||||
|
const CACHE_KEY = `nca:navprops:${location.host}:${CONFIG.entity}`;
|
||||||
|
|
||||||
|
async function tagNavProperties() {
|
||||||
|
if (CONFIG.navProperties.length) return CONFIG.navProperties;
|
||||||
|
try {
|
||||||
|
const cached = sessionStorage.getItem(CACHE_KEY);
|
||||||
|
if (cached) return JSON.parse(cached);
|
||||||
|
} catch { /* storage blocked */ }
|
||||||
|
|
||||||
|
const meta = await get(
|
||||||
|
`/EntityDefinitions(LogicalName='${CONFIG.entity}')?$select=LogicalName`
|
||||||
|
+ '&$expand=OneToManyRelationships($select=ReferencingEntity,ReferencedEntityNavigationPropertyName),'
|
||||||
|
+ 'ManyToManyRelationships($select=Entity1LogicalName,Entity2LogicalName,'
|
||||||
|
+ 'Entity1NavigationPropertyName,Entity2NavigationPropertyName)',
|
||||||
|
);
|
||||||
|
// "stage" contains "tag" — exclude it so BPF stage tables aren't swept in.
|
||||||
|
const looksLikeTag = (n) => /tag/i.test(n) && !/stage/i.test(n);
|
||||||
|
const props = [];
|
||||||
|
for (const r of meta.OneToManyRelationships || []) {
|
||||||
|
if (looksLikeTag(r.ReferencingEntity)) props.push(r.ReferencedEntityNavigationPropertyName);
|
||||||
|
}
|
||||||
|
for (const r of meta.ManyToManyRelationships || []) {
|
||||||
|
if (r.Entity1LogicalName === CONFIG.entity && looksLikeTag(r.Entity2LogicalName)) {
|
||||||
|
props.push(r.Entity1NavigationPropertyName);
|
||||||
|
} else if (r.Entity2LogicalName === CONFIG.entity && looksLikeTag(r.Entity1LogicalName)) {
|
||||||
|
props.push(r.Entity2NavigationPropertyName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unique = [...new Set(props.filter(Boolean))];
|
||||||
|
log('tag relationships discovered:', unique.length ? unique : '(none)');
|
||||||
|
try { sessionStorage.setItem(CACHE_KEY, JSON.stringify(unique)); } catch { /* storage blocked */ }
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a list of human-readable places the tag was found; empty = no tag.
|
||||||
|
async function findTag(recordGuid) {
|
||||||
|
const found = [];
|
||||||
|
const path = `/${CONFIG.entitySet}(${recordGuid})`;
|
||||||
|
|
||||||
|
const pinned = CONFIG.navProperties.length > 0;
|
||||||
|
|
||||||
|
// With the relationship pinned, the record itself is only read when extra
|
||||||
|
// fields are configured; otherwise one small request per check.
|
||||||
|
if (!pinned || CONFIG.fields.length) {
|
||||||
|
const record = await get(path);
|
||||||
|
const onlyFields = CONFIG.fields.length ? CONFIG.fields : null;
|
||||||
|
for (const key of matchingKeys(record, onlyFields)) found.push(`field ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned: fetch just the label + state and compare only the label column.
|
||||||
|
const query = pinned ? `?$select=${CONFIG.tagNameAttribute},statecode` : '?$top=250';
|
||||||
|
const rowFields = pinned ? [CONFIG.tagNameAttribute] : undefined;
|
||||||
|
|
||||||
|
let navProps = [];
|
||||||
|
try {
|
||||||
|
navProps = await tagNavProperties();
|
||||||
|
} catch (err) {
|
||||||
|
warn('relationship metadata lookup failed:', err.message);
|
||||||
|
}
|
||||||
|
await Promise.all(navProps.map(async (nav) => {
|
||||||
|
try {
|
||||||
|
const data = await get(`${path}/${nav}${query}`);
|
||||||
|
for (const row of data.value || []) {
|
||||||
|
if (isActive(row) && matchingKeys(row, rowFields).length) {
|
||||||
|
found.push(`related ${nav}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
warn(`related fetch failed for ${nav}:`, err.message);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- page context
|
||||||
|
|
||||||
|
// UCI is a SPA: watch the URL, fall back to Xrm when the query string lacks ids.
|
||||||
|
function currentRecord() {
|
||||||
|
const params = new URL(location.href).searchParams;
|
||||||
|
let etn = (params.get('etn') || '').toLowerCase();
|
||||||
|
let id = (params.get('id') || '').replace(/[{}]/g, '').toLowerCase();
|
||||||
|
const pagetype = (params.get('pagetype') || '').toLowerCase();
|
||||||
|
if (!GUID_RE.test(id) || !etn) {
|
||||||
|
try {
|
||||||
|
const input = window.Xrm?.Utility?.getPageContext?.()?.input;
|
||||||
|
if (input?.pageType === 'entityrecord') {
|
||||||
|
etn = String(input.entityName || '').toLowerCase();
|
||||||
|
id = String(input.entityId || '').replace(/[{}]/g, '').toLowerCase();
|
||||||
|
}
|
||||||
|
} catch { /* Xrm not ready */ }
|
||||||
|
} else if (pagetype && pagetype !== 'entityrecord') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return etn === CONFIG.entity && GUID_RE.test(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- UI
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
:host { all: initial; }
|
||||||
|
.bubble {
|
||||||
|
position: fixed; top: 60px; right: 16px; z-index: 2147483000;
|
||||||
|
display: flex; align-items: flex-start; gap: 12px;
|
||||||
|
max-width: 340px; padding: 14px 14px 14px 16px;
|
||||||
|
background: #a4262c; color: #fff;
|
||||||
|
border-radius: 10px; border-left: 6px solid #ffb900;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.28), 0 2px 6px rgba(0,0,0,.2);
|
||||||
|
font: 14px/1.35 "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
animation: nca-in .25s ease-out, nca-pulse 1.6s ease-in-out 3 .3s;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
flex: none; width: 28px; height: 28px; border-radius: 50%;
|
||||||
|
background: #ffb900; color: #3b0a0d;
|
||||||
|
display: grid; place-items: center; font-weight: 800; font-size: 18px;
|
||||||
|
}
|
||||||
|
.title { font-weight: 700; font-size: 15px; letter-spacing: .2px; }
|
||||||
|
.body { margin-top: 2px; opacity: .92; }
|
||||||
|
.btn {
|
||||||
|
flex: none; margin-left: 4px; width: 24px; height: 24px; padding: 0;
|
||||||
|
border: 0; border-radius: 4px; background: transparent; color: #fff;
|
||||||
|
font: inherit; font-size: 16px; line-height: 24px; cursor: pointer; opacity: .8;
|
||||||
|
}
|
||||||
|
.btn:hover, .btn:focus-visible { background: rgba(255,255,255,.18); opacity: 1; outline: none; }
|
||||||
|
/* Minimised: a pill that stays on screen — it can't be dismissed entirely. */
|
||||||
|
.bubble.min { padding: 6px 10px 6px 8px; gap: 8px; align-items: center; cursor: pointer; animation: none; }
|
||||||
|
.bubble.min .icon { width: 22px; height: 22px; font-size: 14px; }
|
||||||
|
.bubble.min .body, .bubble.min .btn { display: none; }
|
||||||
|
.bubble.min .title { font-size: 13px; }
|
||||||
|
@keyframes nca-in { from { opacity: 0; transform: translateY(-8px); } }
|
||||||
|
@keyframes nca-pulse {
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(255,185,0,.45), 0 8px 24px rgba(0,0,0,.28); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .bubble { animation: none; } }
|
||||||
|
`;
|
||||||
|
|
||||||
|
let host = null;
|
||||||
|
|
||||||
|
function showAlert() {
|
||||||
|
if (host?.isConnected) return;
|
||||||
|
host = document.createElement('div');
|
||||||
|
host.id = 'nca-alert-host';
|
||||||
|
const root = host.attachShadow({ mode: 'open' });
|
||||||
|
root.innerHTML = `
|
||||||
|
<style>${CSS}</style>
|
||||||
|
<div class="bubble" role="alert" aria-live="assertive">
|
||||||
|
<div class="icon" aria-hidden="true">!</div>
|
||||||
|
<div>
|
||||||
|
<div class="title">${CONFIG.tagValue}</div>
|
||||||
|
<div class="body">This application is tagged ${CONFIG.tagValue}. Check before proceeding.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="button" title="Minimise" aria-label="Minimise alert">–</button>
|
||||||
|
</div>`;
|
||||||
|
const bubble = root.querySelector('.bubble');
|
||||||
|
root.querySelector('.btn').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
bubble.classList.add('min');
|
||||||
|
bubble.title = 'Click to expand';
|
||||||
|
});
|
||||||
|
bubble.addEventListener('click', () => {
|
||||||
|
if (bubble.classList.contains('min')) {
|
||||||
|
bubble.classList.remove('min');
|
||||||
|
bubble.title = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.body.appendChild(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAlert() {
|
||||||
|
host?.remove();
|
||||||
|
host = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- orchestration
|
||||||
|
|
||||||
|
let activeId = null;
|
||||||
|
let checkSeq = 0;
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
const id = activeId;
|
||||||
|
const seq = ++checkSeq;
|
||||||
|
if (!id) { hideAlert(); return; }
|
||||||
|
try {
|
||||||
|
const found = await findTag(id);
|
||||||
|
if (seq !== checkSeq || id !== activeId) return; // navigated away mid-check
|
||||||
|
if (found.length) {
|
||||||
|
log(`"${CONFIG.tagValue}" found on ${id}:`, found);
|
||||||
|
showAlert();
|
||||||
|
} else {
|
||||||
|
log(`no "${CONFIG.tagValue}" tag on ${id}`);
|
||||||
|
hideAlert();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
warn('check failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocation() {
|
||||||
|
const id = currentRecord();
|
||||||
|
if (id === activeId) return;
|
||||||
|
activeId = id;
|
||||||
|
hideAlert();
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentRecord() is cheap and only triggers a fetch when the id changes, so
|
||||||
|
// polling it also covers Xrm becoming ready after the URL stopped changing.
|
||||||
|
setInterval(onLocation, 800);
|
||||||
|
onLocation();
|
||||||
|
setInterval(() => { if (activeId && !document.hidden) check(); }, CONFIG.pollMs);
|
||||||
|
document.addEventListener('visibilitychange', () => { if (!document.hidden && activeId) check(); });
|
||||||
|
window.addEventListener('popstate', onLocation);
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user