optimize key filter
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
# Project Map
|
||||||
|
_Generated: 2026-05-14 17:20 | Git-backed | Custom location requested by user_
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
src/app/modules/songs/ - song list, editing, display, key and transpose services.
|
||||||
|
src/app/modules/shows/ - show list and show-song workflow.
|
||||||
|
src/app/services/ - shared app services including filter state and search helpers.
|
||||||
|
src/app/widget-modules/ - shared UI components and pipes.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
src/app/modules/songs/song-list/filter/filter.component.* - sidebar filters for the song list.
|
||||||
|
src/app/modules/songs/song-list/song-list.component.* - song list view model and filter application.
|
||||||
|
src/app/modules/songs/services/key.helper.ts - canonical key lists, display mapping, and scale selection.
|
||||||
|
src/app/services/filter-store.service.ts - in-memory filter state for songs and shows.
|
||||||
|
src/app/services/filter.helper.ts - text search scoring and filtering helpers.
|
||||||
|
|
||||||
|
## Critical Constraints
|
||||||
|
- Memory markdown files are kept in `.dev/` by user request, not project root.
|
||||||
|
- Song key data stores major keys uppercase and minor keys lowercase.
|
||||||
|
- German notation uses `H`; `B` represents B-flat in flat spellings.
|
||||||
|
|
||||||
|
## Hot Files
|
||||||
|
src/app/modules/songs/song-list/filter/filter.component.ts, src/app/modules/songs/song-list/filter/filter.component.html, src/app/modules/songs/services/key.helper.ts
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Session Log
|
||||||
|
|
||||||
|
## 2026-05-14 17:20 [saved]
|
||||||
|
Goal: Improve song key filter readability.
|
||||||
|
Decisions:
|
||||||
|
- Use root-key controls with refinement because grouped enharmonic dropdowns were still awkward to change.
|
||||||
|
- Keep memory markdown files in `.dev/` because the user requested that location.
|
||||||
|
Rejected:
|
||||||
|
- Keep all sharp and flat variants as separate primary options.
|
||||||
|
- Use a grouped enharmonic dropdown that forces users through reset-like interaction.
|
||||||
|
Open:
|
||||||
|
- User will review the visual result.
|
||||||
@@ -315,3 +315,6 @@ testem.log
|
|||||||
# System Files
|
# System Files
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
firebase.ts
|
firebase.ts
|
||||||
|
|
||||||
|
# AI assistant artifacts
|
||||||
|
context-snapshot.json
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
export interface KeyFilterSelection {
|
||||||
|
root: string;
|
||||||
|
mode: string;
|
||||||
|
accidental: string;
|
||||||
|
includeRelativeMinor: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KEY_FILTER_ROOTS = [
|
||||||
|
{value: '', label: 'Alle'},
|
||||||
|
{value: 'C', label: 'C'},
|
||||||
|
{value: 'D', label: 'D'},
|
||||||
|
{value: 'E', label: 'E'},
|
||||||
|
{value: 'F', label: 'F'},
|
||||||
|
{value: 'G', label: 'G'},
|
||||||
|
{value: 'A', label: 'A'},
|
||||||
|
{value: 'H', label: 'H'},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const KEY_FILTER_MODES = [
|
||||||
|
{value: 'major', label: 'Dur'},
|
||||||
|
{value: 'minor', label: 'Moll'},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const KEY_FILTER_ACCIDENTALS = [
|
||||||
|
{value: '', label: 'keins'},
|
||||||
|
{value: 'sharp', label: '#'},
|
||||||
|
{value: 'flat', label: 'b'},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rootSemitones: {[root: string]: number} = {
|
||||||
|
C: 0,
|
||||||
|
D: 2,
|
||||||
|
E: 4,
|
||||||
|
F: 5,
|
||||||
|
G: 7,
|
||||||
|
A: 9,
|
||||||
|
H: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharpKeysBySemitone = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'H'];
|
||||||
|
const flatKeysBySemitone = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'B', 'H'];
|
||||||
|
|
||||||
|
const relativeMinorByMajorKey: {[key: string]: string} = {
|
||||||
|
C: 'a',
|
||||||
|
'C#': 'a#',
|
||||||
|
Db: 'b',
|
||||||
|
D: 'h',
|
||||||
|
'D#': 'c',
|
||||||
|
Eb: 'c',
|
||||||
|
E: 'c#',
|
||||||
|
F: 'd',
|
||||||
|
'F#': 'd#',
|
||||||
|
Gb: 'eb',
|
||||||
|
G: 'e',
|
||||||
|
'G#': 'f',
|
||||||
|
Ab: 'f',
|
||||||
|
A: 'f#',
|
||||||
|
'A#': 'g',
|
||||||
|
B: 'g',
|
||||||
|
H: 'g#',
|
||||||
|
};
|
||||||
|
|
||||||
|
const relativeMajorByMinorKey: {[key: string]: string} = Object.fromEntries(Object.entries(relativeMinorByMajorKey).map(([majorKey, minorKey]) => [minorKey, majorKey]));
|
||||||
|
|
||||||
|
export const matchesKeyFilter = (songKey: string, filter: KeyFilterSelection): boolean => {
|
||||||
|
if (!filter.root) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getKeysForFilter(filter).includes(songKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getKeysForFilter = (filter: KeyFilterSelection): string[] => {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const majorKey = getKeyForRoot(filter.root, 'major', filter.accidental);
|
||||||
|
const minorKey = getKeyForRoot(filter.root, 'minor', filter.accidental);
|
||||||
|
|
||||||
|
if (filter.mode !== 'minor' && majorKey) {
|
||||||
|
keys.add(majorKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.mode !== 'major' && minorKey) {
|
||||||
|
keys.add(minorKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.includeRelativeMinor && filter.mode !== 'minor' && majorKey && relativeMinorByMajorKey[majorKey]) {
|
||||||
|
keys.add(relativeMinorByMajorKey[majorKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.includeRelativeMinor && filter.mode !== 'major' && minorKey && relativeMajorByMinorKey[minorKey]) {
|
||||||
|
keys.add(relativeMajorByMinorKey[minorKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...keys];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const supportsKeyAccidental = (root: string, accidental: string): boolean => {
|
||||||
|
return !root || rootSemitones[root] !== undefined || !accidental;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasAvailableKeyForRoot = (root: string, availableKeys: string[]): boolean => {
|
||||||
|
if (!root) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = new Set(availableKeys.filter(key => !!key));
|
||||||
|
return KEY_FILTER_MODES.some(mode =>
|
||||||
|
KEY_FILTER_ACCIDENTALS.some(accidental =>
|
||||||
|
getKeysForFilter({
|
||||||
|
root,
|
||||||
|
mode: mode.value,
|
||||||
|
accidental: accidental.value,
|
||||||
|
includeRelativeMinor: false,
|
||||||
|
}).some(key => available.has(key)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKeyForRoot = (root: string, mode: 'major' | 'minor', accidental: string): string => {
|
||||||
|
const semitone = rootSemitones[root];
|
||||||
|
if (semitone === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = accidental === 'sharp' ? 1 : accidental === 'flat' ? -1 : 0;
|
||||||
|
const normalizedSemitone = (semitone + offset + 12) % 12;
|
||||||
|
const key = accidental === 'flat' ? flatKeysBySemitone[normalizedSemitone] : sharpKeysBySemitone[normalizedSemitone];
|
||||||
|
|
||||||
|
return mode === 'minor' ? key.toLowerCase() : key;
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {getScale, scaleMapping} from './key.helper';
|
import {getScale, hasAvailableKeyForRoot, matchesKeyFilter, scaleMapping, supportsKeyAccidental} from './key.helper';
|
||||||
|
|
||||||
describe('key.helper', () => {
|
describe('key.helper', () => {
|
||||||
it('should render Gb correctly', () => {
|
it('should render Gb correctly', () => {
|
||||||
@@ -12,4 +12,58 @@ describe('key.helper', () => {
|
|||||||
it('should keep flat-based spelling for Db', () => {
|
it('should keep flat-based spelling for Db', () => {
|
||||||
void expect(getScale('Db')).toEqual(['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'B', 'H']);
|
void expect(getScale('Db')).toEqual(['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'B', 'H']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should match songs by root, mode and accidental', () => {
|
||||||
|
void expect(matchesKeyFilter('Db', {root: 'D', mode: 'major', accidental: 'flat', includeRelativeMinor: false})).toBe(true);
|
||||||
|
void expect(matchesKeyFilter('d#', {root: 'D', mode: 'minor', accidental: 'sharp', includeRelativeMinor: false})).toBe(true);
|
||||||
|
void expect(matchesKeyFilter('D', {root: 'D', mode: 'minor', accidental: '', includeRelativeMinor: false})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include both major and minor when no mode is selected', () => {
|
||||||
|
void expect(matchesKeyFilter('G', {root: 'G', mode: '', accidental: '', includeRelativeMinor: false})).toBe(true);
|
||||||
|
void expect(matchesKeyFilter('g', {root: 'G', mode: '', accidental: '', includeRelativeMinor: false})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include the relative minor for major keys when requested', () => {
|
||||||
|
void expect(matchesKeyFilter('a', {root: 'C', mode: 'major', accidental: '', includeRelativeMinor: true})).toBe(true);
|
||||||
|
void expect(matchesKeyFilter('h', {root: 'D', mode: 'major', accidental: '', includeRelativeMinor: true})).toBe(true);
|
||||||
|
void expect(matchesKeyFilter('e', {root: 'G', mode: 'major', accidental: '', includeRelativeMinor: true})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include the relative minor when all modes are selected', () => {
|
||||||
|
void expect(matchesKeyFilter('a', {root: 'C', mode: '', accidental: '', includeRelativeMinor: true})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include the relative major for minor keys when requested', () => {
|
||||||
|
void expect(matchesKeyFilter('C', {root: 'A', mode: 'minor', accidental: '', includeRelativeMinor: true})).toBe(true);
|
||||||
|
void expect(matchesKeyFilter('G', {root: 'E', mode: 'minor', accidental: '', includeRelativeMinor: true})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize enharmonic edge spellings to stored keys', () => {
|
||||||
|
void expect(matchesKeyFilter('F', {root: 'E', mode: 'major', accidental: 'sharp', includeRelativeMinor: false})).toBe(true);
|
||||||
|
void expect(matchesKeyFilter('e', {root: 'F', mode: 'minor', accidental: 'flat', includeRelativeMinor: false})).toBe(true);
|
||||||
|
void expect(matchesKeyFilter('H', {root: 'C', mode: 'major', accidental: 'flat', includeRelativeMinor: false})).toBe(true);
|
||||||
|
void expect(matchesKeyFilter('C', {root: 'H', mode: 'major', accidental: 'sharp', includeRelativeMinor: false})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow every accidental for every root', () => {
|
||||||
|
void expect(supportsKeyAccidental('E', 'sharp')).toBe(true);
|
||||||
|
void expect(supportsKeyAccidental('F', 'flat')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect whether a root has any available stored key', () => {
|
||||||
|
void expect(hasAvailableKeyForRoot('C', ['a'])).toBe(true);
|
||||||
|
void expect(hasAvailableKeyForRoot('E', ['F'])).toBe(true);
|
||||||
|
void expect(hasAvailableKeyForRoot('H', ['C'])).toBe(true);
|
||||||
|
void expect(hasAvailableKeyForRoot('D', ['F', 'a'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not enable a root only because its relative counterpart exists', () => {
|
||||||
|
void expect(hasAvailableKeyForRoot('A', ['C'])).toBe(false);
|
||||||
|
void expect(hasAvailableKeyForRoot('C', ['a'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not apply a key filter before a root is selected', () => {
|
||||||
|
void expect(matchesKeyFilter('Db', {root: '', mode: 'major', accidental: 'flat', includeRelativeMinor: true})).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -154,3 +154,6 @@ export const scaleMapping: {[key: string]: string} = {
|
|||||||
|
|
||||||
export const getScale = (key: string): string[] => scaleAssignment[key];
|
export const getScale = (key: string): string[] => scaleAssignment[key];
|
||||||
export const getScaleType = (key: string): string[][] => scaleTypeAssignment[key];
|
export const getScaleType = (key: string): string[][] => scaleTypeAssignment[key];
|
||||||
|
|
||||||
|
export {KEY_FILTER_ACCIDENTALS, KEY_FILTER_MODES, KEY_FILTER_ROOTS, getKeysForFilter, hasAvailableKeyForRoot, matchesKeyFilter, supportsKeyAccidental} from './key-filter.helper';
|
||||||
|
export type {KeyFilterSelection} from './key-filter.helper';
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
export interface FilterValues {
|
export interface FilterValues {
|
||||||
q: string;
|
q: string;
|
||||||
type: string;
|
type: string;
|
||||||
key: string;
|
keyRoot: string;
|
||||||
|
keyMode: string;
|
||||||
|
keyAccidental: string;
|
||||||
|
includeRelativeMinor: boolean;
|
||||||
legalType: string;
|
legalType: string;
|
||||||
flag: string;
|
flag: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,38 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
<div class="key-filter">
|
||||||
<mat-label>Tonart</mat-label>
|
<div class="key-filter-label">Grundtonart</div>
|
||||||
<mat-select formControlName="key">
|
<mat-button-toggle-group aria-label="Grundtonart" class="key-root-toggle-group" formControlName="keyRoot">
|
||||||
<mat-option [value]="null">- kein Filter -</mat-option>
|
@for (root of keyRoots; track root.value) {
|
||||||
@for (key of keys; track key) {
|
<mat-button-toggle [class.key-root-all]="!root.value" [disabled]="isKeyRootDisabled(root.value)" [value]="root.value">{{ root.label }}</mat-button-toggle>
|
||||||
<mat-option [value]="key">{{ key | key }} </mat-option>
|
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-button-toggle-group>
|
||||||
</mat-form-field>
|
|
||||||
|
@if (filterFormGroup.controls.keyRoot.value) {
|
||||||
|
<div class="key-filter-details">
|
||||||
|
<div>
|
||||||
|
<div class="key-filter-label">Vorzeichen</div>
|
||||||
|
<mat-button-toggle-group aria-label="Vorzeichen" class="key-accidental-toggle-group" formControlName="keyAccidental">
|
||||||
|
@for (accidental of keyAccidentals; track accidental.value) {
|
||||||
|
<mat-button-toggle [disabled]="isAccidentalDisabled(accidental.value)" [value]="accidental.value">{{ accidental.label }}</mat-button-toggle>
|
||||||
|
}
|
||||||
|
</mat-button-toggle-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="key-filter-label">Dur/Moll</div>
|
||||||
|
<mat-button-toggle-group aria-label="Dur oder Moll" class="key-mode-toggle-group" formControlName="keyMode">
|
||||||
|
@for (mode of keyModes; track mode.value) {
|
||||||
|
<mat-button-toggle [value]="mode.value">{{ mode.label }}</mat-button-toggle>
|
||||||
|
}
|
||||||
|
</mat-button-toggle-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-checkbox formControlName="includeRelativeMinor">Parallele Tonart einschließen</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>Rechtlicher Status</mat-label>
|
<mat-label>Rechtlicher Status</mat-label>
|
||||||
|
|||||||
@@ -13,3 +13,85 @@ div[formGroup] {
|
|||||||
:host ::ng-deep .mat-mdc-form-field {
|
:host ::ng-deep .mat-mdc-form-field {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.key-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 2px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-filter-label {
|
||||||
|
color: var(--mat-sys-on-surface-variant);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-filter-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-button-toggle-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-mode-toggle-group {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-accidental-toggle-group {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-button-toggle {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-filter mat-button-toggle {
|
||||||
|
--mat-standard-button-toggle-selected-state-background-color: transparent;
|
||||||
|
--mat-standard-button-toggle-selected-state-text-color: var(--mat-sys-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-root-toggle-group {
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-root-all {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .mat-button-toggle-label-content {
|
||||||
|
line-height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .key-filter .mat-button-toggle-checked .mat-button-toggle-label-content {
|
||||||
|
color: var(--mat-sys-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .key-filter .mat-button-toggle-checked {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .key-filter .mat-button-toggle-checked .mat-button-toggle-button {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .key-filter .mat-button-toggle-checkbox-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .key-filter .mat-button-toggle-checked .mat-button-toggle-button {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .key-filter .mat-button-toggle-checked .mat-pseudo-checkbox {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {faFilterCircleXmark} from '@fortawesome/free-solid-svg-icons';
|
|||||||
import {SongService} from '../../services/song.service';
|
import {SongService} from '../../services/song.service';
|
||||||
import {FilterValues} from './filter-values';
|
import {FilterValues} from './filter-values';
|
||||||
import {Song} from '../../services/song';
|
import {Song} from '../../services/song';
|
||||||
import {KEYS} from '../../services/key.helper';
|
import {hasAvailableKeyForRoot, KEY_FILTER_ACCIDENTALS, KEY_FILTER_MODES, KEY_FILTER_ROOTS, supportsKeyAccidental} from '../../services/key.helper';
|
||||||
import {FilterStoreService} from '../../../../services/filter-store.service';
|
import {FilterStoreService} from '../../../../services/filter-store.service';
|
||||||
import {MatFormField, MatLabel} from '@angular/material/form-field';
|
import {MatFormField, MatLabel} from '@angular/material/form-field';
|
||||||
import {MatInput} from '@angular/material/input';
|
import {MatInput} from '@angular/material/input';
|
||||||
@@ -14,15 +14,16 @@ import {MatSelect} from '@angular/material/select';
|
|||||||
import {MatOption} from '@angular/material/core';
|
import {MatOption} from '@angular/material/core';
|
||||||
|
|
||||||
import {LegalTypePipe} from '../../../../widget-modules/pipes/legal-type-translator/legal-type.pipe';
|
import {LegalTypePipe} from '../../../../widget-modules/pipes/legal-type-translator/legal-type.pipe';
|
||||||
import {KeyPipe} from '../../../../widget-modules/pipes/key-translator/key.pipe';
|
|
||||||
import {SongTypePipe} from '../../../../widget-modules/pipes/song-type-translater/song-type.pipe';
|
import {SongTypePipe} from '../../../../widget-modules/pipes/song-type-translater/song-type.pipe';
|
||||||
import {ButtonComponent} from '../../../../widget-modules/components/button/button.component';
|
import {ButtonComponent} from '../../../../widget-modules/components/button/button.component';
|
||||||
|
import {MatButtonToggle, MatButtonToggleGroup} from '@angular/material/button-toggle';
|
||||||
|
import {MatCheckbox} from '@angular/material/checkbox';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-filter',
|
selector: 'app-filter',
|
||||||
templateUrl: './filter.component.html',
|
templateUrl: './filter.component.html',
|
||||||
styleUrls: ['./filter.component.less'],
|
styleUrls: ['./filter.component.less'],
|
||||||
imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput, MatSelect, MatOption, LegalTypePipe, KeyPipe, SongTypePipe, ButtonComponent],
|
imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput, MatSelect, MatOption, LegalTypePipe, SongTypePipe, ButtonComponent, MatButtonToggleGroup, MatButtonToggle, MatCheckbox],
|
||||||
})
|
})
|
||||||
export class FilterComponent {
|
export class FilterComponent {
|
||||||
private filterStore = inject(FilterStoreService);
|
private filterStore = inject(FilterStoreService);
|
||||||
@@ -32,14 +33,19 @@ export class FilterComponent {
|
|||||||
public filterFormGroup: FormGroup<{
|
public filterFormGroup: FormGroup<{
|
||||||
q: FormControl<string>;
|
q: FormControl<string>;
|
||||||
type: FormControl<string>;
|
type: FormControl<string>;
|
||||||
key: FormControl<string>;
|
keyRoot: FormControl<string>;
|
||||||
|
keyMode: FormControl<string>;
|
||||||
|
keyAccidental: FormControl<string>;
|
||||||
|
includeRelativeMinor: FormControl<boolean>;
|
||||||
legalType: FormControl<string>;
|
legalType: FormControl<string>;
|
||||||
flag: FormControl<string>;
|
flag: FormControl<string>;
|
||||||
}>;
|
}>;
|
||||||
@Input() public songs: Song[] = [];
|
@Input() public songs: Song[] = [];
|
||||||
public types = SongService.TYPES;
|
public types = SongService.TYPES;
|
||||||
public legalType = SongService.LEGAL_TYPE;
|
public legalType = SongService.LEGAL_TYPE;
|
||||||
public keys = KEYS;
|
public keyRoots = KEY_FILTER_ROOTS;
|
||||||
|
public keyModes = KEY_FILTER_MODES;
|
||||||
|
public keyAccidentals = KEY_FILTER_ACCIDENTALS;
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
const fb = inject(FormBuilder);
|
const fb = inject(FormBuilder);
|
||||||
@@ -47,7 +53,10 @@ export class FilterComponent {
|
|||||||
this.filterFormGroup = fb.nonNullable.group({
|
this.filterFormGroup = fb.nonNullable.group({
|
||||||
q: '',
|
q: '',
|
||||||
type: '',
|
type: '',
|
||||||
key: '',
|
keyRoot: '',
|
||||||
|
keyMode: 'major',
|
||||||
|
keyAccidental: '',
|
||||||
|
includeRelativeMinor: false as boolean,
|
||||||
legalType: '',
|
legalType: '',
|
||||||
flag: '',
|
flag: '',
|
||||||
});
|
});
|
||||||
@@ -59,7 +68,12 @@ export class FilterComponent {
|
|||||||
this.filterFormGroup.controls.q.valueChanges
|
this.filterFormGroup.controls.q.valueChanges
|
||||||
.pipe(debounceTime(100), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
|
.pipe(debounceTime(100), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe(value => this.filterValueChanged('q', value));
|
.subscribe(value => this.filterValueChanged('q', value));
|
||||||
this.filterFormGroup.controls.key.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('key', value));
|
this.filterFormGroup.controls.keyRoot.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.updateKeyRoot(value));
|
||||||
|
this.filterFormGroup.controls.keyMode.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.updateKeyMode(value));
|
||||||
|
this.filterFormGroup.controls.keyAccidental.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('keyAccidental', value));
|
||||||
|
this.filterFormGroup.controls.includeRelativeMinor.valueChanges
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe(value => this.filterValueChanged('includeRelativeMinor', value));
|
||||||
this.filterFormGroup.controls.type.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('type', value));
|
this.filterFormGroup.controls.type.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('type', value));
|
||||||
this.filterFormGroup.controls.legalType.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('legalType', value));
|
this.filterFormGroup.controls.legalType.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('legalType', value));
|
||||||
this.filterFormGroup.controls.flag.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('flag', value));
|
this.filterFormGroup.controls.flag.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('flag', value));
|
||||||
@@ -78,14 +92,31 @@ export class FilterComponent {
|
|||||||
|
|
||||||
public get filterActive(): boolean {
|
public get filterActive(): boolean {
|
||||||
const filter = this.filterFormGroup.getRawValue();
|
const filter = this.filterFormGroup.getRawValue();
|
||||||
return !!(filter.q || filter.type || filter.key || filter.legalType || filter.flag);
|
return !!(filter.q || filter.type || filter.keyRoot || filter.legalType || filter.flag);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetFilter(): void {
|
public resetFilter(): void {
|
||||||
this.filterStore.resetSongFilter();
|
this.filterStore.resetSongFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
private filterValueChanged(key: keyof FilterValues, value: string): void {
|
public isAccidentalDisabled(accidental: string): boolean {
|
||||||
|
const root = this.filterFormGroup.controls.keyRoot.value;
|
||||||
|
return !!root && !supportsKeyAccidental(root, accidental);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isKeyRootDisabled(root: string): boolean {
|
||||||
|
return !hasAvailableKeyForRoot(root, this.songs.map(song => song.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateKeyRoot(value: string): void {
|
||||||
|
this.filterValueChanged('keyRoot', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateKeyMode(value: string): void {
|
||||||
|
this.filterValueChanged('keyMode', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterValueChanged(key: keyof FilterValues, value: string | boolean): void {
|
||||||
this.filterStore.updateSongFilter({[key]: value} as Partial<FilterValues>);
|
this.filterStore.updateSongFilter({[key]: value} as Partial<FilterValues>);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@if (viewModel$ | async; as viewModel) {
|
@if (viewModel$ | async; as viewModel) {
|
||||||
<app-page-frame title="Lieder" [menuBadge]="viewModel.filterActive">
|
<app-page-frame title="Lieder" [menuBadge]="viewModel.filterActive">
|
||||||
<div class="sidebar-content" sidebar>
|
<div class="sidebar-content" sidebar>
|
||||||
<app-filter [songs]="viewModel.songs"></app-filter>
|
<app-filter [songs]="viewModel.availableSongs"></app-filter>
|
||||||
</div>
|
</div>
|
||||||
<div content>
|
<div content>
|
||||||
<app-card [padding]="false">
|
<app-card [padding]="false">
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||||
import {SongListComponent} from './song-list.component';
|
import {SongListComponent} from './song-list.component';
|
||||||
import {of} from 'rxjs';
|
import {firstValueFrom, of} from 'rxjs';
|
||||||
import {ActivatedRoute} from '@angular/router';
|
import {ActivatedRoute} from '@angular/router';
|
||||||
import {TextRenderingService} from '../services/text-rendering.service';
|
import {TextRenderingService} from '../services/text-rendering.service';
|
||||||
import {UserService} from '../../../services/user/user.service';
|
import {UserService} from '../../../services/user/user.service';
|
||||||
import {NO_ERRORS_SCHEMA} from '@angular/core';
|
import {NO_ERRORS_SCHEMA} from '@angular/core';
|
||||||
|
import {FilterStoreService} from '../../../services/filter-store.service';
|
||||||
|
import {Song} from '../services/song';
|
||||||
|
|
||||||
describe('SongListComponent', () => {
|
describe('SongListComponent', () => {
|
||||||
let component: SongListComponent;
|
let component: SongListComponent;
|
||||||
let fixture: ComponentFixture<SongListComponent>;
|
let fixture: ComponentFixture<SongListComponent>;
|
||||||
|
let filterStore: FilterStoreService;
|
||||||
|
|
||||||
const songs = [{id: 'song-1', title: 'title1', number: 1, text: '', flags: ''}];
|
const songs = [
|
||||||
|
{id: 'song-1', title: 'title1', number: 1, text: '', flags: '', key: 'C'},
|
||||||
|
{id: 'song-2', title: 'title2', number: 2, text: '', flags: '', key: 'D'},
|
||||||
|
] as Song[];
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -27,10 +33,20 @@ describe('SongListComponent', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = TestBed.createComponent(SongListComponent);
|
fixture = TestBed.createComponent(SongListComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
|
filterStore = TestBed.inject(FilterStoreService);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
void expect(component).toBeTruthy();
|
void expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should expose unfiltered songs for filter option availability', async () => {
|
||||||
|
filterStore.updateSongFilter({keyRoot: 'C'});
|
||||||
|
|
||||||
|
const viewModel = await firstValueFrom(component.viewModel$);
|
||||||
|
|
||||||
|
void expect(viewModel.songs.map(song => song.key)).toEqual(['C']);
|
||||||
|
void expect(viewModel.availableSongs.map(song => song.key)).toEqual(['C', 'D']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {RoleDirective} from '../../../services/user/role.directive';
|
|||||||
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
|
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
|
||||||
import {PageFrameComponent} from '../../../widget-modules/components/sidebar/page-frame.component';
|
import {PageFrameComponent} from '../../../widget-modules/components/sidebar/page-frame.component';
|
||||||
import {ButtonComponent} from '../../../widget-modules/components/button/button.component';
|
import {ButtonComponent} from '../../../widget-modules/components/button/button.component';
|
||||||
|
import {matchesKeyFilter} from '../services/key.helper';
|
||||||
|
|
||||||
interface SongListItem extends Song {
|
interface SongListItem extends Song {
|
||||||
hasChordValidationIssues: boolean;
|
hasChordValidationIssues: boolean;
|
||||||
@@ -23,6 +24,7 @@ interface SongListItem extends Song {
|
|||||||
|
|
||||||
interface SongListViewModel {
|
interface SongListViewModel {
|
||||||
songs: SongListItem[];
|
songs: SongListItem[];
|
||||||
|
availableSongs: Song[];
|
||||||
filterActive: boolean;
|
filterActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ export class SongListComponent {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
songs: filteredSongs,
|
songs: filteredSongs,
|
||||||
|
availableSongs: songs,
|
||||||
filterActive: this.isFilterActive(filter),
|
filterActive: this.isFilterActive(filter),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -69,7 +72,14 @@ export class SongListComponent {
|
|||||||
|
|
||||||
private filter(song: Song, filter: FilterValues): boolean {
|
private filter(song: Song, filter: FilterValues): boolean {
|
||||||
let baseFilter = !filter.type || filter.type === song.type;
|
let baseFilter = !filter.type || filter.type === song.type;
|
||||||
baseFilter = baseFilter && (!filter.key || filter.key === song.key);
|
baseFilter =
|
||||||
|
baseFilter &&
|
||||||
|
matchesKeyFilter(song.key, {
|
||||||
|
root: filter.keyRoot,
|
||||||
|
mode: filter.keyMode,
|
||||||
|
accidental: filter.keyAccidental,
|
||||||
|
includeRelativeMinor: filter.includeRelativeMinor,
|
||||||
|
});
|
||||||
baseFilter = baseFilter && (!filter.legalType || filter.legalType === song.legalType);
|
baseFilter = baseFilter && (!filter.legalType || filter.legalType === song.legalType);
|
||||||
baseFilter = baseFilter && (!filter.flag || this.checkFlag(filter.flag, song.flags));
|
baseFilter = baseFilter && (!filter.flag || this.checkFlag(filter.flag, song.flags));
|
||||||
|
|
||||||
@@ -77,7 +87,7 @@ export class SongListComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isFilterActive(filter: FilterValues): boolean {
|
private isFilterActive(filter: FilterValues): boolean {
|
||||||
return !!(filter.q || filter.type || filter.key || filter.legalType || filter.flag);
|
return !!(filter.q || filter.type || filter.keyRoot || filter.legalType || filter.flag);
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkFlag(flag: string, flags: string) {
|
private checkFlag(flag: string, flags: string) {
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import {FilterValues as ShowFilterValues} from '../modules/shows/list/filter/fil
|
|||||||
const DEFAULT_SONG_FILTER: SongFilterValues = {
|
const DEFAULT_SONG_FILTER: SongFilterValues = {
|
||||||
q: '',
|
q: '',
|
||||||
type: '',
|
type: '',
|
||||||
key: '',
|
keyRoot: '',
|
||||||
|
keyMode: 'major',
|
||||||
|
keyAccidental: '',
|
||||||
|
includeRelativeMinor: false,
|
||||||
legalType: '',
|
legalType: '',
|
||||||
flag: '',
|
flag: '',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user