Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 79 additions & 37 deletions frontend/src/components/Requirements.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ import 'react-treeview/react-treeview.css';
import TreeView from 'react-treeview/lib/react-treeview.js';

const REQUIREMENTS_POPOVER_CLEANUP_KEY = '__tigerpathReqPopoverCleanup';
const HEADER_POPOVER_CLEANUP_KEY = '__tigerpathHeaderPopoverCleanup';
const TREE_ITEM_CLICK_HANDLER_KEY = '__tigerpathTreeItemClickHandler';

function escapeHref(url) {
return String(url).replace(/&/g, '&').replace(/"/g, '"');
}

export default function Requirements({ onChange, requirements, schedule }) {
const [loading, setLoading] = useState(false);
const containerRef = useRef(null);
Expand Down Expand Up @@ -52,7 +57,9 @@ export default function Requirements({ onChange, requirements, schedule }) {
const Popover = window.bootstrap?.Popover;
if (!Popover) return;

const reqLabels = containerRef.current.querySelectorAll('.reqLabel');
const reqLabels = containerRef.current.querySelectorAll(
'.reqLabel:not(.reqLabel-main)'
);
reqLabels.forEach((reqLabel) => {
const existingCleanup = reqLabel[REQUIREMENTS_POPOVER_CLEANUP_KEY];
if (typeof existingCleanup === 'function') {
Expand Down Expand Up @@ -100,6 +107,48 @@ export default function Requirements({ onChange, requirements, schedule }) {
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps

// Main-req info icon: manual trigger + shared hover bridge so the popover stays
// open while moving the pointer onto links (Bootstrap hover trigger closes in the gap).
const addHeaderPopovers = useCallback(() => {
if (!containerRef.current) return;
const Popover = window.bootstrap?.Popover;
if (!Popover) return;

const icons = containerRef.current.querySelectorAll('.info-icon');

icons.forEach((icon) => {
const existingCleanup = icon[HEADER_POPOVER_CLEANUP_KEY];
if (typeof existingCleanup === 'function') {
existingCleanup();
}

const existing = Popover.getInstance(icon);
if (existing) existing.dispose();

const popoverInstance = new Popover(icon, {
trigger: 'manual',
html: true,
animation: true,
placement: 'left',
fallbackPlacements: ['left', 'top', 'bottom'],
boundary: 'viewport',
template:
'<div class="popover req-popover" role="tooltip"><div class="popover-arrow"></div><h3 class="popover-header"></h3><div class="popover-body"></div></div>',
sanitize: false,
});

const cleanupHover = bindManualHoverPopover(icon, popoverInstance, {
hideDelayMs: 400,
});

icon[HEADER_POPOVER_CLEANUP_KEY] = () => {
cleanupHover();
popoverInstance.dispose();
delete icon[HEADER_POPOVER_CLEANUP_KEY];
};
});
}, []);

const getReqCourses = (req_path) => {
const searchQueryLabel = 'Satisfying: ' + req_path.split('//').pop();
onChange('searchQuery', searchQueryLabel);
Expand All @@ -118,9 +167,10 @@ export default function Requirements({ onChange, requirements, schedule }) {
requestAnimationFrame(() => {
makeNodesClickable();
addReqPopovers();
addHeaderPopovers();
});
}
}, [requirements, makeNodesClickable, addReqPopovers]);
}, [requirements, makeNodesClickable, addReqPopovers, addHeaderPopovers]);

const toggleSettle = (course, pathTo, settle) => {
let pathToType = pathTo.split('//', 3).join('//');
Expand Down Expand Up @@ -257,39 +307,27 @@ export default function Requirements({ onChange, requirements, schedule }) {
) {
finished = 'req-done';
}
popoverContent = '<div class="popoverContentContainer">';
if (mainReq.explanation) {
popoverContent +=
'<p>' + mainReq.explanation.split('\n').join('<br>') + '</p>';
} else if (mainReq.description) {
popoverContent +=
'<p>' + mainReq.description.split('\n').join('<br>') + '</p>';
}
if (mainReq.contacts) {
popoverContent += '<h6>Contacts:</h6>';
mainReq.contacts.forEach((contact) => {
const urls = Array.isArray(mainReq.urls)
? mainReq.urls.filter(Boolean)
: [];
popoverContent = '<div class="popoverContentContainer main-req-popover">';
if (urls.length > 0) {
const show = urls.slice(0, 2);
const linkLabels =
show.length === 1
? ['Department page']
: ['Department page', 'More information'];
show.forEach((url, i) => {
popoverContent +=
'<p>' +
contact.type +
':<br>' +
contact.name +
'<br><a href="mailto:' +
contact.email +
'">' +
contact.email +
'</a></p>';
});
}
if (mainReq.urls) {
popoverContent += '<h6>Reference Links:</h6>';
mainReq.urls.forEach((url) => {
popoverContent +=
'<p><a href="' +
url +
'<p class="mb-1"><a href="' +
escapeHref(url) +
'" class="ref-link" target="_blank" rel="noopener noreferrer">' +
url +
linkLabels[i] +
'</a></p>';
});
} else {
popoverContent +=
'<p class="small mb-0 text-muted">No official program link is listed for this requirement.</p>';
}
popoverContent += '</div>';
} else {
Expand Down Expand Up @@ -317,14 +355,18 @@ export default function Requirements({ onChange, requirements, schedule }) {
}

let mainReqLabel = (
<div
className="reqLabel"
title={'<span>' + name + '</span>'}
data-bs-content={popoverContent}
>
{name}
<div className="reqLabel reqLabel-main">
<span>{name}</span>
<i
className="fa fa-info-circle info-icon"
data-bs-toggle="popover"
data-bs-html="true"
data-bs-content={popoverContent}
style={{ marginLeft: '5px', cursor: 'pointer' }}
></i>
</div>
);

return (
<TreeView
key={index}
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/styles/Requirements.css
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,10 @@
text-overflow: ellipsis;
display: block;
}

/* moves info icon a little up */
.info-icon {
cursor: pointer;
position: relative;
top: -2px;
}
5 changes: 5 additions & 0 deletions frontend/src/utils/manualHoverPopover.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export function bindManualHoverPopover(
function onTriggerMouseEnter() {
clearHideTimeout();
popoverInstance.show();
}

function onPopoverShown() {
attachPopoverListeners();
if (onShow) {
onShow(getPopoverElement(triggerElement));
Expand All @@ -87,12 +90,14 @@ export function bindManualHoverPopover(

triggerElement.addEventListener('mouseenter', onTriggerMouseEnter);
triggerElement.addEventListener('mouseleave', onTriggerMouseLeave);
triggerElement.addEventListener('shown.bs.popover', onPopoverShown);
triggerElement.addEventListener('hidden.bs.popover', onPopoverHidden);

return () => {
clearHideTimeout();
triggerElement.removeEventListener('mouseenter', onTriggerMouseEnter);
triggerElement.removeEventListener('mouseleave', onTriggerMouseLeave);
triggerElement.removeEventListener('shown.bs.popover', onPopoverShown);
triggerElement.removeEventListener('hidden.bs.popover', onPopoverHidden);
detachPopoverListeners();
};
Expand Down
Loading