Refactor Exercise Creation Components to Utilize Custom Hook for Quick Create Fields
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
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) Successful in 1m20s

- Updated ExerciseAiQuickCreateOffer to set showSketchField to true by default and introduced sketchOptional prop for improved flexibility in exercise creation.
- Refactored ExercisePickerModal and ExercisesListPageRoot to leverage useExerciseAiQuickCreateFields hook, simplifying state management for quick create fields.
- Removed deprecated parsing logic and streamlined error handling for sketch input, enhancing user experience during exercise creation.
- Improved placeholder text and labels for clarity, ensuring better guidance for users when providing input for AI-generated exercises.
This commit is contained in:
Lars 2026-05-22 19:36:22 +02:00
parent 294740b780
commit c816e50c68
4 changed files with 108 additions and 59 deletions

View File

@ -16,14 +16,15 @@ export default function ExerciseAiQuickCreateOffer({
busy = false, busy = false,
error = '', error = '',
onRunAi, onRunAi,
showSketchField = false, showSketchField = true,
sketchOptional = true,
hint, hint,
}) { }) {
const canRun = const canRun =
!busy && !busy &&
(title || '').trim().length >= 3 && (title || '').trim().length >= 3 &&
(sketch || '').trim().length > 0 && focusAreaId &&
focusAreaId (sketchOptional || (sketch || '').trim().length > 0)
return ( return (
<div <div
@ -85,23 +86,27 @@ export default function ExerciseAiQuickCreateOffer({
{showSketchField ? ( {showSketchField ? (
<div> <div>
<label className="form-label" htmlFor="ex-ai-quick-sketch"> <label className="form-label" htmlFor="ex-ai-quick-sketch">
Skizze / Idee * Kurzbeschreibung / Idee{sketchOptional ? ' (optional)' : ' *'}
</label> </label>
<textarea <textarea
id="ex-ai-quick-sketch" id="ex-ai-quick-sketch"
className="form-input" className="form-input"
rows={3} rows={4}
value={sketch} value={sketch}
onChange={(e) => onSketchChange(e.target.value)} onChange={(e) => onSketchChange(e.target.value)}
placeholder="Kurze Beschreibung für die KI …" placeholder={
sketchOptional
? 'Leer lassen: KI schlägt Inhalt frei vor (Titel + Fokus). Oder kurz beschreiben, was die Übung tun soll …'
: 'Kurze Beschreibung für die KI …'
}
/> />
{searchLabel && !(sketch || '').trim() ? (
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
Suchbegriff: {searchLabel} wird als Titel übernommen.
</p>
) : null}
</div> </div>
) : ( ) : null}
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text3)' }}>
Ausgangstext: {(sketch || '').trim().slice(0, 160)}
{(sketch || '').trim().length > 160 ? '…' : ''}
</p>
)}
{error ? ( {error ? (
<p style={{ margin: 0, fontSize: '13px', color: 'var(--danger)' }}>{error}</p> <p style={{ margin: 0, fontSize: '13px', color: 'var(--danger)' }}>{error}</p>

View File

@ -19,11 +19,11 @@ import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
import CatalogRulePicker from './CatalogRulePicker' import CatalogRulePicker from './CatalogRulePicker'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import ExerciseAiQuickCreateOffer from './ExerciseAiQuickCreateOffer' import ExerciseAiQuickCreateOffer from './ExerciseAiQuickCreateOffer'
import { useExerciseAiQuickCreateFields } from '../hooks/useExerciseAiQuickCreateFields'
import { import {
buildQuickCreateAiPreview, buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft, buildQuickCreateExercisePayloadFromDraft,
aiPreviewToQuickCreateDraft, aiPreviewToQuickCreateDraft,
parseSearchQueryForQuickCreate,
} from '../utils/exerciseAiQuickCreate' } from '../utils/exerciseAiQuickCreate'
const PAGE_SIZE = 100 const PAGE_SIZE = 100
@ -61,14 +61,21 @@ export default function ExercisePickerModal({
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [multiPicked, setMultiPicked] = useState([]) const [multiPicked, setMultiPicked] = useState([])
const [quickTitle, setQuickTitle] = useState('')
const [quickSketch, setQuickSketch] = useState('')
const [quickFocusAreaId, setQuickFocusAreaId] = useState('')
const [quickSaving, setQuickSaving] = useState(false) const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('') const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null) const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const pickerScrollRef = useRef(null) const pickerScrollRef = useRef(null)
const {
title: quickTitle,
sketch: quickSketch,
focusAreaId: quickFocusAreaId,
setTitle: setQuickTitle,
setSketch: setQuickSketch,
setFocusAreaId: setQuickFocusAreaId,
resetQuickCreateFields,
} = useExerciseAiQuickCreateFields(debouncedSearch, { enabled: open && enableQuickCreateDraft })
const toggleMultiPick = (ex) => { const toggleMultiPick = (ex) => {
setMultiPicked((prev) => setMultiPicked((prev) =>
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex] prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
@ -85,18 +92,6 @@ export default function ExercisePickerModal({
return () => clearTimeout(t) return () => clearTimeout(t)
}, [aiSearchInput]) }, [aiSearchInput])
const parsedQuickCreate = useMemo(
() => parseSearchQueryForQuickCreate(debouncedSearch),
[debouncedSearch],
)
useEffect(() => {
if (!enableQuickCreateDraft || !debouncedSearch) return
setQuickTitle(parsedQuickCreate.title)
setQuickSketch(parsedQuickCreate.sketch || debouncedSearch)
setQuickAiError('')
}, [enableQuickCreateDraft, debouncedSearch, parsedQuickCreate.title, parsedQuickCreate.sketch])
const showQuickCreateOffer = const showQuickCreateOffer =
enableQuickCreateDraft && enableQuickCreateDraft &&
catalogsReady && catalogsReady &&
@ -147,9 +142,7 @@ export default function ExercisePickerModal({
setList([]) setList([])
setHasMore(false) setHasMore(false)
setMultiPicked([]) setMultiPicked([])
setQuickTitle('') resetQuickCreateFields()
setQuickSketch('')
setQuickFocusAreaId('')
setQuickSaving(false) setQuickSaving(false)
setQuickAiError('') setQuickAiError('')
setQuickCreateDraft(null) setQuickCreateDraft(null)
@ -329,10 +322,7 @@ export default function ExercisePickerModal({
return return
} }
const sketch = (quickSketch || '').trim() const sketch = (quickSketch || '').trim()
if (!sketch) {
alert('Bitte einen Suchbegriff oder eine Skizze eingeben.')
return
}
const focusId = parseInt(String(quickFocusAreaId).trim(), 10) const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) { if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen.') alert('Bitte einen Fokusbereich wählen.')
@ -348,7 +338,7 @@ export default function ExercisePickerModal({
try { try {
const aiRes = await api.suggestExerciseAi({ const aiRes = await api.suggestExerciseAi({
title, title,
goal: sketch, goal: sketch || undefined,
execution: '', execution: '',
preparation: '', preparation: '',
trainer_notes: '', trainer_notes: '',

View File

@ -18,8 +18,8 @@ import {
buildQuickCreateAiPreview, buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft, buildQuickCreateExercisePayloadFromDraft,
aiPreviewToQuickCreateDraft, aiPreviewToQuickCreateDraft,
parseSearchQueryForQuickCreate,
} from '../../utils/exerciseAiQuickCreate' } from '../../utils/exerciseAiQuickCreate'
import { useExerciseAiQuickCreateFields } from '../../hooks/useExerciseAiQuickCreateFields'
import { buildExercisesListReturnContext } from '../../utils/navReturnContext' import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips' import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree' import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
@ -89,13 +89,21 @@ function ExercisesListPageRoot() {
const [peekExercise, setPeekExercise] = useState(null) const [peekExercise, setPeekExercise] = useState(null)
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false) const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false) const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false)
const [quickTitle, setQuickTitle] = useState('')
const [quickSketch, setQuickSketch] = useState('')
const [quickFocusAreaId, setQuickFocusAreaId] = useState('')
const [quickSaving, setQuickSaving] = useState(false) const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('') const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null) const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const {
title: quickTitle,
sketch: quickSketch,
focusAreaId: quickFocusAreaId,
setTitle: setQuickTitle,
setSketch: setQuickSketch,
setFocusAreaId: setQuickFocusAreaId,
} = useExerciseAiQuickCreateFields(debouncedSearch, {
enabled: pageTab === 'list' && (aiQuickCreateEnabled || debouncedSearch.length >= 3),
})
useEffect(() => { useEffect(() => {
if (!user?.id) return if (!user?.id) return
if (prefsAppliedRef.current) return if (prefsAppliedRef.current) return
@ -143,18 +151,6 @@ function ExercisesListPageRoot() {
return () => clearTimeout(t) return () => clearTimeout(t)
}, [aiSearchInput]) }, [aiSearchInput])
const parsedQuickCreate = useMemo(
() => parseSearchQueryForQuickCreate(debouncedSearch),
[debouncedSearch],
)
useEffect(() => {
if (!debouncedSearch) return
setQuickTitle(parsedQuickCreate.title)
setQuickSketch(parsedQuickCreate.sketch || debouncedSearch)
setQuickAiError('')
}, [debouncedSearch, parsedQuickCreate.title, parsedQuickCreate.sketch])
useEffect(() => { useEffect(() => {
if (!filterModalOpen) return if (!filterModalOpen) return
const onKey = (e) => { const onKey = (e) => {
@ -347,10 +343,7 @@ function ExercisesListPageRoot() {
return return
} }
const sketch = (quickSketch || '').trim() const sketch = (quickSketch || '').trim()
if (!sketch) {
alert('Bitte Suchtext oder Skizze eingeben.')
return
}
const focusId = parseInt(String(quickFocusAreaId).trim(), 10) const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) { if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen.') alert('Bitte einen Fokusbereich wählen.')
@ -366,7 +359,7 @@ function ExercisesListPageRoot() {
try { try {
const aiRes = await api.suggestExerciseAi({ const aiRes = await api.suggestExerciseAi({
title, title,
goal: sketch, goal: sketch || undefined,
execution: '', execution: '',
preparation: '', preparation: '',
trainer_notes: '', trainer_notes: '',
@ -668,10 +661,9 @@ function ExercisesListPageRoot() {
busy={quickSaving} busy={quickSaving}
error={quickAiError} error={quickAiError}
onRunAi={runQuickCreateAiSuggest} onRunAi={runQuickCreateAiSuggest}
showSketchField={aiQuickCreateEnabled}
hint={ hint={
aiQuickCreateEnabled aiQuickCreateEnabled
? 'KI-Anlage: Suchtext oder eigene Skizze als Ausgang — Fokusbereich wählen, dann KI-Vorschlag erzeugen und bearbeiten.' ? 'KI-Anlage: Titel aus Suche oder manuell; Kurzbeschreibung optional — leer für freien KI-Vorschlag, ausgefüllt als Ausgangsidee.'
: undefined : undefined
} }
/> />

View File

@ -0,0 +1,62 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { parseSearchQueryForQuickCreate } from '../utils/exerciseAiQuickCreate'
/**
* Titel aus Suche vorbelegen; Kurzbeschreibung optional und manuell editierbar.
* Suchwechsel setzt touched zurück und befüllt neu solange der Nutzer nicht editiert hat.
*/
export function useExerciseAiQuickCreateFields(debouncedSearch, { enabled = true } = {}) {
const [title, setTitleState] = useState('')
const [sketch, setSketchState] = useState('')
const [focusAreaId, setFocusAreaId] = useState('')
const titleTouchedRef = useRef(false)
const sketchTouchedRef = useRef(false)
const lastSearchRef = useRef('')
const parsed = useMemo(() => parseSearchQueryForQuickCreate(debouncedSearch), [debouncedSearch])
useEffect(() => {
if (!enabled) return
if (debouncedSearch !== lastSearchRef.current) {
lastSearchRef.current = debouncedSearch
titleTouchedRef.current = false
sketchTouchedRef.current = false
}
if (!debouncedSearch) return
if (!titleTouchedRef.current) {
setTitleState(parsed.title)
}
if (!sketchTouchedRef.current) {
setSketchState('')
}
}, [enabled, debouncedSearch, parsed.title])
const setTitle = useCallback((v) => {
titleTouchedRef.current = true
setTitleState(v)
}, [])
const setSketch = useCallback((v) => {
sketchTouchedRef.current = true
setSketchState(v)
}, [])
const resetQuickCreateFields = useCallback(() => {
setTitleState('')
setSketchState('')
setFocusAreaId('')
titleTouchedRef.current = false
sketchTouchedRef.current = false
lastSearchRef.current = ''
}, [])
return {
title,
sketch,
focusAreaId,
setTitle,
setSketch,
setFocusAreaId,
resetQuickCreateFields,
}
}