Merge branch 'feature-77417/studvw-tags-formatter-and-header-filter' into studvw_lvvw_2026_08_rc

This commit is contained in:
chfhtw
2026-07-31 12:52:29 +02:00
16 changed files with 1705 additions and 806 deletions
@@ -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);
+6 -1
View File
@@ -73,6 +73,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,
@@ -107,7 +108,8 @@ class Tag_Controller extends FHCAPI_Controller
style,
beschreibung,
tag,
automatisiert
automatisiert,
prioritaet
"
);
$this->NotiztypModel->addOrder('prioritaet');
@@ -317,6 +319,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,
@@ -336,6 +339,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));
+3 -1
View File
@@ -346,7 +346,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,
@@ -355,6 +355,8 @@ class StudentListLib
n.text AS notiz,
nt.style,
n.erledigt AS done,
nt.prioritaet AS prioritaet,
nt.automatisiert AS automatisiert,
nz.prestudent_id,
n.start,
n.ende
@@ -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,9 @@ EOSQL;
text AS notiz,
style,
erledigt AS done,
lehreinheit_id
lehreinheit_id,
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
@@ -5,8 +5,7 @@ return [
field: "lv_kurzbz",
headerFilterFuncParams: { field: 'lv_kurzbz' },
headerFilter: true,
formatter(cell,
formatterParams) {
formatter(cell, formatterParams) {
const rowData = cell.getRow().getData();
const iconKey = (rowData.lehrtyp_kurzbz || '').toLowerCase();
const lvkurzbz = (cell.getValue()).toUpperCase();
@@ -22,8 +21,7 @@ return [
return parentspan;
},
cellClick(e,
cell) {
cellClick(e, cell) {
cell.getRow().treeToggle();
}
},
@@ -268,9 +268,9 @@ return [
},
headerFilter: "tickCross",
headerFilterParams: {
"tristate": true,
tristate: true,
elementAttributes: {
"value": "true"
value: "true"
}
},
headerFilterEmptyCheck(value) {
@@ -326,9 +326,9 @@ return [
},
headerFilter: "tickCross",
headerFilterParams: {
"tristate": true,
tristate: true,
elementAttributes: {
"value": "true"
value: "true"
}
},
headerFilterEmptyCheck(value) {
@@ -298,7 +298,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);
},
@@ -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";
@@ -69,7 +77,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(
combinedFilterStatement,
this.$refs.table.tabulator
);
},
deep: true,
},
},
data() {
return {
@@ -100,8 +121,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,
@@ -115,7 +165,20 @@ export default {
raumtypalternativ: this.lehreinheitRaumtypAlternativeDefault,
lehrfach_id: ''
}
},
tagFilterState: {
initialOptions: [],
selectedOptions: [],
},
tagFilterLabels: {
tag: "Tag",
clear: "Clear",
connectors: {
AND: "AND",
OR: "OR",
NOT: "NOT",
},
},
}
},
computed: {
@@ -146,6 +209,14 @@ export default {
layout: 'fitDataStretch',
height: '100%',
persistenceID: 'lehrveranstaltungen_2026_07_15_v1',
persistence: {
sort: true,
columns: ["width", "visible"],
filter: false,
headerFilter: false,
group: false,
page: false,
},
selectableRowsRangeMode: 'click',
selectableRows: true,
rowContextMenu: (component, e) => {
@@ -204,11 +275,25 @@ export default {
created() {
Tabulator.extendModule("format", "formatters", {
tagHeaderFilter,
tagFormatter: cell => tagFormatter(cell, this.$refs.tagComponent),
tagFormatter: (cell, params, onRendered) => tagFormatter(cell, this.$refs.tagComponent, onRendered),
ja_nein: cell => cell.getValue()
? this.toUpperCase(this.$p.t('ui', 'ja'))
: this.toUpperCase(this.$p.t('ui', 'nein'))
});
LvvColumns.foreach(col => {
if (col.headerFilter == 'input' && col.headerFilterFunc == 'tagHeaderFilter') {
col.headerFilter = customTagFilter;
col.headerFilterParams = {
listOnEmpty: true,
autocomplete: true,
sort: "asc",
initialOptions: this.tagFilterState.initialOptions,
selectedOptions: this.tagFilterState.selectedOptions,
labels: this.tagFilterLabels,
};
col.headerSort = false;
}
});
},
mounted() {
if (this.shouldAutoLoad())
@@ -319,16 +404,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)
{
@@ -502,4 +602,4 @@ export default {
</bs-modal>
</div>
`
};
};
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -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";
+1 -1
View File
@@ -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
+47 -21
View File
@@ -20,25 +20,37 @@ export function addTagInTable(addedTag, rows, matchKey, tagsKey = "tags")
for (const tag of addedTag.response)
{
if (rowData[matchKey] !== tag[matchKey])
continue;
return;
//avoid double inserts
if (tags.some(x => x.id === tag.id))
continue;
let tags;
try {
let initialTags = rowData[tagsKey] || "[]";
if (typeof initialTags === 'string') {
tags = JSON.parse(initialTags);
} else {
tags = initialTags;
}
} catch (e) {
tags = [];
}
const newTag = {
id: tag.id,
prestudent_id: tag.prestudent_id,
//add also information of addedTag
beschreibung: addedTag.beschreibung,
notiz: addedTag.notiz ?? "",
style: addedTag.style,
done: addedTag.done ?? false,
typ_kurzbz: addedTag.tag_typ_kurzbz ?? addedTag.typ_kurzbz, //here seem to be 2 variations
automatisiert: addedTag.automatisiert
};
if (!Array.isArray(tags))
tags = [];
tags.unshift(newTag);
if (tags.some(t => t?.id === tag.id))
return;
let newTag = { ...addedTag, id: tag.id };
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;
}
@@ -69,10 +81,11 @@ export function deleteTagInTable(deletedTag, rows, tagsKeys = ['tags'])
let tags = [];
try {
if (typeof raw === "string") {
tags = JSON.parse(raw || "[]");
} else if (Array.isArray(raw)) {
tags = [...raw];
let initialTags = rowData[key] || "[]";
if (typeof initialTags === 'string') {
tags = JSON.parse(initialTags);
} else {
tags = initialTags;
}
} catch (e) {
tags = [];
@@ -113,7 +126,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;
}
@@ -126,6 +144,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])
@@ -1,163 +1,617 @@
function parseFilterExpression(expression)
{
const collections = [];
let activeTagDropdownCleanup = null;
const TAG_FILTER_CONNECTORS = {
AND: {
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.OR,
TAG_FILTER_CONNECTORS.NOT,
];
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;
function getConnectorLabel(connector, labels) {
return labels?.connectors?.[connector.key] || connector.label;
}
export function extendedHeaderFilter(headerValue, rowValue, rowData, filterParams)
{
const fields = Array.isArray(filterParams?.field)
? filterParams.field
: [filterParams?.field];
export function setTagHeaderFilterValue(value, tabulator, columnField = "tags") {
tabulator?.setHeaderFilterValue(columnField, value);
if (fields.length > 1 && rowData)
{
rowValue = fields
.map(f => rowData[f] ?? '')
.filter(Boolean)
.join(' ');
}
if (typeof headerValue === 'boolean')
{
return rowValue === headerValue;
}
const input = tabulator
?.getColumn(columnField)
?.getElement()
?.querySelector(".tabulator-header-filter input");
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;
if (input) {
input.value = value ?? "";
}
}
export function tagHeaderFilter(headerValue, rowValue, rowData, filterParams)
{
let data;
export function buildTagHeaderFilterExpression(selectedTagOptions) {
let expression = null;
try {
data = typeof rowValue === 'string' ? JSON.parse(rowValue) : rowValue;
} catch (error) {
return false;
}
selectedTagOptions.forEach(({ value, connector }) => {
const connectorDef = TAG_FILTER_CONNECTORS[connector];
if (!connectorDef) return;
let combinedText;
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;
}
});
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 expression;
}
return extendedHeaderFilter(headerValue, combinedText, rowData, filterParams)
}
export function buildTagOptionsFromRows(rows, field = "tags") {
let parsedTags = [
...new Map(
getRawTagValuesFromRows(rows, field)
.filter((tag) => tag && tag.done !== true)
.sort((a, b) => {
return a.prioritaet - b.prioritaet;
})
.map((tag) => {
return {
label: tag.beschreibung || tag.typ_kurzbz,
value: tag.beschreibung || tag.typ_kurzbz,
style: tag.style,
automatisiert: tag.automatisiert,
done: tag.done,
};
})
.map((obj) => [obj.value, obj]),
).values(),
];
if (!Array.isArray(parsedTags)) return;
return parsedTags;
}
export function syncTagHeaderFilterOptions(
rows = [],
initialOptions = [],
selectedOptions = [],
field = "tags"
) {
const options = buildTagOptionsFromRows(rows, field) || [];
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 = [], field = "tags") {
let parsedTags = [];
rows.forEach((row) => {
if (!row || (!row[field]?.length && !row._children?.length)) return;
let newTags = row[field]?.length
? [row[field]]
: getRawTagValuesFromRows(row._children, field);
newTags = newTags
.map((newTag) => {
if (typeof newTag === "string") {
try {
newTag = JSON.parse(newTag);
} catch {
return null;
}
}
return newTag;
})
.filter((newTag) => newTag !== null);
parsedTags = parsedTags.concat(newTags);
});
return parsedTags.flat();
}
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;
let suppressScrollCloseUntil = 0;
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", 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) ||
container.contains(event.target)
) {
return;
}
closeDropdown();
};
const clearFilter = ({ closeDropdownAfterClear = true } = {}) => {
input.value = "";
if (Array.isArray(params.selectedOptions)) {
params.selectedOptions.splice(0, params.selectedOptions.length);
}
success("");
if (closeDropdownAfterClear) {
closeDropdown();
}
};
const openDropdown = () => {
if (activeTagDropdownCleanup) {
activeTagDropdownCleanup();
}
dropdownMenu = getRebuiltDropdown(
params.initialOptions,
params.selectedOptions,
params.labels,
clearFilter,
);
dropdownMenu.addEventListener("mousedown", (event) => {
event.stopPropagation();
});
dropdownMenu.addEventListener("click", suppressScrollClose, true);
document.body.appendChild(dropdownMenu);
activeTagDropdownCleanup = closeDropdown;
positionDropdown();
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", positionDropdown);
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);
});
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);
return container;
}
function getRebuiltDropdown(
initialOptions,
selectedOptions,
labels,
clearFilter,
) {
const dropdownTable = generateDropdownTable(
initialOptions || [],
selectedOptions || [],
labels,
);
const menu = document.createElement("div");
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, labels) {
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 = [
labels?.tag || "Tag",
...TAG_FILTER_CONNECTOR_ORDER.map((connector) =>
getConnectorLabel(connector, labels),
),
];
headers.forEach((h, index) => {
const th = document.createElement("th");
if (index > 0) {
th.className = "text-center";
}
const strong = document.createElement("strong");
strong.textContent = h;
th.appendChild(strong);
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;
if (option.automatisiert) {
label.innerHTML = "<i class='fa-solid fa-lock'></i> " + option.label;
label.className += " tag_auto";
}
labelTd.appendChild(label);
row.appendChild(labelTd);
function makeCheckbox(connector) {
const td = document.createElement("td");
const cb = document.createElement("input");
td.className = "text-center align-middle";
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);
}
+110 -72
View File
@@ -1,90 +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, tagKey = "tags") {
const mappedData = tagComponent[tagKey]?.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 a.prioritaet - b.prioritaet;
});
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";
if (tag.automatisiert){
tagElement.className += " tag_auto";
tagElement.innerHTML = "<i class='fa-solid fa-lock'></i> " + tag.beschreibung;
}
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 =
"<i class='fa-solid fa-lock'></i> " + tag.beschreibung;
}
if (!tagDef && tag.typ_kurzbz?.includes("_auto") || tagDef?.automatisiert) {
tagElement.className += " tag_auto";
tagElement.innerHTML = "<i class='fa-solid fa-lock'></i> " + 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;
}
let widthBuffer = 10;
maxVisibleTags = parsedTags.length;
renderTags();
while (
maxVisibleTags > 0 &&
container.scrollWidth > (container.clientWidth + widthBuffer)
) {
maxVisibleTags--;
renderTags();
}
};
let animationFrame = null;
container.fitTags = () => {
if (animationFrame !== null) return;
animationFrame = requestAnimationFrame(() => {
animationFrame = null;
fitTags();
});
};
if (onRendered) {
onRendered(() => {
container.fitTags();
});
}
renderTags();
return container;
}
+60
View File
@@ -61539,6 +61539,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'
)
)
),
);
+7
View File
@@ -0,0 +1,7 @@
node_modules
.env
.env.*
!.env.example
/cypress/videos
/cypress/screenshots
/results