From 3067b2e6a8f58271416c6b3200084eb44db30f5d Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 10:59:17 +0200 Subject: [PATCH 1/6] Implement Skill Tree Selection Components and Update API Calls - Introduced new `SkillTreeSelect` and `SkillTreeMultiSelect` components for enhanced skill selection in various modals and forms. - Updated API calls to use `listSkillsCatalog` instead of `listSkills` for improved data retrieval. - Enhanced CSS styles for skill selection components to improve user experience and visual consistency across the application. --- frontend/src/app.css | 138 +++++++++++++++ .../src/components/ExercisePickerModal.jsx | 12 +- .../src/components/SkillTreeMultiSelect.jsx | 154 +++++++++++++++++ .../src/components/SkillTreePickerPanel.jsx | 156 +++++++++++++++++ frontend/src/components/SkillTreeSelect.jsx | 84 +++++++++ .../admin/MaturityModelsAdminPanel.jsx | 20 +-- .../exercises/ExerciseFormPageRoot.jsx | 33 ++-- .../exercises/ExerciseListFilterModal.jsx | 8 +- .../exercises/ExercisesListPageRoot.jsx | 9 +- .../hooks/useExerciseListCatalogsAndQuery.js | 2 +- frontend/src/utils/skillCatalogTree.js | 163 ++++++++++++++++++ frontend/src/utils/skillCatalogTree.test.js | 60 +++++++ 12 files changed, 793 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/SkillTreeMultiSelect.jsx create mode 100644 frontend/src/components/SkillTreePickerPanel.jsx create mode 100644 frontend/src/components/SkillTreeSelect.jsx create mode 100644 frontend/src/utils/skillCatalogTree.js create mode 100644 frontend/src/utils/skillCatalogTree.test.js diff --git a/frontend/src/app.css b/frontend/src/app.css index 05f59c5..29877e8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5182,6 +5182,144 @@ html.modal-scroll-locked .app-main { outline-offset: -2px; } +/* Fähigkeiten-Katalog: Treeview-Auswahl */ +.skill-tree-select { + position: relative; + flex: 1 1 200px; + min-width: 0; +} +.skill-tree-select__trigger { + width: 100%; + text-align: left; + cursor: pointer; + display: flex; + align-items: center; + min-height: 40px; +} +.skill-tree-select__placeholder { + color: var(--text3); +} +.skill-tree-select__panel { + position: absolute; + z-index: 120; + left: 0; + right: 0; + top: calc(100% + 4px); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + max-height: min(360px, 55vh); + display: flex; + flex-direction: column; + overflow: hidden; +} +.skill-tree-select__search { + margin: 8px; + flex-shrink: 0; +} +.skill-tree-select__tree-wrap { + overflow: auto; + padding: 0 4px 8px; + flex: 1; + min-height: 0; +} +.skill-tree-multiselect__panel { + position: absolute; + z-index: 120; + left: 0; + right: 0; + top: calc(100% + 4px); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + max-height: min(360px, 55vh); + overflow: auto; +} +.skill-tree { + list-style: none; + margin: 0; + padding: 4px 0; +} +.skill-tree__empty { + padding: 10px 12px; + color: var(--text3); + font-size: 0.88rem; +} +.skill-tree__branch-head { + display: flex; + align-items: center; + gap: 4px; + min-height: 30px; +} +.skill-tree__toggle { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + background: transparent; + color: var(--text2); + cursor: pointer; + border-radius: 6px; +} +.skill-tree__toggle:hover { + background: var(--surface2); +} +.skill-tree__toggle-spacer { + width: 28px; + flex-shrink: 0; +} +.skill-tree__group-label { + font-size: 0.86rem; + font-weight: 600; + color: var(--text2); +} +.skill-tree__group-label--main { + color: var(--accent-dark); + font-size: 0.9rem; +} +.skill-tree__children { + list-style: none; + margin: 0; + padding: 0; +} +.skill-tree__pick { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + padding: 7px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + color: var(--text1); +} +.skill-tree__pick:hover, +.skill-tree__pick:focus-visible { + background: var(--surface2); + outline: none; +} +.skill-tree__pick--path { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} +.skill-tree__pick-name { + font-weight: 500; +} +.skill-tree__pick-path { + font-size: 0.78rem; + color: var(--text3); + line-height: 1.35; +} + .multi-assoc-block { border: 1px solid var(--border); border-radius: 8px; diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 5d04d66..0052eed 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -13,7 +13,7 @@ import { splitMnCatalogRules, splitScalarCatalogRules, } from '../constants/exerciseListFilters' -import MultiSelectCombo from './MultiSelectCombo' +import SkillTreeMultiSelect from './SkillTreeMultiSelect' import ExerciseFocusRulePicker from './ExerciseFocusRulePicker' import CatalogRulePicker from './CatalogRulePicker' @@ -88,7 +88,7 @@ export default function ExercisePickerModal({ api.listStyleDirections(), api.listTrainingTypes(), api.listTargetGroups(), - api.listSkills(), + api.listSkillsCatalog(), ]) if (!cancelled) { setCatalogs({ @@ -146,10 +146,6 @@ export default function ExercisePickerModal({ () => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })), [catalogs.targetGroups] ) - const skillOptions = useMemo( - () => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })), - [catalogs.skills] - ) const visibilityOptions = useMemo( () => [ { id: 'private', label: 'Privat' }, @@ -506,10 +502,10 @@ export default function ExercisePickerModal({
- setFilters((f) => ({ ...f, skill_ids: v }))} - options={skillOptions} + skills={catalogs.skills} placeholder="Fähigkeit …" />
diff --git a/frontend/src/components/SkillTreeMultiSelect.jsx b/frontend/src/components/SkillTreeMultiSelect.jsx new file mode 100644 index 0000000..98dc4c0 --- /dev/null +++ b/frontend/src/components/SkillTreeMultiSelect.jsx @@ -0,0 +1,154 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { collectSkillLeavesFromTree, buildSkillCatalogTree } from '../utils/skillCatalogTree' +import SkillTreePickerPanel from './SkillTreePickerPanel' + +function normId(id) { + return String(id) +} + +/** + * Mehrfachauswahl Fähigkeiten mit hierarchischer Treeview („Alle“) und Pfad-Suche. + */ +export default function SkillTreeMultiSelect({ + value = [], + onChange, + skills = [], + placeholder = 'Fähigkeit suchen …', + browseLabel = '▼ Katalog', + emptyHint = 'Keine Treffer', + className = '', +}) { + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [browseTree, setBrowseTree] = useState(false) + const rootRef = useRef(null) + + const tree = useMemo(() => buildSkillCatalogTree(skills), [skills]) + const selectedSet = useMemo(() => new Set(value.map(normId)), [value]) + + const leaves = useMemo(() => collectSkillLeavesFromTree(tree, value), [tree, value]) + + const selectedLabels = useMemo(() => { + return value.map((id) => { + const leaf = leaves.find((l) => normId(l.id) === normId(id)) || leaves.find(() => false) + const fromSkills = skills.find((s) => normId(s.id) === normId(id)) + return leaf?.pathLabel || fromSkills?.name || `#${id}` + }) + }, [value, leaves, skills]) + + const addId = useCallback( + (id) => { + const sid = normId(id) + if (selectedSet.has(sid)) return + onChange([...value, id]) + setQuery('') + setBrowseTree(false) + }, + [value, onChange, selectedSet] + ) + + const removeAt = useCallback( + (idx) => { + onChange(value.filter((_, i) => i !== idx)) + }, + [value, onChange] + ) + + useEffect(() => { + const onDoc = (e) => { + if (!rootRef.current?.contains(e.target)) { + setOpen(false) + setBrowseTree(false) + } + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const showTree = browseTree || !query.trim() + + return ( +
+
+ {value.map((id, idx) => ( + + ))} +
+
+ { + setQuery(e.target.value) + setOpen(true) + setBrowseTree(false) + }} + onFocus={() => setOpen(true)} + autoComplete="off" + aria-expanded={open} + /> + +
+ {open ? ( +
+ {showTree ? ( + addId(id)} + pickMode="multi" + /> + ) : ( +
    + {leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length === + 0 ? ( +
  • {emptyHint}
  • + ) : ( + leaves + .filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())) + .map((l) => ( +
  • + +
  • + )) + )} +
+ )} +
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/SkillTreePickerPanel.jsx b/frontend/src/components/SkillTreePickerPanel.jsx new file mode 100644 index 0000000..b1b0b8d --- /dev/null +++ b/frontend/src/components/SkillTreePickerPanel.jsx @@ -0,0 +1,156 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { + allExpandableKeys, + buildSkillCatalogTree, + collectSkillLeavesFromTree, + filterSkillTreeByQuery, +} from '../utils/skillCatalogTree' + +function normExclude(excludeIds) { + return new Set( + (excludeIds instanceof Set ? [...excludeIds] : excludeIds || []) + .map((x) => Number(x)) + .filter((n) => Number.isFinite(n)) + ) +} + +function SkillTreeNodes({ nodes, depth, expanded, exclude, onToggle, onPickSkill, pickMode }) { + if (!nodes?.length) { + return
  • Keine Fähigkeiten
  • + } + + return nodes.map((node) => { + if (node.type === 'skill') { + if (exclude.has(node.skillId)) return null + return ( +
  • + +
  • + ) + } + + const hasKids = node.children?.length > 0 + const isOpen = expanded.has(node.key) + return ( +
  • +
    + {hasKids ? ( + + ) : ( + + )} + {node.label} +
    + {hasKids && isOpen ? ( +
      + +
    + ) : null} +
  • + ) + }) +} + +/** + * Ausklappbare Baumliste für Fähigkeiten (Hauptgruppe → Kategorie → Fähigkeit). + */ +export default function SkillTreePickerPanel({ + skills = [], + excludeIds, + searchQuery = '', + onPickSkill, + pickMode = 'single', + className = '', +}) { + const exclude = useMemo(() => normExclude(excludeIds), [excludeIds]) + const fullTree = useMemo(() => buildSkillCatalogTree(skills), [skills]) + const displayTree = useMemo( + () => filterSkillTreeByQuery(fullTree, searchQuery), + [fullTree, searchQuery] + ) + const [expanded, setExpanded] = useState(() => new Set()) + + useEffect(() => { + if (searchQuery.trim()) { + setExpanded(new Set(allExpandableKeys(displayTree))) + } + }, [searchQuery, displayTree]) + + useEffect(() => { + setExpanded(new Set(allExpandableKeys(fullTree))) + }, [fullTree]) + + const onToggle = useCallback((key) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }, []) + + const flatSearchHits = useMemo(() => { + const q = searchQuery.trim() + if (!q) return [] + return collectSkillLeavesFromTree(fullTree, exclude).filter((leaf) => + leaf.pathLabel.toLowerCase().includes(q.toLowerCase()) + ) + }, [fullTree, exclude, searchQuery]) + + if (searchQuery.trim() && flatSearchHits.length > 0) { + return ( +
      + {flatSearchHits.map((leaf) => ( +
    • + +
    • + ))} +
    + ) + } + + return ( +
      + +
    + ) +} diff --git a/frontend/src/components/SkillTreeSelect.jsx b/frontend/src/components/SkillTreeSelect.jsx new file mode 100644 index 0000000..9ef6d1c --- /dev/null +++ b/frontend/src/components/SkillTreeSelect.jsx @@ -0,0 +1,84 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { skillCatalogPathLabel } from '../utils/skillCatalogTree' +import SkillTreePickerPanel from './SkillTreePickerPanel' + +/** + * Einzelauswahl einer Fähigkeit als hierarchische Treeview. + */ +export default function SkillTreeSelect({ + value = '', + onChange, + skills = [], + excludeIds, + placeholder = 'Fähigkeit wählen…', + disabled = false, + className = '', + searchPlaceholder = 'Fähigkeit suchen…', +}) { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const rootRef = useRef(null) + + const selected = useMemo(() => { + const id = value ? Number(value) : NaN + if (!Number.isFinite(id)) return null + return skills.find((s) => Number(s.id) === id) || null + }, [value, skills]) + + const displayLabel = selected ? skillCatalogPathLabel(selected) : '' + + useEffect(() => { + const onDoc = (e) => { + if (!rootRef.current?.contains(e.target)) { + setOpen(false) + setQuery('') + } + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const pick = (skillId) => { + onChange(String(skillId)) + setOpen(false) + setQuery('') + } + + return ( +
    + + {open ? ( +
    + setQuery(e.target.value)} + autoComplete="off" + /> +
    + +
    +
    + ) : null} +
    + ) +} diff --git a/frontend/src/components/admin/MaturityModelsAdminPanel.jsx b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx index dba4e4c..82df068 100644 --- a/frontend/src/components/admin/MaturityModelsAdminPanel.jsx +++ b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { useAuth } from '../../context/AuthContext' import api from '../../utils/api' +import SkillTreeSelect from '../SkillTreeSelect' export default function MaturityModelsAdminPanel() { const { user } = useAuth() @@ -39,7 +40,7 @@ export default function MaturityModelsAdminPanel() { try { const [m, sk] = await Promise.all([ api.listMaturityModels({}), - api.listSkills({ status: 'active' }) + api.listSkillsCatalog({ status: 'active' }) ]) if (!cancelled) { setModels(m) @@ -267,7 +268,7 @@ export default function MaturityModelsAdminPanel() { ? null : Number(skillWikiForm.relevance_level) }) - const sk = await api.listSkills({ status: 'active' }) + const sk = await api.listSkillsCatalog({ status: 'active' }) setAllSkills(sk) closeSkillWikiModal() } catch (e) { @@ -585,16 +586,13 @@ export default function MaturityModelsAdminPanel() {
    - + onChange={setSkillToAdd} + skills={allSkills} + excludeIds={(detail.model_skills || []).map((ms) => ms.skill_id)} + placeholder="— wählen —" + />
    @@ -1880,11 +1873,11 @@ function ExerciseFormPageRoot() {
    {sk?.name || `Skill #${row.skill_id}`} - {sk?.category && ( + {sk ? ( - {sk.category} + {skillCatalogPathLabel(sk)} - )} + ) : null}
    diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 11d384d..301b79a 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -16,6 +16,7 @@ import { import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll' import SkillTreeSelect from '../SkillTreeSelect' import { skillCatalogPathLabel } from '../../utils/skillCatalogTree' +import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels' import { useAuth } from '../../context/AuthContext' import { useToast } from '../../context/ToastContext' import { From 9020e5eb1642ab259e2104e92d225e7f339ecac9 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 11:13:34 +0200 Subject: [PATCH 3/6] Enhance Skill Tree Components with Improved Group Labeling and Default Expansion - 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. --- frontend/src/app.css | 6 +++ .../src/components/SkillTreeMultiSelect.jsx | 1 - .../src/components/SkillTreePickerPanel.jsx | 25 +++++---- frontend/src/components/SkillTreeSelect.jsx | 1 - frontend/src/utils/skillCatalogTree.js | 52 +++++++++++++++---- frontend/src/utils/skillCatalogTree.test.js | 17 +++++- 6 files changed, 79 insertions(+), 23 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 29877e8..2900609 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5320,6 +5320,12 @@ html.modal-scroll-locked .app-main { line-height: 1.35; } +.skill-tree__group-label--skill-group { + font-weight: 500; + color: var(--text2); + font-size: 0.84rem; +} + .multi-assoc-block { border: 1px solid var(--border); border-radius: 8px; diff --git a/frontend/src/components/SkillTreeMultiSelect.jsx b/frontend/src/components/SkillTreeMultiSelect.jsx index d6d6358..98dc4c0 100644 --- a/frontend/src/components/SkillTreeMultiSelect.jsx +++ b/frontend/src/components/SkillTreeMultiSelect.jsx @@ -122,7 +122,6 @@ export default function SkillTreeMultiSelect({ searchQuery={query} onPickSkill={(id) => addId(id)} pickMode="multi" - defaultCollapsed /> ) : (
      diff --git a/frontend/src/components/SkillTreePickerPanel.jsx b/frontend/src/components/SkillTreePickerPanel.jsx index 3b55257..d6647fb 100644 --- a/frontend/src/components/SkillTreePickerPanel.jsx +++ b/frontend/src/components/SkillTreePickerPanel.jsx @@ -4,6 +4,7 @@ import { allExpandableKeys, buildSkillCatalogTree, collectSkillLeavesFromTree, + defaultExpandedKeysForSkillTree, filterSkillTreeByQuery, } from '../utils/skillCatalogTree' @@ -54,7 +55,13 @@ function SkillTreeNodes({ nodes, depth, expanded, exclude, onToggle, onPickSkill ) : ( )} - {node.label} + + {node.label} +
    {hasKids && isOpen ? (
      @@ -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({ skills = [], @@ -84,8 +91,6 @@ export default function SkillTreePickerPanel({ onPickSkill, pickMode = 'single', className = '', - /** true: nur Hauptgruppen sichtbar, bis der Nutzer aufklappt (Filter) */ - defaultCollapsed = true, }) { const exclude = useMemo(() => normExclude(excludeIds), [excludeIds]) const fullTree = useMemo(() => buildSkillCatalogTree(skills), [skills]) @@ -98,16 +103,16 @@ export default function SkillTreePickerPanel({ useEffect(() => { if (searchQuery.trim()) { setExpanded(new Set(allExpandableKeys(displayTree))) - } else if (defaultCollapsed) { - setExpanded(new Set()) + } else { + setExpanded(new Set(defaultExpandedKeysForSkillTree(displayTree))) } - }, [searchQuery, displayTree, defaultCollapsed]) + }, [searchQuery, displayTree]) useEffect(() => { - if (!defaultCollapsed) { - setExpanded(new Set(allExpandableKeys(fullTree))) + if (!searchQuery.trim()) { + setExpanded(new Set(defaultExpandedKeysForSkillTree(fullTree))) } - }, [fullTree, defaultCollapsed]) + }, [fullTree, searchQuery]) const onToggle = useCallback((key) => { setExpanded((prev) => { diff --git a/frontend/src/components/SkillTreeSelect.jsx b/frontend/src/components/SkillTreeSelect.jsx index 728e93b..9ef6d1c 100644 --- a/frontend/src/components/SkillTreeSelect.jsx +++ b/frontend/src/components/SkillTreeSelect.jsx @@ -75,7 +75,6 @@ export default function SkillTreeSelect({ searchQuery={query} onPickSkill={pick} pickMode="single" - defaultCollapsed={false} />
    diff --git a/frontend/src/utils/skillCatalogTree.js b/frontend/src/utils/skillCatalogTree.js index e4578c6..89e4664 100644 --- a/frontend/src/utils/skillCatalogTree.js +++ b/frontend/src/utils/skillCatalogTree.js @@ -80,18 +80,33 @@ export function buildSkillCatalogTree(skills) { 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) => ({ + .map((cat) => { + const skillLeaves = [...cat.skills].sort(bySortThenName).map((skill) => ({ key: `s-${skill.id}`, type: 'skill', label: (skill.name || '').trim() || `#${skill.id}`, skillId: Number(skill.id), 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 }) } } 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') { const hay = `${n.label} ${skillCatalogPathLabel(n.skill)}`.toLowerCase() 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 { const kids = filterNodes(n.children || []) if (kids.length) result.push({ ...n, children: kids }) @@ -147,7 +166,7 @@ export function filterSkillTreeByQuery(tree, query) { return filterNodes(tree) } -/** @param {ReturnType} tree */ +/** Alle aufklappbaren Knoten (inkl. Fähigkeiten-Gruppen unter Kategorien). */ export function allExpandableKeys(tree) { const keys = [] const walk = (nodes) => { @@ -161,3 +180,18 @@ export function allExpandableKeys(tree) { walk(tree) 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 +} diff --git a/frontend/src/utils/skillCatalogTree.test.js b/frontend/src/utils/skillCatalogTree.test.js index 6ddc8ff..c6477d7 100644 --- a/frontend/src/utils/skillCatalogTree.test.js +++ b/frontend/src/utils/skillCatalogTree.test.js @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { buildSkillCatalogTree, collectSkillLeavesFromTree, + defaultExpandedKeysForSkillTree, filterSkillTreeByQuery, skillCatalogPathLabel, } from './skillCatalogTree.js' @@ -38,7 +39,17 @@ describe('skillCatalogTree', () => { expect(tree[0].label).toBe('Karate') expect(tree[0].children).toHaveLength(2) 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', () => { @@ -55,6 +66,8 @@ describe('skillCatalogTree', () => { it('filterSkillTreeByQuery', () => { const tree = buildSkillCatalogTree(sample) 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) }) }) From 39b1fd04f0bc92ff994a9bc78d1deb1a083a9a0d Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 11:16:49 +0200 Subject: [PATCH 4/6] Enhance Skills Catalog Admin with Unassigned Skill Handling and Improved Selection Logic - Introduced utility functions to count skills without main categories and categories, enhancing data management in the Skills Catalog Admin. - Updated the SkillsCatalogAdmin component to handle unassigned main and category IDs, improving user experience when managing skills. - Refactored skill selection logic to utilize new utility functions, ensuring accurate filtering of skills based on selected categories and main categories. - Enhanced the UI to display unassigned skills clearly, improving overall usability and clarity in skill management. --- .../components/admin/SkillsCatalogAdmin.jsx | 190 +++++++++++++++--- frontend/src/utils/skillCatalogTree.js | 48 +++++ frontend/src/utils/skillCatalogTree.test.js | 23 +++ 3 files changed, 233 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/admin/SkillsCatalogAdmin.jsx b/frontend/src/components/admin/SkillsCatalogAdmin.jsx index 83cca0a..3225d01 100644 --- a/frontend/src/components/admin/SkillsCatalogAdmin.jsx +++ b/frontend/src/components/admin/SkillsCatalogAdmin.jsx @@ -1,6 +1,15 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useAuth } from '../../context/AuthContext' import api from '../../utils/api' +import { + catalogSkillsForSelection, + countCatalogSkillsWithoutCategory, + countCatalogSkillsWithoutMain, + SKILL_CATEGORY_UNASSIGNED_KEY, + SKILL_MAIN_UNASSIGNED_KEY, + SKILL_UNASSIGNED_CATEGORY_LABEL, + SKILL_UNASSIGNED_MAIN_LABEL, +} from '../../utils/skillCatalogTree' function bySortThenName(a, b) { const sa = a.sort_order != null ? Number(a.sort_order) : 99999 @@ -72,6 +81,7 @@ export default function SkillsCatalogAdmin() { status: 'active', sort_order: '', category_id: '', + main_category_id: '', karate_relevance: '', relevance_level: '' }) @@ -84,7 +94,7 @@ export default function SkillsCatalogAdmin() { const [editDialog, setEditDialog] = useState(null) const refreshCategories = useCallback(async (mainId) => { - if (mainId == null) { + if (mainId == null || mainId === SKILL_MAIN_UNASSIGNED_KEY || typeof mainId !== 'number') { setCategories([]) return } @@ -128,12 +138,19 @@ export default function SkillsCatalogAdmin() { const sortedCategories = useMemo(() => [...categories].sort(bySortThenName), [categories]) - const skillsInCategory = useMemo(() => { + const unassignedMainCount = useMemo(() => countCatalogSkillsWithoutMain(catalog), [catalog]) + + const unassignedCategoryCount = useMemo( + () => countCatalogSkillsWithoutCategory(catalog, selectedMainId), + [catalog, selectedMainId] + ) + + const skillsInSelection = useMemo(() => { if (selectedCategoryId == null) return [] - return catalog - .filter((s) => s.category_id === selectedCategoryId) - .sort(bySortThenName) - }, [catalog, selectedCategoryId]) + return [...catalogSkillsForSelection(catalog, selectedMainId, selectedCategoryId)].sort( + bySortThenName + ) + }, [catalog, selectedMainId, selectedCategoryId]) useEffect(() => { if (!editDialog) return @@ -179,6 +196,7 @@ export default function SkillsCatalogAdmin() { status: s.status || 'active', sort_order: s.sort_order ?? '', category_id: s.category_id ?? '', + main_category_id: s.main_category_id ?? '', karate_relevance: s.karate_relevance || '', relevance_level: s.relevance_level != null && s.relevance_level !== '' @@ -199,7 +217,7 @@ export default function SkillsCatalogAdmin() { try { await op() await bootstrap() - if (selectedMainId) await refreshCategories(selectedMainId) + if (typeof selectedMainId === 'number') await refreshCategories(selectedMainId) setMessage('Gespeichert.') return true } catch (e) { @@ -238,7 +256,7 @@ export default function SkillsCatalogAdmin() { } async function handleSwapSkill(rowId, delta) { - const sorted = [...skillsInCategory].sort(bySortThenName) + const sorted = [...skillsInSelection].sort(bySortThenName) await run(async () => { await swapNeighborSortById(sorted, rowId, delta, (id, data) => api.updateSkill(id, data)) }) @@ -292,16 +310,18 @@ export default function SkillsCatalogAdmin() { async function handleSaveSkill(e) { e.preventDefault() if (!editDialog || editDialog.type !== 'skill') return - let cid = + const cid = skillForm.category_id === '' || skillForm.category_id == null ? null : Number(skillForm.category_id) - const skillRow = catalog.find((s) => s.id === editDialog.id) - if (cid == null && skillRow?.category_id) { - cid = skillRow.category_id - } + const mid = + cid == null && + skillForm.main_category_id !== '' && + skillForm.main_category_id != null + ? Number(skillForm.main_category_id) + : null const ok = await run(async () => { - await api.updateSkill(editDialog.id, { + const payload = { name: skillForm.name.trim(), description: skillForm.description || null, category: skillForm.category || null, @@ -319,16 +339,26 @@ export default function SkillsCatalogAdmin() { relevance_level: skillForm.relevance_level === '' || skillForm.relevance_level == null ? null - : Number(skillForm.relevance_level) - }) + : Number(skillForm.relevance_level), + } + if (cid == null) { + payload.main_category_id = mid + } + await api.updateSkill(editDialog.id, payload) }) if (ok) { - if (cid != null && cid !== selectedCategoryId) { + if (cid != null) { const cat = allCategories.find((c) => c.id === cid) if (cat?.main_category_id) { setSelectedMainId(cat.main_category_id) setSelectedCategoryId(cid) } + } else if (mid == null) { + setSelectedMainId(SKILL_MAIN_UNASSIGNED_KEY) + setSelectedCategoryId(SKILL_CATEGORY_UNASSIGNED_KEY) + } else { + setSelectedMainId(mid) + setSelectedCategoryId(SKILL_CATEGORY_UNASSIGNED_KEY) } closeEditDialog() } @@ -347,7 +377,7 @@ export default function SkillsCatalogAdmin() { async function handleCreateCategory(e) { e.preventDefault() - if (selectedMainId == null) return + if (selectedMainId == null || selectedMainId === SKILL_MAIN_UNASSIGNED_KEY) return const name = newCategoryName.trim() if (!name) return await run(async () => { @@ -367,11 +397,17 @@ export default function SkillsCatalogAdmin() { const name = newSkillName.trim() if (!name) return await run(async () => { - const created = await api.createSkill({ - name, - category_id: selectedCategoryId, - status: 'active' - }) + const body = { name, status: 'active' } + if (typeof selectedCategoryId === 'number') { + body.category_id = selectedCategoryId + } else if (selectedCategoryId === SKILL_CATEGORY_UNASSIGNED_KEY) { + body.category_id = null + body.main_category_id = + typeof selectedMainId === 'number' ? selectedMainId : null + } else { + return + } + const created = await api.createSkill(body) setNewSkillName('') setSelectedSkillId(created.id) }) @@ -429,8 +465,9 @@ export default function SkillsCatalogAdmin() {

    Struktur: HauptkategorieKategorie →{' '} - Fähigkeit. Reihenfolge mit Pfeilen; Details über das Stift-Symbol (öffnet - ein Fenster — auf dem iPhone nach unten scrollen, falls nötig). + Fähigkeit. Fähigkeiten ohne Zuordnung finden Sie unter{' '} + {SKILL_UNASSIGNED_MAIN_LABEL} bzw. {SKILL_UNASSIGNED_CATEGORY_LABEL}{' '} + (wie in der Auswahlbox). Reihenfolge mit Pfeilen; Details über das Stift-Symbol.

    {error ? ( @@ -455,6 +492,26 @@ export default function SkillsCatalogAdmin() {

    Hauptkategorien

      + {unassignedMainCount > 0 ? ( +
    • +
      + +
      +
    • + ) : null} {sortedMains.map((m) => (
    • Kategorien {selectedMainId == null ? (

      Zuerst eine Hauptkategorie wählen.

      + ) : selectedMainId === SKILL_MAIN_UNASSIGNED_KEY ? ( + <> +
        + {unassignedCategoryCount > 0 ? ( +
      • +
        + +
        +
      • + ) : null} +
      +

      + Unter {SKILL_UNASSIGNED_MAIN_LABEL} gibt es keine Unterkategorien. +

      + ) : ( <>
        + {unassignedCategoryCount > 0 ? ( +
      • +
        + +
        +
      • + ) : null} {sortedCategories.map((c) => (
      • Fähigkeiten

        {selectedCategoryId == null ? ( -

        Zuerst eine Kategorie wählen.

        +

        + Zuerst eine Kategorie oder „{SKILL_UNASSIGNED_CATEGORY_LABEL}“ wählen. +

        ) : ( <>
          - {skillsInCategory.map((s) => ( + {skillsInSelection.map((s) => (
        • + {allCategories.map((c) => ( ))} + {skillForm.category_id === '' || skillForm.category_id == null ? ( + <> + + + + ) : null} 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 +} diff --git a/frontend/src/utils/skillCatalogTree.test.js b/frontend/src/utils/skillCatalogTree.test.js index c6477d7..723c3eb 100644 --- a/frontend/src/utils/skillCatalogTree.test.js +++ b/frontend/src/utils/skillCatalogTree.test.js @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest' import { buildSkillCatalogTree, + catalogSkillsForSelection, collectSkillLeavesFromTree, + countCatalogSkillsWithoutCategory, + countCatalogSkillsWithoutMain, defaultExpandedKeysForSkillTree, filterSkillTreeByQuery, + SKILL_CATEGORY_UNASSIGNED_KEY, + SKILL_MAIN_UNASSIGNED_KEY, skillCatalogPathLabel, } from './skillCatalogTree.js' @@ -70,4 +75,22 @@ describe('skillCatalogTree', () => { const group = kihon?.children?.[0] expect(group?.children?.some((s) => s.skillId === 1)).toBe(true) }) + + it('catalogSkillsForSelection lists unassigned buckets', () => { + const skills = [ + { id: 1, main_category_id: null, category_id: null }, + { id: 2, main_category_id: 10, category_id: null }, + { id: 3, main_category_id: 10, category_id: 20 }, + ] + expect( + catalogSkillsForSelection(skills, SKILL_MAIN_UNASSIGNED_KEY, SKILL_CATEGORY_UNASSIGNED_KEY).map( + (s) => s.id + ) + ).toEqual([1]) + expect(catalogSkillsForSelection(skills, 10, SKILL_CATEGORY_UNASSIGNED_KEY).map((s) => s.id)).toEqual([ + 2, + ]) + expect(countCatalogSkillsWithoutMain(skills)).toBe(1) + expect(countCatalogSkillsWithoutCategory(skills, 10)).toBe(1) + }) }) From b2f77ca627d763541b560fbe22033649958b1e62 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 11:18:34 +0200 Subject: [PATCH 5/6] Refactor Skill Tree Picker and Catalog Functions for Clarity and Efficiency - Simplified the rendering of skill group labels in `SkillTreePickerPanel` for improved readability. - Updated comments in `skillCatalogTree.js` to clarify the structure of expandable nodes and default expansion behavior. - Refactored the skill catalog tree building logic to streamline the mapping of categories and skills, enhancing performance and maintainability. - Adjusted tests in `skillCatalogTree.test.js` to reflect changes in the structure of skill nodes, ensuring accurate validation of functionality. --- frontend/src/app.css | 6 --- .../src/components/SkillTreePickerPanel.jsx | 8 +--- frontend/src/utils/skillCatalogTree.js | 39 +++++-------------- frontend/src/utils/skillCatalogTree.test.js | 11 +++--- 4 files changed, 17 insertions(+), 47 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 2900609..29877e8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5320,12 +5320,6 @@ html.modal-scroll-locked .app-main { line-height: 1.35; } -.skill-tree__group-label--skill-group { - font-weight: 500; - color: var(--text2); - font-size: 0.84rem; -} - .multi-assoc-block { border: 1px solid var(--border); border-radius: 8px; diff --git a/frontend/src/components/SkillTreePickerPanel.jsx b/frontend/src/components/SkillTreePickerPanel.jsx index d6647fb..e127a1a 100644 --- a/frontend/src/components/SkillTreePickerPanel.jsx +++ b/frontend/src/components/SkillTreePickerPanel.jsx @@ -55,11 +55,7 @@ function SkillTreeNodes({ nodes, depth, expanded, exclude, onToggle, onPickSkill ) : ( )} - + {node.label}
          @@ -82,7 +78,7 @@ function SkillTreeNodes({ nodes, depth, expanded, exclude, onToggle, onPickSkill } /** - * Ausklappbare Baumliste: Hauptgruppe → Kategorie (beide standard offen) → Fähigkeiten-Gruppe (standard zu). + * Ausklappbare Baumliste: Hauptgruppe → Kategorie → Fähigkeit (Hauptgruppe und Kategorie standard offen). */ export default function SkillTreePickerPanel({ skills = [], diff --git a/frontend/src/utils/skillCatalogTree.js b/frontend/src/utils/skillCatalogTree.js index cdf62ed..64de7bb 100644 --- a/frontend/src/utils/skillCatalogTree.js +++ b/frontend/src/utils/skillCatalogTree.js @@ -80,33 +80,18 @@ export function buildSkillCatalogTree(skills) { if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder return a.label.localeCompare(b.label, 'de') }) - .map((cat) => { - const skillLeaves = [...cat.skills].sort(bySortThenName).map((skill) => ({ + .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, - })) - 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, - }, - ] - : [], - } - }), + })), + })), })) } @@ -132,8 +117,7 @@ export function collectSkillLeavesFromTree(tree, excludeIds) { out.push({ id: n.skillId, label: n.label, pathLabel, skill: n.skill }) } } else if (n.children?.length) { - const nextPath = n.type === 'skillGroup' ? pathParts : [...pathParts, n.label] - walk(n.children, nextPath) + walk(n.children, [...pathParts, n.label]) } } } @@ -153,9 +137,6 @@ export function filterSkillTreeByQuery(tree, query) { if (n.type === 'skill') { const hay = `${n.label} ${skillCatalogPathLabel(n.skill)}`.toLowerCase() 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 { const kids = filterNodes(n.children || []) if (kids.length) result.push({ ...n, children: kids }) @@ -166,7 +147,7 @@ export function filterSkillTreeByQuery(tree, query) { return filterNodes(tree) } -/** Alle aufklappbaren Knoten (inkl. Fähigkeiten-Gruppen unter Kategorien). */ +/** Alle aufklappbaren Knoten (Hauptgruppe und Kategorie). */ export function allExpandableKeys(tree) { const keys = [] const walk = (nodes) => { @@ -181,7 +162,7 @@ export function allExpandableKeys(tree) { return keys } -/** Standard: Hauptgruppe + Kategorie offen, Fähigkeiten-Liste zugeklappt. */ +/** Standard: Hauptgruppe und Kategorie aufgeklappt (Fähigkeiten direkt darunter sichtbar). */ export function defaultExpandedKeysForSkillTree(tree) { const keys = [] const walk = (nodes) => { diff --git a/frontend/src/utils/skillCatalogTree.test.js b/frontend/src/utils/skillCatalogTree.test.js index 723c3eb..bbd06fd 100644 --- a/frontend/src/utils/skillCatalogTree.test.js +++ b/frontend/src/utils/skillCatalogTree.test.js @@ -44,9 +44,9 @@ describe('skillCatalogTree', () => { expect(tree[0].label).toBe('Karate') expect(tree[0].children).toHaveLength(2) expect(tree[0].children[0].label).toBe('Kata') - const kataSkills = tree[0].children[0].children[0] - expect(kataSkills.type).toBe('skillGroup') - expect(kataSkills.children[0].skillId).toBe(2) + const kataSkill = tree[0].children[0].children[0] + expect(kataSkill.type).toBe('skill') + expect(kataSkill.skillId).toBe(2) }) it('defaultExpandedKeysForSkillTree opens main and category only', () => { @@ -54,7 +54,7 @@ describe('skillCatalogTree', () => { 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) + expect(keys.some((k) => k.startsWith('s-'))).toBe(false) }) it('skillCatalogPathLabel', () => { @@ -72,8 +72,7 @@ describe('skillCatalogTree', () => { const tree = buildSkillCatalogTree(sample) const filtered = filterSkillTreeByQuery(tree, 'dachi') 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) + expect(kihon?.children?.some((s) => s.skillId === 1)).toBe(true) }) it('catalogSkillsForSelection lists unassigned buckets', () => { From ab612a53350a74a2ab4abb8766802527f2d54069 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 11:21:18 +0200 Subject: [PATCH 6/6] Update Skill Tree Picker and Catalog Functions for Clarity - 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. --- frontend/src/components/SkillTreePickerPanel.jsx | 2 +- frontend/src/utils/skillCatalogTree.js | 15 +++------------ frontend/src/utils/skillCatalogTree.test.js | 6 +++--- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/SkillTreePickerPanel.jsx b/frontend/src/components/SkillTreePickerPanel.jsx index e127a1a..6b947a1 100644 --- a/frontend/src/components/SkillTreePickerPanel.jsx +++ b/frontend/src/components/SkillTreePickerPanel.jsx @@ -78,7 +78,7 @@ function SkillTreeNodes({ nodes, depth, expanded, exclude, onToggle, onPickSkill } /** - * Ausklappbare Baumliste: Hauptgruppe → Kategorie → Fähigkeit (Hauptgruppe und Kategorie standard offen). + * Ausklappbare Baumliste: Hauptgruppe → Kategorie → Fähigkeit (nur Hauptgruppe standard offen). */ export default function SkillTreePickerPanel({ skills = [], diff --git a/frontend/src/utils/skillCatalogTree.js b/frontend/src/utils/skillCatalogTree.js index 64de7bb..37f27ba 100644 --- a/frontend/src/utils/skillCatalogTree.js +++ b/frontend/src/utils/skillCatalogTree.js @@ -162,19 +162,10 @@ export function allExpandableKeys(tree) { return keys } -/** Standard: Hauptgruppe und Kategorie aufgeklappt (Fähigkeiten direkt darunter sichtbar). */ +/** Standard: nur Hauptgruppe aufgeklappt; Kategorien und Fähigkeiten 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 + 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. */ diff --git a/frontend/src/utils/skillCatalogTree.test.js b/frontend/src/utils/skillCatalogTree.test.js index bbd06fd..f320a63 100644 --- a/frontend/src/utils/skillCatalogTree.test.js +++ b/frontend/src/utils/skillCatalogTree.test.js @@ -49,11 +49,11 @@ describe('skillCatalogTree', () => { expect(kataSkill.skillId).toBe(2) }) - it('defaultExpandedKeysForSkillTree opens main and category only', () => { + it('defaultExpandedKeysForSkillTree opens main groups 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).toEqual(['m-10']) + expect(keys.some((k) => k.includes('c-'))).toBe(false) expect(keys.some((k) => k.startsWith('s-'))).toBe(false) })