Skip to content
Merged
5 changes: 5 additions & 0 deletions extensions/BugModal/web/bug_modal.css
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,11 @@ input[type="number"] {
font-size: inherit;
}

#dependency-tree-container .tree-container {
max-height: 80dvh; /* Limit the height of the tree container to 80% of the viewport height */
overflow: auto;
}

#hide-dependency-tree-btn {
margin-left: auto;
}
Expand Down
203 changes: 194 additions & 9 deletions js/dependency-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ Bugzilla.DependencyTree = class DependencyTree {
return;
}


this.data = this.$trees.dataset;
this.data.initialized = '1';
this.realDepth = Number(this.data.realDepth);
Expand All @@ -38,6 +37,9 @@ Bugzilla.DependencyTree = class DependencyTree {

this.activateToolbar();
this.activateTrees();

// Track the current update request to prevent race conditions with `showUpdatingMessage()`
this.updateGeneration = 0;
}

/**
Expand All @@ -49,14 +51,22 @@ Bugzilla.DependencyTree = class DependencyTree {
this.$removeLimitBtn = this.$toolbar.querySelector('[data-id="remove-limit"]');
this.$numberInput = this.$toolbar.querySelector('[data-id="custom-limit"]');

this.$toolbar.addEventListener('click', async ({ target }) => {
this.$toolbar.addEventListener('click', ({ target }) => {
if (target.matches('button[type="button"]')) {
await this.onAction(target.dataset.id);
this.onAction(target.dataset.id);
}
});

this.$numberInput?.addEventListener('change', async () => {
await this.onAction('change-limit');
this.$numberInput?.addEventListener('change', () => {
this.onAction('change-limit');
});

this.$numberInput?.addEventListener('keydown', (event) => {
// Prevent form submission on Enter and trigger the limit change action instead
if (event.key === 'Enter') {
event.preventDefault();
this.onAction('change-limit');
}
});
}

Expand Down Expand Up @@ -92,17 +102,152 @@ Bugzilla.DependencyTree = class DependencyTree {
case 'change-limit':
maxDepth = Number(this.$numberInput?.value || this.realDepth);

// Validate that the value is within the acceptable range
if (maxDepth < 1 || maxDepth > this.realDepth) {
// Reset to the current valid value and bail out
this.$numberInput.value = this.data.maxDepth > 0 ? this.data.maxDepth : this.realDepth;
return;
}

if (maxDepth === this.realDepth) {
removeLimit();
}

break;
}

this.disableControllers();
await this.updateTrees({ maxDepth, hideResolved });
this.updateControllers({ maxDepth, hideResolved });
}

/**
* Get the visible height of the tree container using an `IntersectionObserver` so that we can
* position the loading indicator and error message in the center of the container.
* @returns {number} The height of the tree container in pixels.
*/
async getContainerHeight() {
let observer;

return Promise.race([
new Promise((resolve) => {
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
resolve(entry.intersectionRect.height);
observer.disconnect();
});
});

observer.observe(this.$container);
}),
new Promise((resolve) => {
// Fallback: use offsetHeight if observer doesn’t fire within 100ms, e.g. when the container
// is not visible
setTimeout(() => {
resolve(this.$container.clientHeight);
observer?.disconnect();
}, 100);
}),
]);
}

/**
* Show or update a message in the tree container with generation-based lifecycle management.
* Positions the message in the center of the container.
* @param {object} config Configuration object.
* @param {number} config.generation The generation ID of the current update request.
* @param {string} config.messageType The type of message ('error' or 'updating').
* @param {object} config.element Element properties (`className`, `role`, `textContent`, etc.).
* @param {boolean} [config.clearContainer] Whether to clear container `innerHTML` before
* inserting.
* @param {() => void} [config.onShow] Optional callback to run after the message is shown.
*/
async showMessage({
generation,
messageType,
element,
clearContainer = false,
onShow = undefined,
}) {
// Don’t proceed if a newer request has already started
if (generation !== this.updateGeneration) {
return;
}

const containerHeight = await this.getContainerHeight();

// Check again after async operation to ensure this request is still current
if (generation !== this.updateGeneration) {
return;
}

const fieldName = `$${messageType}Message`;

this[fieldName] ??= Object.assign(document.createElement('p'), element);
this[fieldName].style.top = `${containerHeight / 2}px`;

if (clearContainer) {
this.$container.innerHTML = '';
}

// Insert the message
this.$container.insertAdjacentElement('afterbegin', this[fieldName]);

onShow?.();
}

/**
* Show an error message in the tree container if fetching the dependency tree fails.
* @param {number} generation The generation ID of the current update request.
*/
async showErrorMessage(generation) {
await this.showMessage({
generation,
messageType: 'error',
element: {
className: 'error',
role: 'alert',
textContent: 'Failed to load the dependency tree.',
},
clearContainer: true,
});
}

