From 51927720bec262c8f6258213d8d701b08c9f20fb Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Sun, 25 Oct 2026 04:03:07 +0000 Subject: [PATCH] Publish 2026.10.25.1403 --- non-compliant-alert.meta.js | 12 ++ non-compliant-alert.user.js | 312 ++++++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 non-compliant-alert.meta.js create mode 100644 non-compliant-alert.user.js diff --git a/non-compliant-alert.meta.js b/non-compliant-alert.meta.js new file mode 100644 index 0000000..1323078 --- /dev/null +++ b/non-compliant-alert.meta.js @@ -0,0 +1,12 @@ +// ==UserScript== +// @name CRM User Enhancements +// @namespace https://git.home-brightview.com/matt.rb +// @version 2026.10.25.1403 +// @author matt.rb +// @match https://*.dynamics.com/* +// @run-at document-idle +// @grant none +// @noframes +// @updateURL https://code.home-brightview.com/matt.rb/dynamics-userscripts/raw/branch/dist/non-compliant-alert.meta.js +// @downloadURL https://code.home-brightview.com/matt.rb/dynamics-userscripts/raw/branch/dist/non-compliant-alert.user.js +// ==/UserScript== diff --git a/non-compliant-alert.user.js b/non-compliant-alert.user.js new file mode 100644 index 0000000..dab5b1e --- /dev/null +++ b/non-compliant-alert.user.js @@ -0,0 +1,312 @@ +// ==UserScript== +// @name CRM User Enhancements +// @namespace https://git.home-brightview.com/matt.rb +// @version 2026.10.25.1403 +// @author matt.rb +// @match https://*.dynamics.com/* +// @run-at document-idle +// @grant none +// @noframes +// @updateURL https://code.home-brightview.com/matt.rb/dynamics-userscripts/raw/branch/dist/non-compliant-alert.meta.js +// @downloadURL https://code.home-brightview.com/matt.rb/dynamics-userscripts/raw/branch/dist/non-compliant-alert.user.js +// ==/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 = ` + + `; + 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); +})();