From 3067b2e6a8f58271416c6b3200084eb44db30f5d Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 10:59:17 +0200 Subject: [PATCH] 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}