/**
* Hide the error message if it is currently shown.
*/
hideErrorMessage() {
this.$errorMessage?.remove();
}

/**
* Show a loading message in the tree container while the dependency tree is being updated.
* @param {number} generation The generation ID of the current update request.
*/
async showUpdatingMessage(generation) {
await this.showMessage({
generation,
messageType: 'updating',
element: {
className: 'updating',
role: 'status',
ariaLabel: 'Updating the dependency tree',
textContent: 'Updating…',
},
onShow: () => {
this.$container.setAttribute('aria-busy', 'true');
},
});
}

/**
* Hide the loading message if it is currently shown and remove the busy state from the container.
*/
hideUpdatingMessage() {
this.$updatingMessage?.remove();
this.$container.removeAttribute('aria-busy');
}

/**
* Fetch and update the dependency tree HTML based on the given parameters, then inject it into
* the page.
Expand All @@ -119,18 +264,56 @@ Bugzilla.DependencyTree = class DependencyTree {
});

const url = `${this.data.action}?${params}`;
const response = await fetch(`${url}&embed=1&tree_only=1`);
const html = response.ok ? await response.text() : undefined;

// Safe to inject HTML as is: same-origin fetch, Template Toolkit escapes all user-supplied data
this.$container.innerHTML = html ?? '<p class="error">Failed to load the dependency tree.</p>';
// Increment generation counter to invalidate any in-flight message operations
const generation = ++this.updateGeneration;

// Hide any existing error message before starting a new fetch
this.hideErrorMessage();

// Set up a delayed loading indicator — only show after 300ms to avoid flicker on fast loads
const loadingTimeout = setTimeout(() => {
this.showUpdatingMessage(generation);
}, 300);

try {
const response = await fetch(`${url}&embed=1&tree_only=1`);

if (response.ok) {
// Safe to inject HTML as is: Template Toolkit escapes all user-supplied data
this.$container.innerHTML = await response.text();
} else {
console.error('Failed to fetch dependency tree:', response.status);
await this.showErrorMessage(generation);
}
} catch (ex) {
console.error('Error fetching dependency tree:', ex);
await this.showErrorMessage(generation);
} finally {
// Increment generation to invalidate any in-flight message operations from this request
this.updateGeneration++;
// Cancel the loading timeout if it hasn’t fired yet
clearTimeout(loadingTimeout);
// Remove the loading state if it was set
this.hideUpdatingMessage();
}

// Update the URL query parameters if we’re on the dependency tree page
if (location.pathname === this.data.action) {
history.replaceState(null, '', url);
}
}

/**
* Temporarily disable all toolbar buttons and inputs to prevent multiple simultaneous updates.
*/
disableControllers() {
this.$toggleBtn.disabled = true;
this.$setLimitBtn.disabled = true;
this.$removeLimitBtn.disabled = true;
this.$numberInput.disabled = true;
}

/**
* Update the state of the toolbar buttons and inputs based on the current parameters.
* @param {object} params Parameters.
Expand All @@ -144,8 +327,10 @@ Bugzilla.DependencyTree = class DependencyTree {

// Update button states
this.$toggleBtn.textContent = hideResolved ? 'Show Resolved' : 'Hide Resolved';
this.$toggleBtn.disabled = false;
this.$setLimitBtn.disabled = this.realDepth < 2 || maxDepth === 1;
this.$removeLimitBtn.disabled = maxDepth === 0 || maxDepth === this.realDepth;
this.$numberInput.disabled = false;
}

/**
Expand Down
31 changes: 31 additions & 0 deletions skins/standard/dependency-tree.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,37 @@
gap: 4px;
}

#dependency-tree .tree-container {
position: relative;
min-height: 100px; /* Ensure the container has some height for the loading indicator and error message to be positioned */
}

#dependency-tree .tree-container .updating,
#dependency-tree .tree-container .error {
position: absolute;
top: 0; /* To be updated dynamically in JS */
left: 50%;
z-index: 10;
transform: translate(-50%, -50%);
}

#dependency-tree .tree-container .updating {
border-radius: 4px;
padding: 8px 16px;
color: var(--menu-foreground-color);
background-color: var(--menu-background-color);
box-shadow: var(--menu-box-shadow);
}

#dependency-tree .tree-container .error {
color: var(--error-message-foreground-color);
}

#dependency-tree .tree-container[aria-busy="true"] > [role="group"] {
opacity: 0.5;
pointer-events: none;
}

#dependency-tree .tree-container > [role="group"] {
margin-block: 16px 0;
}
Expand Down
Loading