diff --git a/frontend/src/app.sass b/frontend/src/app.sass index 29763b0fd..793c0f393 100644 --- a/frontend/src/app.sass +++ b/frontend/src/app.sass @@ -425,6 +425,7 @@ input[type="button"].btn-info:not(.btn-link):hover, padding-bottom: 10px font-weight: bold text-align: left + vertical-align: middle a color: white diff --git a/frontend/src/services/util-registry/util-registry.js b/frontend/src/services/util-registry/util-registry.js index 346dda06f..57789622e 100644 --- a/frontend/src/services/util-registry/util-registry.js +++ b/frontend/src/services/util-registry/util-registry.js @@ -115,6 +115,7 @@ export class UtilRegistry { } catch(err) { if (DEBUG_MODE > 0) { console.error('Error while trying to initialize a utility!', { util , element, err }); + console.error(err.stack); } utilInstance = null; } diff --git a/frontend/src/utils/check-all/check-all.js b/frontend/src/utils/check-all/check-all.js index 7922cd3ee..6c7a8e441 100644 --- a/frontend/src/utils/check-all/check-all.js +++ b/frontend/src/utils/check-all/check-all.js @@ -8,13 +8,10 @@ const CHECK_ALL_INITIALIZED_CLASS = 'check-all--initialized'; selector: 'table:not([uw-no-check-all])', }) export class CheckAll { - _element; - _app; _columns = []; - _checkboxColumn = []; - _checkAllCheckbox = null; + _checkAllColumns = []; constructor(element, app) { if (!element) { @@ -22,80 +19,81 @@ export class CheckAll { } this._element = element; - this._app = app; if (this._element.classList.contains(CHECK_ALL_INITIALIZED_CLASS)) { return false; } this._gatherColumns(); - this._setupCheckAllCheckbox(); + this._findCheckboxColumns().forEach(columnId => this._checkAllColumns.push(new CheckAllColumn(this._element, app, this._columns[columnId]))); // mark initialized this._element.classList.add(CHECK_ALL_INITIALIZED_CLASS); } - destroy() { - this._checkAllCheckbox.destroy(); - } - - _getCheckboxId() { - return 'check-all-checkbox-' + Math.floor(Math.random() * 100000); - } - _gatherColumns() { - const rows = Array.from(this._element.querySelectorAll('tr')); + const rows = Array.from(this._element.rows); const cols = []; rows.forEach((tr) => { - const cells = Array.from(tr.querySelectorAll('td')); - cells.forEach((cell, cellIndex) => { - if (!cols[cellIndex]) { - cols[cellIndex] = []; + const cells = Array.from(tr.cells); + cells.forEach(cell => { + let i = 0; + for (const sibling of cells.slice(0, cell.cellIndex)) + i += Math.max(1, sibling.colSpan) || 1; + + for (let j = i; j < i + cell.colSpan; j++) { + if (!cols[j]) { + cols[j] = []; + } + cols[j].push(cell); } - cols[cellIndex].push(cell); }); }); this._columns = cols; } - _findCheckboxColumn(columns) { - let checkboxColumnId = null; - columns.forEach((col, i) => { + _findCheckboxColumns() { + let checkboxColumnIds = []; + this._columns.forEach((col, i) => { if (this._isCheckboxColumn(col)) { - checkboxColumnId = i; + checkboxColumnIds.push(i); } }); - return checkboxColumnId; + return checkboxColumnIds; } _isCheckboxColumn(col) { - let onlyCheckboxes = true; - col.forEach((cell) => { - if (onlyCheckboxes && !cell.querySelector(CHECKBOX_SELECTOR)) { - onlyCheckboxes = false; - } - }); - return onlyCheckboxes; + return col.every(cell => cell.tagName == 'TH' || cell.querySelector(CHECKBOX_SELECTOR)) + && col.some(cell => cell.querySelector(CHECKBOX_SELECTOR)); } +} - _setupCheckAllCheckbox() { - const checkboxColumnId = this._findCheckboxColumn(this._columns); - if (checkboxColumnId === null) { - return; - } +class CheckAllColumn { + _app; + _table; + _column; + + _checkAllCheckbox; + _checkboxId = 'check-all-checkbox-' + Math.floor(Math.random() * 100000); + + constructor(table, app, column) { + this._column = column; + this._table = table; + this._app = app; + + const th = this._column.filter(element => element.tagName == 'TH')[0]; + if (!th) + return false; - this._checkboxColumn = this._columns[checkboxColumnId]; - const firstRow = this._element.querySelector('tr'); - const th = Array.from(firstRow.querySelectorAll('th, td'))[checkboxColumnId]; this._checkAllCheckbox = document.createElement('input'); this._checkAllCheckbox.setAttribute('type', 'checkbox'); - this._checkAllCheckbox.setAttribute('id', this._getCheckboxId()); + this._checkAllCheckbox.setAttribute('id', this._checkboxId); th.insertBefore(this._checkAllCheckbox, th.firstChild); // set up new checkbox this._app.utilRegistry.initAll(th); - this._checkAllCheckbox.addEventListener('input', () => this._onCheckAllCheckboxInput()); + this._checkAllCheckbox.addEventListener('input', this._onCheckAllCheckboxInput.bind(this)); this._setupCheckboxListeners(); } @@ -104,23 +102,25 @@ export class CheckAll { } _setupCheckboxListeners() { - this._checkboxColumn.map((cell) => { - return cell.querySelector(CHECKBOX_SELECTOR); - }) - .forEach((checkbox) => { - checkbox.addEventListener('input', () => this._updateCheckAllCheckboxState()); - }); + this._column + .flatMap(cell => cell.tagName == 'TH' ? new Array() : Array.from(cell.querySelectorAll(CHECKBOX_SELECTOR))) + .forEach(checkbox => + checkbox.addEventListener('input', this._updateCheckAllCheckboxState.bind(this)) + ); } _updateCheckAllCheckboxState() { - const allChecked = this._checkboxColumn.every((cell) => { - return cell.querySelector(CHECKBOX_SELECTOR).checked; - }); + const allChecked = this._column.every(cell => + cell.tagName == 'TH' || cell.querySelector(CHECKBOX_SELECTOR).checked + ); this._checkAllCheckbox.checked = allChecked; } _toggleAll(checked) { - this._checkboxColumn.forEach((cell) => { + this._column.forEach(cell => { + if (cell.tagName == 'TH') + return; + cell.querySelector(CHECKBOX_SELECTOR).checked = checked; }); } diff --git a/frontend/src/utils/hide-columns/hide-columns.js b/frontend/src/utils/hide-columns/hide-columns.js index c9b2dc94b..29a99670a 100644 --- a/frontend/src/utils/hide-columns/hide-columns.js +++ b/frontend/src/utils/hide-columns/hide-columns.js @@ -1,11 +1,13 @@ import { Utility } from '../../core/utility'; import { StorageManager, LOCATION } from '../../lib/storage-manager/storage-manager'; import './hide-columns.sass'; +import * as memoize from 'lodash.memoize'; const HIDE_COLUMNS_CONTAINER_IDENT = 'uw-hide-columns'; const TABLE_HEADER_IDENT = 'uw-hide-column-header'; const HIDE_COLUMNS_HIDER_LABEL = 'uw-hide-columns--hider-label'; const HIDE_COLUMNS_NO_HIDE = 'uw-hide-columns--no-hide'; +const HIDE_COLUMNS_DEFAULT_HIDDEN = 'uw-hide-column-default-hidden'; const TABLE_UTILS_ATTR = 'table-utils'; const TABLE_UTILS_CONTAINER_SELECTOR = `[${TABLE_UTILS_ATTR}]`; @@ -18,6 +20,8 @@ const TABLE_PILL_CLASS = 'table-pill'; const CELL_HIDDEN_CLASS = 'hide-columns--hidden-cell'; const CELL_ORIGINAL_COLSPAN = 'uw-hide-column-original-colspan'; +const HIDE_COLUMNS_INITIALIZED = 'uw-hide-columns--initialized'; + @Utility({ selector: `[${HIDE_COLUMNS_CONTAINER_IDENT}] table`, }) @@ -44,14 +48,15 @@ export class HideColumns { constructor(element) { this._autoHide = this._storageManager.load('autoHide', {}) || false; - if (!element) { + if (!element) throw new Error('Hide Columns utility cannot be setup without an element!'); - } // do not provide hide-column ability in tables inside modals, async forms with response or tail.datetime instances - if (element.closest('[uw-modal], .async-form__response, .tail-datetime-calendar')) { + if (element.closest('[uw-modal], .async-form__response, .tail-datetime-calendar')) + return false; + + if (element.classList.contains(HIDE_COLUMNS_INITIALIZED)) return false; - } this._element = element; @@ -74,10 +79,12 @@ export class HideColumns { this._mutationObserver = new MutationObserver(this._tableMutated.bind(this)); this._mutationObserver.observe(this._element, { childList: true, subtree: true }); + + this._element.classList.add(HIDE_COLUMNS_INITIALIZED); } setupHideButton(th) { - const preHidden = this.isHiddenColumn(th); + const preHidden = this.isHiddenTH(th); const hider = document.createElement('span'); @@ -104,20 +111,20 @@ export class HideColumns { hider.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); - this.switchColumnDisplay(th, hider); + this.switchColumnDisplay(th); // this._tableHiderContainer.getElementsByClassName(TABLE_HIDER_CLASS).forEach(hider => this.hideHiderBehindHeader(hider)); }); hider.addEventListener('mouseover', () => { hider.classList.add(TABLE_HIDER_VISIBLE_CLASS); - const currentlyHidden = this.isHiddenColumn(th); + const currentlyHidden = this.hiderStatus(th); this.updateHiderIcon(hider, !currentlyHidden); }); hider.addEventListener('mouseout', () => { if (hider.classList.contains(TABLE_HIDER_CLASS)) { hider.classList.remove(TABLE_HIDER_VISIBLE_CLASS); } - const currentlyHidden = this.isHiddenColumn(th); + const currentlyHidden = this.hiderStatus(th); this.updateHiderIcon(hider, currentlyHidden); }); @@ -126,64 +133,76 @@ export class HideColumns { // reposition hider on each window resize event // window.addEventListener('resize', () => this.repositionHider(hider)); - this.updateColumnDisplay(this.colIndex(th), preHidden); - this.updateHider(hider, preHidden); - - if (preHidden) { - this._tableUtilContainer.appendChild(hider); - } else { - this.hideHiderBehindHeader(hider); - } + this.switchColumnDisplay(th, preHidden); } - switchColumnDisplay(th, hider) { - const hidden = !this.isHiddenColumn(th); - const originalColspan = Math.max(1, th.getAttribute(CELL_ORIGINAL_COLSPAN)) || 1; - const colspan = Math.max(1, th.colSpan) || 1; - const columnIndex = this.colIndex(th); + switchColumnDisplay(th, hidden) { + hidden = typeof(hidden) === 'undefined' ? !this.isHiddenTH(th) : !!hidden; - for (let i = 0; i < Math.max(colspan, originalColspan); i++) { - this.updateColumnDisplay(columnIndex + i, hidden); - } - this.updateHider(hider, hidden); - - // persist new hidden setting for column - if ((hidden && this.isEmptyColumn(columnIndex) && this._autoHide) || (!hidden && (!this.isEmptyColumn(columnIndex) || !this._autoHide))) { - this._storageManager.remove(this.getStorageKey(th)); - } else { - this._storageManager.save(this.getStorageKey(th), hidden); - } + this.cellColumns(th).forEach(columnIndex => this.updateColumnDisplay(columnIndex, hidden)); } updateColumnDisplay(columnIndex, hidden) { - this._element.getElementsByTagName('tr').forEach(row => { + // console.debug('updateColumnDisplay', { columnIndex, hidden }); + this._element.rows.forEach(row => { const cell = this.getCol(row, columnIndex); if (cell) { const originalColspan = cell.getAttribute(CELL_ORIGINAL_COLSPAN); const colspan = Math.max(1, cell.colSpan) || 1; + const visibleColumns = this.cellColumns(cell).reduce((count, cColumnIndex) => (cColumnIndex === columnIndex ? hidden : this.isHiddenColumn(cColumnIndex)) ? count : count + 1, 0); - if (hidden) { - if (colspan > 1) { + // if (cell.tagName === 'TH') { + // console.debug({cell, originalColspan, colspan, visibleColumns, isHidden: cell.classList.contains(CELL_HIDDEN_CLASS)}); + // } + + + if (visibleColumns <= 0) { + cell.classList.add(CELL_HIDDEN_CLASS); + } else { + cell.classList.remove(CELL_HIDDEN_CLASS); + + if (colspan !== visibleColumns) { if (!originalColspan) { cell.setAttribute(CELL_ORIGINAL_COLSPAN, colspan); } - cell.colSpan--; - } else { - cell.classList.add(CELL_HIDDEN_CLASS); - } - } else { - if (cell.classList.contains(CELL_HIDDEN_CLASS)) { - cell.classList.remove(CELL_HIDDEN_CLASS); - } else if (originalColspan && colspan < originalColspan) { - cell.colSpan++; + cell.colSpan = visibleColumns; } } } }); + + const touchedColumns = new Array(); + + this.columnTHs(columnIndex) + .forEach(th => { + touchedColumns.push(...this.cellColumns(th)); + + if (!this._element.classList.contains(HIDE_COLUMNS_INITIALIZED)) + return; + + const thHidden = this.cellColumns(th).every(cColumnIndex => cColumnIndex === columnIndex ? hidden : this.isHiddenColumn(cColumnIndex)); + // persist new hidden setting for column + if (thHidden == this.isDefaultHiddenTH(th)) { + this._storageManager.remove(this.getStorageKey(th)); + } else { + this._storageManager.save(this.getStorageKey(th), thHidden); + } + }); + + touchedColumns.flatMap(cColumnIndex => this.columnTHs(cColumnIndex)) + .forEach(th => { + const hider = this.headerToHider.get(th); + if (!hider) + return; + + this.updateHider(hider, this.hiderStatus(th)); + }); } updateHider(hider, hidden) { + // console.debug({hider, hidden, columnIndex: this.colIndex(this.hiderToHeader.get(hider)), colSpan: this.colSpan(this.hiderToHeader.get(hider))}); + if (hidden) { hider.classList.remove(TABLE_HIDER_CLASS); hider.classList.add(TABLE_PILL_CLASS); @@ -223,12 +242,21 @@ export class HideColumns { } _tableMutated(mutationList) { - // console.log('_tableMutated', mutationList, observer); - if (!Array.from(mutationList).some(mutation => mutation.type === 'childList')) return; - [...this._element.querySelectorAll('th')].filter(th => !th.hasAttribute(HIDE_COLUMNS_NO_HIDE)).forEach(th => this.updateColumnDisplay(this.colIndex(th), this.isHiddenColumn(th))); + if (Array.from(mutationList).every(mRecord => mRecord.type === 'childList' && [...mRecord.addedNodes, ...mRecord.removedNodes].every(isTableHider))) + return; + + // console.debug('_tableMutated', { mutationList }); + + this._colIndexMemoized = undefined; + this._getColMemoized = undefined; + + Array.from(this._element.rows) + .flatMap(row => Array.from(row.cells)) + .filter(th => th.tagName === 'TH' && !th.hasAttribute(HIDE_COLUMNS_NO_HIDE)) + .forEach(th => this.updateColumnDisplay(this.colIndex(th), this.isHiddenTH(th))); } getStorageKey(th) { @@ -257,9 +285,9 @@ export class HideColumns { } isEmptyColumn(columnIndex) { - for (let row of this._element.getElementsByTagName('tr')) { + for (let row of this._element.rows) { const cell = this.getCol(row, columnIndex); - if (cell.matches('th')) + if (!cell || cell.tagName == 'TH') continue; if (cell.querySelector('.table__td-content')) { for (let child of cell.children) { @@ -273,10 +301,47 @@ export class HideColumns { } } - isHiddenColumn(th) { - const hidden = this._storageManager.load(this.getStorageKey(th)), - emptyColumn = this.isEmptyColumn(this.colIndex(th)); - return hidden === true || hidden === undefined && emptyColumn && this._autoHide; + columnTHs(columnIndex) { + return Array.from(this._element.rows) + .map(row => this.getCol(row, columnIndex)) + .filter(cell => cell && cell.tagName === 'TH'); + } + + cellColumns(cell) { + const columnIndex = this.colIndex(cell); + return Array.from(new Array(this.colSpan(cell)), (_x, i) => columnIndex + i); + } + + isHiddenTH(th) { + return this.cellColumns(th).every(columnIndex => this.isHiddenColumn(columnIndex)); + } + + hiderStatus(th) { + const columnsHidden = this.isHiddenTH(th); + const shadowed = this.cellColumns(th).every(columnIndex => this.isHiddenColumn(columnIndex, this.columnTHs(columnIndex).filter(oTH => oTH !== th))); + const isFirst = this.cellColumns(th).some(columnIndex => this.columnTHs(columnIndex)[0] === th); + + // console.debug("hiderStatus", { th, columnsHidden, shadowed, isFirst }); + + return columnsHidden && (!shadowed || isFirst); + } + + isDefaultHiddenTH(th) { + return this.cellColumns(th).every(columnIndex => this.isDefaultHiddenColumn(columnIndex, Array.of(th))); + } + + isHiddenColumn(columnIndex, ths) { + ths = ths === undefined ? this.columnTHs(columnIndex) : ths; + + const hidden = ths.map(th => this._storageManager.load(this.getStorageKey(th))); + + return hidden.every(h => h === undefined) ? this.isDefaultHiddenColumn(columnIndex, ths) : hidden.some(h => h); + } + + isDefaultHiddenColumn(columnIndex, ths) { + ths = ths === undefined ? this.columnTHs(columnIndex) : ths; + + return this.isEmptyColumn(columnIndex) && this._autoHide || ths.some(th => th.hasAttribute(HIDE_COLUMNS_DEFAULT_HIDDEN)); } colSpan(cell) { @@ -289,7 +354,7 @@ export class HideColumns { return originalColspan ? Math.max(colspan, originalColspan) : colspan; } - colIndex(cell) { + colIndexDirect(cell) { if (!cell) return 0; @@ -298,23 +363,62 @@ export class HideColumns { if (!rowParent) return 0; - let i = 0; - for (const sibling of Array.from(rowParent.cells).slice(0, cell.cellIndex)) { - i += this.colSpan(sibling); + let rowBefore = 0; + for (const cell of Array.from(rowParent.cells).slice(0, cell.cellIndex)) { + if (!cell) + continue; + + rowBefore += this.colSpan(cell); } + let i = 0; + for (const pRow of Array.from(this._element.rows).slice(0, rowParent.rowIndex + 1)) { + if (!pRow) + continue; + + let space = 0; + for (const cell of pRow === rowParent ? Array.from(pRow.cells).slice(0, cell.cellIndex) : pRow.cells) { + if (!cell) + continue; + + const rowSpan = cell.rowSpan || 1; + if (rowParent.rowIndex - pRow.rowIndex < rowSpan) + i += this.colSpan(cell); + else if (pRow !== rowParent) + space += this.colSpan(cell); + + if (space > rowBefore) + break; + } + } + + // console.debug({ rowParent, cell, rowBefore, i }); + return i; } - getCol(row, columnIndex) { - let c = 0; + _colIndexMemoized; - for (const cell of row.cells) { - c += cell ? this.colSpan(cell) : 1; + colIndex(cell) { + if (!this._colIndexMemoized) + this._colIndexMemoized = memoize(this.colIndexDirect.bind(this)); - if (columnIndex < c) + return this._colIndexMemoized(cell); + } + + getColDirect(row, columnIndex) { + for (const cell of row.cells) + if (cell && this.colIndex(cell) <= columnIndex && this.colIndex(cell) + this.colSpan(cell) > columnIndex) return cell; - } + } + + _getColMemoized; + + getCol(row, columnIndex) { + if (!this._getColMemoized) + this._getColMemoized = memoize(this.getColDirect.bind(this), (row, columnIndex) => Array.of(row.rowIndex, columnIndex).toString()); + + return this._getColMemoized(row, columnIndex); } } @@ -326,3 +430,8 @@ function isEmptyElement(element) { return true; } +function isTableHider(element) { + return element.classList.contains(TABLE_HIDER_CLASS) + || element.classList.contains(TABLE_HIDER_VISIBLE_CLASS) + || element.classList.contains(TABLE_PILL_CLASS); +} diff --git a/messages/uniworx/de-de-formal.msg b/messages/uniworx/de-de-formal.msg index 486bf31ae..ac81efc23 100644 --- a/messages/uniworx/de-de-formal.msg +++ b/messages/uniworx/de-de-formal.msg @@ -2599,6 +2599,7 @@ CourseParticipantActive: Teilnehmer CourseParticipantInactive: Abgemeldet CourseParticipantNoShow: Nicht erschienen CourseUserState: Zustand +CourseUserSheets: Übungsblätter TestDownload: Download-Test TestDownloadMaxSize: Maximale Dateigröße diff --git a/messages/uniworx/en-eu.msg b/messages/uniworx/en-eu.msg index 386421505..6fe436270 100644 --- a/messages/uniworx/en-eu.msg +++ b/messages/uniworx/en-eu.msg @@ -2599,6 +2599,7 @@ CourseParticipantActive: Participant CourseParticipantInactive: Deregistered CourseParticipantNoShow: No show CourseUserState: State +CourseUserSheets: Exercise sheets TestDownload: Download test TestDownloadMaxSize: Maximum filesize diff --git a/src/Handler/Course/Users.hs b/src/Handler/Course/Users.hs index b50644b09..4297bb253 100644 --- a/src/Handler/Course/Users.hs +++ b/src/Handler/Course/Users.hs @@ -1,4 +1,4 @@ -{-# OPTIONS_GHC -fno-warn-orphans #-} +{-# OPTIONS_GHC -fno-warn-orphans -fno-warn-redundant-constraints #-} module Handler.Course.Users ( queryUser @@ -98,6 +98,7 @@ type UserTableData = DBRow ( Entity User , ([Entity Tutorial], Map (CI Text) (Maybe (Entity Tutorial))) , [Entity Exam] , Maybe (Entity SubmissionGroup) + , Map SheetName (SheetType, Maybe Points) ) instance HasEntity UserTableData User where @@ -130,13 +131,15 @@ _userExams = _dbrOutput . _6 _userSubmissionGroup :: Traversal' UserTableData (Entity SubmissionGroup) _userSubmissionGroup = _dbrOutput . _7 . _Just +_userSheets :: Lens' UserTableData (Map SheetName (SheetType, Maybe Points)) +_userSheets = _dbrOutput . _8 + colUserComment :: IsDBTable m c => TermId -> SchoolId -> CourseShorthand -> Colonnade Sortable UserTableData (DBCell m c) colUserComment tid ssh csh = - sortable (Just "note") (i18nCell MsgCourseUserNote) - $ \DBRow{ dbrOutput=(Entity uid _, _, mbNoteKey, _, _, _, _) } -> - maybeEmpty mbNoteKey $ const $ - anchorCellM (courseLink <$> encrypt uid) (hasComment True) + sortable (Just "note") (i18nCell MsgCourseUserNote) $ views (_dbrOutput . $(multifocusG 2) (_1 . _entityKey) _3) $ \(uid, mbNoteKey) -> + maybeEmpty mbNoteKey $ const $ + anchorCellM (courseLink <$> encrypt uid) (hasComment True) where courseLink = CourseR tid ssh csh . CUserR @@ -183,6 +186,20 @@ colUserSubmissionGroup :: IsDBTable m c => Colonnade Sortable UserTableData (DBC colUserSubmissionGroup = sortable (Just "submission-group") (i18nCell MsgSubmissionGroup) $ foldMap (cell . toWidget) . preview (_userSubmissionGroup . _entityVal . _submissionGroupName) +colUserSheets :: forall m c. IsDBTable m c => [SheetName] -> Cornice Sortable ('Cap 'Base) UserTableData (DBCell m c) +colUserSheets shns = cap (Sortable Nothing caption) $ foldMap userSheetCol shns + where + caption = i18nCell MsgCourseUserSheets + & cellAttrs <>~ [ ("uw-hide-column-header", "sheets") + , ("uw-hide-column-default-hidden", "") + ] + + userSheetCol :: SheetName -> Colonnade Sortable UserTableData (DBCell m c) + userSheetCol shn = sortable (Just . SortingKey $ "sheet-" <> shn) (i18nCell shn) . views (_userSheets . at shn) $ \case + Just (preview _grading -> Just Points{..}, Just points) -> i18nCell $ MsgAchievedOf points maxPoints + Just (preview _grading -> Just grading', Just points) -> i18nCell . bool MsgNotPassed MsgPassed . fromMaybe False $ gradingPassed grading' points + _other -> mempty + data UserTableCsvStudyFeature = UserTableCsvStudyFeature { csvUserField :: Text @@ -310,14 +327,15 @@ data CourseUserActionData = CourseUserSendMailData deriving (Eq, Ord, Read, Show, Generic, Typeable) -makeCourseUserTable :: forall h act act'. +makeCourseUserTable :: forall h p cols act act'. ( Functor h, ToSortable h , Ord act, PathPiece act, RenderMessage UniWorX act + , AsCornice h p UserTableData (DBCell (MForm Handler) (FormResult (First act', DBFormResult UserId Bool UserTableData))) cols ) => CourseId -> Map act (AForm Handler act') -> (UserTableExpr -> E.SqlExpr (E.Value Bool)) - -> Colonnade h UserTableData (DBCell (MForm Handler) (FormResult (First act', DBFormResult UserId Bool UserTableData))) + -> cols -> PSValidator (MForm Handler) (FormResult (First act', DBFormResult UserId Bool UserTableData)) -> Maybe (Csv.Name -> Bool) -> DB (FormResult (act', Set UserId), Widget) @@ -336,12 +354,23 @@ makeCourseUserTable cid acts restrict colChoices psValidator csvColumns = do dbtProj = traverse $ \(user, participant, E.Value userNoteId, (feature,degree,terms), subGroup) -> do tuts'' <- selectList [ TutorialParticipantUser ==. entityKey user, TutorialParticipantTutorial <-. map entityKey tutorials ] [] exams' <- selectList [ ExamRegistrationUser ==. entityKey user ] [] + subs' <- E.select . E.from $ \(sheet `E.InnerJoin` (submission `E.InnerJoin` submissionUser)) -> do + E.on $ submissionUser E.^. SubmissionUserSubmission E.==. submission E.^. SubmissionId + E.on $ sheet E.^. SheetId E.==. submission E.^. SubmissionSheet + E.&&. submissionUser E.^. SubmissionUserUser E.==. E.val (entityKey user) + E.where_ $ sheet E.^. SheetCourse E.==. E.val cid + return ( sheet E.^. SheetName + , ( sheet E.^. SheetType + , submission + ) + ) let regGroups = setOf (folded . _entityVal . _tutorialRegGroup . _Just) tutorials tuts' = filter (\(Entity tutId _) -> any ((== tutId) . tutorialParticipantTutorial . entityVal) tuts'') tutorials tuts = foldr (\tut@(Entity _ Tutorial{..}) -> maybe (over _1 $ cons tut) (over _2 . flip (Map.insertWith (<|>)) (Just tut)) tutorialRegGroup) ([], Map.fromSet (const Nothing) regGroups) tuts' exs = filter (\(Entity eId _) -> any ((== eId) . examRegistrationExam . entityVal) exams') exams - return (user, participant, userNoteId, (entityVal <$> feature, entityVal <$> degree, entityVal <$> terms), tuts, exs, subGroup) + subs = Map.fromList $ mapMaybe (over (mapped . _2 . _2) (submissionRatingPoints . entityVal) . assertM' (views (_2 . _2 . _entityVal) submissionRatingDone) . over _1 E.unValue . over (_2 . _1) E.unValue) subs' + return (user, participant, userNoteId, (entityVal <$> feature, entityVal <$> degree, entityVal <$> terms), tuts, exs, subGroup, subs) dbtColonnade = colChoices dbtSorting = mconcat [ single $ sortUserNameLink queryUser -- slower sorting through clicking name column header @@ -541,22 +570,24 @@ postCUsersR tid ssh csh = do E.&&. courseParticipant E.^. CourseParticipantCourse E.==. submissionGroup E.^. SubmissionGroupCourse E.on $ submissionGroupUser E.^. SubmissionGroupUserSubmissionGroup E.==. submissionGroup E.^. SubmissionGroupId E.where_ $ submissionGroup E.^. SubmissionGroupCourse E.==. E.val cid + sheetList <- selectList [SheetCourse ==. cid] [Desc SheetActiveTo, Desc SheetActiveFrom] let exams = nubOn entityKey $ examOccurrencesPerExam ^.. folded . _1 let colChoices = mconcat $ catMaybes - [ pure $ dbSelect (applying _2) id (return . view (hasEntity . _entityKey)) - , pure $ colUserNameLink (CourseR tid ssh csh . CUserR) - , guardOn showSex $ colUserSex' - , pure $ colUserEmail - , pure $ colUserMatriclenr - , pure $ colUserDegreeShort - , pure $ colUserField - , pure $ colUserSemester - , guardOn hasSubmissionGroups colUserSubmissionGroup - , guardOn hasTutorials $ colUserTutorials tid ssh csh - , guardOn hasExams $ colUserExams tid ssh csh - , pure $ sortable (Just "registration") (i18nCell MsgRegisteredSince) (maybe mempty dateCell . preview (_Just . _userTableRegistration) . assertM' (has $ _userTableParticipant . _entityVal . _courseParticipantState . _CourseParticipantActive)) - , pure $ sortable (Just "state") (i18nCell MsgCourseUserState) (i18nCell . view (_userTableParticipant . _entityVal . _courseParticipantState)) - , pure $ colUserComment tid ssh csh + [ pure . cap' $ dbSelect (applying _2) id (return . view (hasEntity . _entityKey)) + , pure . cap' $ colUserNameLink (CourseR tid ssh csh . CUserR) + , guardOn showSex . cap' $ colUserSex' + , pure . cap' $ colUserEmail + , pure . cap' $ colUserMatriclenr + , pure . cap' $ colUserDegreeShort + , pure . cap' $ colUserField + , pure . cap' $ colUserSemester + , guardOn hasSubmissionGroups $ cap' colUserSubmissionGroup + , guardOn hasTutorials . cap' $ colUserTutorials tid ssh csh + , guardOn hasExams . cap' $ colUserExams tid ssh csh + , pure . cap' $ sortable (Just "registration") (i18nCell MsgRegisteredSince) (maybe mempty dateCell . preview (_Just . _userTableRegistration) . assertM' (has $ _userTableParticipant . _entityVal . _courseParticipantState . _CourseParticipantActive)) + , pure . cap' $ sortable (Just "state") (i18nCell MsgCourseUserState) (i18nCell . view (_userTableParticipant . _entityVal . _courseParticipantState)) + , guardOn (not $ null sheetList) . colUserSheets $ map (sheetName . entityVal) sheetList + , pure . cap' $ colUserComment tid ssh csh ] psValidator = def & defaultSortingByName & defaultFilter (singletonMap "active" [toPathPiece True]) diff --git a/src/Handler/Utils/Table/Pagination.hs b/src/Handler/Utils/Table/Pagination.hs index 46d06d62b..de94f9949 100644 --- a/src/Handler/Utils/Table/Pagination.hs +++ b/src/Handler/Utils/Table/Pagination.hs @@ -42,6 +42,7 @@ module Handler.Utils.Table.Pagination , formCell, DBFormResult(..), getDBFormResult , dbSelect , (&) + , cap' , module Control.Monad.Trans.Maybe , module Colonnade , DBSTemplateMode(..) @@ -59,6 +60,8 @@ import Utils.Lens import Import hiding (pi) +import qualified Data.Foldable as Foldable + import qualified Yesod.Form.Functions as Yesod import qualified Database.Esqueleto as E @@ -68,12 +71,11 @@ import qualified Database.Esqueleto.Internal.Sql as E (SqlSelect,unsafeSqlValue) import qualified Network.Wai as Wai import Control.Monad.RWS (RWST(..), execRWS) -import Control.Monad.State (evalStateT) +import Control.Monad.State (evalStateT, execStateT) import Control.Monad.Trans.Maybe import Control.Monad.State.Class (modify) import qualified Control.Monad.State.Class as State - -import Data.Foldable (Foldable(foldMap)) +import Control.Monad.Trans.Writer.Lazy (censor) import Data.Map ((!)) import qualified Data.Map as Map @@ -90,7 +92,7 @@ import Colonnade.Encode hiding (row) import Text.Hamlet (hamletFile) -import Data.List (elemIndex) +import Data.List (elemIndex, inits) import Data.Maybe (fromJust) @@ -108,7 +110,8 @@ import qualified Data.ByteString.Lazy as LBS import Data.Semigroup as Sem (Semigroup(..)) -import qualified Data.Conduit.List as C +import qualified Data.Conduit.List as C (sourceList) +import qualified Data.Conduit.Combinators as C import Handler.Utils.DateTime (formatTimeRangeW) import qualified Control.Monad.Catch as Catch @@ -1136,7 +1139,7 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db | otherwise = id in C.sourceList <=< lift . doHandle . runConduit $ dbtCsvComputeActions x .| C.foldMap pure - innerAct .| C.fold accActionMap Map.empty + innerAct .| C.foldl accActionMap Map.empty actionMap <- flip evalStateT Map.empty . runConduit $ sourceDiff .| transPipe lift dbtCsvComputeActions' when (Map.null actionMap) $ do @@ -1266,12 +1269,47 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db DBSTDefault{..} -> dbstmNumber rowCount _other -> False - genHeaders :: _ -> [WriterT x m Widget] - genHeaders SortableP{..} = flip (headersMonoidal Nothing) (annotate $ dbtColonnade ^. _Cornice) $ pure - ( \Sized{ sizedSize, sizedContent = toSortable -> Sortable{..} } -> pure $ do + genHeaders :: forall h. Cornice h _ _ (DBCell m x) -> SortableP h -> WriterT x m Widget + genHeaders cornice SortableP{..} = execWriterT . go mempty $ annotate cornice + where + go :: forall (p' :: Pillar) r'. + [(Int, Int, Int)] + -> AnnotatedCornice (Maybe Int) h p' r' (DBCell m x) + -> WriterT Widget (WriterT x m) () + go rowspanAcc (AnnotatedCorniceBase _ (Colonnade (toList -> v))) = censor wrap . forM_ (zip (inits v) v) $ \(before, OneColonnade Sized{..} _) -> do + let (_, cellSize') = compCellSize rowspanAcc (map oneColonnadeHead before) Sized{..} + whenIsJust cellSize' $ \cellSize -> tellM $ fromContent Sized { sizedSize = cellSize, sizedContent } + go rowspanAcc (AnnotatedCorniceCap _ v@(toList -> oneCornices)) = do + rowspanAcc' <- (execStateT ?? rowspanAcc) . hoist (censor wrap) . forM_ (zip (inits oneCornices) oneCornices) $ \(before, OneCornice h (size -> sz')) -> do + let sz = Sized sz' h + let (beforeSize, cellSize') = compCellSize rowspanAcc (concatMap (map oneColonnadeHead . toList . getColonnade . uncapAnnotated . oneCorniceBody) before) sz + whenIsJust cellSize' $ \cellSize -> do + let Sized{..} = sz + lift . tellM $ fromContent Sized { sizedSize = cellSize, sizedContent } + if | [n] <- mapMaybe (\(key, val) -> guardOnM (is _Rowspan key) $ readMay val) (toSortable sizedContent ^. _sortableContent . cellAttrs) + -> State.modify $ (:) (n, beforeSize, cellSize) + | otherwise -> return () + let rowspanAcc'' = rowspanAcc' + & traverse . _1 %~ pred + whenIsJust (flattenAnnotated v) $ go rowspanAcc'' + + compCellSize :: forall h' c. [(Int, Int, Int)] -> [Sized (Maybe Int) h' c] -> Sized (Maybe Int) h' c -> (Int, Maybe Int) + compCellSize rowspanAcc before Sized{..} = (beforeSize,) . assertM' (> 0) $ fromMaybe 1 sizedSize - shadowed + where Sum beforeSize = foldMap (\(Sized sz _) -> Sum $ fromMaybe 1 sz) before + Sum shadowed = flip foldMap rowspanAcc $ \(rowsRem, firstCol, sz) -> fromMaybe mempty $ do + guard $ rowsRem > 0 + guard $ firstCol <= beforeSize + guard $ beforeSize < firstCol + sz + return . Sum $ sz - (beforeSize - firstCol) + + wrap :: Widget -> Widget + wrap row = case dbsTemplate of + DBSTCourse{} -> row + DBSTDefault{} -> $(widgetFile "table/header") + fromContent :: Sized Int h (DBCell m x) -> WriterT x m Widget + fromContent Sized{ sizedSize = cellSize, sizedContent = toSortable -> Sortable{..} } = do widget <- sortableContent ^. cellContents let - cellSize = fromMaybe 1 sizedSize directions = [dir | SortingSetting k dir <- psSorting, Just k == sortableKey ] isSortable = isJust sortableKey isSorted dir = fromMaybe False $ (==) <$> (SortingSetting <$> sortableKey <*> pure dir) <*> listToMaybe psSorting @@ -1280,12 +1318,8 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db case dbsTemplate of DBSTCourse{} -> return $(widgetFile "table/course/header") DBSTDefault{} -> return $(widgetFile "table/cell/header") - , case dbsTemplate of - DBSTCourse{} -> id - DBSTDefault{} -> pure . (>>= \row -> return $(widgetFile "table/header")) . foldMapM id - ) in do - wHeaders <- maybe (return Nothing) (fmap Just . foldMapM id . genHeaders) pSortable + wHeaders <- maybe (return Nothing) (fmap Just . genHeaders (dbtColonnade ^. _Cornice)) pSortable case dbsTemplate of DBSTCourse c l r s a -> do wRows <- forM rows $ \row' -> let @@ -1611,3 +1645,27 @@ dbSelect resLens selLens genIndex = Colonnade.singleton (headednessPure $ mempty genForm _ mkUnique = do (selResult, selWidget) <- mreq checkBoxField (fsUniq mkUnique "select") (Just False) return (set selLens <$> selResult, [whamlet|^{fvWidget selWidget}|]) + + +cap' :: ( AsCornice Sortable p r' (DBCell m x) colonnade + , IsDBTable m x + ) + => colonnade + -> Cornice Sortable ('Cap p) r' (DBCell m x) +cap' (view _Cornice -> cornice) = case cornice of + CorniceBase Colonnade{..} + | [OneColonnade{..}] <- toList getColonnade + -> recap (oneColonnadeHead & _sortableContent . cellAttrs %~ incRowspan) cornice + CorniceCap cornices + -> CorniceCap $ fmap (\OneCornice{..} -> OneCornice { oneCorniceHead = oneCorniceHead & _sortableContent . cellAttrs %~ incRowspan, oneCorniceBody = cap' oneCorniceBody }) cornices + other + -> recap (fromSortable . Sortable Nothing $ cell mempty) other + where + incRowspan :: [(Text, Text)] -> [(Text, Text)] + incRowspan attrs + | [n] <- mapMaybe (\(key, val) -> guardOnM (is _Rowspan key) $ readMay val) attrs + = (_Rowspan # (), tshow (succ n :: Natural)) : filter (hasn't $ _1 . _Rowspan) attrs + | otherwise = (_Rowspan # (), "2") : filter (hasn't $ _1 . _Rowspan) attrs + +_Rowspan :: Prism' Text () +_Rowspan = prism' (\() -> "rowspan") $ flip guardOn () . ((==) `on` CI.mk) "rowspan" diff --git a/src/Handler/Utils/Table/Pagination/Types.hs b/src/Handler/Utils/Table/Pagination/Types.hs index 69566c3a9..7e8141003 100644 --- a/src/Handler/Utils/Table/Pagination/Types.hs +++ b/src/Handler/Utils/Table/Pagination/Types.hs @@ -2,10 +2,11 @@ module Handler.Utils.Table.Pagination.Types ( FilterKey(FilterKey), SortingKey(SortingKey) - , Sortable(..) + , Sortable(..), _sortableKey, _sortableContent , sortable , ToSortable(..), FromSortable(..) , SortableP(..) + , IsSortable, _Sortable , DBTableInvalid(..) , AsCornice(..) ) where @@ -30,6 +31,8 @@ data Sortable a = Sortable , sortableContent :: a } +makeLenses_ ''Sortable + sortable :: Maybe SortingKey -> c -> (a -> c) -> Colonnade Sortable a c sortable k h = singleton (Sortable k h) @@ -69,6 +72,12 @@ instance FromSortable Headless where fromSortable _ = Headless +type IsSortable h = (ToSortable h, FromSortable h) + +_Sortable :: IsSortable h => Prism' (h a) (Sortable a) +_Sortable = prism' fromSortable $ \x -> ($ x) . toSortable <$> pSortable + + data DBTableInvalid = DBTIRowsMissing Int deriving (Eq, Ord, Read, Show, Generic, Typeable) diff --git a/src/Utils.hs b/src/Utils.hs index 94b1b4426..60e87f633 100644 --- a/src/Utils.hs +++ b/src/Utils.hs @@ -55,7 +55,7 @@ import Control.Arrow as Utils ((>>>)) import Control.Monad.Trans.Except (ExceptT(..), throwE, runExceptT) import Control.Monad.Except (MonadError(..)) import Control.Monad.Trans.Maybe as Utils (MaybeT(..)) -import Control.Monad.Trans.Writer.Lazy (execWriterT, tell) +import Control.Monad.Trans.Writer.Lazy (WriterT, execWriterT, tell) import Control.Monad.Catch import Control.Monad.Morph (hoist) import Control.Monad.Fail @@ -802,6 +802,9 @@ diffTimeout timeoutLength timeoutRes act = fromMaybe timeoutRes <$> timeout time = let (MkFixed micro :: Micro) = realToFrac timeoutLength in fromInteger micro +tellM :: (Monad m, Monoid x) => m x -> WriterT x m () +tellM = tell <=< lift + ------------- -- Conduit -- ------------- diff --git a/stack.yaml b/stack.yaml index ab91595e4..a24808702 100644 --- a/stack.yaml +++ b/stack.yaml @@ -31,7 +31,7 @@ extra-deps: - git: git@gitlab2.rz.ifi.lmu.de:uni2work/xss-sanitize.git commit: 074ed7c8810aca81f60f2c535f9e7bad67e9d95a - git: git@gitlab2.rz.ifi.lmu.de:uni2work/colonnade.git - commit: 65164334e9704afc24603a9f3197b4581c996ad8 + commit: f8170266ab25b533576e96715bedffc5aa4f19fa subdirs: - colonnade diff --git a/stack.yaml.lock b/stack.yaml.lock index eea400a87..3d8d3b3a3 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -144,12 +144,12 @@ packages: git: git@gitlab2.rz.ifi.lmu.de:uni2work/colonnade.git pantry-tree: size: 481 - sha256: c7137405813404f4e0d2334d67876ab7150e7dc7f8b9f23ad452c5ee76ce4737 - commit: 65164334e9704afc24603a9f3197b4581c996ad8 + sha256: 392393652cc0f354d351482557b9385c8e6122e706359b030373656565f2e045 + commit: f8170266ab25b533576e96715bedffc5aa4f19fa original: subdir: colonnade git: git@gitlab2.rz.ifi.lmu.de:uni2work/colonnade.git - commit: 65164334e9704afc24603a9f3197b4581c996ad8 + commit: f8170266ab25b533576e96715bedffc5aa4f19fa - completed: hackage: hsass-0.8.0@sha256:82d55fb2a10342accbc4fe80d263163f40a138d8636e275aa31ffa81b14abf01,2792 pantry-tree: diff --git a/templates/table/cell/header.hamlet b/templates/table/cell/header.hamlet index e05dd9568..ecc5ab994 100644 --- a/templates/table/cell/header.hamlet +++ b/templates/table/cell/header.hamlet @@ -1,5 +1,5 @@ $newline never - + $maybe flag <- sortableKey $case directions $of [SortAsc]