feat(allocations): tooltips listing courses in users table
major improvements to tooltips
This commit is contained in:
parent
cccbd146cc
commit
6bca64cf5f
124
frontend/src/lib/movement-observer/movement-observer.js
Normal file
124
frontend/src/lib/movement-observer/movement-observer.js
Normal file
@ -0,0 +1,124 @@
|
||||
import * as debounce from 'lodash.debounce';
|
||||
|
||||
export const MOVEMENT_INDICATOR_ELEMENT_CLASS = 'movement-indicator';
|
||||
const MOVEMENT_DEBOUNCE = 250;
|
||||
const MOVEMENT_DEADZONE = 5;
|
||||
|
||||
export class MovementObserver {
|
||||
|
||||
element;
|
||||
trailingCallback = () => {};
|
||||
leadingCallback = () => {};
|
||||
|
||||
_intersectionObserver;
|
||||
_debouncedIntersectChange;
|
||||
_debouncedLeadingCallback;
|
||||
_oldPosition;
|
||||
|
||||
constructor(element, options) {
|
||||
this.element = element;
|
||||
|
||||
if (!this.element) {
|
||||
throw new Error('Cannot setup MovementObserver without element');
|
||||
}
|
||||
|
||||
if (options && options.trailingCallback) {
|
||||
this.trailingCallback = options.trailingCallback;
|
||||
if (typeof this.trailingCallback !== 'function') {
|
||||
throw new Error('Cannot setup MovementObserver with trailingCallback not being a function');
|
||||
}
|
||||
}
|
||||
|
||||
if (options && options.leadingCallback) {
|
||||
this.leadingCallback = options.leadingCallback;
|
||||
if (typeof this.leadingCallback !== 'function') {
|
||||
throw new Error('Cannot setup MovementObserver with leadingCallback not being a function');
|
||||
}
|
||||
}
|
||||
|
||||
this._debouncedIntersectChange = debounce(this._intersectChange.bind(this), MOVEMENT_DEBOUNCE, { leading: false, trailing: true });
|
||||
this._debouncedLeadingCallback = debounce(() => { this._positionUpdate(false, true); }, MOVEMENT_DEBOUNCE, { leading: true, trailing: false });
|
||||
}
|
||||
|
||||
observe() {
|
||||
this._resetIntersectionObserver();
|
||||
this._positionUpdate(false, false);
|
||||
this._intersectionObserver.observe(this.element);
|
||||
}
|
||||
|
||||
unobserve() {
|
||||
// console.log('MovementObserver', 'unobserve');
|
||||
this._debouncedIntersectChange.cancel();
|
||||
this._debouncedLeadingCallback.cancel();
|
||||
if (this._intersectionObserver) {
|
||||
this._intersectionObserver.unobserve(this.element);
|
||||
this._intersectionObserver.disconnect();
|
||||
}
|
||||
this._intersectionObserver = undefined;
|
||||
this._oldPosition = undefined;
|
||||
}
|
||||
|
||||
_intersectChange(entries, observer) {
|
||||
// console.log('MovementObserver', '_intersectChange', entries, observer);
|
||||
|
||||
if (!this._positionUpdate(true, false))
|
||||
return;
|
||||
|
||||
observer.disconnect();
|
||||
observer.unobserve(this.element);
|
||||
this.observe();
|
||||
}
|
||||
|
||||
_positionUpdate(trailingCallback, leadingCallback) {
|
||||
const currentPosition = this.element.getBoundingClientRect();
|
||||
|
||||
if (!this._oldPosition || Math.abs(this._oldPosition.top - currentPosition.top) > MOVEMENT_DEADZONE || Math.abs(this._oldPosition.left - currentPosition.left) > MOVEMENT_DEADZONE) {
|
||||
// console.log('MovementObserver', '_positionUpdate', leadingCallback, trailingCallback);
|
||||
|
||||
if (leadingCallback)
|
||||
this.leadingCallback();
|
||||
|
||||
if (trailingCallback)
|
||||
this.trailingCallback();
|
||||
|
||||
if (!leadingCallback)
|
||||
this._oldPosition = currentPosition;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_resetIntersectionObserver() {
|
||||
// console.log('MovementObserver', '_resetIntersectionObserver');
|
||||
|
||||
const currentPosition = this.element.getBoundingClientRect();
|
||||
const windowWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
const windowHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
|
||||
const top = -1 * (currentPosition.top - MOVEMENT_DEADZONE);
|
||||
const left = -1 * (currentPosition.left - MOVEMENT_DEADZONE);
|
||||
const bottom = -1 * (windowHeight - currentPosition.bottom - MOVEMENT_DEADZONE);
|
||||
const right = -1 * (windowWidth - currentPosition.right - MOVEMENT_DEADZONE);
|
||||
|
||||
const observerOptions = {
|
||||
rootMargin: `${top}px ${right}px ${bottom}px ${left}px`,
|
||||
threshold: 0.9,
|
||||
};
|
||||
|
||||
const observerCallback = (event, observer) => {
|
||||
this._debouncedLeadingCallback();
|
||||
this._debouncedIntersectChange(event, observer);
|
||||
};
|
||||
|
||||
if (this._intersectionObserver) {
|
||||
this._debouncedIntersectChange.cancel();
|
||||
this._debouncedLeadingCallback.cancel();
|
||||
this._intersectionObserver.unobserve(this.element);
|
||||
this._intersectionObserver.disconnect();
|
||||
this._intersectionObserver = undefined;
|
||||
}
|
||||
this._intersectionObserver = new IntersectionObserver(observerCallback.bind(this), observerOptions);
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,174 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './tooltips.sass';
|
||||
import { MovementObserver } from '../../lib/movement-observer/movement-observer';
|
||||
|
||||
var TOOLTIP_CLASS = 'tooltip';
|
||||
var TOOLTIP_INITIALIZED_CLASS = 'tooltip--initialized';
|
||||
var TOOLTIP_OPEN_CLASS = 'tooltip--active';
|
||||
|
||||
// empty 'shell' to be able to load styles
|
||||
@Utility({
|
||||
selector: '[not-something-that-would-be-found]',
|
||||
selector: `.${TOOLTIP_CLASS}`,
|
||||
})
|
||||
export class Tooltip {
|
||||
_element;
|
||||
_content;
|
||||
|
||||
_movementObserver;
|
||||
|
||||
_openedPersistent = false;
|
||||
|
||||
_xOffset = 3;
|
||||
_yOffset = -10;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Tooltip utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(TOOLTIP_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = element.querySelector('.tooltip__content');
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Tooltip utility cannot be setup without content!');
|
||||
}
|
||||
|
||||
this._content = content;
|
||||
|
||||
this._element = element;
|
||||
|
||||
this._movementObserver = new MovementObserver(this._element, { leadingCallback: this.close.bind(this) });
|
||||
|
||||
element.classList.add(TOOLTIP_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
start() {
|
||||
this._element.addEventListener('mouseover', () => { this.open(false); });
|
||||
this._element.addEventListener('mouseout', this._leave.bind(this));
|
||||
this._content.addEventListener('mouseout', this._leave.bind(this));
|
||||
this._element.addEventListener('click', this._click.bind(this));
|
||||
}
|
||||
|
||||
open(persistent) {
|
||||
if (this.isOpen())
|
||||
return;
|
||||
|
||||
this._element.classList.add(TOOLTIP_OPEN_CLASS);
|
||||
this._reposition();
|
||||
this._movementObserver.observe();
|
||||
this._openedPersistent = !!persistent;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.isOpen())
|
||||
return;
|
||||
|
||||
this._movementObserver.unobserve();
|
||||
this._element.classList.remove(TOOLTIP_OPEN_CLASS);
|
||||
this._openedPersistent = false;
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return this._element.classList.contains(TOOLTIP_OPEN_CLASS);
|
||||
}
|
||||
|
||||
_click(event) {
|
||||
if (this.isOpen() && !this._openedPersistent) {
|
||||
this._openedPersistent = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._content == event.target || this._content.contains(event.target))
|
||||
return;
|
||||
|
||||
if (this.isOpen())
|
||||
this.close();
|
||||
else
|
||||
this.open(true);
|
||||
}
|
||||
|
||||
_leave(event) {
|
||||
if (!this.isOpen() || this._openedPersistent)
|
||||
return;
|
||||
|
||||
const newElement = event.toElement || event.relatedTarget;
|
||||
|
||||
// console.log('tooltips', '_leave', event, this._element, newElement);
|
||||
|
||||
if (this._element === newElement || this._element.contains(newElement) || this._content === newElement || this._content.contains(newElement))
|
||||
return;
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
_reposition() {
|
||||
const doRight = this._decideRight();
|
||||
const doBottom = this._decideBottom();
|
||||
|
||||
// console.log('doRight', doRight);
|
||||
// console.log('doBottom', doBottom);
|
||||
|
||||
if (doBottom)
|
||||
this._element.classList.add('tooltip--bottom');
|
||||
else
|
||||
this._element.classList.remove('tooltip--bottom');
|
||||
|
||||
if (doRight)
|
||||
this._element.classList.add('tooltip--right');
|
||||
else
|
||||
this._element.classList.remove('tooltip--right');
|
||||
|
||||
const handleCoords = this._element.getBoundingClientRect();
|
||||
|
||||
const left = doRight ? handleCoords.right - this._xOffset : handleCoords.left + this._xOffset;
|
||||
const bottom = doBottom ? handleCoords.bottom - this._yOffset : handleCoords.top + this._yOffset;
|
||||
|
||||
if (doRight) {
|
||||
const right = left - this._content.offsetWidth;
|
||||
this._content.style.left = `${right}px`;
|
||||
} else
|
||||
this._content.style.left = `${left}px`;
|
||||
|
||||
if (doBottom)
|
||||
this._content.style.top = `${bottom}px`;
|
||||
else {
|
||||
const top = bottom - this._content.offsetHeight;
|
||||
this._content.style.top = `${top}px`;
|
||||
}
|
||||
}
|
||||
|
||||
_decideBottom() {
|
||||
const handleCoords = this._element.getBoundingClientRect();
|
||||
const windowHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
|
||||
const bottom = handleCoords.top + this._yOffset;
|
||||
const bottomBottom = handleCoords.bottom - this._yOffset;
|
||||
|
||||
const isHeight = Math.min(windowHeight, bottom) - Math.max(0, bottom - this._content.offsetHeight);
|
||||
const isHeightBottom = Math.min(windowHeight, bottomBottom) - Math.max(0, bottomBottom - this._content.offsetHeight);
|
||||
|
||||
// console.log('_decideBottom', isHeight, isHeightBottom);
|
||||
|
||||
return isHeightBottom > isHeight;
|
||||
}
|
||||
|
||||
_decideRight() {
|
||||
const handleCoords = this._element.getBoundingClientRect();
|
||||
const windowWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
|
||||
const left = handleCoords.left + this._yOffset;
|
||||
const leftRight = handleCoords.right - this._yOffset;
|
||||
|
||||
const isWidth = Math.max(0, Math.min(windowWidth - left , this._content.offsetWidth)) + Math.min(0, left);
|
||||
const isWidthRight = Math.max(0, Math.min(windowWidth - leftRight, this._content.offsetWidth)) + Math.min(0, leftRight);
|
||||
|
||||
// console.log('_decideRight', isWidth, isWidthRight);
|
||||
|
||||
return isWidthRight > isWidth;
|
||||
}
|
||||
|
||||
|
||||
destroy() {}
|
||||
};
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
@use "../../common" as *
|
||||
|
||||
.tooltip
|
||||
position: relative
|
||||
display: inline-block
|
||||
@ -6,6 +8,12 @@
|
||||
&:hover .tooltip__content
|
||||
display: inline-block
|
||||
|
||||
&.tooltip--initialized .tooltip__content
|
||||
display: none
|
||||
|
||||
&.tooltip--initialized.tooltip--active .tooltip__content
|
||||
display: inline-block
|
||||
|
||||
.tooltip__handle
|
||||
color: var(--color-light)
|
||||
height: 1.5rem
|
||||
@ -16,7 +24,7 @@
|
||||
margin: 0 10px
|
||||
cursor: default
|
||||
position: relative
|
||||
|
||||
|
||||
&::before
|
||||
position: absolute
|
||||
top: 0
|
||||
@ -35,7 +43,7 @@
|
||||
&.tooltip__handle.urgency__error
|
||||
color: var(--color-error)
|
||||
|
||||
&:hover
|
||||
&:hover, .tooltip--active &
|
||||
color: var(--color-dark)
|
||||
|
||||
&.tooltip__handle.urgency__success
|
||||
@ -47,6 +55,9 @@
|
||||
&.tooltip__handle.urgency__error
|
||||
color: var(--color-error-dark)
|
||||
|
||||
.table__th &
|
||||
color: white
|
||||
|
||||
.tooltip.tooltip__inline
|
||||
.tooltip__handle
|
||||
height: 1.0rem
|
||||
@ -59,12 +70,18 @@
|
||||
top: -10px
|
||||
transform: translateY(-100%)
|
||||
left: 3px
|
||||
width: 275px
|
||||
min-width: 150px
|
||||
max-width: 400px
|
||||
min-height: 30px
|
||||
z-index: 10
|
||||
background-color: #fafafa
|
||||
border-radius: 4px
|
||||
border-radius: 7px
|
||||
padding: 13px 17px
|
||||
box-shadow: 0 0 20px 4px rgba(0, 0, 0, 0.1)
|
||||
box-shadow: 0 0 20px 4px rgba(0, 0, 0, 0.2)
|
||||
|
||||
.table__th &
|
||||
color: var(--color-font)
|
||||
font-weight: normal
|
||||
|
||||
&::after
|
||||
content: ''
|
||||
@ -76,17 +93,48 @@
|
||||
left: 10px
|
||||
bottom: -8px
|
||||
|
||||
@media (max-width: 768px)
|
||||
.tooltip
|
||||
display: block
|
||||
margin-top: 10px
|
||||
.tooltip--right &
|
||||
left: unset
|
||||
right: 10px
|
||||
.tooltip--bottom &
|
||||
bottom: unset
|
||||
top: -8px
|
||||
|
||||
.tooltip__content
|
||||
left: 3px
|
||||
right: 3px
|
||||
width: auto
|
||||
.tooltip--initialized
|
||||
.tooltip__content
|
||||
position: fixed
|
||||
z-index: 23
|
||||
left: unset
|
||||
top: unset
|
||||
transform: unset
|
||||
|
||||
// fix font color when used in tableheaders
|
||||
th .tooltip__content
|
||||
color: var(--color-font)
|
||||
font-weight: normal
|
||||
.tooltip--spread
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
.tooltip--no-handle
|
||||
position: relative
|
||||
box-shadow: inset 0 0 2px 0 rgba(0,0,0,0.2)
|
||||
padding: 4px 17px 4px 4px
|
||||
|
||||
&::after
|
||||
@extend .fas
|
||||
@extend .fa-fw
|
||||
|
||||
content: '\f129'
|
||||
position: absolute
|
||||
right: 2px
|
||||
top: 6px
|
||||
color: rgba(0,0,0,0.2)
|
||||
font-size: 12px
|
||||
|
||||
&:hover, &.tooltip--active
|
||||
box-shadow: inset 0 0 2px 0 rgba(0,0,0,0.4)
|
||||
|
||||
&::after
|
||||
color: rgba(0,0,0,0.4)
|
||||
|
||||
.table__td-content &.tooltip--spread
|
||||
box-shadow: unset
|
||||
padding: 2px 15px 2px 2px
|
||||
background-color: rgba(0,0,0,0.05)
|
||||
|
||||
@ -140,9 +140,9 @@ postAUsersR tid ssh ash = do
|
||||
[ colUserDisplayName $ resultUser . _entityVal . $(multifocusL 2) _userDisplayName _userSurname
|
||||
, colUserMatriculation $ resultUser . _entityVal . _userMatrikelnummer
|
||||
, colAllocationRequested $ resultAllocationUser . _entityVal . _allocationUserTotalCourses
|
||||
, colAllocationApplied resultAppliedCourses
|
||||
, colAllocationVetoed resultVetoedCourses
|
||||
, assignedHeated $ colAllocationAssigned resultAssignedCourses
|
||||
, coursesModalApplied $ colAllocationApplied resultAppliedCourses
|
||||
, coursesModalVetoed $ colAllocationVetoed resultVetoedCourses
|
||||
, coursesModalAssigned . assignedHeated $ colAllocationAssigned resultAssignedCourses
|
||||
, emptyOpticColonnade (resultAllocationUser . _entityVal . _allocationUserPriority . _Just) colAllocationPriority
|
||||
]
|
||||
where
|
||||
@ -157,6 +157,33 @@ postAUsersR tid ssh ash = do
|
||||
in cellAttrs <>~ [ ("class", "heated")
|
||||
, ("style", [st|--hotness: #{tshow (heat maxAssign assigned)}|])
|
||||
]
|
||||
coursesModalApplied = coursesModal $ \res -> E.from $ \(course `E.InnerJoin` courseApplication) -> do
|
||||
E.on $ course E.^. CourseId E.==. courseApplication E.^. CourseApplicationCourse
|
||||
E.where_ $ courseApplication E.^. CourseApplicationAllocation E.==. E.val (Just aId)
|
||||
E.&&. courseApplication E.^. CourseApplicationUser E.==. E.val (res ^. resultUser . _entityKey)
|
||||
E.orderBy [E.desc $ courseApplication E.^. CourseApplicationAllocationPriority]
|
||||
return course
|
||||
coursesModalVetoed = coursesModal $ \res -> E.from $ \(course `E.InnerJoin` courseApplication) -> do
|
||||
E.on $ course E.^. CourseId E.==. courseApplication E.^. CourseApplicationCourse
|
||||
E.where_ $ courseApplication E.^. CourseApplicationAllocation E.==. E.val (Just aId)
|
||||
E.&&. courseApplication E.^. CourseApplicationUser E.==. E.val (res ^. resultUser . _entityKey)
|
||||
E.where_ $ courseApplication E.^. CourseApplicationRatingVeto
|
||||
E.||. courseApplication E.^. CourseApplicationRatingPoints `E.in_` E.valList (map Just $ filter (view $ passingGrade . _Wrapped . to not) universeF)
|
||||
return course
|
||||
coursesModalAssigned = coursesModal $ \res -> E.from $ \(course `E.InnerJoin` courseParticipant) -> do
|
||||
E.on $ courseParticipant E.^. CourseParticipantCourse E.==. course E.^. CourseId
|
||||
E.&&. courseParticipant E.^. CourseParticipantAllocated E.==. E.val (Just aId)
|
||||
E.where_ $ courseParticipant E.^. CourseParticipantUser E.==. E.val (res ^. resultUser . _entityKey)
|
||||
E.orderBy [E.asc $ courseParticipant E.^. CourseParticipantRegistration]
|
||||
return course
|
||||
coursesModal courseSel = imapColonnade coursesModal'
|
||||
where
|
||||
coursesModal' res innerCell = review dbCell . (innerCell ^. cellAttrs, ) $ do
|
||||
courses <- lift . E.select $ courseSel res
|
||||
contents <- innerCell ^. cellContents
|
||||
return $ if
|
||||
| null courses -> contents
|
||||
| otherwise -> $(widgetFile "table/cell/allocation-courses")
|
||||
dbtSorting = mconcat
|
||||
[ sortUserName' $ queryUser . $(multifocusG 2) (to (E.^. UserDisplayName)) (to (E.^. UserSurname))
|
||||
, sortUserMatriculation $ queryUser . (to (E.^. UserMatrikelnummer))
|
||||
@ -193,7 +220,7 @@ postAUsersR tid ssh ash = do
|
||||
& defaultSorting [SortAscBy "priority", SortAscBy "user-matriculation"]
|
||||
& defaultPagesize PagesizeAll
|
||||
|
||||
dbTableWidget' allocationUsersDBTableValidator allocationUsersDBTable
|
||||
dbTableDB' allocationUsersDBTableValidator allocationUsersDBTable
|
||||
|
||||
siteLayoutMsg MsgMenuAllocationUsers $ do
|
||||
setTitleI $ MsgAllocationUsersTitle tid ssh ash
|
||||
|
||||
@ -29,6 +29,7 @@ module Handler.Utils.Table.Pagination
|
||||
, ToSortable(..), Sortable(..)
|
||||
, dbTable
|
||||
, dbTableWidget, dbTableWidget'
|
||||
, dbTableDB, dbTableDB'
|
||||
, widgetColonnade, formColonnade, dbColonnade
|
||||
, cell, textCell, stringCell, i18nCell
|
||||
, anchorCell, anchorCell', anchorCellM, anchorCellM'
|
||||
@ -1222,6 +1223,17 @@ dbTableWidget' :: PSValidator (HandlerFor UniWorX) ()
|
||||
-> DB Widget
|
||||
dbTableWidget' = fmap (fmap snd) . dbTable
|
||||
|
||||
dbTableDB :: Monoid x
|
||||
=> PSValidator DB x
|
||||
-> DBTable DB x
|
||||
-> DB (DBResult DB x)
|
||||
dbTableDB = dbTable
|
||||
|
||||
dbTableDB' :: PSValidator DB ()
|
||||
-> DBTable DB ()
|
||||
-> DB Widget
|
||||
dbTableDB' = fmap (fmap snd) . dbTable
|
||||
|
||||
widgetColonnade :: Colonnade h r (DBCell (HandlerFor UniWorX) x)
|
||||
-> Colonnade h r (DBCell (HandlerFor UniWorX) x)
|
||||
widgetColonnade = id
|
||||
@ -1230,8 +1242,8 @@ formColonnade :: Colonnade h r (DBCell (RWST (Maybe (Env, FileEnv), UniWorX, [La
|
||||
-> Colonnade h r (DBCell (RWST (Maybe (Env, FileEnv), UniWorX, [Lang]) Enctype Ints (HandlerFor UniWorX)) (FormResult a))
|
||||
formColonnade = id
|
||||
|
||||
dbColonnade :: Colonnade h r (DBCell (ReaderT SqlBackend (HandlerFor UniWorX)) x)
|
||||
-> Colonnade h r (DBCell (ReaderT SqlBackend (HandlerFor UniWorX)) x)
|
||||
dbColonnade :: Colonnade h r (DBCell DB x)
|
||||
-> Colonnade h r (DBCell DB x)
|
||||
dbColonnade = id
|
||||
|
||||
pagesizeOptions :: PagesizeLimit -- ^ Current/previous value
|
||||
|
||||
@ -27,7 +27,9 @@
|
||||
<th .table__th colspan=2> Kopf A
|
||||
<th .table__th rowspan=2> Kopf
|
||||
<tr .table__row .table__row--head>
|
||||
<th .table__th> B
|
||||
<th .table__th>
|
||||
B
|
||||
^{iconTooltip testTooltipMsg Nothing True}
|
||||
<th .table__th> C
|
||||
<tr .table__row title="Ein Beispiel für ein Zeilentooltip">
|
||||
<td .table__td>1
|
||||
|
||||
@ -28,7 +28,9 @@
|
||||
<th .table__th colspan=2> Header A
|
||||
<th .table__th rowspan=2> Header
|
||||
<tr .table__row .table__row--head>
|
||||
<th .table__th> B
|
||||
<th .table__th>
|
||||
B
|
||||
^{iconTooltip testTooltipMsg Nothing True}
|
||||
<th .table__th> C
|
||||
<tr .table__row title="Example of line tooltip">
|
||||
<td .table__td>1
|
||||
|
||||
9
templates/table/cell/allocation-courses.hamlet
Normal file
9
templates/table/cell/allocation-courses.hamlet
Normal file
@ -0,0 +1,9 @@
|
||||
$newline never
|
||||
<div .tooltip .tooltip--spread .tooltip--no-handle>
|
||||
<div>
|
||||
^{contents}
|
||||
<div .tooltip__content>
|
||||
<ul>
|
||||
$forall Entity _ Course{courseTerm, courseSchool, courseName} <- courses
|
||||
<li>
|
||||
#{courseTerm} - #{courseSchool} - #{courseName}
|
||||
Loading…
Reference in New Issue
Block a user