From c47ac926d2aa5935a6c4c1d2be3943da0d15aecc Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Thu, 25 Jun 2026 15:23:41 +0200 Subject: [PATCH 01/15] WIP --- .../Stv/Studentenverwaltung/List.js | 1670 +++++++++++------ .../tabulator/filters/extendedHeaderFilter.js | 4 +- public/js/tabulator/formatter/tags.js | 178 +- tests/cypress/.gitignore | 4 + 4 files changed, 1177 insertions(+), 679 deletions(-) create mode 100644 tests/cypress/.gitignore diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index 8b2edb3e2..e70c48d83 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -1,643 +1,1095 @@ -import {CoreFilterCmpt} from "../../filter/Filter.js"; -import ListNew from './List/New.js'; -import CoreTag from '../../Tag/Tag.js'; +import { CoreFilterCmpt } from "../../filter/Filter.js"; +import ListNew from "./List/New.js"; +import CoreTag from "../../Tag/Tag.js"; import { tagHeaderFilter } from "../../../tabulator/filters/extendedHeaderFilter.js"; -import { addTagInTable, deleteTagInTable, updateTagInTable } from "../../../../js/helpers/TagHelper.js"; +import { + addTagInTable, + deleteTagInTable, + updateTagInTable, +} from "../../../../js/helpers/TagHelper.js"; import { tagFormatter } from "../../../../js/tabulator/formatter/tags.js"; import ApiTag from "../../../api/factory/stv/tag.js"; -import ListFilter from './List/Filter.js'; +import ListFilter from "./List/Filter.js"; -import { capitalize } from '../../../helpers/StringHelpers.js'; +import { capitalize } from "../../../helpers/StringHelpers.js"; -import draggable from '../../../directives/draggable.js'; +import draggable from "../../../directives/draggable.js"; export default { - name: "ListPrestudents", - components: { - CoreFilterCmpt, - ListNew, - CoreTag, - ListFilter - }, - directives: { - draggable - }, - inject: { - lists: { - from: 'lists', - required: true - }, - $reloadList: { - from: '$reloadList', - required: true - }, - currentSemester: { - from: 'currentSemester', - required: true - }, - tagsEnabled: { - from: 'configStvTagsEnabled', - default: false - }, - }, - props: { - selected: Array, - studiengangKz: Number, - studiensemesterKurzbz: String - }, - emits: [ - 'update:selected', - 'filterActive' - ], - data() { - function dateFormatter(cell) - { - let val = cell.getValue(); - if (!val) - return ' '; - let date = new Date(val); - return date.toLocaleDateString('de-AT', { - "day": "2-digit", - "month": "2-digit", - "year": "numeric" - }); - } + name: "ListPrestudents", + components: { + CoreFilterCmpt, + ListNew, + CoreTag, + ListFilter, + }, + directives: { + draggable, + }, + inject: { + lists: { + from: "lists", + required: true, + }, + $reloadList: { + from: "$reloadList", + required: true, + }, + currentSemester: { + from: "currentSemester", + required: true, + }, + tagsEnabled: { + from: "configStvTagsEnabled", + default: false, + }, + }, + props: { + selected: Array, + studiengangKz: Number, + studiensemesterKurzbz: String, + }, + emits: ["update:selected", "filterActive"], + data() { + function dateFormatter(cell) { + let val = cell.getValue(); + if (!val) return " "; + let date = new Date(val); + return date.toLocaleDateString("de-AT", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } - return { - tabulatorOptions: { - columns:[ - {title:"UID", field:"uid", headerFilter: true}, - {title:"TitelPre", field:"titelpre", headerFilter: "list", headerFilterParams: {valuesLookup:true, listOnEmpty:true, autocomplete:true, sort:"asc"}}, - {title:"Nachname", field:"nachname", headerFilter: true}, - {title:"Vorname", field:"vorname", headerFilter: true}, - {title:"Wahlname", field:"wahlname", visible:false, headerFilter: true}, - {title:"Vornamen", field:"vornamen", visible:false, headerFilter: true}, - {title:"TitelPost", field:"titelpost", headerFilter: "list", headerFilterParams: {valuesLookup:true, listOnEmpty:true, autocomplete:true, sort:"asc"}}, - {title:"Ersatzkennzeichen", field:"ersatzkennzeichen", headerFilter: true}, - { - title: "Geburtsdatum", - field: "gebdatum", - formatter: dateFormatter, - headerFilter: true, - headerFilterFunc(headerValue, rowValue) { - const matches = headerValue.match(/^(([0-9]{2})\.)?([0-9]{2})\.([0-9]{4})?$/); - let comparestr = headerValue; - if(matches !== null) { - const year = (matches[4] !== undefined) ? matches[4] : ''; - const month = matches[3]; - const day = (matches[2] !== undefined) ? matches[2] : ''; - comparestr = year + '-' + month + '-' + day; - } - return rowValue.match(comparestr); - } - }, - {title:"Geschlecht", field:"geschlecht", headerFilter: "list", headerFilterParams: {values:{'m':'männlich','w':'weiblich','x':'divers','u':'unbekannt'}, listOnEmpty:true, autocomplete:true}}, - {title:"Sem.", field:"semester_berechnet", headerFilter: "list", headerFilterParams: {valuesLookup:true, listOnEmpty:true, autocomplete:true, sort:"asc"}}, - {title:"Verb.", field:"verband", headerFilter: "list", headerFilterParams: {valuesLookup:true, listOnEmpty:true, autocomplete:true, sort:"asc"}}, - {title:"Grp.", field:"gruppe", headerFilter: "list", headerFilterParams: {valuesLookup:true, listOnEmpty:true, autocomplete:true, sort:"asc"}}, - {title:"Studiengang", field:"studiengang", headerFilter: "list", headerFilterParams: {valuesLookup:true, listOnEmpty:true, autocomplete:true, sort:"asc"}}, - {title:"Studiengang_kz", field:"studiengang_kz", visible:false, headerFilter: true}, - {title:"Personenkennzeichen", field:"matrikelnr", headerFilter: true}, - {title:"PersonID", field:"person_id", headerFilter: true}, - {title:"Status", field:"status", headerFilter: "list", headerFilterParams: {valuesLookup:true, listOnEmpty:true, autocomplete:true, sort:"asc"}}, - {title:"Status Datum", field:"status_datum", visible:false, formatter:dateFormatter}, - {title:"Status Bestaetigung", field:"status_bestaetigung", visible:false, formatter:dateFormatter, headerFilter: true}, - {title:"EMail (Privat)", field:"mail_privat", visible:false, headerFilter: true}, - {title:"EMail (Intern)", field:"mail_intern", visible:false, headerFilter: true}, - {title:"Anmerkungen", field:"anmerkungen", visible:false, headerFilter: true}, - {title:"AnmerkungPre", field:"anmerkung", visible:false, headerFilter: true}, - {title:"OrgForm", field:"orgform_kurzbz", headerFilter: "list", headerFilterParams: {valuesLookup:true, listOnEmpty:true, autocomplete:true, sort:"asc"}}, - {title:"Aufmerksamdurch", field:"aufmerksamdurch_kurzbz", visible:false}, - {title:"Gesamtpunkte", field:"punkte", visible:false}, - {title:"Aufnahmegruppe", field:"aufnahmegruppe_kurzbz", visible:false}, - {title:"Dual", field:"dual", visible:false, - formatter:'tickCross', formatterParams: { - tickElement: '', - crossElement: '' - }, - headerFilter:"tickCross", headerFilterParams: { - "tristate":true, elementAttributes:{"value":"true"} - }, headerFilterEmptyCheck:function(value){return value === null} - }, - {title:"Matrikelnummer", field:"matr_nr", visible:false, headerFilter: true}, - {title:"Studienplan", field:"studienplan_bezeichnung", headerFilter: "list", headerFilterParams: {valuesLookup:true, listOnEmpty:true, autocomplete:true, sort:"asc"}}, - {title:"PreStudentInnenID", field:"prestudent_id", headerFilter: true}, - {title:"Priorität", field:"priorisierung_relativ"}, - {title:"Mentor", field:"mentor", visible:false}, - {title:"Aktiv", field:"bnaktiv", visible:false, - formatter:'tickCross', formatterParams: { - allowEmpty:true, - tickElement: '', - crossElement: '' - }, - headerFilter:"tickCross", headerFilterParams: { - "tristate":true, elementAttributes:{"value":"true"} - }, headerFilterEmptyCheck:function(value){return value === null} - }, - {title:"Unruly", field:"unruly", visible:false}, - ], - rowFormatter(row) { - if (row.getData().bnaktiv === false) { - row.getElement().classList.add('text-black','text-opacity-50','fst-italic'); - } - row.getElement().draggable = true - }, + return { + initialTagData: [], + selectedTagOptions: [], + tabulatorOptions: { + columns: [ + { title: "UID", field: "uid", headerFilter: true }, + { + title: "TitelPre", + field: "titelpre", + headerFilter: "list", + headerFilterParams: { + valuesLookup: true, + listOnEmpty: true, + autocomplete: true, + sort: "asc", + }, + }, + { + title: "Nachname", + field: "nachname", + headerFilter: true, + headerFilter: "list", + headerFilterParams: { + valuesLookup: true, + listOnEmpty: true, + autocomplete: true, + sort: "asc", + }, + }, + { title: "Vorname", field: "vorname", headerFilter: true }, + { + title: "Wahlname", + field: "wahlname", + visible: false, + headerFilter: true, + }, + { + title: "Vornamen", + field: "vornamen", + visible: false, + headerFilter: true, + }, + { + title: "TitelPost", + field: "titelpost", + headerFilter: "list", + headerFilterParams: { + valuesLookup: true, + listOnEmpty: true, + autocomplete: true, + sort: "asc", + }, + }, + { + title: "Ersatzkennzeichen", + field: "ersatzkennzeichen", + headerFilter: true, + }, + { + title: "Geburtsdatum", + field: "gebdatum", + formatter: dateFormatter, + headerFilter: true, + headerFilterFunc(headerValue, rowValue) { + const matches = headerValue.match( + /^(([0-9]{2})\.)?([0-9]{2})\.([0-9]{4})?$/, + ); + let comparestr = headerValue; + if (matches !== null) { + const year = matches[4] !== undefined ? matches[4] : ""; + const month = matches[3]; + const day = matches[2] !== undefined ? matches[2] : ""; + comparestr = year + "-" + month + "-" + day; + } + return rowValue.match(comparestr); + }, + }, + { + title: "Geschlecht", + field: "geschlecht", + headerFilter: "list", + headerFilterParams: { + values: { + m: "männlich", + w: "weiblich", + x: "divers", + u: "unbekannt", + }, + listOnEmpty: true, + autocomplete: true, + }, + }, + { + title: "Sem.", + field: "semester_berechnet", + headerFilter: "list", + headerFilterParams: { + valuesLookup: true, - ajaxRequestFunc: (url, config, params) => { - if( url === '' ) - { - return Promise.resolve({ data: []}); - } - /** - * NOTE(chris): Because of a bug in Tabulator - * we need to get the params from elsewhere. - * @see https://github.com/olifolkerd/tabulator/issues/4318 - */ - const apiconfig = { - ...this.tabulatorOptions.ajaxConfig, - url: this.tabulatorOptions.ajaxURL, - params: this.tabulatorOptions.ajaxParams - }; - return this.$api.call(apiconfig); - }, - ajaxResponse: (url, params, response) => { - return response?.data; - }, + listOnEmpty: true, + autocomplete: true, + sort: "asc", + }, + }, + { + title: "Verb.", + field: "verband", + headerFilter: "list", + headerFilterParams: { + valuesLookup: true, + listOnEmpty: true, + autocomplete: true, + sort: "asc", + }, + }, + { + title: "Grp.", + field: "gruppe", + headerFilter: "list", + headerFilterParams: { + valuesLookup: true, + listOnEmpty: true, + autocomplete: true, + sort: "asc", + }, + }, + { + title: "Studiengang", + field: "studiengang", + headerFilter: "list", + headerFilterParams: { + valuesLookup: true, + listOnEmpty: true, + autocomplete: true, + sort: "asc", + }, + }, + { + title: "Studiengang_kz", + field: "studiengang_kz", + visible: false, + headerFilter: true, + }, + { + title: "Personenkennzeichen", + field: "matrikelnr", + headerFilter: true, + }, + { title: "PersonID", field: "person_id", headerFilter: true }, + { + title: "Status", + field: "status", + headerFilter: "list", + headerFilterParams: { + valuesLookup: true, + listOnEmpty: true, + autocomplete: true, + sort: "asc", + }, + }, + { + title: "Status Datum", + field: "status_datum", + visible: false, + formatter: dateFormatter, + }, + { + title: "Status Bestaetigung", + field: "status_bestaetigung", + visible: false, + formatter: dateFormatter, + headerFilter: true, + }, + { + title: "EMail (Privat)", + field: "mail_privat", + visible: false, + headerFilter: true, + }, + { + title: "EMail (Intern)", + field: "mail_intern", + visible: false, + headerFilter: true, + }, + { + title: "Anmerkungen", + field: "anmerkungen", + visible: false, + headerFilter: true, + }, + { + title: "AnmerkungPre", + field: "anmerkung", + visible: false, + headerFilter: true, + }, + { + title: "OrgForm", + field: "orgform_kurzbz", + headerFilter: "list", + headerFilterParams: { + valuesLookup: true, + listOnEmpty: true, + autocomplete: true, + sort: "asc", + }, + }, + { + title: "Aufmerksamdurch", + field: "aufmerksamdurch_kurzbz", + visible: false, + }, + { title: "Gesamtpunkte", field: "punkte", visible: false }, + { + title: "Aufnahmegruppe", + field: "aufnahmegruppe_kurzbz", + visible: false, + }, + { + title: "Dual", + field: "dual", + visible: false, + formatter: "tickCross", + formatterParams: { + tickElement: '', + crossElement: '', + }, + headerFilter: "tickCross", + headerFilterParams: { + tristate: true, + elementAttributes: { value: "true" }, + }, + headerFilterEmptyCheck: function (value) { + return value === null; + }, + }, + { + title: "Matrikelnummer", + field: "matr_nr", + visible: false, + headerFilter: true, + }, + { + title: "Studienplan", + field: "studienplan_bezeichnung", + headerFilter: "list", + headerFilterParams: { + valuesLookup: true, + listOnEmpty: true, + autocomplete: true, + sort: "asc", + }, + }, + { + title: "PreStudentInnenID", + field: "prestudent_id", + headerFilter: true, + }, + { title: "Priorität", field: "priorisierung_relativ" }, + { title: "Mentor", field: "mentor", visible: false }, + { + title: "Aktiv", + field: "bnaktiv", + visible: false, + formatter: "tickCross", + formatterParams: { + allowEmpty: true, + tickElement: '', + crossElement: '', + }, + headerFilter: "tickCross", + headerFilterParams: { + tristate: true, + elementAttributes: { value: "true" }, + }, + headerFilterEmptyCheck: function (value) { + return value === null; + }, + }, + { title: "Unruly", field: "unruly", visible: false }, + ], + rowFormatter(row) { + if (row.getData().bnaktiv === false) { + row + .getElement() + .classList.add("text-black", "text-opacity-50", "fst-italic"); + } + row.getElement().draggable = true; + }, - layout: 'fitDataStretch', - layoutColumnsOnNewData: false, - height: '100%', - selectableRows: true, - selectableRowsRangeMode: 'click', - index: 'prestudent_id', - persistenceID: 'stv-list-20260223_01' - }, - tabulatorEvents: [ - { - event: 'rowSelectionChanged', - handler: this.rowSelectionChanged - }, - { - event: 'dataLoading', - handler: this.handleDataLoading - }, - { - event: 'renderComplete', - handler: this.handleRenderComplete - }, - { - event: 'dataProcessed', - handler: (data) => { - this.getAllRows() - this.autoSelectRows(data) - } - }, - { - event: 'dataLoaded', - handler: data => { - if (Array.isArray(data)) { - this.count = data.length; - this.allPrestudents = data.map(item => item.prestudent_id); - } else { - this.count = 0; - this.allPrestudents = []; - } - } - }, - { - event: 'dataFiltered', - handler: (filters, rows) => this.filteredcount = rows.length - }, - { - event: 'rowClick', - handler: this.handleRowClick // TODO(chris): this should be in the filter component - }, - { - event: 'dataTreeRowExpanded', - handler: (data) => { - this.getExpandedRows() - } - }, - { - event: 'dataTreeRowCollapsed', - handler: (data) => { - this.getExpandedRows() - } - }, - { - event: 'rowMouseDown', - handler: this.handleMouseDown - } - ], - focusObj: null, // TODO(chris): this should be in the filter component - lastSelected: null, - filter: [], - count: 0, - filteredcount: 0, - selectedcount: 0, - //tags - expanded: [], - selectedColumnValues: [], - tagEndpoint: ApiTag, - currentEndpoint: null, - headerFilterActive: false, - dragSource: [], - oldScrollUrl: '', - oldScrollLeft: 0, - oldScrollTop: 0, - allPrestudents: [] - } - }, - computed: { - countsToHTML: function() { - return this.$p.t('global/ausgewaehlt') - + ': ' + (this.selectedcount || 0) + '' - + ' | ' - + this.$p.t('global/gefiltert') - + ': ' - + '' + (this.filteredcount || 0) + '' - + ' | ' - + this.$p.t('global/gesamt') - + ': ' + (this.count || 0) + ''; - }, - selectedDragObject() { - let items = this.dragSource?.length ? this.dragSource : this.selected; + ajaxRequestFunc: (url, config, params) => { + if (url === "") { + return Promise.resolve({ data: [] }); + } + /** + * NOTE(chris): Because of a bug in Tabulator + * we need to get the params from elsewhere. + * @see https://github.com/olifolkerd/tabulator/issues/4318 + */ + const apiconfig = { + ...this.tabulatorOptions.ajaxConfig, + url: this.tabulatorOptions.ajaxURL, + params: this.tabulatorOptions.ajaxParams, + }; + return this.$api.call(apiconfig); + }, + ajaxResponse: (url, params, response) => { + return response?.data; + }, - return items.map(item => { - let type, id; - if (item.uid) { - type = 'student'; - id = item.uid; - } else if (item.prestudent_id) { - type = 'prestudent'; - id = item.prestudent_id; - } else if (item.person_id) { - type = 'person'; - id = item.person_id; - } - return { - ...item, - type, - id - }; - }); - }, - //TODO(Manu) check: replace download or additional entry? - downloadConfig() { - return { - csv: { - formatter: 'csv', - file: this.fileString, - options: { - delimiter: ';', - bom: true, - } - } - }; - }, - fileString() { - let today = new Date().toLocaleDateString('en-GB') - .replace(/\//g, '_'); - return "StudentList_" + today + ".csv"; - }, - selectedPrestudents() { - if (this.selected && this.selected.length > 0) { - return this.selected.map(item => item.prestudent_id); - } else { - // fallback whole list of prestudents - return this.allPrestudents || []; - } - }, + layout: "fitDataStretch", + layoutColumnsOnNewData: false, + height: "100%", + selectableRows: true, + selectableRowsRangeMode: "click", + index: "prestudent_id", + persistence: false, + }, + tabulatorEvents: [ + { + event: "rowSelectionChanged", + handler: this.rowSelectionChanged, + }, + { + event: "dataLoading", + handler: this.handleDataLoading, + }, + { + event: "renderComplete", + handler: this.handleRenderComplete, + }, + { + event: "dataProcessed", + handler: (data) => { + console.log("dataProcessed", data); + this.getAllRows(); + this.autoSelectRows(data); + }, + }, + { + event: "dataLoaded", + handler: (data) => { + console.log("dataLoaded", data); + if (Array.isArray(data)) { + this.count = data.length; + this.allPrestudents = data.map((item) => item.prestudent_id); + } else { + this.count = 0; + this.allPrestudents = []; + } + }, + }, + { + event: "dataFiltered", + handler: (filters, rows) => { + console.log("dataFiltered", filters, rows); + this.filteredcount = rows.length; + }, + }, + { + event: "rowClick", + handler: this.handleRowClick, // TODO(chris): this should be in the filter component + }, + { + event: "dataTreeRowExpanded", + handler: (data) => { + console.log("dataTreeRowExpanded", data); + this.getExpandedRows(); + }, + }, + { + event: "dataTreeRowCollapsed", + handler: (data) => { + console.log("dataTreeRowCollapsed", data); + this.getExpandedRows(); + }, + }, + { + event: "rowMouseDown", + handler: this.handleMouseDown, + }, + { + event: "columnWidth", + handler: (column) => { + console.log("columnWidth", column); + if (column.getField() !== "tags") return; - linkXLS(){ - return FHC_JS_DATA_STORAGE_OBJECT.app_root - + 'content/statistik/studentenexportextended.xls.php?' - + '&studiensemester_kurzbz=' + this.currentSemester - + '&data=' + this.selectedPrestudents.join(";"); - }, - }, - created: function() { + column.getCells().forEach((cell) => { + cell.getElement().firstElementChild?.fitTags?.(); + }); + }, + }, + ], + focusObj: null, // TODO(chris): this should be in the filter component + lastSelected: null, + filter: [], + count: 0, + filteredcount: 0, + selectedcount: 0, + //tags + expanded: [], + selectedColumnValues: [], + tagEndpoint: ApiTag, + currentEndpoint: null, + headerFilterActive: false, + dragSource: [], + oldScrollUrl: "", + oldScrollLeft: 0, + oldScrollTop: 0, + allPrestudents: [], + }; + }, + computed: { + countsToHTML: function () { + return ( + this.$p.t("global/ausgewaehlt") + + ": " + + (this.selectedcount || 0) + + "" + + " | " + + this.$p.t("global/gefiltert") + + ": " + + "" + + (this.filteredcount || 0) + + "" + + " | " + + this.$p.t("global/gesamt") + + ": " + + (this.count || 0) + + "" + ); + }, + selectedDragObject() { + let items = this.dragSource?.length ? this.dragSource : this.selected; - if(this.tagsEnabled) { - const coltags = { - title: 'Tags', - field: 'tags', - tooltip: false, - headerFilter: "input", - headerFilterFunc: tagHeaderFilter, - headerFilterFuncParams: {field: 'tags'}, - formatter: (cell) => tagFormatter(cell, this.$refs.tagComponent), - width: 150, - }; - this.tabulatorOptions.columns.splice(2, 0, coltags); - } - }, - watch: { - '$p.user_language.value'(n, o) { - if (n !== o && o !== undefined && this.$refs.table.tableBuilt) { - this.translateTabulator(); - } - }, - }, - methods: { - translateTabulator() { - this.$p - .loadCategory(['global', 'person', 'lehre', 'ui', 'profilUpdate', 'admission', 'stv']) - .then(() => { - const translations = { - uid: capitalize(this.$p.t('person/uid')), - titelpre: capitalize(this.$p.t('person/titelpre')), - nachname: capitalize(this.$p.t('person/nachname')), - vorname: capitalize(this.$p.t('person/vorname')), - wahlname: capitalize(this.$p.t('person/wahlname')), - vornamen: capitalize(this.$p.t('person/vornamen')), - titelpost: capitalize(this.$p.t('person/titelpost')), - ersatzkennzeichen: capitalize(this.$p.t('person/ersatzkennzeichen')), - gebdatum: capitalize(this.$p.t('person/geburtsdatum')), - geschlecht: capitalize(this.$p.t('person/geschlecht')), - semester_berechnet: capitalize(this.$p.t('lehre/sem')), - verband: capitalize(this.$p.t('lehre/verb')), - gruppe: capitalize(this.$p.t('lehre/grp')), - studiengang: capitalize(this.$p.t('lehre/studiengang')), - studiengang_kz: capitalize(this.$p.t('lehre/studiengang_kz')), - matrikelnr: capitalize(this.$p.t('person/personenkennzeichen')), - person_id: capitalize(this.$p.t('person/person_id')), - status: capitalize(this.$p.t('global/status')), - status_datum: capitalize(this.$p.t('profilUpdate/statusDate')), - status_bestaetigung: capitalize(this.$p.t('global/status_bestaetigung')), - mail_privat: capitalize(this.$p.t('person/email_private')), - mail_intern: capitalize(this.$p.t('person/email_intern')), - anmerkungen: capitalize(this.$p.t('stv/notes_person')), - anmerkung: capitalize(this.$p.t('stv/notes_prestudent')), - orgform_kurzbz: capitalize(this.$p.t('lehre/orgform')), - aufmerksamdurch_kurzbz: capitalize(this.$p.t('person/aufmerksamDurch')), - punkte: capitalize(this.$p.t('admission/gesamtpunkte')), - aufnahmegruppe_kurzbz: capitalize(this.$p.t('stv/aufnahmegruppe_kurzbz')), - dual: capitalize(this.$p.t('lehre/dual_short')), - matr_nr: capitalize(this.$p.t('person/matrikelnummer')), - studienplan_bezeichnung: capitalize(this.$p.t('lehre/studienplan')), - prestudent_id: capitalize(this.$p.t('ui/prestudent_id')), - priorisierung_relativ: capitalize(this.$p.t('lehre/prioritaet')), - mentor: capitalize(this.$p.t('stv/mentor')), - bnaktiv: capitalize(this.$p.t('person/aktiv')) - }; + return items.map((item) => { + let type, id; + if (item.uid) { + type = "student"; + id = item.uid; + } else if (item.prestudent_id) { + type = "prestudent"; + id = item.prestudent_id; + } else if (item.person_id) { + type = "person"; + id = item.person_id; + } + return { + ...item, + type, + id, + }; + }); + }, + //TODO(Manu) check: replace download or additional entry? + downloadConfig() { + return { + csv: { + formatter: "csv", + file: this.fileString, + options: { + delimiter: ";", + bom: true, + }, + }, + }; + }, + fileString() { + let today = new Date().toLocaleDateString("en-GB").replace(/\//g, "_"); + return "StudentList_" + today + ".csv"; + }, + selectedPrestudents() { + if (this.selected && this.selected.length > 0) { + return this.selected.map((item) => item.prestudent_id); + } else { + // fallback whole list of prestudents + return this.allPrestudents || []; + } + }, - /** NOTE(chris): - * use this approach because updateDefinition - * on the Tabulator columns is way slower and - * freezes up the GUI. - */ - // Overwrite definition for column show/hide - this.$refs.table.tabulator.getColumns().forEach(col => { - const trans = translations[col.getField()]; - if (!trans) - return; - col.getDefinition().title = trans; - }); - // Overwrite node in dom - this.$refs.table.tabulator.element - .querySelectorAll('.tabulator-col[tabulator-field]') - .forEach(el => { - const field = el.getAttribute('tabulator-field'); - if (!translations[field]) - return; + linkXLS() { + return ( + FHC_JS_DATA_STORAGE_OBJECT.app_root + + "content/statistik/studentenexportextended.xls.php?" + + "&studiensemester_kurzbz=" + + this.currentSemester + + "&data=" + + this.selectedPrestudents.join(";") + ); + }, + }, + created: function () { + if (this.tagsEnabled) { + const coltags = { + title: "Tags", + field: "tags", + tooltip: false, + headerFilter: "list", + headerFilterParams: { + valuesLookup: () => { + console.log("headerFilterParams.valuesLookup called"); + const options = new Map(); - const title = el.querySelector('.tabulator-col-title'); - if (!title) - return; + this.$refs.table.tabulator.getData("active").forEach((row) => { + let tags = row.tags; - title.innerText = translations[field]; - }); - }); - }, - reload() { - this.$refs.table.reloadTable(); - }, - actionNewPrestudent() { - this.$refs.new.open(); - }, - rowSelectionChanged(data, rows, selected, deselected) { - this.selectedcount = data.length; + if (typeof tags === "string") { + try { + tags = JSON.parse(tags); + } catch { + return; + } + } - if(selected.length > 0 || deselected.length > 0){ - this.lastSelected = this.selected; + if (!Array.isArray(tags)) return; - //for tags - this.selectedRows = this.$refs.table.tabulator.getSelectedRows(); - this.selectedColumnValues = this.selectedRows.filter( - row => row.getData().prestudent_id !== undefined - && row.getData().prestudent_id - ).map( - row => row.getData().prestudent_id - ); + tags + .filter((tag) => tag && tag.done !== true) + .forEach((tag) => { + options.set(tag.beschreibung, { + label: tag.beschreibung, + value: tag.beschreibung, + style: tag.style, + }); + }); + }); - this.$emit('update:selected', data); - } - }, - autoSelectRows(data) { - if (Array.isArray(this.lastSelected) && this.lastSelected.length){ - // NOTE(chris): reselect rows on refresh - let selected = this.lastSelected.map(el => this.$refs.table.tabulator.getRow(el.prestudent_id)) - // TODO(chris): unselect current item if it's no longer in the table? - // or maybe reselect only the last one? - selected = selected.filter(el => el); + return [ + { + isHeader: true, + label: "test", + subOptions: [...options.values()], + }, + ]; + }, + itemFormatter: (value, label, item) => { + console.log("value, label, item", value, label, item); + if (!item.isHeader) return ""; - if (selected.length) - this.$refs.table.tabulator.selectRow(selected); - } else if(data && this.lastSelected === undefined) { - // NOTE(chris): select row if it's the only one (preferably only on startup) - if (data.length == 1) { - this.$refs.table.tabulator.selectRow(this.$refs.table.tabulator.getRows()); - } - } - }, - updateFilter(filter) { - this.filter = filter; - this.$emit('filterActive', filter); - this.updateUrl(); - }, - updateUrl(endpoint, first) { - this.lastSelected = first ? undefined : this.selected; + let table = document.createElement("table"); + table.className = "table table-sm mb-0"; + table.appendChild(document.createElement("thead")); + table + .querySelector("thead") + .appendChild(document.createElement("tr")); -/* console.log('function param endpoint: ' + JSON.stringify(endpoint)); + let firstHeading = document.createElement("th"); + firstHeading.innerHTML = "" + value + ""; + table.querySelector("tr").appendChild(firstHeading); + + let secondHeading = document.createElement("th"); + secondHeading.innerHTML = "AND"; + table.querySelector("tr").appendChild(secondHeading); + + let thirdHeading = document.createElement("th"); + thirdHeading.innerHTML = "OR"; + table.querySelector("tr").appendChild(thirdHeading); + + let fourthHeading = document.createElement("th"); + fourthHeading.innerHTML = "NOT"; + table.querySelector("tr").appendChild(fourthHeading); + + table.appendChild(document.createElement("tbody")); + + item.subOptions.forEach((option) => { + let row = document.createElement("tr"); + + let firstColumn = document.createElement("td"); + + let optionLabel = document.createElement("span"); + optionLabel.className = "tag " + option.style; + optionLabel.innerText = option.label; + firstColumn.appendChild(optionLabel); + row.appendChild(firstColumn); + + let secondColumn = document.createElement("td"); + let firstCheckbox = document.createElement("input"); + firstCheckbox.type = "checkbox"; + firstCheckbox.label = "test"; + firstCheckbox.style.cursor = "pointer"; + firstCheckbox.checked = this.selectedTagOptions.some( + (tag) => tag.value === option.value && tag.connector === "AND", + ); + firstCheckbox.addEventListener("click", (event) => { + event.stopPropagation(); + if (event.target.checked) { + this.selectedTagOptions.push({ + label: option.label, + value: option.value, + connector: "AND", + }); + } else { + this.selectedTagOptions = this.selectedTagOptions.filter( + (tag) => + tag.value !== option.value || tag.connector !== "AND", + ); + } + console.log("selectedTagOptions", this.selectedTagOptions); + }); + secondColumn.appendChild(firstCheckbox); + row.appendChild(secondColumn); + + let thirdColumn = document.createElement("td"); + let secondCheckbox = document.createElement("input"); + secondCheckbox.type = "checkbox"; + secondCheckbox.label = "test"; + secondCheckbox.style.cursor = "pointer"; + secondCheckbox.checked = this.selectedTagOptions.some( + (tag) => tag.value === option.value && tag.connector === "OR", + ); + secondCheckbox.addEventListener("click", (event) => { + event.stopPropagation(); + if (event.target.checked) { + this.selectedTagOptions.push({ + label: option.label, + value: option.value, + connector: "OR", + }); + } else { + this.selectedTagOptions = this.selectedTagOptions.filter( + (tag) => + tag.value !== option.value || tag.connector !== "OR", + ); + } + }); + thirdColumn.appendChild(secondCheckbox); + row.appendChild(thirdColumn); + + let fourthColumn = document.createElement("td"); + let thirdCheckbox = document.createElement("input"); + thirdCheckbox.type = "checkbox"; + thirdCheckbox.label = "test"; + thirdCheckbox.style.cursor = "pointer"; + thirdCheckbox.checked = this.selectedTagOptions.some( + (tag) => tag.value === option.value && tag.connector === "NOT", + ); + thirdCheckbox.addEventListener("click", (event) => { + event.stopPropagation(); + console.log(event.target.checked); + if (event.target.checked) { + this.selectedTagOptions.push({ + label: option.label, + value: option.value, + connector: "NOT", + }); + } else { + this.selectedTagOptions = this.selectedTagOptions.filter( + (tag) => + tag.value !== option.value || tag.connector !== "NOT", + ); + } + }); + fourthColumn.appendChild(thirdCheckbox); + row.appendChild(fourthColumn); + + row.addEventListener("click", (event) => { + event.stopPropagation(); + }); + + table.querySelector("tbody").appendChild(row); + }); + return table; + }, + listOnEmpty: true, + autocomplete: true, + sort: "asc", + }, + headerFilterFunc: tagHeaderFilter, + headerFilterFuncParams: { field: "tags" }, + formatter: (cell, formatterParams, onRendered) => + tagFormatter(cell, this.$refs.tagComponent, onRendered), + width: 150, + }; + this.tabulatorOptions.columns.splice(2, 0, coltags); + } + }, + watch: { + "$p.user_language.value"(n, o) { + if (n !== o && o !== undefined && this.$refs.table.tableBuilt) { + this.translateTabulator(); + } + }, + selectedTagOptions: { + handler() { + let combinedFilterStatement = null; + + this.selectedTagOptions.forEach((tagOption) => { + const { value, connector } = tagOption; + + let mappedConnector = connector === "AND" ? "&&" : connector === "OR" ? "||" : "!"; + if (connector === "NOT") { + combinedFilterStatement = combinedFilterStatement + ? `${combinedFilterStatement} && ${mappedConnector + value}` + : value; + } + else { + combinedFilterStatement = combinedFilterStatement + ? `${combinedFilterStatement} ${mappedConnector} ${value}` + : value; + } + }); + + this.$refs.table.tabulator.setHeaderFilterValue( + "tags", + combinedFilterStatement, + ); + this.$emit("filterActive", this.selectedTagOptions.length > 0); + console.log("selectedTagOptions changed:"); + console.log(combinedFilterStatement); + }, + deep: true, + }, + }, + methods: { + translateTabulator() { + this.$p + .loadCategory([ + "global", + "person", + "lehre", + "ui", + "profilUpdate", + "admission", + "stv", + ]) + .then(() => { + const translations = { + uid: capitalize(this.$p.t("person/uid")), + titelpre: capitalize(this.$p.t("person/titelpre")), + nachname: capitalize(this.$p.t("person/nachname")), + vorname: capitalize(this.$p.t("person/vorname")), + wahlname: capitalize(this.$p.t("person/wahlname")), + vornamen: capitalize(this.$p.t("person/vornamen")), + titelpost: capitalize(this.$p.t("person/titelpost")), + ersatzkennzeichen: capitalize( + this.$p.t("person/ersatzkennzeichen"), + ), + gebdatum: capitalize(this.$p.t("person/geburtsdatum")), + geschlecht: capitalize(this.$p.t("person/geschlecht")), + semester_berechnet: capitalize(this.$p.t("lehre/sem")), + verband: capitalize(this.$p.t("lehre/verb")), + gruppe: capitalize(this.$p.t("lehre/grp")), + studiengang: capitalize(this.$p.t("lehre/studiengang")), + studiengang_kz: capitalize(this.$p.t("lehre/studiengang_kz")), + matrikelnr: capitalize(this.$p.t("person/personenkennzeichen")), + person_id: capitalize(this.$p.t("person/person_id")), + status: capitalize(this.$p.t("global/status")), + status_datum: capitalize(this.$p.t("profilUpdate/statusDate")), + status_bestaetigung: capitalize( + this.$p.t("global/status_bestaetigung"), + ), + mail_privat: capitalize(this.$p.t("person/email_private")), + mail_intern: capitalize(this.$p.t("person/email_intern")), + anmerkungen: capitalize(this.$p.t("stv/notes_person")), + anmerkung: capitalize(this.$p.t("stv/notes_prestudent")), + orgform_kurzbz: capitalize(this.$p.t("lehre/orgform")), + aufmerksamdurch_kurzbz: capitalize( + this.$p.t("person/aufmerksamDurch"), + ), + punkte: capitalize(this.$p.t("admission/gesamtpunkte")), + aufnahmegruppe_kurzbz: capitalize( + this.$p.t("stv/aufnahmegruppe_kurzbz"), + ), + dual: capitalize(this.$p.t("lehre/dual_short")), + matr_nr: capitalize(this.$p.t("person/matrikelnummer")), + studienplan_bezeichnung: capitalize(this.$p.t("lehre/studienplan")), + prestudent_id: capitalize(this.$p.t("ui/prestudent_id")), + priorisierung_relativ: capitalize(this.$p.t("lehre/prioritaet")), + mentor: capitalize(this.$p.t("stv/mentor")), + bnaktiv: capitalize(this.$p.t("person/aktiv")), + }; + + /** NOTE(chris): + * use this approach because updateDefinition + * on the Tabulator columns is way slower and + * freezes up the GUI. + */ + // Overwrite definition for column show/hide + this.$refs.table.tabulator.getColumns().forEach((col) => { + const trans = translations[col.getField()]; + if (!trans) return; + col.getDefinition().title = trans; + }); + // Overwrite node in dom + this.$refs.table.tabulator.element + .querySelectorAll(".tabulator-col[tabulator-field]") + .forEach((el) => { + const field = el.getAttribute("tabulator-field"); + if (!translations[field]) return; + + const title = el.querySelector(".tabulator-col-title"); + if (!title) return; + + title.innerText = translations[field]; + }); + }); + }, + reload() { + this.$refs.table.reloadTable(); + }, + actionNewPrestudent() { + this.$refs.new.open(); + }, + rowSelectionChanged(data, rows, selected, deselected) { + console.log("rowSelectionChanged", data, rows, selected, deselected); + this.selectedcount = data.length; + + if (selected.length > 0 || deselected.length > 0) { + this.lastSelected = this.selected; + + //for tags + this.selectedRows = this.$refs.table.tabulator.getSelectedRows(); + this.selectedColumnValues = this.selectedRows + .filter( + (row) => + row.getData().prestudent_id !== undefined && + row.getData().prestudent_id, + ) + .map((row) => row.getData().prestudent_id); + + this.$emit("update:selected", data); + } + }, + autoSelectRows(data) { + if (Array.isArray(this.lastSelected) && this.lastSelected.length) { + // NOTE(chris): reselect rows on refresh + let selected = this.lastSelected.map((el) => + this.$refs.table.tabulator.getRow(el.prestudent_id), + ); + // TODO(chris): unselect current item if it's no longer in the table? + // or maybe reselect only the last one? + selected = selected.filter((el) => el); + + if (selected.length) this.$refs.table.tabulator.selectRow(selected); + } else if (data && this.lastSelected === undefined) { + // NOTE(chris): select row if it's the only one (preferably only on startup) + if (data.length == 1) { + this.$refs.table.tabulator.selectRow( + this.$refs.table.tabulator.getRows(), + ); + } + } + }, + updateFilter(filter) { + this.filter = filter; + this.$emit("filterActive", filter); + this.updateUrl(); + }, + updateUrl(endpoint, first) { + this.lastSelected = first ? undefined : this.selected; + + /* console.log('function param endpoint: ' + JSON.stringify(endpoint)); console.log('current endpoint: ' + JSON.stringify(this.currentEndpoint));*/ - if( endpoint === undefined && this.currentEndpoint === null) - { - endpoint = { url: '' }; - } - else if( endpoint === undefined ) - { - endpoint = JSON.parse(JSON.stringify(this.currentEndpoint)); - } - else - { - this.currentEndpoint = JSON.parse(JSON.stringify(endpoint)); - } + if (endpoint === undefined && this.currentEndpoint === null) { + endpoint = { url: "" }; + } else if (endpoint === undefined) { + endpoint = JSON.parse(JSON.stringify(this.currentEndpoint)); + } else { + this.currentEndpoint = JSON.parse(JSON.stringify(endpoint)); + } - endpoint.url = endpoint.url.replace( - 'CURRENT_SEMESTER', - encodeURIComponent(this.currentSemester) - ); + endpoint.url = endpoint.url.replace( + "CURRENT_SEMESTER", + encodeURIComponent(this.currentSemester), + ); - const params = (endpoint?.params !== undefined) ? endpoint.params : {}; - let method = (endpoint?.method !== undefined) ? endpoint.method : 'get'; - if (this.filter.length && !endpoint.url.match(/\/search\//)) - { - params.filter = this.filter; - method = 'post'; - } + const params = endpoint?.params !== undefined ? endpoint.params : {}; + let method = endpoint?.method !== undefined ? endpoint.method : "get"; + if (this.filter.length && !endpoint.url.match(/\/search\//)) { + params.filter = this.filter; + method = "post"; + } - this.tabulatorOptions.ajaxURL = endpoint.url; - this.tabulatorOptions.ajaxParams = { ...params }; - this.tabulatorOptions.ajaxConfig = {method}; + this.tabulatorOptions.ajaxURL = endpoint.url; + this.tabulatorOptions.ajaxParams = { ...params }; + this.tabulatorOptions.ajaxConfig = { method }; - if (!this.$refs.table.tableBuilt) { - if (this.$refs.table.tabulator) { - this.$refs.table.tabulator.on("tableBuilt", () => { - this.$refs.table.tabulator.setData(endpoint.url, params, method); - }); - } - } else - this.$refs.table.tabulator.setData(endpoint.url, params, method); - }, - dragCleanup(evt) { - this.dragSource = []; - if (evt.dataTransfer.dropEffect == 'none') - return; // aborted or wrong target - - this.$reloadList(); - }, - onKeydown(e) { // TODO(chris): this should be in the filter component - if (!this.focusObj) - return; + if (!this.$refs.table.tableBuilt) { + if (this.$refs.table.tabulator) { + this.$refs.table.tabulator.on("tableBuilt", () => { + this.$refs.table.tabulator.setData(endpoint.url, params, method); + }); + } + } else this.$refs.table.tabulator.setData(endpoint.url, params, method); + }, + dragCleanup(evt) { + this.dragSource = []; + if (evt.dataTransfer.dropEffect == "none") return; // aborted or wrong target - // Ignore typing inside editable elements - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - e.target.isContentEditable - ) - return; + this.$reloadList(); + }, + onKeydown(e) { + // TODO(chris): this should be in the filter component + if (!this.focusObj) return; - var next; - switch (e.code) { - case 'Enter': - case 'Space': - e.preventDefault(); - var e2 = new Event('click', e); - e2.altKey = e.altKey; - e2.ctrlKey = e.ctrlKey; - e2.shiftKey = e.shiftKey; - this.focusObj.dispatchEvent(e2); - //row.component.toggleSelect(); - break; - case 'ArrowUp': - e.preventDefault(); - next = this.focusObj.previousElementSibling; - if (next) - this.changeFocus(this.focusObj, next); - break; - case 'ArrowDown': - e.preventDefault(); - next = this.focusObj.nextElementSibling; - if (next) - this.changeFocus(this.focusObj, next); - break; - } - }, - changeFocus(a, b) { // TODO(chris): this should be in the filter component - if (b) { - b.tabIndex = 0; - this.focusObj = b; - b.focus(); - } else { - this.focusObj = null; - } - a.tabIndex = -1; - return this.focusObj; - }, - onFocus(e) { // TODO(chris): this should be in the filter component - if (!this.focusObj) { - var container, target; - if (e.target.classList.contains('tabulator-container')) { - container = e.target; - target = container.querySelector('.tabulator-row'); - } else if (e.target.classList.contains('tabulator-row')) { - container = e.target.closest('.tabulator-container'); - target = e.target; - } - if (container && target) { - this.changeFocus(container, target); - } - } - }, - handleRowClick(e, row) { // TODO(chris): this should be in the filter component - if (this.focusObj) { - let el = row.getElement(); - if (el != this.focusObj) - this.changeFocus(this.focusObj, el); - } - }, - clearSelection(){ - this.lastSelected = []; - this.$emit('update:selected',[]); - }, - //methods tags - addedTag(addedTag) - { - addTagInTable(addedTag, this.allRows, 'prestudent_id') - }, - deletedTag(deletedTag) - { - deleteTagInTable(deletedTag, this.allRows); - }, - updatedTag(updatedTag) - { - updateTagInTable(updatedTag, this.allRows) - }, - getAllRows() { - this.allRows = this.$refs.table.tabulator.getRows(); - }, - resetFilter(){ - this.$refs.listfilter.resetFilter(); - this.$refs.table.clearFilters(); - }, - handleHeaderFilter(filterActive){ - this.headerFilterActive = filterActive; - }, - handleMouseDown(e, row) - { - let data = row.getData(); - let id = data.uid ?? data.prestudent_id ?? data.person_id; + // Ignore typing inside editable elements + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target.isContentEditable + ) + return; - const isAlreadySelected = this.selected?.some(row => (row.uid ?? row.prestudent_id ?? row.person_id) === id); + var next; + switch (e.code) { + case "Enter": + case "Space": + e.preventDefault(); + var e2 = new Event("click", e); + e2.altKey = e.altKey; + e2.ctrlKey = e.ctrlKey; + e2.shiftKey = e.shiftKey; + this.focusObj.dispatchEvent(e2); + //row.component.toggleSelect(); + break; + case "ArrowUp": + e.preventDefault(); + next = this.focusObj.previousElementSibling; + if (next) this.changeFocus(this.focusObj, next); + break; + case "ArrowDown": + e.preventDefault(); + next = this.focusObj.nextElementSibling; + if (next) this.changeFocus(this.focusObj, next); + break; + } + }, + changeFocus(a, b) { + // TODO(chris): this should be in the filter component + if (b) { + b.tabIndex = 0; + this.focusObj = b; + b.focus(); + } else { + this.focusObj = null; + } + a.tabIndex = -1; + return this.focusObj; + }, + onFocus(e) { + // TODO(chris): this should be in the filter component + if (!this.focusObj) { + var container, target; + if (e.target.classList.contains("tabulator-container")) { + container = e.target; + target = container.querySelector(".tabulator-row"); + } else if (e.target.classList.contains("tabulator-row")) { + container = e.target.closest(".tabulator-container"); + target = e.target; + } + if (container && target) { + this.changeFocus(container, target); + } + } + }, + handleRowClick(e, row) { + console.log("handleRowClick", e, row); + // TODO(chris): this should be in the filter component + if (this.focusObj) { + let el = row.getElement(); + if (el != this.focusObj) this.changeFocus(this.focusObj, el); + } + }, + clearSelection() { + this.lastSelected = []; + this.$emit("update:selected", []); + }, + //methods tags + addedTag(addedTag) { + addTagInTable(addedTag, this.allRows, "prestudent_id"); + }, + deletedTag(deletedTag) { + deleteTagInTable(deletedTag, this.allRows); + }, + updatedTag(updatedTag) { + updateTagInTable(updatedTag, this.allRows); + }, + getAllRows() { + this.allRows = this.$refs.table.tabulator.getRows(); + }, + resetFilter() { + this.$refs.listfilter.resetFilter(); + this.$refs.table.clearFilters(); + }, + handleHeaderFilter(filterActive) { + this.headerFilterActive = filterActive; + }, + handleMouseDown(e, row) { + console.log("handleMouseDown", e, row); + let data = row.getData(); + let id = data.uid ?? data.prestudent_id ?? data.person_id; - this.dragSource = (isAlreadySelected && this.selected?.length) ? this.selected : [data]; - }, - handleDataLoading() { - this.oldScrollLeft = this.$refs.table.tabulator.rowManager.scrollLeft; - this.oldScrollTop = this.$refs.table.tabulator.rowManager.scrollTop; - }, - handleRenderComplete() { - const table = this.$refs.table.tabulator.element.querySelector('.tabulator-tableholder'); - if(table) { - const curAjaxUrl = this.$refs.table.tabulator.getAjaxUrl(); - if(this.oldScrollUrl === curAjaxUrl) { - table.scrollLeft = this.oldScrollLeft; - table.scrollTop = this.oldScrollTop; - } else { - this.oldScrollLeft = table.scrollLeft; - this.oldScrollTop = table.scrollTop; - } - this.oldScrollUrl = this.$refs.table.tabulator.getAjaxUrl(); - } - }, - }, - // TODO(chris): focusin, focusout, keydown and tabindex should be in the filter component - // TODO(chris): filter component column chooser has no accessibilty features - template: ` + const isAlreadySelected = this.selected?.some( + (row) => (row.uid ?? row.prestudent_id ?? row.person_id) === id, + ); + + this.dragSource = + isAlreadySelected && this.selected?.length ? this.selected : [data]; + }, + handleDataLoading() { + console.log("handleDataLoading"); + this.oldScrollLeft = this.$refs.table.tabulator.rowManager.scrollLeft; + this.oldScrollTop = this.$refs.table.tabulator.rowManager.scrollTop; + }, + handleRenderComplete() { + console.log("handleRenderComplete"); + const table = this.$refs.table.tabulator.element.querySelector( + ".tabulator-tableholder", + ); + if (table) { + const curAjaxUrl = this.$refs.table.tabulator.getAjaxUrl(); + if (this.oldScrollUrl === curAjaxUrl) { + table.scrollLeft = this.oldScrollLeft; + table.scrollTop = this.oldScrollTop; + } else { + this.oldScrollLeft = table.scrollLeft; + this.oldScrollTop = table.scrollTop; + } + this.oldScrollUrl = this.$refs.table.tabulator.getAjaxUrl(); + } + }, + }, + // TODO(chris): focusin, focusout, keydown and tabindex should be in the filter component + // TODO(chris): filter component column chooser has no accessibilty features + template: `
-
` + `, }; diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index 35b66dc1c..148ddb7fa 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -132,9 +132,9 @@ export function extendedHeaderFilter(headerValue, rowValue, rowData, filterParam } export function tagHeaderFilter(headerValue, rowValue, rowData, filterParams) { - + console.log('tagHeaderFilter', headerValue, rowValue, rowData, filterParams); let data; - + console.log('rowValue', rowValue); try { data = typeof rowValue === 'string' ? JSON.parse(rowValue) : rowValue; } catch (error) { diff --git a/public/js/tabulator/formatter/tags.js b/public/js/tabulator/formatter/tags.js index 367a8a5fb..48c360b89 100644 --- a/public/js/tabulator/formatter/tags.js +++ b/public/js/tabulator/formatter/tags.js @@ -1,86 +1,128 @@ -export function tagFormatter(cell, tagComponent) -{ - const mappedData = tagComponent.tags.map(tag => ({ - typ_kurzbz: tag.tag_typ_kurzbz, - automatisiert: tag.automatisiert - })); +export function tagFormatter(cell, tagComponent, onRendered) { + const mappedData = tagComponent.tags.map((tag) => ({ + typ_kurzbz: tag.tag_typ_kurzbz, + automatisiert: tag.automatisiert, + })); + let tags = cell.getValue(); + if (!tags) return; - let tags = cell.getValue(); - if (!tags) return; + let container = document.createElement("div"); + container.className = "d-flex gap-1"; - let container = document.createElement('div'); - container.className = "d-flex gap-1"; + let parsedTags = []; + if (typeof tags === "string") { + parsedTags = JSON.parse(tags); + } else if (Array.isArray(tags)) { + parsedTags = tags; + } - let parsedTags = []; - if (typeof tags === 'string') { - parsedTags = JSON.parse(tags); - } else if (Array.isArray(tags)) { - parsedTags = tags; - } + let maxVisibleTags = 3; - let maxVisibleTags = 2; + const rowData = cell.getRow().getData(); + if (rowData._tagExpanded === undefined) { + rowData._tagExpanded = false; + } - const rowData = cell.getRow().getData(); - if (rowData._tagExpanded === undefined) { - rowData._tagExpanded = false; - } + const renderTags = () => { + container.innerHTML = ""; + parsedTags = parsedTags.filter((item) => item !== null); - const renderTags = () => { - container.innerHTML = ''; - parsedTags = parsedTags.filter(item => item !== null); + parsedTags.sort((a, b) => { + let adone = a.done ? 1 : 0; + let bbone = b.done ? 1 : 0; - parsedTags.sort((a, b) => { - let adone = a.done ? 1 : 0; - let bbone = b.done ? 1 : 0; + if (adone !== bbone) { + return adone - bbone; + } + return b.id - a.id; + }); + const tagsToShow = rowData._tagExpanded + ? parsedTags + : parsedTags.slice(0, maxVisibleTags); - if (adone !== bbone) - { - return adone - bbone; - } - return b.id - a.id; - }); - const tagsToShow = rowData._tagExpanded ? parsedTags : parsedTags.slice(0, maxVisibleTags); + tagsToShow.forEach((tag) => { + if (!tag) return; + let tagElement = document.createElement("span"); + tagElement.innerText = tag.beschreibung; + tagElement.title = tag.notiz; + tagElement.className = "tag " + tag.style; + if (tag.done) tagElement.className += " tag_done"; - tagsToShow.forEach(tag => { - if (!tag) return; - let tagElement = document.createElement('span'); - tagElement.innerText = tag.beschreibung; - tagElement.title = tag.notiz; - tagElement.className = "tag " + tag.style; - if (tag.done) tagElement.className += " tag_done"; + const tagDef = mappedData.find((t) => t.typ_kurzbz === tag.typ_kurzbz); - const tagDef = mappedData.find(t => t.typ_kurzbz === tag.typ_kurzbz); + if ( + (!tagDef && tag.typ_kurzbz?.includes("_auto")) || + tagDef?.automatisiert + ) { + tagElement.className += " tag_auto"; + tagElement.innerHTML = + " " + tag.beschreibung; + } - if (!tagDef && tag.typ_kurzbz?.includes("_auto") || tagDef?.automatisiert) { - tagElement.className += " tag_auto"; - tagElement.innerHTML = " " + tag.beschreibung; - } + tagElement.addEventListener("click", (event) => { + event.stopPropagation(); + event.preventDefault(); + tagComponent.editTag(tag.id); + }); - tagElement.addEventListener('click', (event) => { - event.stopPropagation(); - event.preventDefault(); - tagComponent.editTag(tag.id) - }); + container.appendChild(tagElement); + }); - container.appendChild(tagElement); - }); + if (parsedTags.length > maxVisibleTags) { + let toggle = document.createElement("button"); + toggle.innerText = + (rowData._tagExpanded ? "- " : "+ ") + + (parsedTags.length - maxVisibleTags); + toggle.className = "display_all"; + toggle.title = rowData._tagExpanded + ? "Tags ausblenden" + : "Tags einblenden"; - if (parsedTags.length > maxVisibleTags) { - let toggle = document.createElement('button'); - toggle.innerText = (rowData._tagExpanded ? '- ' : '+ ') + (parsedTags.length - maxVisibleTags); - toggle.className = "display_all"; - toggle.title = rowData._tagExpanded ? "Tags ausblenden" : "Tags einblenden"; + toggle.addEventListener("click", () => { + rowData._tagExpanded = !rowData._tagExpanded; + renderTags(); + }); - toggle.addEventListener('click', () => { - rowData._tagExpanded = !rowData._tagExpanded; - renderTags(); - }); + container.appendChild(toggle); + } + }; - container.appendChild(toggle); - } - }; + const fitTags = () => { + if (rowData._tagExpanded) { + renderTags(); + return; + } - renderTags(); - return container; -} \ No newline at end of file + maxVisibleTags = parsedTags.length; + renderTags(); + + while ( + maxVisibleTags > 0 && + container.scrollWidth > container.clientWidth + ) { + maxVisibleTags--; + renderTags(); + } + }; + + let animationFrame = null; + container.fitTags = () => { + if (animationFrame !== null) return; + + animationFrame = requestAnimationFrame(() => { + animationFrame = null; + fitTags(); + }); + }; + + if (onRendered) { + onRendered(() => { + container.fitTags(); + }); + } + + console.log("tagFormatter called with:") + renderTags(); + return container; +} diff --git a/tests/cypress/.gitignore b/tests/cypress/.gitignore new file mode 100644 index 000000000..48e0213b8 --- /dev/null +++ b/tests/cypress/.gitignore @@ -0,0 +1,4 @@ +node_modules +.env +.env.* +!.env.example \ No newline at end of file From 4ed87b5c2b59eb3b5acdfaa7ab56c23fc57ff7b0 Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Sat, 27 Jun 2026 12:29:28 +0200 Subject: [PATCH 02/15] add custom tag filter --- .../Stv/Studentenverwaltung/List.js | 258 ++------ .../tabulator/filters/extendedHeaderFilter.js | 595 +++++++++++++----- 2 files changed, 497 insertions(+), 356 deletions(-) diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index e70c48d83..3079eb080 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -1,7 +1,7 @@ import { CoreFilterCmpt } from "../../filter/Filter.js"; import ListNew from "./List/New.js"; import CoreTag from "../../Tag/Tag.js"; -import { tagHeaderFilter } from "../../../tabulator/filters/extendedHeaderFilter.js"; +import { buildTagHeaderFilterExpression, buildTagOptionsFromRows, customTagFilter, setTagHeaderFilterValue, tagHeaderFilter } from "../../../tabulator/filters/extendedHeaderFilter.js"; import { addTagInTable, deleteTagInTable, @@ -64,8 +64,11 @@ export default { } return { - initialTagData: [], - selectedTagOptions: [], + tagFilterState: { + initialOptions: [], + selectedOptions: [], + }, + selectedColumnValues: [], tabulatorOptions: { columns: [ { title: "UID", field: "uid", headerFilter: true }, @@ -398,7 +401,7 @@ export default { { event: "dataProcessed", handler: (data) => { - console.log("dataProcessed", data); + //console.log("dataProcessed", data); this.getAllRows(); this.autoSelectRows(data); }, @@ -406,7 +409,7 @@ export default { { event: "dataLoaded", handler: (data) => { - console.log("dataLoaded", data); + //console.log("dataLoaded", data); if (Array.isArray(data)) { this.count = data.length; this.allPrestudents = data.map((item) => item.prestudent_id); @@ -414,12 +417,13 @@ export default { this.count = 0; this.allPrestudents = []; } + this.refreshTagHeaderFilterInitialOptions(Array.isArray(data) ? data : []); }, }, { event: "dataFiltered", handler: (filters, rows) => { - console.log("dataFiltered", filters, rows); + //console.log("dataFiltered", filters, rows); this.filteredcount = rows.length; }, }, @@ -430,14 +434,14 @@ export default { { event: "dataTreeRowExpanded", handler: (data) => { - console.log("dataTreeRowExpanded", data); + //console.log("dataTreeRowExpanded", data); this.getExpandedRows(); }, }, { event: "dataTreeRowCollapsed", handler: (data) => { - console.log("dataTreeRowCollapsed", data); + //console.log("dataTreeRowCollapsed", data); this.getExpandedRows(); }, }, @@ -448,7 +452,7 @@ export default { { event: "columnWidth", handler: (column) => { - console.log("columnWidth", column); + //console.log("columnWidth", column); if (column.getField() !== "tags") return; column.getCells().forEach((cell) => { @@ -465,7 +469,6 @@ export default { selectedcount: 0, //tags expanded: [], - selectedColumnValues: [], tagEndpoint: ApiTag, currentEndpoint: null, headerFilterActive: false, @@ -561,175 +564,13 @@ export default { title: "Tags", field: "tags", tooltip: false, - headerFilter: "list", + headerFilter: customTagFilter, headerFilterParams: { - valuesLookup: () => { - console.log("headerFilterParams.valuesLookup called"); - const options = new Map(); - - this.$refs.table.tabulator.getData("active").forEach((row) => { - let tags = row.tags; - - if (typeof tags === "string") { - try { - tags = JSON.parse(tags); - } catch { - return; - } - } - - if (!Array.isArray(tags)) return; - - tags - .filter((tag) => tag && tag.done !== true) - .forEach((tag) => { - options.set(tag.beschreibung, { - label: tag.beschreibung, - value: tag.beschreibung, - style: tag.style, - }); - }); - }); - - return [ - { - isHeader: true, - label: "test", - subOptions: [...options.values()], - }, - ]; - }, - itemFormatter: (value, label, item) => { - console.log("value, label, item", value, label, item); - if (!item.isHeader) return ""; - - let table = document.createElement("table"); - table.className = "table table-sm mb-0"; - table.appendChild(document.createElement("thead")); - table - .querySelector("thead") - .appendChild(document.createElement("tr")); - - let firstHeading = document.createElement("th"); - firstHeading.innerHTML = "" + value + ""; - table.querySelector("tr").appendChild(firstHeading); - - let secondHeading = document.createElement("th"); - secondHeading.innerHTML = "AND"; - table.querySelector("tr").appendChild(secondHeading); - - let thirdHeading = document.createElement("th"); - thirdHeading.innerHTML = "OR"; - table.querySelector("tr").appendChild(thirdHeading); - - let fourthHeading = document.createElement("th"); - fourthHeading.innerHTML = "NOT"; - table.querySelector("tr").appendChild(fourthHeading); - - table.appendChild(document.createElement("tbody")); - - item.subOptions.forEach((option) => { - let row = document.createElement("tr"); - - let firstColumn = document.createElement("td"); - - let optionLabel = document.createElement("span"); - optionLabel.className = "tag " + option.style; - optionLabel.innerText = option.label; - firstColumn.appendChild(optionLabel); - row.appendChild(firstColumn); - - let secondColumn = document.createElement("td"); - let firstCheckbox = document.createElement("input"); - firstCheckbox.type = "checkbox"; - firstCheckbox.label = "test"; - firstCheckbox.style.cursor = "pointer"; - firstCheckbox.checked = this.selectedTagOptions.some( - (tag) => tag.value === option.value && tag.connector === "AND", - ); - firstCheckbox.addEventListener("click", (event) => { - event.stopPropagation(); - if (event.target.checked) { - this.selectedTagOptions.push({ - label: option.label, - value: option.value, - connector: "AND", - }); - } else { - this.selectedTagOptions = this.selectedTagOptions.filter( - (tag) => - tag.value !== option.value || tag.connector !== "AND", - ); - } - console.log("selectedTagOptions", this.selectedTagOptions); - }); - secondColumn.appendChild(firstCheckbox); - row.appendChild(secondColumn); - - let thirdColumn = document.createElement("td"); - let secondCheckbox = document.createElement("input"); - secondCheckbox.type = "checkbox"; - secondCheckbox.label = "test"; - secondCheckbox.style.cursor = "pointer"; - secondCheckbox.checked = this.selectedTagOptions.some( - (tag) => tag.value === option.value && tag.connector === "OR", - ); - secondCheckbox.addEventListener("click", (event) => { - event.stopPropagation(); - if (event.target.checked) { - this.selectedTagOptions.push({ - label: option.label, - value: option.value, - connector: "OR", - }); - } else { - this.selectedTagOptions = this.selectedTagOptions.filter( - (tag) => - tag.value !== option.value || tag.connector !== "OR", - ); - } - }); - thirdColumn.appendChild(secondCheckbox); - row.appendChild(thirdColumn); - - let fourthColumn = document.createElement("td"); - let thirdCheckbox = document.createElement("input"); - thirdCheckbox.type = "checkbox"; - thirdCheckbox.label = "test"; - thirdCheckbox.style.cursor = "pointer"; - thirdCheckbox.checked = this.selectedTagOptions.some( - (tag) => tag.value === option.value && tag.connector === "NOT", - ); - thirdCheckbox.addEventListener("click", (event) => { - event.stopPropagation(); - console.log(event.target.checked); - if (event.target.checked) { - this.selectedTagOptions.push({ - label: option.label, - value: option.value, - connector: "NOT", - }); - } else { - this.selectedTagOptions = this.selectedTagOptions.filter( - (tag) => - tag.value !== option.value || tag.connector !== "NOT", - ); - } - }); - fourthColumn.appendChild(thirdCheckbox); - row.appendChild(fourthColumn); - - row.addEventListener("click", (event) => { - event.stopPropagation(); - }); - - table.querySelector("tbody").appendChild(row); - }); - return table; - }, listOnEmpty: true, autocomplete: true, sort: "asc", + initialOptions: this.tagFilterState.initialOptions, + selectedOptions: this.tagFilterState.selectedOptions, }, headerFilterFunc: tagHeaderFilter, headerFilterFuncParams: { field: "tags" }, @@ -746,33 +587,18 @@ export default { this.translateTabulator(); } }, - selectedTagOptions: { + "tagFilterState.selectedOptions": { handler() { - let combinedFilterStatement = null; + const selectedOptions = this.tagFilterState.selectedOptions; + const combinedFilterStatement = buildTagHeaderFilterExpression( + selectedOptions, + ); - this.selectedTagOptions.forEach((tagOption) => { - const { value, connector } = tagOption; - - let mappedConnector = connector === "AND" ? "&&" : connector === "OR" ? "||" : "!"; - if (connector === "NOT") { - combinedFilterStatement = combinedFilterStatement - ? `${combinedFilterStatement} && ${mappedConnector + value}` - : value; - } - else { - combinedFilterStatement = combinedFilterStatement - ? `${combinedFilterStatement} ${mappedConnector} ${value}` - : value; - } - }); - - this.$refs.table.tabulator.setHeaderFilterValue( - "tags", + setTagHeaderFilterValue( + this.$refs.table.tabulator, combinedFilterStatement, ); - this.$emit("filterActive", this.selectedTagOptions.length > 0); - console.log("selectedTagOptions changed:"); - console.log(combinedFilterStatement); + this.$emit("filterActive", selectedOptions.length > 0); }, deep: true, }, @@ -868,7 +694,7 @@ export default { this.$refs.new.open(); }, rowSelectionChanged(data, rows, selected, deselected) { - console.log("rowSelectionChanged", data, rows, selected, deselected); + //console.log("rowSelectionChanged", data, rows, selected, deselected); this.selectedcount = data.length; if (selected.length > 0 || deselected.length > 0) { @@ -915,8 +741,8 @@ export default { updateUrl(endpoint, first) { this.lastSelected = first ? undefined : this.selected; - /* console.log('function param endpoint: ' + JSON.stringify(endpoint)); - console.log('current endpoint: ' + JSON.stringify(this.currentEndpoint));*/ + /* //console.log('function param endpoint: ' + JSON.stringify(endpoint)); + //console.log('current endpoint: ' + JSON.stringify(this.currentEndpoint));*/ if (endpoint === undefined && this.currentEndpoint === null) { endpoint = { url: "" }; @@ -1021,7 +847,7 @@ export default { } }, handleRowClick(e, row) { - console.log("handleRowClick", e, row); + //console.log("handleRowClick", e, row); // TODO(chris): this should be in the filter component if (this.focusObj) { let el = row.getElement(); @@ -1035,12 +861,34 @@ export default { //methods tags addedTag(addedTag) { addTagInTable(addedTag, this.allRows, "prestudent_id"); + this.refreshTagHeaderFilterInitialOptions(); }, deletedTag(deletedTag) { + console.log("deletedTag", deletedTag); deleteTagInTable(deletedTag, this.allRows); + this.refreshTagHeaderFilterInitialOptions(); }, updatedTag(updatedTag) { updateTagInTable(updatedTag, this.allRows); + this.refreshTagHeaderFilterInitialOptions(); + }, + refreshTagHeaderFilterInitialOptions(rows) { + const data = + rows || this.$refs.table?.tabulator?.getData() || []; + + const options = buildTagOptionsFromRows(data); + this.tagFilterState.initialOptions.splice( + 0, + this.tagFilterState.initialOptions.length, + ...options, + ); + + const availableValues = new Set(options.map((option) => option.value)); + for (let i = this.tagFilterState.selectedOptions.length - 1; i >= 0; i--) { + if (!availableValues.has(this.tagFilterState.selectedOptions[i].value)) { + this.tagFilterState.selectedOptions.splice(i, 1); + } + } }, getAllRows() { this.allRows = this.$refs.table.tabulator.getRows(); @@ -1053,7 +901,7 @@ export default { this.headerFilterActive = filterActive; }, handleMouseDown(e, row) { - console.log("handleMouseDown", e, row); + //console.log("handleMouseDown", e, row); let data = row.getData(); let id = data.uid ?? data.prestudent_id ?? data.person_id; @@ -1065,12 +913,12 @@ export default { isAlreadySelected && this.selected?.length ? this.selected : [data]; }, handleDataLoading() { - console.log("handleDataLoading"); + //console.log("handleDataLoading"); this.oldScrollLeft = this.$refs.table.tabulator.rowManager.scrollLeft; this.oldScrollTop = this.$refs.table.tabulator.rowManager.scrollTop; }, handleRenderComplete() { - console.log("handleRenderComplete"); + //console.log("handleRenderComplete"); const table = this.$refs.table.tabulator.element.querySelector( ".tabulator-tableholder", ); diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index 148ddb7fa..d9f6faf7a 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -1,163 +1,456 @@ -function parseFilterExpression(expression) -{ - const collections = []; +let activeTagDropdownCleanup = null; +const TAG_FILTER_CONNECTORS = { + AND: { + key: "AND", + label: "AND", + operator: "&&", + }, + OR: { + key: "OR", + label: "OR", + operator: "||", + }, + NOT: { + key: "NOT", + label: "NOT", + operator: "!", + }, +}; +const TAG_FILTER_CONNECTOR_ORDER = [ + TAG_FILTER_CONNECTORS.AND, + TAG_FILTER_CONNECTORS.OR, + TAG_FILTER_CONNECTORS.NOT, +]; - try { - const orParts = expression.split('||').map(part => part.trim()); +export function setTagHeaderFilterValue(tabulator, value) { + tabulator?.setHeaderFilterValue("tags", value); - orParts.forEach(part => { + const input = tabulator + ?.getColumn("tags") + ?.getElement() + ?.querySelector(".tabulator-header-filter input"); - const andParts = part.split('&&').map(p => p.trim()); - - const collection = { positives: [], negatives: [] }; - - andParts.forEach(term => { - - const comparisonMatch = term.match(/^(<=|>=|<|>|=|!=)\s*(-?\d+(?:[.,]\d+)?)$/); - - if (comparisonMatch) - { - const operator = comparisonMatch[1]; - const numberStr = comparisonMatch[2].replace(',', '.'); - const number = parseFloat(numberStr); - collection.positives.push({ type: 'comparison', operator, number }); - } - else if (term.startsWith('!')) - { - const excludeTerm = term.substring(1).trim().replace(/\*/g, '.*'); - collection.negatives.push({ type: 'regex', regex: new RegExp(excludeTerm, 'i') }); - } - else - { - const includeTerm = term.replace(/\*/g, '.*'); - collection.positives.push({ type: 'regex', regex: new RegExp(includeTerm, 'i') }); - } - }); - collections.push(collection); - }); - } catch (e) {} - return collections; + if (input) { + input.value = value ?? ""; + } } -export function extendedHeaderFilter(headerValue, rowValue, rowData, filterParams) -{ - const fields = Array.isArray(filterParams?.field) - ? filterParams.field - : [filterParams?.field]; +export function buildTagHeaderFilterExpression(selectedTagOptions) { + let expression = null; - if (fields.length > 1 && rowData) - { - rowValue = fields - .map(f => rowData[f] ?? '') - .filter(Boolean) - .join(' '); - } - if (typeof headerValue === 'boolean') - { - return rowValue === headerValue; - } + selectedTagOptions.forEach(({ value, connector }) => { + const connectorDef = TAG_FILTER_CONNECTORS[connector]; + if (!connectorDef) return; - const collections = parseFilterExpression(headerValue); + if (connectorDef.key === TAG_FILTER_CONNECTORS.NOT.key) { + expression = expression + ? `${expression} ${TAG_FILTER_CONNECTORS.AND.operator} ${connectorDef.operator}${value}` + : `${connectorDef.operator}${value}`; + } else { + expression = expression + ? `${expression} ${connectorDef.operator} ${value}` + : value; + } + }); - function matchValue(value) - { - try { - return collections.some(collection => { - - let positives = collection.positives.length === 0 || collection.positives.every(condition => { - - if (condition.type === 'comparison') - { - let value = parseFloat(rowValue); - if (isNaN(value)) return false; - - switch (condition.operator) { - case '<': - return value < condition.number; - case '>': - return value > condition.number; - case '<=': - return value <= condition.number; - case '>=': - return value >= condition.number; - case '=': - return value === condition.number; - case '!=': - return value !== condition.number; - default: - return false; - } - } - else if (condition.type === 'regex') - { - return condition.regex.test(rowValue); - } - return false; - }); - - let negatives = collection.negatives.every(condition => { - return !condition.regex.test(rowValue); - }); - - return positives && negatives; - }); - } catch (e) { - - } - } - - if (matchValue(rowValue)) - return true; - - if (rowData && filterParams) - { - const childrenField = filterParams?.children || '_children'; - const field = filterParams?.field; - - const children = rowData[childrenField]; - if (Array.isArray(children)) - { - for (let child of children) - { - let childValue = child[field]; - if (extendedHeaderFilter(headerValue, childValue, child, filterParams)) - { - return true; - } - } - } - } - - return false; + return expression; } -export function tagHeaderFilter(headerValue, rowValue, rowData, filterParams) -{ - console.log('tagHeaderFilter', headerValue, rowValue, rowData, filterParams); - let data; - console.log('rowValue', rowValue); - try { - data = typeof rowValue === 'string' ? JSON.parse(rowValue) : rowValue; - } catch (error) { - return false; - } - let combinedText; +export function buildTagOptionsFromRows(rows = []) { + const options = new Map(); - if (Array.isArray(data)) - { - combinedText = data - .filter(item => item?.done !== true) - .map(item => `${item?.beschreibung} ${item?.notiz}`) - .join(' '); - } - else if (typeof data === 'object' && data !== null) - { - combinedText = data?.erledigt === false ? `${data?.beschreibung} ${data?.notiz}` : ''; - } - else - { - combinedText = String(data); - } + rows.forEach((row) => { + let tags = row.tags; - return extendedHeaderFilter(headerValue, combinedText, rowData, filterParams) -} \ No newline at end of file + if (typeof tags === "string") { + try { + tags = JSON.parse(tags); + } catch { + return; + } + } + + if (!Array.isArray(tags)) return; + + tags + .filter((tag) => tag && tag.done !== true) + .forEach((tag) => { + options.set(tag.beschreibung, { + label: tag.beschreibung, + value: tag.beschreibung, + style: tag.style, + }); + }); + }); + + return [...options.values()]; +} + +export function customTagFilter(cell, onRendered, success, cancel, params) { + const container = document.createElement("div"); + container.style.width = "100%"; + + const input = document.createElement("input"); + input.style.width = "100%"; + input.type = "text"; + let dropdownMenu = null; + + Object.defineProperty(container, "value", { + get: () => input.value, + set: (value) => { + input.value = value ?? ""; + }, + }); + + const positionDropdown = () => { + if (!dropdownMenu) return; + + const rect = input.getBoundingClientRect(); + const width = Math.min(Math.max(rect.width, 360), window.innerWidth - 16); + const left = Math.min( + Math.max(rect.left, 8), + window.innerWidth - width - 8, + ); + + dropdownMenu.style.position = "fixed"; + dropdownMenu.style.left = `${left}px`; + dropdownMenu.style.top = `${rect.bottom + 2}px`; + dropdownMenu.style.width = `${width}px`; + dropdownMenu.style.maxHeight = "50vh"; + dropdownMenu.style.overflowY = "auto"; + dropdownMenu.style.zIndex = "2000"; + }; + + const closeDropdown = () => { + if (dropdownMenu) { + dropdownMenu.remove(); + dropdownMenu = null; + } + + if (activeTagDropdownCleanup === closeDropdown) { + activeTagDropdownCleanup = null; + } + + window.removeEventListener("scroll", positionDropdown, true); + window.removeEventListener("resize", positionDropdown); + document.removeEventListener("mousedown", handleOutsideClick, true); + }; + + const handleOutsideClick = (event) => { + if ( + dropdownMenu?.contains(event.target) || + container.contains(event.target) + ) { + return; + } + + closeDropdown(); + }; + + const clearFilter = () => { + input.value = ""; + + if (Array.isArray(params.selectedOptions)) { + params.selectedOptions.splice( + 0, + params.selectedOptions.length, + ); + } + + success(""); + closeDropdown(); + }; + + input.addEventListener("input", () => { + success(input.value); + }); + input.addEventListener("change", () => { + success(input.value); + }); + input.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + + event.preventDefault(); + event.stopPropagation(); + clearFilter(); + }); + input.addEventListener("focus", (e) => { + e.stopPropagation(); + + if (activeTagDropdownCleanup) { + activeTagDropdownCleanup(); + } + + dropdownMenu = getRebuiltDropdown( + params.initialOptions, + params.selectedOptions, + ); + dropdownMenu.addEventListener("mousedown", (event) => { + event.stopPropagation(); + }); + + document.body.appendChild(dropdownMenu); + activeTagDropdownCleanup = closeDropdown; + + positionDropdown(); + + window.addEventListener("scroll", positionDropdown, true); + window.addEventListener("resize", positionDropdown); + document.addEventListener("mousedown", handleOutsideClick, true); + }); + + input.addEventListener("blur", (e) => { + }); + + container.appendChild(input); + + return container; +} + +function getRebuiltDropdown(initialOptions, selectedOptions) { + let dropdownTable = generateDropdownTable( + initialOptions || [], + selectedOptions || [], + ); + + const menu = document.createElement("div"); + menu.className = "dropdown-menu show"; + + menu.appendChild(dropdownTable); + + return menu; +} + +function generateDropdownTable(options, selectedTagOptions) { + const table = document.createElement("table"); + table.className = "table table-sm mb-0"; + + // HEADER + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + + const headers = [ + "Tag", + ...TAG_FILTER_CONNECTOR_ORDER.map((connector) => connector.label), + ]; + + headers.forEach((h) => { + const th = document.createElement("th"); + th.innerHTML = `${h}`; + headRow.appendChild(th); + }); + + thead.appendChild(headRow); + table.appendChild(thead); + + // BODY + const tbody = document.createElement("tbody"); + options.forEach((option) => { + const row = document.createElement("tr"); + + // LABEL + const labelTd = document.createElement("td"); + const label = document.createElement("span"); + label.className = "tag " + option.style; + label.innerText = option.label; + labelTd.appendChild(label); + row.appendChild(labelTd); + + function makeCheckbox(connector) { + const td = document.createElement("td"); + const cb = document.createElement("input"); + + cb.type = "checkbox"; + cb.style.cursor = "pointer"; + + cb.checked = selectedTagOptions.some( + (t) => t.value === option.value && t.connector === connector.key, + ); + + cb.addEventListener("click", (event) => { + event.stopPropagation(); + + if (event.target.checked) { + selectedTagOptions.push({ + label: option.label, + value: option.value, + connector: connector.key, + }); + } else { + const idx = selectedTagOptions.findIndex( + (t) => t.value === option.value && t.connector === connector.key, + ); + + if (idx !== -1) { + selectedTagOptions.splice(idx, 1); + } + } + + }); + + td.appendChild(cb); + return td; + } + + TAG_FILTER_CONNECTOR_ORDER.forEach((connector) => { + row.appendChild(makeCheckbox(connector)); + }); + + tbody.appendChild(row); + }); + + table.appendChild(tbody); + + return table; +} + +function parseFilterExpression(expression) { + const collections = []; + + try { + const orParts = expression.split("||").map((part) => part.trim()); + + orParts.forEach((part) => { + const andParts = part.split("&&").map((p) => p.trim()); + + const collection = { positives: [], negatives: [] }; + + andParts.forEach((term) => { + const comparisonMatch = term.match( + /^(<=|>=|<|>|=|!=)\s*(-?\d+(?:[.,]\d+)?)$/, + ); + + if (comparisonMatch) { + const operator = comparisonMatch[1]; + const numberStr = comparisonMatch[2].replace(",", "."); + const number = parseFloat(numberStr); + collection.positives.push({ type: "comparison", operator, number }); + } else if (term.startsWith("!")) { + const excludeTerm = term.substring(1).trim().replace(/\*/g, ".*"); + collection.negatives.push({ + type: "regex", + regex: new RegExp(excludeTerm, "i"), + }); + } else { + const includeTerm = term.replace(/\*/g, ".*"); + collection.positives.push({ + type: "regex", + regex: new RegExp(includeTerm, "i"), + }); + } + }); + collections.push(collection); + }); + } catch (e) {} + return collections; +} + +export function extendedHeaderFilter( + headerValue, + rowValue, + rowData, + filterParams, +) { + const fields = Array.isArray(filterParams?.field) + ? filterParams.field + : [filterParams?.field]; + + if (fields.length > 1 && rowData) { + rowValue = fields + .map((f) => rowData[f] ?? "") + .filter(Boolean) + .join(" "); + } + if (typeof headerValue === "boolean") { + return rowValue === headerValue; + } + + const collections = parseFilterExpression(headerValue); + + function matchValue(value) { + try { + return collections.some((collection) => { + let positives = + collection.positives.length === 0 || + collection.positives.every((condition) => { + if (condition.type === "comparison") { + let value = parseFloat(rowValue); + if (isNaN(value)) return false; + + switch (condition.operator) { + case "<": + return value < condition.number; + case ">": + return value > condition.number; + case "<=": + return value <= condition.number; + case ">=": + return value >= condition.number; + case "=": + return value === condition.number; + case "!=": + return value !== condition.number; + default: + return false; + } + } else if (condition.type === "regex") { + return condition.regex.test(rowValue); + } + return false; + }); + + let negatives = collection.negatives.every((condition) => { + return !condition.regex.test(rowValue); + }); + + return positives && negatives; + }); + } catch (e) {} + } + + if (matchValue(rowValue)) return true; + + if (rowData && filterParams) { + const childrenField = filterParams?.children || "_children"; + const field = filterParams?.field; + + const children = rowData[childrenField]; + if (Array.isArray(children)) { + for (let child of children) { + let childValue = child[field]; + if ( + extendedHeaderFilter(headerValue, childValue, child, filterParams) + ) { + return true; + } + } + } + } + + return false; +} +export function tagHeaderFilter(headerValue, rowValue, rowData, filterParams) { + let data; + + try { + data = typeof rowValue === "string" ? JSON.parse(rowValue) : rowValue; + } catch (error) { + return false; + } + + let combinedText; + + if (Array.isArray(data)) { + combinedText = data + .filter((item) => item?.done !== true) + .map((item) => `${item?.beschreibung} ${item?.notiz}`) + .join(" "); + } else if (typeof data === "object" && data !== null) { + combinedText = + data?.erledigt === false ? `${data?.beschreibung} ${data?.notiz}` : ""; + } else { + combinedText = String(data); + } + + return extendedHeaderFilter(headerValue, combinedText, rowData, filterParams); +} From db5339e4a52cdc9d13a10007c44b988c1a2cff4d Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Sat, 27 Jun 2026 17:54:24 +0200 Subject: [PATCH 03/15] BUG: Fix random issues found during testing --- .../Stv/Studentenverwaltung/List.js | 149 ++++++++++++------ public/js/helpers/TagHelper.js | 22 ++- .../tabulator/filters/extendedHeaderFilter.js | 66 ++++++-- public/js/tabulator/formatter/tags.js | 1 - system/phrasesupdate.php | 60 +++++++ 5 files changed, 234 insertions(+), 64 deletions(-) diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index 3079eb080..32791850f 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -1,7 +1,13 @@ import { CoreFilterCmpt } from "../../filter/Filter.js"; import ListNew from "./List/New.js"; import CoreTag from "../../Tag/Tag.js"; -import { buildTagHeaderFilterExpression, buildTagOptionsFromRows, customTagFilter, setTagHeaderFilterValue, tagHeaderFilter } from "../../../tabulator/filters/extendedHeaderFilter.js"; +import { + buildTagHeaderFilterExpression, + buildTagOptionsFromRows, + customTagFilter, + setTagHeaderFilterValue, + tagHeaderFilter, +} from "../../../tabulator/filters/extendedHeaderFilter.js"; import { addTagInTable, deleteTagInTable, @@ -68,6 +74,15 @@ export default { initialOptions: [], selectedOptions: [], }, + tagFilterLabels: { + tag: "Tag", + clear: "Clear", + connectors: { + AND: "AND", + OR: "OR", + NOT: "NOT", + }, + }, selectedColumnValues: [], tabulatorOptions: { columns: [ @@ -383,7 +398,15 @@ export default { selectableRows: true, selectableRowsRangeMode: "click", index: "prestudent_id", - persistence: false, + persistence: { + sort: true, + columns: ["width", "visible"], + filter: false, + headerFilter: false, + group: false, + page: false, + }, + persistenceID: "stv-studentenverwaltung-list-2", }, tabulatorEvents: [ { @@ -401,7 +424,6 @@ export default { { event: "dataProcessed", handler: (data) => { - //console.log("dataProcessed", data); this.getAllRows(); this.autoSelectRows(data); }, @@ -409,7 +431,6 @@ export default { { event: "dataLoaded", handler: (data) => { - //console.log("dataLoaded", data); if (Array.isArray(data)) { this.count = data.length; this.allPrestudents = data.map((item) => item.prestudent_id); @@ -417,14 +438,16 @@ export default { this.count = 0; this.allPrestudents = []; } - this.refreshTagHeaderFilterInitialOptions(Array.isArray(data) ? data : []); + this.refreshTagHeaderFilterInitialOptions( + Array.isArray(data) ? data : [], + ); }, }, { event: "dataFiltered", handler: (filters, rows) => { - //console.log("dataFiltered", filters, rows); this.filteredcount = rows.length; + this.syncSelectedTagOptionsWithHeaderFilters(filters); }, }, { @@ -434,14 +457,12 @@ export default { { event: "dataTreeRowExpanded", handler: (data) => { - //console.log("dataTreeRowExpanded", data); this.getExpandedRows(); }, }, { event: "dataTreeRowCollapsed", handler: (data) => { - //console.log("dataTreeRowCollapsed", data); this.getExpandedRows(); }, }, @@ -452,7 +473,6 @@ export default { { event: "columnWidth", handler: (column) => { - //console.log("columnWidth", column); if (column.getField() !== "tags") return; column.getCells().forEach((cell) => { @@ -558,29 +578,6 @@ export default { ); }, }, - created: function () { - if (this.tagsEnabled) { - const coltags = { - title: "Tags", - field: "tags", - tooltip: false, - headerFilter: customTagFilter, - headerFilterParams: { - listOnEmpty: true, - autocomplete: true, - sort: "asc", - initialOptions: this.tagFilterState.initialOptions, - selectedOptions: this.tagFilterState.selectedOptions, - }, - headerFilterFunc: tagHeaderFilter, - headerFilterFuncParams: { field: "tags" }, - formatter: (cell, formatterParams, onRendered) => - tagFormatter(cell, this.$refs.tagComponent, onRendered), - width: 150, - }; - this.tabulatorOptions.columns.splice(2, 0, coltags); - } - }, watch: { "$p.user_language.value"(n, o) { if (n !== o && o !== undefined && this.$refs.table.tableBuilt) { @@ -590,9 +587,8 @@ export default { "tagFilterState.selectedOptions": { handler() { const selectedOptions = this.tagFilterState.selectedOptions; - const combinedFilterStatement = buildTagHeaderFilterExpression( - selectedOptions, - ); + const combinedFilterStatement = + buildTagHeaderFilterExpression(selectedOptions); setTagHeaderFilterValue( this.$refs.table.tabulator, @@ -616,6 +612,8 @@ export default { "stv", ]) .then(() => { + this.updateTagFilterLabels(); + const translations = { uid: capitalize(this.$p.t("person/uid")), titelpre: capitalize(this.$p.t("person/titelpre")), @@ -694,13 +692,11 @@ export default { this.$refs.new.open(); }, rowSelectionChanged(data, rows, selected, deselected) { - //console.log("rowSelectionChanged", data, rows, selected, deselected); this.selectedcount = data.length; if (selected.length > 0 || deselected.length > 0) { this.lastSelected = this.selected; - //for tags this.selectedRows = this.$refs.table.tabulator.getSelectedRows(); this.selectedColumnValues = this.selectedRows .filter( @@ -738,12 +734,16 @@ export default { this.$emit("filterActive", filter); this.updateUrl(); }, + updateTagFilterLabels() { + this.tagFilterLabels.clear = this.$p.t("ui/filterdelete"); + this.tagFilterLabels.tag = 'Tag'; + this.tagFilterLabels.connectors.AND = this.$p.t("ui/andCondition").toUpperCase(); + this.tagFilterLabels.connectors.OR = this.$p.t("ui/orCondition").toUpperCase(); + this.tagFilterLabels.connectors.NOT = this.$p.t("ui/notCondition").toUpperCase(); + }, updateUrl(endpoint, first) { this.lastSelected = first ? undefined : this.selected; - /* //console.log('function param endpoint: ' + JSON.stringify(endpoint)); - //console.log('current endpoint: ' + JSON.stringify(this.currentEndpoint));*/ - if (endpoint === undefined && this.currentEndpoint === null) { endpoint = { url: "" }; } else if (endpoint === undefined) { @@ -847,7 +847,6 @@ export default { } }, handleRowClick(e, row) { - //console.log("handleRowClick", e, row); // TODO(chris): this should be in the filter component if (this.focusObj) { let el = row.getElement(); @@ -864,7 +863,6 @@ export default { this.refreshTagHeaderFilterInitialOptions(); }, deletedTag(deletedTag) { - console.log("deletedTag", deletedTag); deleteTagInTable(deletedTag, this.allRows); this.refreshTagHeaderFilterInitialOptions(); }, @@ -873,8 +871,7 @@ export default { this.refreshTagHeaderFilterInitialOptions(); }, refreshTagHeaderFilterInitialOptions(rows) { - const data = - rows || this.$refs.table?.tabulator?.getData() || []; + const data = rows || this.$refs.table?.tabulator?.getData() || []; const options = buildTagOptionsFromRows(data); this.tagFilterState.initialOptions.splice( @@ -884,8 +881,14 @@ export default { ); const availableValues = new Set(options.map((option) => option.value)); - for (let i = this.tagFilterState.selectedOptions.length - 1; i >= 0; i--) { - if (!availableValues.has(this.tagFilterState.selectedOptions[i].value)) { + for ( + let i = this.tagFilterState.selectedOptions.length - 1; + i >= 0; + i-- + ) { + if ( + !availableValues.has(this.tagFilterState.selectedOptions[i].value) + ) { this.tagFilterState.selectedOptions.splice(i, 1); } } @@ -894,14 +897,37 @@ export default { this.allRows = this.$refs.table.tabulator.getRows(); }, resetFilter() { + this.clearSelectedTagHeaderFilters(); this.$refs.listfilter.resetFilter(); this.$refs.table.clearFilters(); }, + clearSelectedTagHeaderFilters() { + this.tagFilterState.selectedOptions.splice( + 0, + this.tagFilterState.selectedOptions.length, + ); + }, + syncSelectedTagOptionsWithHeaderFilters(filters) { + if (!this.tagsEnabled || !this.tagFilterState.selectedOptions.length) { + return; + } + + const hasActiveTagFilter = filters.some( + (filter) => + filter.field === "tags" && + filter.value !== "" && + filter.value !== null && + filter.value !== undefined, + ); + + if (!hasActiveTagFilter) { + this.clearSelectedTagHeaderFilters(); + } + }, handleHeaderFilter(filterActive) { this.headerFilterActive = filterActive; }, handleMouseDown(e, row) { - //console.log("handleMouseDown", e, row); let data = row.getData(); let id = data.uid ?? data.prestudent_id ?? data.person_id; @@ -913,12 +939,10 @@ export default { isAlreadySelected && this.selected?.length ? this.selected : [data]; }, handleDataLoading() { - //console.log("handleDataLoading"); this.oldScrollLeft = this.$refs.table.tabulator.rowManager.scrollLeft; this.oldScrollTop = this.$refs.table.tabulator.rowManager.scrollTop; }, handleRenderComplete() { - //console.log("handleRenderComplete"); const table = this.$refs.table.tabulator.element.querySelector( ".tabulator-tableholder", ); @@ -935,6 +959,33 @@ export default { } }, }, + created: function () { + if (this.tagsEnabled) { + const coltags = { + title: "Tags", + field: "tags", + tooltip: false, + headerFilter: customTagFilter, + headerFilterParams: { + listOnEmpty: true, + autocomplete: true, + sort: "asc", + initialOptions: this.tagFilterState.initialOptions, + selectedOptions: this.tagFilterState.selectedOptions, + labels: this.tagFilterLabels, + }, + headerFilterFunc: tagHeaderFilter, + headerFilterFuncParams: { field: "tags" }, + formatter: (cell, formatterParams, onRendered) => + tagFormatter(cell, this.$refs.tagComponent, onRendered), + width: 150, + }; + this.tabulatorOptions.columns.splice(2, 0, coltags); + this.$p.loadCategory("ui").then(() => { + this.updateTagFilterLabels(); + }); + } + }, // TODO(chris): focusin, focusout, keydown and tabindex should be in the filter component // TODO(chris): filter component column chooser has no accessibilty features template: ` diff --git a/public/js/helpers/TagHelper.js b/public/js/helpers/TagHelper.js index 9282aa167..3d287c792 100644 --- a/public/js/helpers/TagHelper.js +++ b/public/js/helpers/TagHelper.js @@ -15,7 +15,12 @@ export function addTagInTable(addedTag, rows, matchKey, tagsKey = "tags") let tags; try { - tags = JSON.parse(rowData[tagsKey] || "[]"); + let initialTags = rowData[tagsKey] || "[]"; + if (typeof initialTags === 'string') { + tags = JSON.parse(initialTags); + } else { + tags = initialTags; + } } catch (e) { tags = []; } @@ -53,7 +58,12 @@ export function deleteTagInTable(deletedTag, rows, tagsKeys = ['tags']) let tags; try { - tags = JSON.parse(rowData[key] || "[]"); + let initialTags = rowData[key] || "[]"; + if (typeof initialTags === 'string') { + tags = JSON.parse(initialTags); + } else { + tags = initialTags; + } } catch (e) { tags = []; } @@ -62,7 +72,6 @@ export function deleteTagInTable(deletedTag, rows, tagsKeys = ['tags']) return; let filtered = tags.filter(tag => tag?.id !== deletedTag); - if (filtered.length !== tags.length) { updates[key] = JSON.stringify(filtered); @@ -95,7 +104,12 @@ export function updateTagInTable(updatedTag, rows, fields = ['tags']) let fieldData; try { - fieldData = JSON.parse(rowData[field] || "[]"); + let initialTags = rowData[field] || "[]"; + if (typeof initialTags === 'string') { + fieldData = JSON.parse(initialTags); + } else { + fieldData = initialTags; + } } catch (e) { return; } diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index d9f6faf7a..79fd19743 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -22,6 +22,10 @@ const TAG_FILTER_CONNECTOR_ORDER = [ TAG_FILTER_CONNECTORS.NOT, ]; +function getConnectorLabel(connector, labels) { + return labels?.connectors?.[connector.key] || connector.label; +} + export function setTagHeaderFilterValue(tabulator, value) { tabulator?.setHeaderFilterValue("tags", value); @@ -147,7 +151,7 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { closeDropdown(); }; - const clearFilter = () => { + const clearFilter = ({ closeDropdownAfterClear = true } = {}) => { input.value = ""; if (Array.isArray(params.selectedOptions)) { @@ -158,7 +162,10 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { } success(""); - closeDropdown(); + + if (closeDropdownAfterClear) { + closeDropdown(); + } }; input.addEventListener("input", () => { @@ -184,6 +191,8 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { dropdownMenu = getRebuiltDropdown( params.initialOptions, params.selectedOptions, + params.labels, + clearFilter, ); dropdownMenu.addEventListener("mousedown", (event) => { event.stopPropagation(); @@ -207,21 +216,48 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { return container; } -function getRebuiltDropdown(initialOptions, selectedOptions) { - let dropdownTable = generateDropdownTable( +function getRebuiltDropdown( + initialOptions, + selectedOptions, + labels, + clearFilter, +) { + const dropdownTable = generateDropdownTable( initialOptions || [], selectedOptions || [], + labels, ); const menu = document.createElement("div"); - menu.className = "dropdown-menu show"; + menu.className = "dropdown-menu show py-3 px-1"; + const clearButton = document.createElement("button"); + clearButton.type = "button"; + clearButton.className = + "btn btn-link btn-sm p-0 position-absolute top-0 end-0 m-1"; + clearButton.title = labels?.clear || "Clear"; + clearButton.setAttribute("aria-label", labels?.clear || "Clear"); + + const clearIcon = document.createElement("i"); + clearIcon.className = "fa-solid fa-filter-circle-xmark"; + clearButton.appendChild(clearIcon); + + clearButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + clearFilter({ closeDropdownAfterClear: false }); + menu.querySelectorAll("input[type='checkbox']").forEach((checkbox) => { + checkbox.checked = false; + }); + }); + + menu.appendChild(clearButton); menu.appendChild(dropdownTable); return menu; } -function generateDropdownTable(options, selectedTagOptions) { +function generateDropdownTable(options, selectedTagOptions, labels) { const table = document.createElement("table"); table.className = "table table-sm mb-0"; @@ -230,13 +266,21 @@ function generateDropdownTable(options, selectedTagOptions) { const headRow = document.createElement("tr"); const headers = [ - "Tag", - ...TAG_FILTER_CONNECTOR_ORDER.map((connector) => connector.label), + labels?.tag || "Tag", + ...TAG_FILTER_CONNECTOR_ORDER.map((connector) => + getConnectorLabel(connector, labels), + ), ]; - headers.forEach((h) => { + headers.forEach((h, index) => { const th = document.createElement("th"); - th.innerHTML = `${h}`; + if (index > 0) { + th.className = "text-center"; + } + + const strong = document.createElement("strong"); + strong.textContent = h; + th.appendChild(strong); headRow.appendChild(th); }); @@ -260,6 +304,8 @@ function generateDropdownTable(options, selectedTagOptions) { const td = document.createElement("td"); const cb = document.createElement("input"); + td.className = "text-center align-middle"; + cb.type = "checkbox"; cb.style.cursor = "pointer"; diff --git a/public/js/tabulator/formatter/tags.js b/public/js/tabulator/formatter/tags.js index 48c360b89..2200ca669 100644 --- a/public/js/tabulator/formatter/tags.js +++ b/public/js/tabulator/formatter/tags.js @@ -122,7 +122,6 @@ export function tagFormatter(cell, tagComponent, onRendered) { }); } - console.log("tagFormatter called with:") renderTags(); return container; } diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 05bdfbbef..e9ae6a554 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -58377,6 +58377,66 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'core', + 'category' => 'ui', + 'phrase' => 'andCondition', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'und', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'and', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'ui', + 'phrase' => 'orCondition', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'oder', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'or', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'ui', + 'phrase' => 'notCondition', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'nicht', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'not', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), ); From 48f07eb3caee165a4f210984878ada81739ceb3d Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Mon, 29 Jun 2026 09:41:52 +0200 Subject: [PATCH 04/15] added width buffer for tags display --- public/js/tabulator/formatter/tags.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/js/tabulator/formatter/tags.js b/public/js/tabulator/formatter/tags.js index 2200ca669..06d252d0e 100644 --- a/public/js/tabulator/formatter/tags.js +++ b/public/js/tabulator/formatter/tags.js @@ -94,12 +94,13 @@ export function tagFormatter(cell, tagComponent, onRendered) { return; } + let widthBuffer = 15; maxVisibleTags = parsedTags.length; renderTags(); while ( maxVisibleTags > 0 && - container.scrollWidth > container.clientWidth + container.scrollWidth > (container.clientWidth + widthBuffer) ) { maxVisibleTags--; renderTags(); From b047b95505e91b498940a6ad131c93490457d51b Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Mon, 29 Jun 2026 11:36:03 +0200 Subject: [PATCH 05/15] extracted repeated code to function in extendedHeaderFilter.js --- .../tabulator/filters/extendedHeaderFilter.js | 40 ++++++++++--------- public/js/tabulator/formatter/tags.js | 2 +- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index 79fd19743..9115b0785 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -168,22 +168,7 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { } }; - input.addEventListener("input", () => { - success(input.value); - }); - input.addEventListener("change", () => { - success(input.value); - }); - input.addEventListener("keydown", (event) => { - if (event.key !== "Escape") return; - - event.preventDefault(); - event.stopPropagation(); - clearFilter(); - }); - input.addEventListener("focus", (e) => { - e.stopPropagation(); - + const openDropdown = () => { if (activeTagDropdownCleanup) { activeTagDropdownCleanup(); } @@ -206,9 +191,28 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { window.addEventListener("scroll", positionDropdown, true); window.addEventListener("resize", positionDropdown); document.addEventListener("mousedown", handleOutsideClick, true); - }); + }; - input.addEventListener("blur", (e) => { + input.addEventListener("input", () => { + success(input.value); + }); + input.addEventListener("change", () => { + success(input.value); + }); + input.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + + event.preventDefault(); + event.stopPropagation(); + + clearFilter({closeDropdownAfterClear: false}); + + openDropdown(); + }); + input.addEventListener("focus", (e) => { + e.stopPropagation(); + + openDropdown(); }); container.appendChild(input); diff --git a/public/js/tabulator/formatter/tags.js b/public/js/tabulator/formatter/tags.js index 06d252d0e..48a66a10c 100644 --- a/public/js/tabulator/formatter/tags.js +++ b/public/js/tabulator/formatter/tags.js @@ -94,7 +94,7 @@ export function tagFormatter(cell, tagComponent, onRendered) { return; } - let widthBuffer = 15; + let widthBuffer = 10; maxVisibleTags = parsedTags.length; renderTags(); From 5399375bc1e89aba1a46aaf1a0bc67c0152024ff Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Mon, 29 Jun 2026 13:12:29 +0200 Subject: [PATCH 06/15] add tags extraction from nested rows --- .../Stv/Studentenverwaltung/List.js | 14 ++-- .../tabulator/filters/extendedHeaderFilter.js | 70 ++++++++++++------- 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index 32791850f..34079dcf6 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -736,10 +736,16 @@ export default { }, updateTagFilterLabels() { this.tagFilterLabels.clear = this.$p.t("ui/filterdelete"); - this.tagFilterLabels.tag = 'Tag'; - this.tagFilterLabels.connectors.AND = this.$p.t("ui/andCondition").toUpperCase(); - this.tagFilterLabels.connectors.OR = this.$p.t("ui/orCondition").toUpperCase(); - this.tagFilterLabels.connectors.NOT = this.$p.t("ui/notCondition").toUpperCase(); + this.tagFilterLabels.tag = "Tag"; + this.tagFilterLabels.connectors.AND = this.$p + .t("ui/andCondition") + .toUpperCase(); + this.tagFilterLabels.connectors.OR = this.$p + .t("ui/orCondition") + .toUpperCase(); + this.tagFilterLabels.connectors.NOT = this.$p + .t("ui/notCondition") + .toUpperCase(); }, updateUrl(endpoint, first) { this.lastSelected = first ? undefined : this.selected; diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index 9115b0785..cbbc13875 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -61,33 +61,55 @@ export function buildTagHeaderFilterExpression(selectedTagOptions) { } export function buildTagOptionsFromRows(rows = []) { - const options = new Map(); + let options = []; + + let tags = [ + ...new Map( + getRawTagValuesFromRows(rows) + .filter((tag) => tag && tag.done !== true) + .map((tag) => { + return { + label: tag.beschreibung || tag.typ_kurzbz, + value: tag.beschreibung || tag.typ_kurzbz, + style: tag.style, + }; + }) + .map((obj) => [JSON.stringify(obj), obj]), + ).values(), + ]; + if (!Array.isArray(tags)) return; + + return tags; +} + +export function getRawTagValuesFromRows(rows = []) { + let tags = []; rows.forEach((row) => { - let tags = row.tags; + if (!row || (!row.tags?.length && !row._children?.length)) return; - if (typeof tags === "string") { - try { - tags = JSON.parse(tags); - } catch { - return; - } - } + let newTags = row.tags?.length + ? [row.tags] + : getRawTagValuesFromRows(row._children); - if (!Array.isArray(tags)) return; + newTags = newTags + .map((newTag) => { + if (typeof newTag === "string") { + try { + newTag = JSON.parse(newTag); + } catch { + return null; + } + } - tags - .filter((tag) => tag && tag.done !== true) - .forEach((tag) => { - options.set(tag.beschreibung, { - label: tag.beschreibung, - value: tag.beschreibung, - style: tag.style, - }); - }); + return newTag; + }) + .filter((newTag) => newTag !== null); + + tags = tags.concat(newTags); }); - return [...options.values()]; + return tags.flat(); } export function customTagFilter(cell, onRendered, success, cancel, params) { @@ -155,10 +177,7 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { input.value = ""; if (Array.isArray(params.selectedOptions)) { - params.selectedOptions.splice( - 0, - params.selectedOptions.length, - ); + params.selectedOptions.splice(0, params.selectedOptions.length); } success(""); @@ -205,7 +224,7 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { event.preventDefault(); event.stopPropagation(); - clearFilter({closeDropdownAfterClear: false}); + clearFilter({ closeDropdownAfterClear: false }); openDropdown(); }); @@ -335,7 +354,6 @@ function generateDropdownTable(options, selectedTagOptions, labels) { selectedTagOptions.splice(idx, 1); } } - }); td.appendChild(cb); From 03b2d3d1a461ffe46f94f2063d4005b0cbf1522b Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Mon, 29 Jun 2026 14:02:17 +0200 Subject: [PATCH 07/15] extracted some code related to tags from component to helper --- .../Stv/Studentenverwaltung/List.js | 70 +++++++------------ .../tabulator/filters/extendedHeaderFilter.js | 55 +++++++++++++++ 2 files changed, 79 insertions(+), 46 deletions(-) diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index 34079dcf6..83629a8aa 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -3,9 +3,10 @@ import ListNew from "./List/New.js"; import CoreTag from "../../Tag/Tag.js"; import { buildTagHeaderFilterExpression, - buildTagOptionsFromRows, customTagFilter, setTagHeaderFilterValue, + syncSelectedTagOptionsWithHeaderFilters, + syncTagHeaderFilterOptions, tagHeaderFilter, } from "../../../tabulator/filters/extendedHeaderFilter.js"; import { @@ -438,8 +439,10 @@ export default { this.count = 0; this.allPrestudents = []; } - this.refreshTagHeaderFilterInitialOptions( + syncTagHeaderFilterOptions( Array.isArray(data) ? data : [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, ); }, }, @@ -447,7 +450,11 @@ export default { event: "dataFiltered", handler: (filters, rows) => { this.filteredcount = rows.length; - this.syncSelectedTagOptionsWithHeaderFilters(filters); + syncSelectedTagOptionsWithHeaderFilters( + filters, + this.tagFilterState.selectedOptions, + this.tagsEnabled, + ); }, }, { @@ -594,7 +601,6 @@ export default { this.$refs.table.tabulator, combinedFilterStatement, ); - this.$emit("filterActive", selectedOptions.length > 0); }, deep: true, }, @@ -866,38 +872,27 @@ export default { //methods tags addedTag(addedTag) { addTagInTable(addedTag, this.allRows, "prestudent_id"); - this.refreshTagHeaderFilterInitialOptions(); + syncTagHeaderFilterOptions( + this.$refs.table?.tabulator?.getData() || [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, + ); }, deletedTag(deletedTag) { deleteTagInTable(deletedTag, this.allRows); - this.refreshTagHeaderFilterInitialOptions(); + syncTagHeaderFilterOptions( + this.$refs.table?.tabulator?.getData() || [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, + ); }, updatedTag(updatedTag) { updateTagInTable(updatedTag, this.allRows); - this.refreshTagHeaderFilterInitialOptions(); - }, - refreshTagHeaderFilterInitialOptions(rows) { - const data = rows || this.$refs.table?.tabulator?.getData() || []; - - const options = buildTagOptionsFromRows(data); - this.tagFilterState.initialOptions.splice( - 0, - this.tagFilterState.initialOptions.length, - ...options, + syncTagHeaderFilterOptions( + this.$refs.table?.tabulator?.getData() || [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, ); - - const availableValues = new Set(options.map((option) => option.value)); - for ( - let i = this.tagFilterState.selectedOptions.length - 1; - i >= 0; - i-- - ) { - if ( - !availableValues.has(this.tagFilterState.selectedOptions[i].value) - ) { - this.tagFilterState.selectedOptions.splice(i, 1); - } - } }, getAllRows() { this.allRows = this.$refs.table.tabulator.getRows(); @@ -913,23 +908,6 @@ export default { this.tagFilterState.selectedOptions.length, ); }, - syncSelectedTagOptionsWithHeaderFilters(filters) { - if (!this.tagsEnabled || !this.tagFilterState.selectedOptions.length) { - return; - } - - const hasActiveTagFilter = filters.some( - (filter) => - filter.field === "tags" && - filter.value !== "" && - filter.value !== null && - filter.value !== undefined, - ); - - if (!hasActiveTagFilter) { - this.clearSelectedTagHeaderFilters(); - } - }, handleHeaderFilter(filterActive) { this.headerFilterActive = filterActive; }, diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index cbbc13875..6e58758a2 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -82,6 +82,61 @@ export function buildTagOptionsFromRows(rows = []) { return tags; } +export function syncTagHeaderFilterOptions( + rows = [], + initialOptions = [], + selectedOptions = [], +) { + const options = buildTagOptionsFromRows(rows) || []; + + if (Array.isArray(initialOptions)) { + initialOptions.splice( + 0, + initialOptions.length, + ...options, + ); + } + + if (Array.isArray(selectedOptions)) { + const availableValues = new Set(options.map((option) => option.value)); + + for (let i = selectedOptions.length - 1; i >= 0; i--) { + if (!availableValues.has(selectedOptions[i].value)) { + selectedOptions.splice(i, 1); + } + } + } + + return options; +} + +export function syncSelectedTagOptionsWithHeaderFilters( + filters = [], + selectedOptions = [], + tagsEnabled = true, + field = "tags", +) { + if (!tagsEnabled || !Array.isArray(selectedOptions) || !selectedOptions.length) { + return false; + } + + const hasActiveTagFilter = filters.some( + (filter) => + filter.field === field && + filter.value !== "" && + filter.value !== null && + filter.value !== undefined, + ); + + if (hasActiveTagFilter) { + return false; + } + + selectedOptions.splice(0, selectedOptions.length); + + return true; +} + export function getRawTagValuesFromRows(rows = []) { let tags = []; From 086b271480c3ad7f4c05a926979ed4fb3071997b Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Mon, 29 Jun 2026 14:47:43 +0200 Subject: [PATCH 08/15] add implementation for custom tag filter in "LVVerwaltung" --- .../js/components/LVVerwaltung/Setup/Table.js | 110 ++++++++++++++++-- 1 file changed, 102 insertions(+), 8 deletions(-) diff --git a/public/js/components/LVVerwaltung/Setup/Table.js b/public/js/components/LVVerwaltung/Setup/Table.js index 67c63c3bb..6d8a944f9 100644 --- a/public/js/components/LVVerwaltung/Setup/Table.js +++ b/public/js/components/LVVerwaltung/Setup/Table.js @@ -2,8 +2,16 @@ import {CoreFilterCmpt} from "../../filter/Filter.js"; import BsModal from "../../Bootstrap/Modal.js"; import DetailsForm from "../Details/Form.js"; import CoreTag from '../../Tag/Tag.js'; -import { tagHeaderFilter } from '../../../../js/tabulator/filters/extendedHeaderFilter.js'; -import { extendedHeaderFilter } from "../../../../js/tabulator/filters/extendedHeaderFilter.js"; +import { + buildTagHeaderFilterExpression, + buildTagOptionsFromRows, + customTagFilter, + setTagHeaderFilterValue, + tagHeaderFilter, + extendedHeaderFilter, + syncTagHeaderFilterOptions, + syncSelectedTagOptionsWithHeaderFilters +} from "../../../../js/tabulator/filters/extendedHeaderFilter.js"; import { tagFormatter } from "../../../../js/tabulator/formatter/tags.js"; import { addTagInTable, deleteTagInTable, updateTagInTable } from "../../../../js/helpers/TagHelper.js"; @@ -65,7 +73,20 @@ export default { this.lv_info_default.studiensemester_kurzbz = newVal this.lv_info = false; } - } + }, + "tagFilterState.selectedOptions": { + handler() { + const selectedOptions = this.tagFilterState.selectedOptions; + const combinedFilterStatement = + buildTagHeaderFilterExpression(selectedOptions); + + setTagHeaderFilterValue( + this.$refs.table.tabulator, + combinedFilterStatement, + ); + }, + deep: true, + }, }, data() { return { @@ -96,8 +117,37 @@ export default { handler: (data) => { this.getExpandedRows() } - } + }, + { + event: "dataLoaded", + handler: (data) => { + syncTagHeaderFilterOptions( + Array.isArray(data) ? data : [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, + ); + }, + }, + { + event: "dataFiltered", + handler: (filters, rows) => { + syncSelectedTagOptionsWithHeaderFilters( + filters, + this.tagFilterState.selectedOptions, + this.tagsEnabled, + ); + }, + }, + { + event: "columnWidth", + handler: (column) => { + if (column.getField() !== "tags") return; + column.getCells().forEach((cell) => { + cell.getElement().firstElementChild?.fitTags?.(); + }); + }, + }, ], formData: {}, lv_info: false, @@ -111,7 +161,20 @@ export default { raumtypalternativ: this.lehreinheitRaumtypAlternativeDefault, lehrfach_id: '' - } + }, + tagFilterState: { + initialOptions: [], + selectedOptions: [], + }, + tagFilterLabels: { + tag: "Tag", + clear: "Clear", + connectors: { + AND: "AND", + OR: "OR", + NOT: "NOT", + }, + }, } }, computed: { @@ -140,6 +203,14 @@ export default { headerFilterFunc: extendedHeaderFilter, }, layout: 'fitDataStretch', + persistence: { + sort: true, + columns: ["width", "visible"], + filter: false, + headerFilter: false, + group: false, + page: false, + }, persistenceID: 'lehrveranstaltungen_2025_07_31_v1', selectableRowsRangeMode: 'click', selectableRows: true, @@ -222,10 +293,18 @@ export default { title: 'Tags', field: 'tags', tooltip: false, - headerFilter: "input", + headerFilter: customTagFilter, + headerFilterParams: { + listOnEmpty: true, + autocomplete: true, + sort: "asc", + initialOptions: this.tagFilterState.initialOptions, + selectedOptions: this.tagFilterState.selectedOptions, + labels: this.tagFilterLabels, + }, headerFilterFunc: tagHeaderFilter, headerFilterFuncParams: {field: 'tags'}, - formatter: (cell) => tagFormatter(cell, this.$refs.tagComponent), + formatter: (cell, formatterParams, onRendered) => tagFormatter(cell, this.$refs.tagComponent, onRendered), width: 150, }, { @@ -381,16 +460,31 @@ export default { }, addedTag(addedTag) { - addTagInTable(addedTag, this.allRows, 'lehreinheit_id') + addTagInTable(addedTag, this.allRows, 'lehreinheit_id'); + syncTagHeaderFilterOptions( + this.$refs.table?.tabulator?.getData() || [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, + ); }, deletedTag(deletedTag) { deleteTagInTable(deletedTag, this.allRows); + syncTagHeaderFilterOptions( + this.$refs.table?.tabulator?.getData() || [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, + ); }, updatedTag(updatedTag) { updateTagInTable(updatedTag, this.allRows); + syncTagHeaderFilterOptions( + this.$refs.table?.tabulator?.getData() || [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, + ); }, async copyLehreinheit(row, art) { From 69b19562f4975fa4d804bbbbd56be6e90c5d2612 Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Tue, 30 Jun 2026 10:27:16 +0200 Subject: [PATCH 09/15] BUG: add issue fix for tag ordering --- application/controllers/api/v1/system/Tag.php | 1 + application/core/Tag_Controller.php | 7 ++++++- application/libraries/stv/StudentListLib.php | 3 ++- .../models/education/Lehreinheit_model.php | 5 +++-- .../components/DetailHeader/DetailHeader.js | 10 +++++++++- public/js/components/Tag/Tag.js | 9 ++++++--- public/js/components/Tag/tagFormatter.js | 2 +- public/js/helpers/TagHelper.js | 16 ++++++++++++++- .../tabulator/filters/extendedHeaderFilter.js | 20 ++++++++++++------- public/js/tabulator/formatter/tags.js | 2 +- 10 files changed, 57 insertions(+), 18 deletions(-) diff --git a/application/controllers/api/v1/system/Tag.php b/application/controllers/api/v1/system/Tag.php index 7ce375217..df3003b44 100644 --- a/application/controllers/api/v1/system/Tag.php +++ b/application/controllers/api/v1/system/Tag.php @@ -37,6 +37,7 @@ class Tag extends API_Controller if (isset($tag)) { + $this->TagModel->addJoin('public.tbl_notiz_typ', 'typ_kurzbz', 'typ_kurzbz'); $result = $this->TagModel->load($tag); $this->response($result, REST_Controller::HTTP_OK); diff --git a/application/core/Tag_Controller.php b/application/core/Tag_Controller.php index fb14563ba..322831022 100644 --- a/application/core/Tag_Controller.php +++ b/application/core/Tag_Controller.php @@ -70,6 +70,7 @@ class Tag_Controller extends FHCAPI_Controller tbl_notiz.notiz_id, tbl_notiz_typ.style, tbl_notiz_typ.automatisiert, + tbl_notiz_typ.prioritaet, tbl_notiz.erledigt as done, tbl_notiz.insertamum, tbl_notiz.updateamum, @@ -103,7 +104,8 @@ class Tag_Controller extends FHCAPI_Controller style, beschreibung, tag, - automatisiert + automatisiert, + prioritaet " ); $this->NotiztypModel->addOrder('prioritaet'); @@ -311,6 +313,7 @@ class Tag_Controller extends FHCAPI_Controller tbl_notiz.notiz_id, tbl_notiz_typ.style, tbl_notiz_typ.automatisiert, + tbl_notiz_typ.prioritaet, tbl_notiz.erledigt as done, tbl_notiz.insertamum, tbl_notiz.updateamum, @@ -330,6 +333,8 @@ class Tag_Controller extends FHCAPI_Controller $this->NotizModel->addJoin('public.tbl_notizzuordnung notizzuordnung', 'tbl_notiz.notiz_id = notizzuordnung.notiz_id'); + $this->NotizModel->addOrder('done'); + $this->NotizModel->addOrder('tbl_notiz_typ.prioritaet'); $notiz = $this->NotizModel->loadWhere(array('prestudent_id' => $prestudent_id)); diff --git a/application/libraries/stv/StudentListLib.php b/application/libraries/stv/StudentListLib.php index a00f92822..5ce9f354d 100644 --- a/application/libraries/stv/StudentListLib.php +++ b/application/libraries/stv/StudentListLib.php @@ -321,7 +321,7 @@ class StudentListLib $subQueryTag = "( SELECT tag.prestudent_id, - COALESCE(json_agg(tag ORDER BY tag.done), '[]'::json) AS tags + COALESCE(json_agg(tag ORDER BY tag.done, tag.prioritaet), '[]'::json) AS tags FROM ( SELECT DISTINCT ON (n.notiz_id) n.notiz_id AS id, @@ -330,6 +330,7 @@ class StudentListLib n.text AS notiz, nt.style, n.erledigt AS done, + nt.prioritaet AS prioritaet, nz.prestudent_id, n.start, n.ende diff --git a/application/models/education/Lehreinheit_model.php b/application/models/education/Lehreinheit_model.php index 2f955c295..5b2cc2cf1 100644 --- a/application/models/education/Lehreinheit_model.php +++ b/application/models/education/Lehreinheit_model.php @@ -719,7 +719,7 @@ EOSQL; return "tag_data_agg AS ( SELECT lehreinheit_id, - COALESCE(json_agg(tag ORDER BY done), '[]'::json) AS tags + COALESCE(json_agg(tag ORDER BY done, prioritaet), '[]'::json) AS tags FROM ( SELECT DISTINCT ON (public.tbl_notiz.notiz_id) tbl_notiz.notiz_id AS id, @@ -728,7 +728,8 @@ EOSQL; text AS notiz, style, erledigt AS done, - lehreinheit_id + lehreinheit_id, + prioritaet FROM public.tbl_notizzuordnung JOIN public.tbl_notiz ON tbl_notizzuordnung.notiz_id = tbl_notiz.notiz_id JOIN public.tbl_notiz_typ ON tbl_notiz.typ = tbl_notiz_typ.typ_kurzbz diff --git a/public/js/components/DetailHeader/DetailHeader.js b/public/js/components/DetailHeader/DetailHeader.js index 3509761e3..a444c7957 100644 --- a/public/js/components/DetailHeader/DetailHeader.js +++ b/public/js/components/DetailHeader/DetailHeader.js @@ -300,7 +300,15 @@ export default { return this.$api .call(ApiTag.getAllTagsPrestudent({prestudent_id})) .then(result => { - this.tagData = result.data; + this.tagData = result.data.sort((a, b) => { + let adone = a.done ? 1 : 0; + let bdone = b.done ? 1 : 0; + + if (adone !== bdone) { + return adone - bdone; + } + return a.prioritaet - b.prioritaet; + }); }) .catch(this.$fhcAlert.handleSystemError); }, diff --git a/public/js/components/Tag/Tag.js b/public/js/components/Tag/Tag.js index 894da343e..faef12f15 100644 --- a/public/js/components/Tag/Tag.js +++ b/public/js/components/Tag/Tag.js @@ -47,7 +47,8 @@ export default { updatevon: "", response: "", start: "", - ende: "" + ende: "", + prioritaet: 100, }, mode: "create" }; @@ -94,7 +95,8 @@ export default { this.tagData.automatisiert = item.automatisiert; this.tagData.start = this.formatDateTimeDay(item.start); this.tagData.ende = this.formatDateTimeDay(item.ende); - + this.tagData.prioritaet = item.prioritaet || 100; + if (item && item.notiz_id) { this.selectedTagId = item.notiz_id; @@ -192,7 +194,8 @@ export default { updateamum: "", bearbeiter: "", response: "", - readonly: false + readonly: false, + prioritaet: 100, }; this.selectedTagId = null; this.mode = "create"; diff --git a/public/js/components/Tag/tagFormatter.js b/public/js/components/Tag/tagFormatter.js index 4040111b9..072906be8 100644 --- a/public/js/components/Tag/tagFormatter.js +++ b/public/js/components/Tag/tagFormatter.js @@ -59,7 +59,7 @@ export function idTagFormatter (id, tagData, tagComponent, typeId, semesterStart let bdone = b.done ? 1 : 0; if (adone !== bdone) return adone - bdone; - return b.id - a.id; + return a.prioritaet - b.prioritaet; }); const tagsToShow = expanded diff --git a/public/js/helpers/TagHelper.js b/public/js/helpers/TagHelper.js index 3d287c792..826e4126a 100644 --- a/public/js/helpers/TagHelper.js +++ b/public/js/helpers/TagHelper.js @@ -33,8 +33,14 @@ export function addTagInTable(addedTag, rows, matchKey, tagsKey = "tags") let newTag = { ...addedTag, id: tag.id }; - tags.unshift(newTag); + tags.push(newTag); + tags.sort((a, b) => { + let adone = a.done ? 1 : 0; + let bdone = b.done ? 1 : 0; + if (adone !== bdone) return adone - bdone; + return a.prioritaet - b.prioritaet; + }); rowData[tagsKey] = JSON.stringify(tags); updated = true; }); @@ -122,6 +128,14 @@ export function updateTagInTable(updatedTag, rows, fields = ['tags']) if (index !== -1) { fieldData[index] = { ...updatedTag }; + fieldData.sort((a, b) => { + let adone = a.done ? 1 : 0; + let bdone = b.done ? 1 : 0; + + if (adone !== bdone) return adone - bdone; + return a.prioritaet - b.prioritaet; + }); + let updatedFieldData = JSON.stringify(fieldData); if (updatedFieldData !== rowData[field]) diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index 6e58758a2..c3e51f815 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -4,20 +4,23 @@ const TAG_FILTER_CONNECTORS = { key: "AND", label: "AND", operator: "&&", + isVisible: false, }, OR: { key: "OR", label: "OR", operator: "||", + isVisible: true, }, NOT: { key: "NOT", label: "NOT", operator: "!", + isVisible: true, }, }; const TAG_FILTER_CONNECTOR_ORDER = [ - TAG_FILTER_CONNECTORS.AND, + //TAG_FILTER_CONNECTORS.AND, TAG_FILTER_CONNECTORS.OR, TAG_FILTER_CONNECTORS.NOT, ]; @@ -67,6 +70,9 @@ export function buildTagOptionsFromRows(rows = []) { ...new Map( getRawTagValuesFromRows(rows) .filter((tag) => tag && tag.done !== true) + .sort((a, b) => { + return a.prioritaet - b.prioritaet; + }) .map((tag) => { return { label: tag.beschreibung || tag.typ_kurzbz, @@ -90,11 +96,7 @@ export function syncTagHeaderFilterOptions( const options = buildTagOptionsFromRows(rows) || []; if (Array.isArray(initialOptions)) { - initialOptions.splice( - 0, - initialOptions.length, - ...options, - ); + initialOptions.splice(0, initialOptions.length, ...options); } if (Array.isArray(selectedOptions)) { @@ -116,7 +118,11 @@ export function syncSelectedTagOptionsWithHeaderFilters( tagsEnabled = true, field = "tags", ) { - if (!tagsEnabled || !Array.isArray(selectedOptions) || !selectedOptions.length) { + if ( + !tagsEnabled || + !Array.isArray(selectedOptions) || + !selectedOptions.length + ) { return false; } diff --git a/public/js/tabulator/formatter/tags.js b/public/js/tabulator/formatter/tags.js index 48a66a10c..5cd848bbd 100644 --- a/public/js/tabulator/formatter/tags.js +++ b/public/js/tabulator/formatter/tags.js @@ -35,7 +35,7 @@ export function tagFormatter(cell, tagComponent, onRendered) { if (adone !== bbone) { return adone - bbone; } - return b.id - a.id; + return a.prioritaet - b.prioritaet; }); const tagsToShow = rowData._tagExpanded ? parsedTags From f9b3939d093133de43047b3e8d4eb5bfddc09e7d Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Tue, 30 Jun 2026 10:33:24 +0200 Subject: [PATCH 10/15] BUG: add issue fix for tag dropdown close on column drag --- public/js/tabulator/filters/extendedHeaderFilter.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index c3e51f815..95baacdc9 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -291,9 +291,12 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { }); input.addEventListener("focus", (e) => { e.stopPropagation(); - openDropdown(); }); + input.addEventListener("blur", (e) => { + e.stopPropagation(); + clearFilter(); + }); container.appendChild(input); From eb885bfea7a5f41d65a9f7cf65e62dd6fc2b6242 Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Tue, 30 Jun 2026 14:20:04 +0200 Subject: [PATCH 11/15] BUG: fix issue with unexpected closing of tag filter dropdown --- public/js/tabulator/filters/extendedHeaderFilter.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index 95baacdc9..0f8acdc3c 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -293,10 +293,6 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { e.stopPropagation(); openDropdown(); }); - input.addEventListener("blur", (e) => { - e.stopPropagation(); - clearFilter(); - }); container.appendChild(input); From 6c550372a30015adb15babb9b3f42c2df4e2d1e3 Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Tue, 7 Jul 2026 15:49:16 +0200 Subject: [PATCH 12/15] add definable tag column in modified helpers --- application/libraries/stv/StudentListLib.php | 1 + .../models/education/Lehreinheit_model.php | 3 +- .../js/components/LVVerwaltung/Setup/Table.js | 2 +- .../Stv/Studentenverwaltung/List.js | 4 +- .../tabulator/filters/extendedHeaderFilter.js | 43 +++++++++++-------- public/js/tabulator/formatter/tags.js | 6 +-- 6 files changed, 34 insertions(+), 25 deletions(-) diff --git a/application/libraries/stv/StudentListLib.php b/application/libraries/stv/StudentListLib.php index 5ce9f354d..a00d2e153 100644 --- a/application/libraries/stv/StudentListLib.php +++ b/application/libraries/stv/StudentListLib.php @@ -331,6 +331,7 @@ class StudentListLib nt.style, n.erledigt AS done, nt.prioritaet AS prioritaet, + nt.automatisiert AS automatisiert, nz.prestudent_id, n.start, n.ende diff --git a/application/models/education/Lehreinheit_model.php b/application/models/education/Lehreinheit_model.php index 5b2cc2cf1..808c4e443 100644 --- a/application/models/education/Lehreinheit_model.php +++ b/application/models/education/Lehreinheit_model.php @@ -729,7 +729,8 @@ EOSQL; style, erledigt AS done, lehreinheit_id, - prioritaet + prioritaet, + automatisiert FROM public.tbl_notizzuordnung JOIN public.tbl_notiz ON tbl_notizzuordnung.notiz_id = tbl_notiz.notiz_id JOIN public.tbl_notiz_typ ON tbl_notiz.typ = tbl_notiz_typ.typ_kurzbz diff --git a/public/js/components/LVVerwaltung/Setup/Table.js b/public/js/components/LVVerwaltung/Setup/Table.js index 6d8a944f9..f07748413 100644 --- a/public/js/components/LVVerwaltung/Setup/Table.js +++ b/public/js/components/LVVerwaltung/Setup/Table.js @@ -81,8 +81,8 @@ export default { buildTagHeaderFilterExpression(selectedOptions); setTagHeaderFilterValue( - this.$refs.table.tabulator, combinedFilterStatement, + this.$refs.table.tabulator ); }, deep: true, diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index 83629a8aa..45ac50cfc 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -494,7 +494,6 @@ export default { count: 0, filteredcount: 0, selectedcount: 0, - //tags expanded: [], tagEndpoint: ApiTag, currentEndpoint: null, @@ -598,8 +597,8 @@ export default { buildTagHeaderFilterExpression(selectedOptions); setTagHeaderFilterValue( - this.$refs.table.tabulator, combinedFilterStatement, + this.$refs.table.tabulator, ); }, deep: true, @@ -869,7 +868,6 @@ export default { this.lastSelected = []; this.$emit("update:selected", []); }, - //methods tags addedTag(addedTag) { addTagInTable(addedTag, this.allRows, "prestudent_id"); syncTagHeaderFilterOptions( diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index 0f8acdc3c..89d250d1c 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -29,11 +29,11 @@ function getConnectorLabel(connector, labels) { return labels?.connectors?.[connector.key] || connector.label; } -export function setTagHeaderFilterValue(tabulator, value) { - tabulator?.setHeaderFilterValue("tags", value); +export function setTagHeaderFilterValue(value, tabulator, columnField = "tags") { + tabulator?.setHeaderFilterValue(columnField, value); const input = tabulator - ?.getColumn("tags") + ?.getColumn(columnField) ?.getElement() ?.querySelector(".tabulator-header-filter input"); @@ -63,12 +63,12 @@ export function buildTagHeaderFilterExpression(selectedTagOptions) { return expression; } -export function buildTagOptionsFromRows(rows = []) { +export function buildTagOptionsFromRows(rows, field = "tags") { let options = []; - let tags = [ + let parsedTags = [ ...new Map( - getRawTagValuesFromRows(rows) + getRawTagValuesFromRows(rows, field) .filter((tag) => tag && tag.done !== true) .sort((a, b) => { return a.prioritaet - b.prioritaet; @@ -78,22 +78,25 @@ export function buildTagOptionsFromRows(rows = []) { label: tag.beschreibung || tag.typ_kurzbz, value: tag.beschreibung || tag.typ_kurzbz, style: tag.style, + automatisiert: tag.automatisiert, + done: tag.done, }; }) .map((obj) => [JSON.stringify(obj), obj]), ).values(), ]; - if (!Array.isArray(tags)) return; + if (!Array.isArray(parsedTags)) return; - return tags; + return parsedTags; } export function syncTagHeaderFilterOptions( rows = [], initialOptions = [], selectedOptions = [], + field = "tags" ) { - const options = buildTagOptionsFromRows(rows) || []; + const options = buildTagOptionsFromRows(rows, field) || []; if (Array.isArray(initialOptions)) { initialOptions.splice(0, initialOptions.length, ...options); @@ -143,15 +146,15 @@ export function syncSelectedTagOptionsWithHeaderFilters( return true; } -export function getRawTagValuesFromRows(rows = []) { - let tags = []; +export function getRawTagValuesFromRows(rows = [], field = "tags") { + let parsedTags = []; rows.forEach((row) => { - if (!row || (!row.tags?.length && !row._children?.length)) return; + if (!row || (!row[field]?.length && !row._children?.length)) return; - let newTags = row.tags?.length - ? [row.tags] - : getRawTagValuesFromRows(row._children); + let newTags = row[field]?.length + ? [row[field]] + : getRawTagValuesFromRows(row._children, field); newTags = newTags .map((newTag) => { @@ -167,10 +170,10 @@ export function getRawTagValuesFromRows(rows = []) { }) .filter((newTag) => newTag !== null); - tags = tags.concat(newTags); + parsedTags = parsedTags.concat(newTags); }); - return tags.flat(); + return parsedTags.flat(); } export function customTagFilter(cell, onRendered, success, cancel, params) { @@ -380,6 +383,12 @@ function generateDropdownTable(options, selectedTagOptions, labels) { const label = document.createElement("span"); label.className = "tag " + option.style; label.innerText = option.label; + + if (option.automatisiert) { + label.innerHTML = " " + option.label; + label.className += " tag_auto"; + } + labelTd.appendChild(label); row.appendChild(labelTd); diff --git a/public/js/tabulator/formatter/tags.js b/public/js/tabulator/formatter/tags.js index 5cd848bbd..d78ed2026 100644 --- a/public/js/tabulator/formatter/tags.js +++ b/public/js/tabulator/formatter/tags.js @@ -1,5 +1,5 @@ -export function tagFormatter(cell, tagComponent, onRendered) { - const mappedData = tagComponent.tags.map((tag) => ({ +export function tagFormatter(cell, tagComponent, onRendered, tagKey = "tags") { + const mappedData = tagComponent[tagKey]?.map((tag) => ({ typ_kurzbz: tag.tag_typ_kurzbz, automatisiert: tag.automatisiert, })); @@ -49,7 +49,7 @@ export function tagFormatter(cell, tagComponent, onRendered) { tagElement.className = "tag " + tag.style; if (tag.done) tagElement.className += " tag_done"; - const tagDef = mappedData.find((t) => t.typ_kurzbz === tag.typ_kurzbz); + const tagDef = mappedData?.find((t) => t.typ_kurzbz === tag.typ_kurzbz); if ( (!tagDef && tag.typ_kurzbz?.includes("_auto")) || From 0be3505163013be0d0170684bd45cc7aee7adba5 Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Tue, 7 Jul 2026 16:50:44 +0200 Subject: [PATCH 13/15] BUG: Add fix for tag dropdown issue during tag column drag --- public/js/tabulator/filters/extendedHeaderFilter.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index 89d250d1c..f17783765 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -276,6 +276,14 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { document.addEventListener("mousedown", handleOutsideClick, true); }; + const stopHeaderFilterGesture = (event) => { + event.stopPropagation(); + }; + + input.addEventListener("pointerdown", stopHeaderFilterGesture); + input.addEventListener("mousedown", stopHeaderFilterGesture); + input.addEventListener("touchstart", stopHeaderFilterGesture); + input.addEventListener("click", stopHeaderFilterGesture); input.addEventListener("input", () => { success(input.value); }); From f3b617d339cf60e3cce4007e568e70107a89be9b Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Tue, 7 Jul 2026 17:13:51 +0200 Subject: [PATCH 14/15] add removal of tag header sorting in LV and Student verwaltung --- public/js/components/LVVerwaltung/Setup/Table.js | 1 + public/js/components/Stv/Studentenverwaltung/List.js | 1 + 2 files changed, 2 insertions(+) diff --git a/public/js/components/LVVerwaltung/Setup/Table.js b/public/js/components/LVVerwaltung/Setup/Table.js index f07748413..b9311ec68 100644 --- a/public/js/components/LVVerwaltung/Setup/Table.js +++ b/public/js/components/LVVerwaltung/Setup/Table.js @@ -306,6 +306,7 @@ export default { headerFilterFuncParams: {field: 'tags'}, formatter: (cell, formatterParams, onRendered) => tagFormatter(cell, this.$refs.tagComponent, onRendered), width: 150, + headerSort: false, }, { title: this.$p.t('lehre', 'lehrveranstaltung_id'), diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index 45ac50cfc..c175a465b 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -961,6 +961,7 @@ export default { formatter: (cell, formatterParams, onRendered) => tagFormatter(cell, this.$refs.tagComponent, onRendered), width: 150, + headerSort: false, }; this.tabulatorOptions.columns.splice(2, 0, coltags); this.$p.loadCategory("ui").then(() => { From 86c06e4756b45f969e23d79f9a299711c51e8975 Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Fri, 10 Jul 2026 11:50:07 +0200 Subject: [PATCH 15/15] BUG: issue fix for dropdown reposition errror --- .../tabulator/filters/extendedHeaderFilter.js | 26 +++++++++++++++---- tests/cypress/.gitignore | 5 +++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index f17783765..8626ad4f9 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -64,8 +64,6 @@ export function buildTagHeaderFilterExpression(selectedTagOptions) { } export function buildTagOptionsFromRows(rows, field = "tags") { - let options = []; - let parsedTags = [ ...new Map( getRawTagValuesFromRows(rows, field) @@ -82,7 +80,7 @@ export function buildTagOptionsFromRows(rows, field = "tags") { done: tag.done, }; }) - .map((obj) => [JSON.stringify(obj), obj]), + .map((obj) => [obj.value, obj]), ).values(), ]; if (!Array.isArray(parsedTags)) return; @@ -184,6 +182,7 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { input.style.width = "100%"; input.type = "text"; let dropdownMenu = null; + let suppressScrollCloseUntil = 0; Object.defineProperty(container, "value", { get: () => input.value, @@ -221,11 +220,27 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { activeTagDropdownCleanup = null; } - window.removeEventListener("scroll", positionDropdown, true); + window.removeEventListener("scroll", handleScroll, true); window.removeEventListener("resize", positionDropdown); document.removeEventListener("mousedown", handleOutsideClick, true); }; + const suppressScrollClose = () => { + suppressScrollCloseUntil = Date.now() + 300; + }; + + const handleScroll = (event) => { + const target = event.target; + const isDropdownScroll = + target instanceof Node && dropdownMenu?.contains(target); + + if (isDropdownScroll || Date.now() < suppressScrollCloseUntil) { + return; + } + + closeDropdown(); + }; + const handleOutsideClick = (event) => { if ( dropdownMenu?.contains(event.target) || @@ -265,13 +280,14 @@ export function customTagFilter(cell, onRendered, success, cancel, params) { dropdownMenu.addEventListener("mousedown", (event) => { event.stopPropagation(); }); + dropdownMenu.addEventListener("click", suppressScrollClose, true); document.body.appendChild(dropdownMenu); activeTagDropdownCleanup = closeDropdown; positionDropdown(); - window.addEventListener("scroll", positionDropdown, true); + window.addEventListener("scroll", handleScroll, true); window.addEventListener("resize", positionDropdown); document.addEventListener("mousedown", handleOutsideClick, true); }; diff --git a/tests/cypress/.gitignore b/tests/cypress/.gitignore index 48e0213b8..25e6d917c 100644 --- a/tests/cypress/.gitignore +++ b/tests/cypress/.gitignore @@ -1,4 +1,7 @@ node_modules .env .env.* -!.env.example \ No newline at end of file +!.env.example +/cypress/videos +/cypress/screenshots +/results \ No newline at end of file