diff --git a/frontend/src/lib/movement-observer/movement-observer.js b/frontend/src/lib/movement-observer/movement-observer.js new file mode 100644 index 000000000..b6846c10e --- /dev/null +++ b/frontend/src/lib/movement-observer/movement-observer.js @@ -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); + } +} diff --git a/frontend/src/utils/tooltips/tooltips.js b/frontend/src/utils/tooltips/tooltips.js index c4813957e..bfe3ab3a7 100644 --- a/frontend/src/utils/tooltips/tooltips.js +++ b/frontend/src/utils/tooltips/tooltips.js @@ -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() {} }; diff --git a/frontend/src/utils/tooltips/tooltips.sass b/frontend/src/utils/tooltips/tooltips.sass index dc3861fee..360f96ecd 100644 --- a/frontend/src/utils/tooltips/tooltips.sass +++ b/frontend/src/utils/tooltips/tooltips.sass @@ -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) diff --git a/src/Handler/Allocation/Users.hs b/src/Handler/Allocation/Users.hs index 200f9a4a9..4e3183426 100644 --- a/src/Handler/Allocation/Users.hs +++ b/src/Handler/Allocation/Users.hs @@ -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 diff --git a/src/Handler/Utils/Table/Pagination.hs b/src/Handler/Utils/Table/Pagination.hs index e3689add8..a967a9347 100644 --- a/src/Handler/Utils/Table/Pagination.hs +++ b/src/Handler/Utils/Table/Pagination.hs @@ -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 diff --git a/templates/i18n/admin-test/de-de-formal.hamlet b/templates/i18n/admin-test/de-de-formal.hamlet index 45d994a0c..0b81a7844 100644 --- a/templates/i18n/admin-test/de-de-formal.hamlet +++ b/templates/i18n/admin-test/de-de-formal.hamlet @@ -27,7 +27,9 @@ Kopf A Kopf - B + + B + ^{iconTooltip testTooltipMsg Nothing True} C 1 diff --git a/templates/i18n/admin-test/en-eu.hamlet b/templates/i18n/admin-test/en-eu.hamlet index 37710392b..ebfbf1124 100644 --- a/templates/i18n/admin-test/en-eu.hamlet +++ b/templates/i18n/admin-test/en-eu.hamlet @@ -28,7 +28,9 @@ Header A Header - B + + B + ^{iconTooltip testTooltipMsg Nothing True} C 1 diff --git a/templates/table/cell/allocation-courses.hamlet b/templates/table/cell/allocation-courses.hamlet new file mode 100644 index 000000000..422932e55 --- /dev/null +++ b/templates/table/cell/allocation-courses.hamlet @@ -0,0 +1,9 @@ +$newline never +
+
+ ^{contents} +
+
    + $forall Entity _ Course{courseTerm, courseSchool, courseName} <- courses +
  • + #{courseTerm} - #{courseSchool} - #{courseName}