All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s
Test Suite / pytest-backend (pull_request) Successful in 35s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 12s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m14s
- Modified comments in `SkillTreePickerPanel` and `skillCatalogTree.js` to accurately reflect the default expansion behavior, specifying that only main groups are open by default. - Refactored `defaultExpandedKeysForSkillTree` to simplify the logic, ensuring only main groups are returned as expanded. - Adjusted tests in `skillCatalogTree.test.js` to validate the updated behavior, confirming that only main groups are opened in the skill tree.
218 lines
7.1 KiB
JavaScript
218 lines
7.1 KiB
JavaScript
/**
|
||
* Hierarchie Fähigkeiten-Katalog: Hauptgruppe → Kategorie → Fähigkeit.
|
||
* Datenbasis: GET /api/skills/catalog (catalog_* Felder).
|
||
*/
|
||
|
||
export function bySortThenName(a, b) {
|
||
const sa = a.sort_order != null ? Number(a.sort_order) : 99999
|
||
const sb = b.sort_order != null ? Number(b.sort_order) : 99999
|
||
if (sa !== sb) return sa - sb
|
||
return String(a.name || a.label || '').localeCompare(String(b.name || b.label || ''), 'de')
|
||
}
|
||
|
||
/** @param {object} skill */
|
||
export function skillCatalogPathLabel(skill) {
|
||
if (!skill || typeof skill !== 'object') return ''
|
||
const parts = []
|
||
const main = (skill.catalog_main_category_name || '').trim()
|
||
const cat = (skill.catalog_category_name || '').trim()
|
||
if (main) parts.push(main)
|
||
if (cat) parts.push(cat)
|
||
parts.push((skill.name || '').trim() || `#${skill.id}`)
|
||
return parts.join(' › ')
|
||
}
|
||
|
||
/**
|
||
* @param {object[]} skills
|
||
* @returns {Array<{ key: string, type: string, label: string, skillId?: number, skill?: object, children?: object[] }>}
|
||
*/
|
||
export function buildSkillCatalogTree(skills) {
|
||
if (!Array.isArray(skills) || skills.length === 0) return []
|
||
|
||
/** @type {Map<string, { key: string, type: string, label: string, sortOrder: number, children: Map<string, object> }>} */
|
||
const mainMap = new Map()
|
||
|
||
for (const skill of skills) {
|
||
const mainId = skill.main_category_id ?? null
|
||
const mainKey = mainId != null ? `m-${mainId}` : 'm-none'
|
||
const mainLabel = (skill.catalog_main_category_name || '').trim() || 'Ohne Hauptgruppe'
|
||
const mainSort = Number(skill.catalog_main_sort ?? 99999)
|
||
|
||
if (!mainMap.has(mainKey)) {
|
||
mainMap.set(mainKey, {
|
||
key: mainKey,
|
||
type: 'main',
|
||
label: mainLabel,
|
||
sortOrder: mainSort,
|
||
children: new Map(),
|
||
})
|
||
}
|
||
const mainNode = mainMap.get(mainKey)
|
||
|
||
const catId = skill.category_id ?? null
|
||
const catKey = catId != null ? `c-${catId}` : 'c-none'
|
||
const catLabel = (skill.catalog_category_name || '').trim() || 'Ohne Kategorie'
|
||
const catSort = Number(skill.catalog_category_sort ?? 99999)
|
||
|
||
if (!mainNode.children.has(catKey)) {
|
||
mainNode.children.set(catKey, {
|
||
key: `${mainKey}-${catKey}`,
|
||
type: 'category',
|
||
label: catLabel,
|
||
sortOrder: catSort,
|
||
skills: [],
|
||
})
|
||
}
|
||
mainNode.children.get(catKey).skills.push(skill)
|
||
}
|
||
|
||
return [...mainMap.values()]
|
||
.sort((a, b) => {
|
||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||
return a.label.localeCompare(b.label, 'de')
|
||
})
|
||
.map((main) => ({
|
||
key: main.key,
|
||
type: main.type,
|
||
label: main.label,
|
||
children: [...main.children.values()]
|
||
.sort((a, b) => {
|
||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||
return a.label.localeCompare(b.label, 'de')
|
||
})
|
||
.map((cat) => ({
|
||
key: cat.key,
|
||
type: cat.type,
|
||
label: cat.label,
|
||
children: [...cat.skills].sort(bySortThenName).map((skill) => ({
|
||
key: `s-${skill.id}`,
|
||
type: 'skill',
|
||
label: (skill.name || '').trim() || `#${skill.id}`,
|
||
skillId: Number(skill.id),
|
||
skill,
|
||
})),
|
||
})),
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* Alle wählbaren Blätter mit Pfad-Label (für Suche / Chips).
|
||
* @param {ReturnType<typeof buildSkillCatalogTree>} tree
|
||
* @param {Set<number>|number[]} [excludeIds]
|
||
*/
|
||
export function collectSkillLeavesFromTree(tree, excludeIds) {
|
||
const exclude = new Set(
|
||
(excludeIds instanceof Set ? [...excludeIds] : excludeIds || [])
|
||
.map((x) => Number(x))
|
||
.filter((n) => Number.isFinite(n))
|
||
)
|
||
/** @type {Array<{ id: number, label: string, pathLabel: string, skill: object }>} */
|
||
const out = []
|
||
|
||
const walk = (nodes, pathParts) => {
|
||
for (const n of nodes) {
|
||
if (n.type === 'skill' && n.skillId != null) {
|
||
if (!exclude.has(n.skillId)) {
|
||
const pathLabel = [...pathParts, n.label].join(' › ')
|
||
out.push({ id: n.skillId, label: n.label, pathLabel, skill: n.skill })
|
||
}
|
||
} else if (n.children?.length) {
|
||
walk(n.children, [...pathParts, n.label])
|
||
}
|
||
}
|
||
}
|
||
walk(tree, [])
|
||
return out
|
||
}
|
||
|
||
/** @param {ReturnType<typeof buildSkillCatalogTree>} tree */
|
||
export function filterSkillTreeByQuery(tree, query) {
|
||
const q = String(query || '').trim().toLowerCase()
|
||
if (!q) return tree
|
||
|
||
const filterNodes = (nodes) => {
|
||
/** @type {typeof nodes} */
|
||
const result = []
|
||
for (const n of nodes) {
|
||
if (n.type === 'skill') {
|
||
const hay = `${n.label} ${skillCatalogPathLabel(n.skill)}`.toLowerCase()
|
||
if (hay.includes(q)) result.push(n)
|
||
} else {
|
||
const kids = filterNodes(n.children || [])
|
||
if (kids.length) result.push({ ...n, children: kids })
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
return filterNodes(tree)
|
||
}
|
||
|
||
/** Alle aufklappbaren Knoten (Hauptgruppe und Kategorie). */
|
||
export function allExpandableKeys(tree) {
|
||
const keys = []
|
||
const walk = (nodes) => {
|
||
for (const n of nodes) {
|
||
if (n.type !== 'skill' && n.children?.length) {
|
||
keys.push(n.key)
|
||
walk(n.children)
|
||
}
|
||
}
|
||
}
|
||
walk(tree)
|
||
return keys
|
||
}
|
||
|
||
/** Standard: nur Hauptgruppe aufgeklappt; Kategorien und Fähigkeiten zugeklappt. */
|
||
export function defaultExpandedKeysForSkillTree(tree) {
|
||
if (!Array.isArray(tree)) return []
|
||
return tree.filter((n) => n.type === 'main').map((n) => n.key)
|
||
}
|
||
|
||
/** Sentinel für Katalog-Admin: keine Hauptgruppe gewählt. */
|
||
export const SKILL_MAIN_UNASSIGNED_KEY = '__unassigned_main__'
|
||
/** Sentinel für Katalog-Admin: keine Kategorie gewählt. */
|
||
export const SKILL_CATEGORY_UNASSIGNED_KEY = '__unassigned_category__'
|
||
export const SKILL_UNASSIGNED_MAIN_LABEL = 'Ohne Hauptgruppe'
|
||
export const SKILL_UNASSIGNED_CATEGORY_LABEL = 'Ohne Kategorie'
|
||
|
||
/**
|
||
* Fähigkeiten für die Admin-Katalog-Auswahl (Hauptgruppe + Kategorie-Slot).
|
||
* @param {object[]} skills
|
||
* @param {number|string|null} mainId
|
||
* @param {number|string|null} categoryId
|
||
*/
|
||
export function catalogSkillsForSelection(skills, mainId, categoryId) {
|
||
if (!Array.isArray(skills) || categoryId == null) return []
|
||
if (categoryId === SKILL_CATEGORY_UNASSIGNED_KEY) {
|
||
if (mainId === SKILL_MAIN_UNASSIGNED_KEY) {
|
||
return skills.filter((s) => s.main_category_id == null && s.category_id == null)
|
||
}
|
||
if (typeof mainId === 'number') {
|
||
return skills.filter((s) => s.main_category_id === mainId && s.category_id == null)
|
||
}
|
||
return []
|
||
}
|
||
if (typeof categoryId === 'number') {
|
||
return skills.filter((s) => s.category_id === categoryId)
|
||
}
|
||
return []
|
||
}
|
||
|
||
/** @param {object[]} skills */
|
||
export function countCatalogSkillsWithoutMain(skills) {
|
||
if (!Array.isArray(skills)) return 0
|
||
return skills.filter((s) => s.main_category_id == null).length
|
||
}
|
||
|
||
/** @param {object[]} skills @param {number|string|null} mainId */
|
||
export function countCatalogSkillsWithoutCategory(skills, mainId) {
|
||
if (!Array.isArray(skills)) return 0
|
||
if (mainId === SKILL_MAIN_UNASSIGNED_KEY) {
|
||
return skills.filter((s) => s.main_category_id == null && s.category_id == null).length
|
||
}
|
||
if (typeof mainId === 'number') {
|
||
return skills.filter((s) => s.main_category_id === mainId && s.category_id == null).length
|
||
}
|
||
return 0
|
||
}
|