refactor: enhance filtering logic and UI for ExercisesListPage
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 2m1s

- Removed the countActiveFilterGroups function and replaced it with a more comprehensive filterChips implementation to manage active filters.
- Improved the rendering of active filters with dynamic chips that allow users to remove individual filters.
- Updated the UI to reflect the new filtering logic, enhancing user experience and interaction with the filter options.
- Adjusted the layout and styling for better visibility and usability of the filter components.
This commit is contained in:
Lars 2026-04-28 08:58:17 +02:00
parent 0d4ad9a2c8
commit 2c831d6cea
2 changed files with 343 additions and 114 deletions

View File

@ -1586,16 +1586,98 @@ a.analysis-split__nav-item {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
}
.exercise-filter-level-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.exercise-filters-modal-grid--two {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
@media (max-width: 480px) {
.exercise-filter-level-row {
grid-template-columns: 1fr;
}
.exercise-filter-chips-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 10px;
}
.exercise-filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface2);
font-size: 12px;
cursor: pointer;
font-family: inherit;
color: var(--text1);
text-align: left;
}
.exercise-filter-chip__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: min(260px, 88vw);
}
.exercise-filter-chip__x {
flex-shrink: 0;
opacity: 0.65;
font-weight: 700;
line-height: 1;
}
.exercise-filter-section {
margin-bottom: 20px;
}
.exercise-filter-section--last {
margin-bottom: 0;
}
.exercise-filter-section-title {
margin: 0 0 10px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text3);
font-weight: 700;
}
.exercise-filter-skill-block {
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
background: var(--surface2);
}
.exercise-filter-skill-block > .form-label {
margin-bottom: 6px;
}
.exercise-filter-skill-hint {
margin: 10px 0 8px;
font-size: 12px;
color: var(--text3);
line-height: 1.35;
}
.exercise-filter-skill-levels-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
}
.exercise-filter-skill-level-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.exercise-filter-skill-level-caption {
font-size: 11px;
color: var(--text3);
font-weight: 600;
}
.exercise-filter-level-select {
width: 72px;
padding: 6px 8px;
font-size: 14px;
}
.exercise-filter-skill-dash {
padding-bottom: 8px;
color: var(--text3);
font-weight: 600;
}
/* Reifegradmodell-Admin: klare Schritte, responsives Raster */

View File

