Zendesk — ticket priorities v1.1.0

← Back to User Scripts

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