Zendesk — ticket priorities v1.1.0
This userscript adds drag-and-drop priority ordering to tickets on a specific Zendesk filter view. Ticket priorities are persisted in localStorage and restored on page load. Prioritised tickets are highlighted with a green bar, and unprioritised tickets appear at the bottom with a yellow bar and background. When a ticket is moved, only that ticket and previously-prioritised tickets are shown in green; other tickets remain yellow.
Script Content
// ==UserScript==
// @name Zendesk: Ticket Priorities
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.1.0
// @description Drag and drop tickets into a priority order, stored in local storage.
// @author Tim Hilton using GitHub Copilot
// @match https://audacia.zendesk.com/agent/filters/23644823128732
// @grant none
// ==/UserScript==
(function () {
'use strict';
const LOG_PREFIX = '[Zendesk: Ticket Priorities]';
const STORAGE_KEY = 'zendesk-ticket-priorities';
const SCROLL_EDGE_MARGIN = 40;
const SCROLL_SPEED = 8;
console.debug(`${LOG_PREFIX} Script initialised`);
// ── Helpers ──────────────────────────────────────────────────────────
const loadPriorityOrder = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch (e) {
console.debug(`${LOG_PREFIX} Failed to parse localStorage, resetting`);
return [];
}
};
const savePriorityOrder = (order) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
console.debug(`${LOG_PREFIX} Saved priority order: ${order.length} tickets`);
};
const getTicketId = (row) => {
const idCell = row.querySelector('[data-test-id="generic-table-cells-id"]');
return idCell ? idCell.textContent.trim().replace('#', '') : null;
};
// ── Styles ──────────────────────────────────────────────────────────
const injectStyles = () => {
if (document.getElementById('otp-styles')) return;
const style = document.createElement('style');
style.id = 'otp-styles';
style.textContent = `
:root {
--otp-unprioritised-colour: #e6a817;
--otp-prioritised-colour: #28a745;
--otp-drop-indicator-colour: #1f73b7;
}
[data-otp-unprioritised="true"] {
border-left: 4px solid var(--otp-unprioritised-colour) !important;
background-color: rgba(230, 168, 23, 0.06) !important;
}
[data-otp-prioritised="true"] {
border-left: 4px solid var(--otp-prioritised-colour) !important;
}
[data-otp-dragging="true"] {
opacity: 0.4 !important;
}
.otp-clone {
position: fixed;
pointer-events: none;
z-index: 100000;
opacity: 0.85;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
border-radius: 4px;
background: #fff;
transition: none;
}
.otp-drop-indicator {
position: absolute;
left: 0;
right: 0;
height: 3px;
background: var(--otp-drop-indicator-colour);
border-radius: 2px;
z-index: 99999;
pointer-events: none;
transition: top 0.1s ease;
}
[data-test-id="generic-table-row"] {
touch-action: none;
cursor: grab;
user-select: none;
}
[data-test-id="generic-table-row"]:active {
cursor: grabbing;
}
`;
document.head.appendChild(style);
console.debug(`${LOG_PREFIX} Styles injected`);
};
// ── Core Logic ──────────────────────────────────────────────────────
const processTable = () => {
const table = document.querySelector('[data-test-id="generic-table"]');
if (!table) return;
if (table.dataset.otpProcessed === 'true') {
console.debug(`${LOG_PREFIX} Table already processed, skipping`);
return;
}
console.debug(`${LOG_PREFIX} Processing table...`);
const tbody = table.querySelector('tbody');
if (!tbody) {
console.debug(`${LOG_PREFIX} No tbody found`);
return;
}
const rows = Array.from(tbody.querySelectorAll('[data-test-id="generic-table-row"]'));
if (rows.length === 0) {
console.debug(`${LOG_PREFIX} No ticket rows found`);
return;
}
console.debug(`${LOG_PREFIX} Found ${rows.length} ticket rows`);
// Build a map of ticketId → row
const rowMap = new Map();
const viewTicketIds = new Set();
for (const row of rows) {
const id = getTicketId(row);
if (!id) continue;
rowMap.set(id, row);
viewTicketIds.add(id);
}
// Load and clean priority order – remove tickets no longer in the view
let priorityOrder = loadPriorityOrder();
const originalLength = priorityOrder.length;
priorityOrder = priorityOrder.filter(id => viewTicketIds.has(id));
if (priorityOrder.length !== originalLength) {
console.debug(`${LOG_PREFIX} Removed ${originalLength - priorityOrder.length} tickets no longer in the view from priority list`);
savePriorityOrder(priorityOrder);
}
// Determine which tickets are prioritised vs unprioritised
const prioritisedSet = new Set(priorityOrder);
const allVisibleIds = Array.from(rowMap.keys());
const unprioritisedIds = allVisibleIds.filter(id => !prioritisedSet.has(id));
console.debug(`${LOG_PREFIX} Prioritised: ${priorityOrder.length}, Unprioritised: ${unprioritisedIds.length}`);
// Reorder DOM: prioritised first (in order), then unprioritised
const orderedIds = [...priorityOrder.filter(id => rowMap.has(id)), ...unprioritisedIds];
for (const id of orderedIds) {
const row = rowMap.get(id);
if (row) {
tbody.appendChild(row);
}
}
// Apply visual state for prioritised and unprioritised rows
for (const [id, row] of rowMap) {
if (unprioritisedIds.includes(id)) {
row.setAttribute('data-otp-unprioritised', 'true');
row.removeAttribute('data-otp-prioritised');
} else {
row.removeAttribute('data-otp-unprioritised');
row.setAttribute('data-otp-prioritised', 'true');
}
}
// Attach drag handlers
setupDragAndDrop(tbody, rowMap);
table.dataset.otpProcessed = 'true';
console.log(`${LOG_PREFIX} ✅ Table processed – ${priorityOrder.length} prioritised, ${unprioritisedIds.length} unprioritised`);
};
// ── Drag & Drop (Pointer Events) ────────────────────────────────────
const setupDragAndDrop = (tbody, rowMap) => {
let dragState = null;
const getRowUnderPointer = (clientX, clientY, excludeRow) => {
const elements = document.elementsFromPoint(clientX, clientY);
for (const el of elements) {
const row = el.closest('[data-test-id="generic-table-row"]');
if (row && row !== excludeRow && tbody.contains(row)) {
return row;
}
}
return null;
};
const createClone = (row, clientX, clientY) => {
const rect = row.getBoundingClientRect();
const clone = row.cloneNode(true);
clone.classList.add('otp-clone');
clone.style.width = `${rect.width}px`;
clone.style.top = `${clientY - (rect.height / 2)}px`;
clone.style.left = `${rect.left}px`;
document.body.appendChild(clone);
return clone;
};
const createDropIndicator = () => {
const indicator = document.createElement('div');
indicator.classList.add('otp-drop-indicator');
return indicator;
};
const updateDropIndicator = (indicator, targetRow, clientY) => {
if (!targetRow) {
if (indicator.parentNode) indicator.parentNode.removeChild(indicator);
return;
}
const rect = targetRow.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const insertBefore = clientY < midY;
// Position the indicator relative to the tbody
const tbodyRect = tbody.getBoundingClientRect();
if (!indicator.parentNode || indicator.parentNode !== tbody) {
tbody.style.position = 'relative';
tbody.appendChild(indicator);
}
if (insertBefore) {
indicator.style.top = `${rect.top - tbodyRect.top}px`;
} else {
indicator.style.top = `${rect.bottom - tbodyRect.top}px`;
}
return insertBefore;
};
const onPointerDown = (e) => {
// Only respond to primary button (left click / touch)
if (e.button !== 0) return;
const row = e.target.closest('[data-test-id="generic-table-row"]');
if (!row || !tbody.contains(row)) return;
e.preventDefault();
const clone = createClone(row, e.clientX, e.clientY);
const indicator = createDropIndicator();
row.setAttribute('data-otp-dragging', 'true');
row.setPointerCapture(e.pointerId);
dragState = {
row,
clone,
indicator,
pointerId: e.pointerId,
insertBefore: true,
targetRow: null
};
console.debug(`${LOG_PREFIX} Drag started for ticket #${getTicketId(row)}`);
};
const onPointerMove = (e) => {
if (!dragState || e.pointerId !== dragState.pointerId) return;
e.preventDefault();
// Move clone to follow pointer
const rect = dragState.row.getBoundingClientRect();
dragState.clone.style.top = `${e.clientY - (rect.height / 2)}px`;
// Find the row under the pointer
const targetRow = getRowUnderPointer(e.clientX, e.clientY, dragState.row);
dragState.targetRow = targetRow;
if (targetRow) {
dragState.insertBefore = updateDropIndicator(dragState.indicator, targetRow, e.clientY);
} else if (dragState.indicator.parentNode) {
dragState.indicator.parentNode.removeChild(dragState.indicator);
}
// Find nearest scrollable ancestor — Zendesk wraps the table in
// various scroll containers whose class names vary, so we fall back
// to the direct parent if no obvious scroll wrapper is found.
const scrollContainer = tbody.closest('[class*="scroll"]') || tbody.parentElement;
if (scrollContainer) {
const containerRect = scrollContainer.getBoundingClientRect();
if (e.clientY < containerRect.top + SCROLL_EDGE_MARGIN) {
scrollContainer.scrollTop -= SCROLL_SPEED;
} else if (e.clientY > containerRect.bottom - SCROLL_EDGE_MARGIN) {
scrollContainer.scrollTop += SCROLL_SPEED;
}
}
};
const onPointerUp = (e) => {
if (!dragState || e.pointerId !== dragState.pointerId) return;
e.preventDefault();
const { row, clone, indicator, targetRow, insertBefore } = dragState;
// Clean up visual elements
row.removeAttribute('data-otp-dragging');
if (clone.parentNode) clone.parentNode.removeChild(clone);
if (indicator.parentNode) indicator.parentNode.removeChild(indicator);
// Perform the reorder if we have a valid target
if (targetRow && targetRow !== row) {
if (insertBefore) {
tbody.insertBefore(row, targetRow);
} else {
tbody.insertBefore(row, targetRow.nextSibling);
}
console.debug(`${LOG_PREFIX} Dropped ticket #${getTicketId(row)} ${insertBefore ? 'before' : 'after'} #${getTicketId(targetRow)}`);
// Update localStorage with new order.
// Only the moved ticket and previously-prioritised tickets are
// considered prioritised; other tickets remain unprioritised.
const newOrder = [];
const updatedRows = tbody.querySelectorAll('[data-test-id="generic-table-row"]');
for (const r of updatedRows) {
const id = getTicketId(r);
if (id) {
const wasPrioritised = r.getAttribute('data-otp-prioritised') === 'true';
const isMovedRow = r === row;
if (wasPrioritised || isMovedRow) {
newOrder.push(id);
r.removeAttribute('data-otp-unprioritised');
r.setAttribute('data-otp-prioritised', 'true');
}
// Unprioritised tickets keep their yellow styling
}
}
savePriorityOrder(newOrder);
console.log(`${LOG_PREFIX} ✅ Priority order updated`);
}
dragState = null;
};
const onPointerCancel = (e) => {
if (!dragState || e.pointerId !== dragState.pointerId) return;
const { row, clone, indicator } = dragState;
row.removeAttribute('data-otp-dragging');
if (clone.parentNode) clone.parentNode.removeChild(clone);
if (indicator.parentNode) indicator.parentNode.removeChild(indicator);
dragState = null;
console.debug(`${LOG_PREFIX} Drag cancelled`);
};
// Attach listeners to tbody so they cover all rows (including future ones)
tbody.addEventListener('pointerdown', onPointerDown);
tbody.addEventListener('pointermove', onPointerMove);
tbody.addEventListener('pointerup', onPointerUp);
tbody.addEventListener('pointercancel', onPointerCancel);
console.debug(`${LOG_PREFIX} Drag & drop handlers attached`);
};
// ── Initialisation ──────────────────────────────────────────────────
injectStyles();
let mutationCount = 0;
const observer = new MutationObserver(() => {
mutationCount++;
if (mutationCount % 50 === 0) {
console.debug(`${LOG_PREFIX} MutationObserver fired (#${mutationCount})`);
}
processTable();
});
console.debug(`${LOG_PREFIX} Starting MutationObserver on document.body`);
observer.observe(document.body, {
childList: true,
subtree: true,
});
// Also try to process immediately in case the table is already loaded
processTable();
console.log(`${LOG_PREFIX} 🎉 Script ready and monitoring for ticket table`);
})();