@ -19,17 +19,9 @@ const INITIAL_FILTERS = {
status_any: [],
}
function countActiveFilterGroups(f) {
let n = 0
if (f.focus_area_ids?.length) n++
if (f.style_direction_ids?.length) n++
if (f.training_type_ids?.length) n++
if (f.target_group_ids?.length) n++
if (f.skill_ids?.length) n++
if (f.skill_min_level || f.skill_max_level) n++
if (f.visibility_any?.length) n++
if (f.status_any?.length) n++
return n
function levelOptionShort(levelStr) {
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
return o ? String(o.level) : String(levelStr)
}
function ExercisesListPage() {
@ -53,8 +45,6 @@ function ExercisesListPage() {
const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS }))
const [filterModalOpen, setFilterModalOpen] = useState(false)
const activeFilterGroups = useMemo(() => countActiveFilterGroups(filters), [filters])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
return () => clearTimeout(t)
@ -116,6 +106,122 @@ function ExercisesListPage() {
[]
)
const filterChips = useMemo(() => {
const chips = []
;(filters.focus_area_ids || []).forEach((id) => {
const opt = focusOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `fa-${id}`,
label: `Fokus: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)),
})),
})
})
;(filters.style_direction_ids || []).forEach((id) => {
const opt = styleOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `sd-${id}`,
label: `Stil: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
style_direction_ids: prev.style_direction_ids.filter((x) => String(x) !== String(id)),
})),
})
})
;(filters.training_type_ids || []).forEach((id) => {
const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tt-${id}`,
label: `Trainingsstil: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
training_type_ids: prev.training_type_ids.filter((x) => String(x) !== String(id)),
})),
})
})
;(filters.target_group_ids || []).forEach((id) => {
const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tg-${id}`,
label: `Zielgruppe: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
target_group_ids: prev.target_group_ids.filter((x) => String(x) !== String(id)),
})),
})
})
;(filters.skill_ids || []).forEach((id) => {
const opt = skillOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `sk-${id}`,
label: `Fähigkeit: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
skill_ids: prev.skill_ids.filter((x) => String(x) !== String(id)),
})),
})
})
if (filters.skill_min_level || filters.skill_max_level) {
const a = filters.skill_min_level ? levelOptionShort(filters.skill_min_level) : '…'
const b = filters.skill_max_level ? levelOptionShort(filters.skill_max_level) : '…'
chips.push({
key: 'skill-levels',
label: `Stufe ${a}${b}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
skill_min_level: '',
skill_max_level: '',
})),
})
}
;(filters.visibility_any || []).forEach((id) => {
const opt = visibilityOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `vis-${id}`,
label: `Sichtbarkeit: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
visibility_any: prev.visibility_any.filter((x) => String(x) !== String(id)),
})),
})
})
;(filters.status_any || []).forEach((id) => {
const opt = statusOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `st-${id}`,
label: `Status: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
status_any: prev.status_any.filter((x) => String(x) !== String(id)),
})),
})
})
return chips
}, [
filters,
focusOptions,
styleOptions,
trainingTypeOptions,
targetGroupOptions,
skillOptions,
visibilityOptions,
statusOptions,
])
const queryBase = useMemo(() => {
const q = {}
const n = (v) => (v === '' || v == null ? undefined : Number(v))
@ -278,18 +384,37 @@ function ExercisesListPage() {
<div className="exercise-search-bar__actions">
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
Filter
{activeFilterGroups > 0 ? (
{filterChips.length > 0 ? (
<span className="exercise-filter-badge" aria-hidden>
{activeFilterGroups}
{filterChips.length}
</span>
) : null}
</button>
{activeFilterGroups > 0 ? (
{filterChips.length > 0 ? (
<button type="button" className="btn" onClick={resetAllFilters}>
Filter löschen
Alle entfernen
</button>
) : null}
</div>
{filterChips.length > 0 ? (
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
{filterChips.map((c) => (
<button
key={c.key}
type="button"
role="listitem"
className="exercise-filter-chip"
title="Filter entfernen"
onClick={() => c.onRemove()}
>
<span className="exercise-filter-chip__text">{c.label}</span>
<span className="exercise-filter-chip__x" aria-hidden>
×
</span>
</button>
))}
</div>
) : null}
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '10px', marginBottom: 0 }}>
Vereins-/Trainerfilter folgen später. Fachliche Filter über Filter zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
</p>
@ -324,47 +449,55 @@ function ExercisesListPage() {
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: 0, marginBottom: '14px' }}>
Zwischen den Bereichen gilt <strong>UND</strong>. Innerhalb eines Bereichs werden mehrere Einträge mit{' '}
<strong>ODER</strong> verknüpft (die Übung muss mindestens eine gewählte Zuordnung erfüllen).
Zwischen den Bereichen gilt <strong>UND</strong>. Innerhalb eines Feldes werden mehrere Einträge mit{' '}
<strong>ODER</strong> verknüpft.
</p>
<div className="exercise-filters-modal-grid">
<div>
<label className="form-label">Fokus</label>
<MultiSelectCombo
value={filters.focus_area_ids}
onChange={(v) => setFilters({ ...filters, focus_area_ids: v })}
options={focusOptions}
placeholder="Fokus suchen oder „▼ Alle“ …"
/>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Zuordnung</h4>
<div className="exercise-filters-modal-grid">
<div>
<label className="form-label">Fokus</label>
<MultiSelectCombo
value={filters.focus_area_ids}
onChange={(v) => setFilters({ ...filters, focus_area_ids: v })}
options={focusOptions}
placeholder="Fokus suchen oder „▼ Alle“ …"
/>
</div>
<div>
<label className="form-label">Stilrichtung</label>
<MultiSelectCombo
value={filters.style_direction_ids}
onChange={(v) => setFilters({ ...filters, style_direction_ids: v })}
options={styleOptions}
placeholder="Stilrichtung suchen …"
/>
</div>
<div>
<label className="form-label">Trainingsstil</label>
<MultiSelectCombo
value={filters.training_type_ids}
onChange={(v) => setFilters({ ...filters, training_type_ids: v })}
options={trainingTypeOptions}
placeholder="Trainingsstil suchen …"
/>
</div>
<div>
<label className="form-label">Zielgruppe</label>
<MultiSelectCombo
value={filters.target_group_ids}
onChange={(v) => setFilters({ ...filters, target_group_ids: v })}
options={targetGroupOptions}
placeholder="Zielgruppe suchen …"
/>
</div>
</div>
<div>
<label className="form-label">Stilrichtung</label>
<MultiSelectCombo
value={filters.style_direction_ids}
onChange={(v) => setFilters({ ...filters, style_direction_ids: v })}
options={styleOptions}
placeholder="Stilrichtung suchen …"
/>
</div>
<div>
<label className="form-label">Trainingsstil</label>
<MultiSelectCombo
value={filters.training_type_ids}
onChange={(v) => setFilters({ ...filters, training_type_ids: v })}
options={trainingTypeOptions}
placeholder="Trainingsstil suchen …"
/>
</div>
<div>
<label className="form-label">Zielgruppe</label>
<MultiSelectCombo
value={filters.target_group_ids}
onChange={(v) => setFilters({ ...filters, target_group_ids: v })}
options={targetGroupOptions}
placeholder="Zielgruppe suchen …"
/>
</div>
<div>
</section>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
<div className="exercise-filter-skill-block">
<label className="form-label">Fähigkeit</label>
<MultiSelectCombo
value={filters.skill_ids}
@ -372,58 +505,72 @@ function ExercisesListPage() {
options={skillOptions}
placeholder="Fähigkeit suchen …"
/>
</div>
<div className="exercise-filter-level-row">
<div>
<label className="form-label">Fähigkeit Stufe von</label>
<select
className="form-input"
value={filters.skill_min_level}
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
>
<option value="">egal</option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={o.value} value={String(o.level)}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="form-label">bis</label>
<select
className="form-input"
value={filters.skill_max_level}
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
>
<option value="">egal</option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={`m-${o.value}`} value={String(o.level)}>
{o.label}
</option>
))}
</select>
<p className="exercise-filter-skill-hint">
Die Stufen filtern nach dem Niveau der Zuordnung Übung Fähigkeit (vonbis).
</p>
<div className="exercise-filter-skill-levels-row">
<label className="exercise-filter-skill-level-field">
<span className="exercise-filter-skill-level-caption">von</span>
<select
className="form-input exercise-filter-level-select"
title="Mindest-Stufe"
value={filters.skill_min_level}
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
>
<option value=""></option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={o.value} value={String(o.level)} title={o.label}>
{o.level}
</option>
))}
</select>
</label>
<span className="exercise-filter-skill-dash" aria-hidden>
</span>
<label className="exercise-filter-skill-level-field">
<span className="exercise-filter-skill-level-caption">bis</span>
<select
className="form-input exercise-filter-level-select"
title="Höchst-Stufe"
value={filters.skill_max_level}
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
>
<option value=""></option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={`m-${o.value}`} value={String(o.level)} title={o.label}>
{o.level}
</option>
))}
</select>
</label>
</div>
</div>
<div>
<label className="form-label">Sichtbarkeit</label>
<MultiSelectCombo
value={filters.visibility_any}
onChange={(v) => setFilters({ ...filters, visibility_any: v })}
options={visibilityOptions}
placeholder="Sichtbarkeit wählen …"
/>
</section>
<section className="exercise-filter-section exercise-filter-section--last">
<h4 className="exercise-filter-section-title">Freigabe</h4>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
<div>
<label className="form-label">Sichtbarkeit</label>
<MultiSelectCombo
value={filters.visibility_any}
onChange={(v) => setFilters({ ...filters, visibility_any: v })}
options={visibilityOptions}
placeholder="Sichtbarkeit wählen …"
/>
</div>
<div>
<label className="form-label">Status</label>
<MultiSelectCombo
value={filters.status_any}
onChange={(v) => setFilters({ ...filters, status_any: v })}
options={statusOptions}
placeholder="Status wählen …"
/>
</div>
</div>
<div>
<label className="form-label">Status</label>
<MultiSelectCombo
value={filters.status_any}
onChange={(v) => setFilters({ ...filters, status_any: v })}
options={statusOptions}
placeholder="Status wählen …"
/>
</div>
</div>
</section>
</div>
<div className="exercise-filter-modal__footer">
<button type="button" className="btn" onClick={resetAllFilters}>