Enhance Skills Catalog Admin with Unassigned Skill Handling and Improved Selection Logic
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Has been cancelled
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Has been cancelled
- 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.
This commit is contained in:
parent
9020e5eb16
commit
39b1fd04f0
|
|
@ -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() {
|
|||
<div className="skills-catalog-admin">
|
||||
<p className="skills-catalog-admin__intro muted">
|
||||
Struktur: <strong>Hauptkategorie</strong> → <strong>Kategorie</strong> →{' '}
|
||||
<strong>Fähigkeit</strong>. Reihenfolge mit Pfeilen; Details über das Stift-Symbol (öffnet
|
||||
ein Fenster — auf dem iPhone nach unten scrollen, falls nötig).
|
||||
<strong>Fähigkeit</strong>. Fähigkeiten ohne Zuordnung finden Sie unter{' '}
|
||||
<strong>{SKILL_UNASSIGNED_MAIN_LABEL}</strong> bzw. <strong>{SKILL_UNASSIGNED_CATEGORY_LABEL}</strong>{' '}
|
||||
(wie in der Auswahlbox). Reihenfolge mit Pfeilen; Details über das Stift-Symbol.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
|
|
@ -455,6 +492,26 @@ export default function SkillsCatalogAdmin() {
|
|||
<section className="skills-catalog-column" aria-label="Hauptkategorien">
|
||||
<h3 className="skills-catalog-column__title">Hauptkategorien</h3>
|
||||
<ul className="skills-catalog-list">
|
||||
{unassignedMainCount > 0 ? (
|
||||
<li>
|
||||
<div
|
||||
className={
|
||||
'skills-catalog-row' +
|
||||
(selectedMainId === SKILL_MAIN_UNASSIGNED_KEY
|
||||
? ' skills-catalog-row--active'
|
||||
: '')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="skills-catalog-row__label"
|
||||
onClick={() => selectMain(SKILL_MAIN_UNASSIGNED_KEY)}
|
||||
>
|
||||
{SKILL_UNASSIGNED_MAIN_LABEL} ({unassignedMainCount})
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
{sortedMains.map((m) => (
|
||||
<li key={m.id}>
|
||||
<div
|
||||
|
|
@ -534,9 +591,57 @@ export default function SkillsCatalogAdmin() {
|
|||
<h3 className="skills-catalog-column__title">Kategorien</h3>
|
||||
{selectedMainId == null ? (
|
||||
<p className="muted skills-catalog-placeholder">Zuerst eine Hauptkategorie wählen.</p>
|
||||
) : selectedMainId === SKILL_MAIN_UNASSIGNED_KEY ? (
|
||||
<>
|
||||
<ul className="skills-catalog-list">
|
||||
{unassignedCategoryCount > 0 ? (
|
||||
<li>
|
||||
<div
|
||||
className={
|
||||
'skills-catalog-row' +
|
||||
(selectedCategoryId === SKILL_CATEGORY_UNASSIGNED_KEY
|
||||
? ' skills-catalog-row--active'
|
||||
: '')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="skills-catalog-row__label"
|
||||
onClick={() => selectCategory(SKILL_CATEGORY_UNASSIGNED_KEY)}
|
||||
>
|
||||
{SKILL_UNASSIGNED_CATEGORY_LABEL} ({unassignedCategoryCount})
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
<p className="muted skills-catalog-placeholder">
|
||||
Unter {SKILL_UNASSIGNED_MAIN_LABEL} gibt es keine Unterkategorien.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ul className="skills-catalog-list">
|
||||
{unassignedCategoryCount > 0 ? (
|
||||
<li>
|
||||
<div
|
||||
className={
|
||||
'skills-catalog-row' +
|
||||
(selectedCategoryId === SKILL_CATEGORY_UNASSIGNED_KEY
|
||||
? ' skills-catalog-row--active'
|
||||
: '')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="skills-catalog-row__label"
|
||||
onClick={() => selectCategory(SKILL_CATEGORY_UNASSIGNED_KEY)}
|
||||
>
|
||||
{SKILL_UNASSIGNED_CATEGORY_LABEL} ({unassignedCategoryCount})
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
{sortedCategories.map((c) => (
|
||||
<li key={c.id}>
|
||||
<div
|
||||
|
|
@ -617,11 +722,13 @@ export default function SkillsCatalogAdmin() {
|
|||
<section className="skills-catalog-column" aria-label="Fähigkeiten">
|
||||
<h3 className="skills-catalog-column__title">Fähigkeiten</h3>
|
||||
{selectedCategoryId == null ? (
|
||||
<p className="muted skills-catalog-placeholder">Zuerst eine Kategorie wählen.</p>
|
||||
<p className="muted skills-catalog-placeholder">
|
||||
Zuerst eine Kategorie oder „{SKILL_UNASSIGNED_CATEGORY_LABEL}“ wählen.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="skills-catalog-list">
|
||||
{skillsInCategory.map((s) => (
|
||||
{skillsInSelection.map((s) => (
|
||||
<li key={s.id}>
|
||||
<div
|
||||
className={
|
||||
|
|
@ -897,14 +1004,41 @@ export default function SkillsCatalogAdmin() {
|
|||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
required
|
||||
>
|
||||
<option value="">{SKILL_UNASSIGNED_CATEGORY_LABEL}</option>
|
||||
{allCategories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{(c.main_category_name ? c.main_category_name + ' · ' : '') + c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{skillForm.category_id === '' || skillForm.category_id == null ? (
|
||||
<>
|
||||
<label className="form-label">Hauptkategorie</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={
|
||||
skillForm.main_category_id === '' || skillForm.main_category_id == null
|
||||
? ''
|
||||
: String(skillForm.main_category_id)
|
||||
}
|
||||
onChange={(e) =>
|
||||
setSkillForm((f) => ({
|
||||
...f,
|
||||
main_category_id: e.target.value === '' ? '' : e.target.value
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
<option value="">{SKILL_UNASSIGNED_MAIN_LABEL}</option>
|
||||
{sortedMains.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : null}
|
||||
<label className="form-label">Legacy-Kurzlabel „category“ (optional)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
|
|
|
|||
|
|
@ -195,3 +195,51 @@ export function defaultExpandedKeysForSkillTree(tree) {
|
|||
walk(tree)
|
||||
return keys
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user