Enhance Skill Tree Components with Improved Group Labeling and Default Expansion
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Has been cancelled

- Added CSS styles for skill group labels in the skill tree to improve visual hierarchy and readability.
- Updated `SkillTreePickerPanel` and `SkillTreeMultiSelect` components to utilize the new default expansion logic, ensuring main and category nodes are open by default while skill groups remain collapsed.
- Refactored state management in `SkillTreePickerPanel` to align with the new default expansion behavior.
- Enhanced utility functions to support the new default expansion logic for skill trees.
This commit is contained in:
Lars 2026-05-20 11:13:34 +02:00
parent 46feb4c867
commit 9020e5eb16
6 changed files with 79 additions and 23 deletions

View File

@ -5320,6 +5320,12 @@ html.modal-scroll-locked .app-main {
line-height: 1.35; line-height: 1.35;
} }
.skill-tree__group-label--skill-group {
font-weight: 500;
color: var(--text2);
font-size: 0.84rem;
}
.multi-assoc-block { .multi-assoc-block {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;

View File

@ -122,7 +122,6 @@ export default function SkillTreeMultiSelect({
searchQuery={query} searchQuery={query}
onPickSkill={(id) => addId(id)} onPickSkill={(id) => addId(id)}
pickMode="multi" pickMode="multi"
defaultCollapsed
/> />
) : ( ) : (
<ul className="multiselect-combo__list" role="listbox"> <ul className="multiselect-combo__list" role="listbox">

View File

@ -4,6 +4,7 @@ import {
allExpandableKeys, allExpandableKeys,
buildSkillCatalogTree, buildSkillCatalogTree,
collectSkillLeavesFromTree, collectSkillLeavesFromTree,
defaultExpandedKeysForSkillTree,
filterSkillTreeByQuery, filterSkillTreeByQuery,
} from '../utils/skillCatalogTree' } from '../utils/skillCatalogTree'
@ -54,7 +55,13 @@ function SkillTreeNodes({ nodes, depth, expanded, exclude, onToggle, onPickSkill
) : ( ) : (
<span className="skill-tree__toggle-spacer" aria-hidden /> <span className="skill-tree__toggle-spacer" aria-hidden />
)} )}
<span className={`skill-tree__group-label skill-tree__group-label--${node.type}`}>{node.label}</span> <span
className={`skill-tree__group-label skill-tree__group-label--${node.type}${
node.type === 'skillGroup' ? ' skill-tree__group-label--skill-group' : ''
}`}
>
{node.label}
</span>
</div> </div>
{hasKids && isOpen ? ( {hasKids && isOpen ? (
<ul className="skill-tree__children" role={pickMode === 'multi' ? 'group' : 'tree'}> <ul className="skill-tree__children" role={pickMode === 'multi' ? 'group' : 'tree'}>
@ -75,7 +82,7 @@ function SkillTreeNodes({ nodes, depth, expanded, exclude, onToggle, onPickSkill
} }
/** /**
* Ausklappbare Baumliste für Fähigkeiten (Hauptgruppe Kategorie Fähigkeit). * Ausklappbare Baumliste: Hauptgruppe Kategorie (beide standard offen) Fähigkeiten-Gruppe (standard zu).
*/ */
export default function SkillTreePickerPanel({ export default function SkillTreePickerPanel({
skills = [], skills = [],
@ -84,8 +91,6 @@ export default function SkillTreePickerPanel({
onPickSkill, onPickSkill,
pickMode = 'single', pickMode = 'single',
className = '', className = '',
/** true: nur Hauptgruppen sichtbar, bis der Nutzer aufklappt (Filter) */
defaultCollapsed = true,
}) { }) {
const exclude = useMemo(() => normExclude(excludeIds), [excludeIds]) const exclude = useMemo(() => normExclude(excludeIds), [excludeIds])
const fullTree = useMemo(() => buildSkillCatalogTree(skills), [skills]) const fullTree = useMemo(() => buildSkillCatalogTree(skills), [skills])
@ -98,16 +103,16 @@ export default function SkillTreePickerPanel({
useEffect(() => { useEffect(() => {
if (searchQuery.trim()) { if (searchQuery.trim()) {
setExpanded(new Set(allExpandableKeys(displayTree))) setExpanded(new Set(allExpandableKeys(displayTree)))
} else if (defaultCollapsed) { } else {
setExpanded(new Set()) setExpanded(new Set(defaultExpandedKeysForSkillTree(displayTree)))
} }
}, [searchQuery, displayTree, defaultCollapsed]) }, [searchQuery, displayTree])
useEffect(() => { useEffect(() => {
if (!defaultCollapsed) { if (!searchQuery.trim()) {
setExpanded(new Set(allExpandableKeys(fullTree))) setExpanded(new Set(defaultExpandedKeysForSkillTree(fullTree)))
} }
}, [fullTree, defaultCollapsed]) }, [fullTree, searchQuery])
const onToggle = useCallback((key) => { const onToggle = useCallback((key) => {
setExpanded((prev) => { setExpanded((prev) => {

View File

@ -75,7 +75,6 @@ export default function SkillTreeSelect({
searchQuery={query} searchQuery={query}
onPickSkill={pick} onPickSkill={pick}
pickMode="single" pickMode="single"
defaultCollapsed={false}
/> />
</div> </div>
</div> </div>

View File

@ -80,18 +80,33 @@ export function buildSkillCatalogTree(skills) {
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
return a.label.localeCompare(b.label, 'de') return a.label.localeCompare(b.label, 'de')
}) })
.map((cat) => ({ .map((cat) => {
key: cat.key, const skillLeaves = [...cat.skills].sort(bySortThenName).map((skill) => ({
type: cat.type,
label: cat.label,
children: [...cat.skills].sort(bySortThenName).map((skill) => ({
key: `s-${skill.id}`, key: `s-${skill.id}`,
type: 'skill', type: 'skill',
label: (skill.name || '').trim() || `#${skill.id}`, label: (skill.name || '').trim() || `#${skill.id}`,
skillId: Number(skill.id), skillId: Number(skill.id),
skill, skill,
})), }))
})), const n = skillLeaves.length
return {
key: cat.key,
type: cat.type,
label: cat.label,
children:
n > 0
? [
{
key: `${cat.key}-skills`,
type: 'skillGroup',
label: n === 1 ? '1 Fähigkeit' : `${n} Fähigkeiten`,
skillCount: n,
children: skillLeaves,
},
]
: [],
}
}),
})) }))
} }
@ -117,7 +132,8 @@ export function collectSkillLeavesFromTree(tree, excludeIds) {
out.push({ id: n.skillId, label: n.label, pathLabel, skill: n.skill }) out.push({ id: n.skillId, label: n.label, pathLabel, skill: n.skill })
} }
} else if (n.children?.length) { } else if (n.children?.length) {
walk(n.children, [...pathParts, n.label]) const nextPath = n.type === 'skillGroup' ? pathParts : [...pathParts, n.label]
walk(n.children, nextPath)
} }
} }
} }
@ -137,6 +153,9 @@ export function filterSkillTreeByQuery(tree, query) {
if (n.type === 'skill') { if (n.type === 'skill') {
const hay = `${n.label} ${skillCatalogPathLabel(n.skill)}`.toLowerCase() const hay = `${n.label} ${skillCatalogPathLabel(n.skill)}`.toLowerCase()
if (hay.includes(q)) result.push(n) if (hay.includes(q)) result.push(n)
} else if (n.type === 'skillGroup') {
const kids = filterNodes(n.children || [])
if (kids.length) result.push({ ...n, children: kids })
} else { } else {
const kids = filterNodes(n.children || []) const kids = filterNodes(n.children || [])
if (kids.length) result.push({ ...n, children: kids }) if (kids.length) result.push({ ...n, children: kids })
@ -147,7 +166,7 @@ export function filterSkillTreeByQuery(tree, query) {
return filterNodes(tree) return filterNodes(tree)
} }
/** @param {ReturnType<typeof buildSkillCatalogTree>} tree */ /** Alle aufklappbaren Knoten (inkl. Fähigkeiten-Gruppen unter Kategorien). */
export function allExpandableKeys(tree) { export function allExpandableKeys(tree) {
const keys = [] const keys = []
const walk = (nodes) => { const walk = (nodes) => {
@ -161,3 +180,18 @@ export function allExpandableKeys(tree) {
walk(tree) walk(tree)
return keys return keys
} }
/** Standard: Hauptgruppe + Kategorie offen, Fähigkeiten-Liste zugeklappt. */
export function defaultExpandedKeysForSkillTree(tree) {
const keys = []
const walk = (nodes) => {
for (const n of nodes) {
if (n.type === 'main' || n.type === 'category') {
keys.push(n.key)
walk(n.children || [])
}
}
}
walk(tree)
return keys
}

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { import {
buildSkillCatalogTree, buildSkillCatalogTree,
collectSkillLeavesFromTree, collectSkillLeavesFromTree,
defaultExpandedKeysForSkillTree,
filterSkillTreeByQuery, filterSkillTreeByQuery,
skillCatalogPathLabel, skillCatalogPathLabel,
} from './skillCatalogTree.js' } from './skillCatalogTree.js'
@ -38,7 +39,17 @@ describe('skillCatalogTree', () => {
expect(tree[0].label).toBe('Karate') expect(tree[0].label).toBe('Karate')
expect(tree[0].children).toHaveLength(2) expect(tree[0].children).toHaveLength(2)
expect(tree[0].children[0].label).toBe('Kata') expect(tree[0].children[0].label).toBe('Kata')
expect(tree[0].children[0].children[0].skillId).toBe(2) const kataSkills = tree[0].children[0].children[0]
expect(kataSkills.type).toBe('skillGroup')
expect(kataSkills.children[0].skillId).toBe(2)
})
it('defaultExpandedKeysForSkillTree opens main and category only', () => {
const tree = buildSkillCatalogTree(sample)
const keys = defaultExpandedKeysForSkillTree(tree)
expect(keys).toContain('m-10')
expect(keys.some((k) => k.includes('c-21'))).toBe(true)
expect(keys.some((k) => k.endsWith('-skills'))).toBe(false)
}) })
it('skillCatalogPathLabel', () => { it('skillCatalogPathLabel', () => {
@ -55,6 +66,8 @@ describe('skillCatalogTree', () => {
it('filterSkillTreeByQuery', () => { it('filterSkillTreeByQuery', () => {
const tree = buildSkillCatalogTree(sample) const tree = buildSkillCatalogTree(sample)
const filtered = filterSkillTreeByQuery(tree, 'dachi') const filtered = filterSkillTreeByQuery(tree, 'dachi')
expect(filtered[0].children.some((c) => c.children?.some((s) => s.skillId === 1))).toBe(true) const kihon = filtered[0].children.find((c) => c.label === 'Kihon')
const group = kihon?.children?.[0]
expect(group?.children?.some((s) => s.skillId === 1)).toBe(true)
}) })
}) })