feat(allocations): tooltips listing courses in users table

major improvements to tooltips
This commit is contained in:
Gregor Kleen 2020-02-28 18:42:31 +01:00
parent cccbd146cc
commit 6bca64cf5f
8 changed files with 415 additions and 27 deletions

View 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);
}
}

View File

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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View 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}