manage default myLv layout mode via localStorage; actions row takes remaining space via fitDataStretch and wraps around into new row if action buttons take up too much space; added tabulator persistence on TableBuilt event; slight watcher adjustments to combat race conditions; loading spinner while tabulatorUuid has not been defined yet -> maybe worth improving but seems to work fine;

This commit is contained in:
Johann Hoffmann
2026-04-23 15:59:38 +02:00
parent c36f259571
commit e86e7f0bd8
5 changed files with 190 additions and 124 deletions
@@ -62,6 +62,10 @@ class LvMenu extends FHCAPI_Controller
/**
* alternative function to get multiple lvMenus with a single http request
* not yet working as intended as the menu_lv.inc.php scripts called by the
* lvMenuBuild event have logic coupled to require_once import which results in
* a wrong logic after the first invocation -> faulty results for lvinfo, moodle
* and several others
*/
public function getMultipleLvMenu(){
$lvMenuOptionList = $this->input->post('lvMenuOptionList', true);
+5
View File
@@ -11,3 +11,8 @@
.mylv-semester-table .tabulator-cell {
overflow: unset;
}
.mylv-semester-table .tabulator-cell .action-col {
/*min-height: 2.5rem;*/
align-items: flex-start; /* so wrapped rows don't stretch vertically */
}
+2 -14
View File
@@ -17,7 +17,7 @@ export default {
studiensemester: null,
lvs: {},
currentSemester: null,
mode: 'table' // TODO: load from local storage
mode: localStorage.getItem('myLvaDefaultMode') ?? 'cards'
};
},
provide() {
@@ -47,19 +47,6 @@ export default {
this.lvs[this.currentSemester].lvs = res.data.retval || [];
this.firstLoad = false;
// pretty slow to load all at once if one lva has a weird moodle / lvinfo situation
// that multiplies the loadtime for everything by a factor of 3-5
// this.$api.call(ApiAddons.getMultipleLvMenu(this.lvs[this.currentSemester].lvs, this.currentSemester)).then(res => {
// if(res.data) {
// Object.entries(res.data).forEach((entry) => {
// // entry[0] -> key -> lvid
// // entry[1] -> value -> menu
// const lv = this.lvs[this.currentSemester].lvs.find(lv => lv.lehrveranstaltung_id == entry[0])
// lv.menu = entry[1]
// })
// }
// })
this.lvs[this.currentSemester].lvs.forEach(lv=>{
this.$api.call(ApiAddons.getLvMenu(lv.lehrveranstaltung_id, this.currentSemester)).then(res => {
@@ -108,6 +95,7 @@ export default {
},
methods: {
clickMode(evt, mode) {
localStorage.setItem('myLvaDefaultMode', mode)
this.mode = mode
},
prevSem() {
@@ -28,13 +28,13 @@ export default {
ects: String,
incoming: Number,
positiv: Boolean,
note_index: String
note_index: String,
menu: [Array, String]
},
data: () => {
return {
pruefungenData: null,
info: null,
menu: null,
preselectedMenuItem: null,
}
},
@@ -104,11 +104,6 @@ export default {
});
}
},
watch:{
studien_semester(newValue){
this.fetchMenu(this.lehrveranstaltung_id, newValue);
}
},
created() {
if(this.type == 'student') {
this.$api
@@ -119,9 +114,6 @@ export default {
});
}
},
mounted() {
this.fetchMenu(this.lehrveranstaltung_id, this.studien_semester);
},
template: /*html*/`
<div class="mylv-semester-studiengang-lv card">
<div class="p-2" :class="is_organisatorische_einheit?'':'card-header'">
+145 -68
View File
@@ -13,40 +13,34 @@ export default {
return {
phrasenPromise: null,
phrasenResolved: false,
tabulatorUuid: Vue.ref(0),
tabulatorUuid: null,
tableBuiltResolve: null,
tableBuiltPromise: null,
mylvTableOptions: {
height: Vue.ref(400),
index: 'lehrveranstaltung_id',
layout: 'fitData',
layout: 'fitDataStretch',
placeholder: this.$p.t('global/noDataAvailable'),
columns: [
{title: Vue.computed(() => this.$p.t('lehre/studiengang')), field: 'sg_bezeichnung', widthGrow: 1},
{title: Vue.computed(() => this.$p.t('global/bezeichnung')), field: 'bezeichnung', widthGrow: 2},
{title: Vue.computed(() => this.$p.t('lehre/orgform')), field: 'orgform_kurzbz', widthGrow: 1},
{title: Vue.computed(() => this.$p.t('lehre/kurzbz')), field: 'studiengang_kuerzel', widthGrow: 1},
{title: Vue.computed(() => this.$p.t('lehre/semesterstunden')), field: 'semesterstunden',
bottomCalc: this.semesterstundenCalc, widthGrow: 1, visible: true},
{title: Vue.computed(() => this.$p.t('global/actions')), headerSort: false,
{title: Vue.computed(() => this.$capitalize(this.$p.t('lehre/studiengang'))), field: 'sg_bezeichnung', widthGrow: 1},
{title: Vue.computed(() => this.$capitalize(this.$p.t('global/bezeichnung'))), field: 'bezeichnung', widthGrow: 2},
{title: Vue.computed(() => this.$capitalize(this.$p.t('lehre/orgform'))), field: 'orgform_kurzbz', widthGrow: 1},
{title: Vue.computed(() => this.$capitalize(this.$p.t('lehre/kurzbz'))), field: 'studiengang_kuerzel', widthGrow: 1},
{title: Vue.computed(() => this.$capitalize(this.$p.t('lehre/semesterstunden'))), field: 'semesterstunden',
bottomCalc: this.semesterstundenCalc, widthGrow: 1, visible: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('global/actions'))), headerSort: false,
field: 'menu', formatter: this.actionFormatter, widthGrow: 1, tooltip: this.spoofingFunc}
],
persistence: false,
persistenceID: "mylv_2026_04_17"
},
mylvTableEventHandlers: [
{
event: "tableBuilt",
handler: async () => {
this.tableBuiltResolve()
}
}
]
}
},
computed: {
ready() { return this.lvs !== null; },
},
methods: {
semesterstundenCalc(values, data) {
@@ -61,10 +55,6 @@ export default {
// to individual button tooltips
return ''
},
c4_target(menuItem) {
if (menuItem.c4_moodle_links?.length > 0) return null;
return menuItem.c4_target ?? null;
},
c4_link(menuItem) {
if (!menuItem) return null;
if (Array.isArray(menuItem.c4_moodle_links) && menuItem.c4_moodle_links.length) {
@@ -84,13 +74,9 @@ export default {
container.className = "d-flex gap-2";
const data = cell.getData()
console.log(data)
if(data.menu && data.menu.length) {
const calculatedMinWidth = data.menu.length * 120;
container.style.minWidth = `${calculatedMinWidth}px`;
const abbreviate = (str, limit = 12) =>
str.length > limit + 3 ? `${str.slice(0, limit)}...` : str;
container.className = "d-flex flex-wrap gap-2"
data.menu.forEach((lvLink) => {
// render dropdown if we have a link and some some linklist
@@ -102,30 +88,7 @@ export default {
group.className = 'btn-group';
// main action button
const button = document.createElement('a');
button.className = 'fhc-body text-decoration-none text-truncate';
if (!lvLink.c4_link) button.classList.add('unavailable');
button.id = lvLink.name;
const icon = lvLink.c4_icon2 ?? 'fa-solid fa-pen-to-square';
const label = lvLink.phrase ? this.$p.t(lvLink.phrase) : lvLink.name;
button.title = label;
button.innerHTML = `<i class="${icon}"></i><span style="margin-left:2px;">${abbreviate(label)}</span>`;
button.addEventListener('click', (event) => {
event.preventDefault();
const url = this.c4_link(lvLink);
if (url) {
const target = lvLink.c4_target || '_blank';
if (target === '_blank') {
window.open(url, '_blank', 'noopener,noreferrer');
} else {
window.location.href = url;
}
} else {
console.warn("Link is unavailable for:", lvLink.name);
}
});
const button= this.createActionButton(lvLink)
// toggle button
const toggle = document.createElement('button');
@@ -144,9 +107,6 @@ export default {
? lvLink.c4_moodle_links.map(item => ({ text: item.lehrform, href: item.url }))
: lvLink.c4_linkList.map(([text, link]) => ({ text, href: link }));
for(let i = 0; i < 10; i++) {
items.push({text: 'puffer', href: 'www.google.com'})
}
items.forEach(({ text, href }) => {
const li = document.createElement('li');
@@ -165,16 +125,25 @@ export default {
container.appendChild(group);
} else {
// action button only
container.appendChild(this.createActionButton(lvLink));
}
})
}
return container;
},
createActionButton(lvLink){
const button = document.createElement('a');
button.className = 'fhc-body text-decoration-none text-truncate';
if (!lvLink.c4_link) button.classList.add('unavailable');
button.id = lvLink.name;
button.id = `${lvLink.name}_${lvLink.lehrveranstaltung_id}`;
const icon = lvLink.c4_icon2 ?? 'fa-solid fa-pen-to-square';
const label = lvLink.phrase ? this.$p.t(lvLink.phrase) : lvLink.name;
button.title = label;
button.innerHTML = `<i class="${icon}"></i><span style="margin-left:2px;">${abbreviate(label)}</span>`;
button.innerHTML = `<i class="${icon}"></i><span style="margin-left:2px;">${label}</span>`;
button.addEventListener('click', (event) => {
event.preventDefault();
@@ -190,25 +159,125 @@ export default {
console.warn("Link is unavailable for:", lvLink.name);
}
});
return button
},
loadState() {
return JSON.parse(localStorage.getItem(this.mylvTableOptions.persistenceID) || "null");
},
saveState(table) {
// avoid storing state after first restore part happened
if(!this.stateRestored) return
const rawLayout = table.getColumnLayout();
const state = {
columns: rawLayout.map(col => ({
field: col.field,
visible: col.visible,
width: col.width,
})),
sort: table.getSorters().map(s => ({
field: s.field,
dir: s.dir,
})),
filters: table.getFilters(),
headerFilters: table.getHeaderFilters()
};
container.appendChild(button);
localStorage.setItem(this.mylvTableOptions.persistenceID, JSON.stringify(state));
},
handleTableBuilt() {
const table = this.$refs.mylvTable.tabulator
this.tableBuiltResolve()
table.on("columnMoved", () => {
this.saveState(table);
});
table.on("columnResized", () => {
this.saveState(table);
});
table.on("columnVisibilityChanged", () => {
this.saveState(table);
});
table.on("filterChanged", () => {
this.saveState(table);
});
table.on("headerFilterChanged", () => {
this.saveState(table);
});
table.on("dataSorted", () => {
this.saveState(table);
});
table.on("columnSorted", () => {
this.saveState(table);
});
table.on("sortersChanged", () => {
this.saveState(table);
});
const saved = this.loadState();
table.on("renderComplete", () => {
if(!this.stateRestored) {
if (saved?.columns && !this.colLayoutRestored) {
const layout = saved.columns.map(col => ({
field: col.field,
width: col.width,
visible: col.visible,
// add more if needed, but keep it simple
}));
table.setColumnLayout(layout);
this.colLayoutRestored = true;
}
})
if (saved?.filters && !this.filtersRestored) {
this.filtersRestored = true // instantly avoid retriggers
table.setFilter(saved.filters);
}
if (saved?.headerFilters && !this.headerFiltersRestored) {
this.headerFiltersRestored = true // instantly avoid retriggers
for (let hf of saved.headerFilters) {
table.setHeaderFilterValue(hf.field, hf.value);
}
}
if (saved?.sort?.length && !this.sortRestored) {
this.sortRestored = true;
setTimeout(() => {
const sortList = saved.sort.map(s => {
const col = table.columnManager.findColumn(s.field);
if (!col) {
return null;
}
return { column: col, dir: s.dir };
}).filter(Boolean);
table.setSort(sortList);
}, 100);
}
this.stateRestored = true
}
return container;
});
},
async setupData() {
this.$refs.mylvTable.tabulator.setData(this.lvs);
},
async setupMounted() {
// console.log('mounted pre table promise')
this.tableBuiltPromise = new Promise(this.tableResolve)
await this.tableBuiltPromise
console.log('mounted post table promise')
this.setupData()
const tableID = this.tabulatorUuid ? ('-' + this.tabulatorUuid) : ''
@@ -216,9 +285,13 @@ export default {
if(!tableDataSet) return
const rect = tableDataSet.getBoundingClientRect();
const h = window.visualViewport.height - rect.top - 100
const h = window.visualViewport.height - rect.top - 50
if(this.$refs.mylvTable) {
this.$refs.mylvTable.$refs.table.style.setProperty('height', h+'px')
// necessary so the wrapping action row resolves to the full rowHeight required
// without the redraw here actions past the initial rowHeight would be clipped off
this.$refs.mylvTable.tabulator.redraw(true)
}
}
@@ -232,10 +305,10 @@ export default {
},
watch: {
lvs: {
handler(newVal, oldVal) {
console.log('watcher')
if(this.$refs.mylvTable) {
console.log('watcher inside if ref table clause')
async handler(newVal) {
await this.tableBuiltPromise;
if(!this.$refs.mylvTable?.tabulator) return
this.$refs.mylvTable.tabulator.setData(newVal);
const tableID = this.tabulatorUuid ? ('-' + this.tabulatorUuid) : ''
@@ -243,11 +316,10 @@ export default {
if(!tableDataSet) return
const rect = tableDataSet.getBoundingClientRect();
const h = window.visualViewport.height - rect.top - 100
const h = window.visualViewport.height - rect.top - 50
if(this.$refs.mylvTable) {
this.$refs.mylvTable.$refs.table.style.setProperty('height', h+'px')
}
}
},
deep: true
}
@@ -261,8 +333,13 @@ export default {
ref="mylvTable"
:tabulator-options="mylvTableOptions"
:tabulator-events="mylvTableEventHandlers"
@tableBuilt="handleTableBuilt"
tableOnly
:sideMenu="false"
/>
</div>`
</div>
<div v-if="tabulatorUuid === null" class="text-center d-flex justify-content-center align-items-center h-100" >
<i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
`
};