Skip to content

Commit

Permalink
feature: multi select for organisations
Browse files Browse the repository at this point in the history
  • Loading branch information
patrickuhlmann committed Nov 24, 2024
1 parent f36319f commit 548e659
Show file tree
Hide file tree
Showing 13 changed files with 156 additions and 30 deletions.
5 changes: 5 additions & 0 deletions backend/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Version 1.0.11, 24.11.2024

- feature: multi select for organisations
- chore: bump spring boot to 3.3.5

## Version 1.0.10, 27.09.2024

- chore: bump spring boot to 3.3.4
Expand Down
2 changes: 1 addition & 1 deletion backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</parent>
<groupId>ch.cevi.db</groupId>
<artifactId>adapter</artifactId>
<version>1.0.10</version>
<version>1.0.11</version>
<name>adapter</name>
<description>Adapter to cevi.db</description>
<properties>
Expand Down
25 changes: 13 additions & 12 deletions backend/src/main/java/ch/cevi/db/adapter/EventFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@
import ch.cevi.db.adapter.domain.CeviEventType;

import java.time.LocalDate;
import java.util.List;
import java.util.Locale;

/**
* Filter to apply to the returned events. Only filter properties != null are applied
*
* @param group
* @param groups
* @param earliestStartAt
* @param latestStartAt
* @param nameContains
* @param eventType
* @param kursart
* @param hasAvailablePlaces
*/
public record EventFilter(String group,
public record EventFilter(List<String> groups,
LocalDate earliestStartAt,
LocalDate latestStartAt,
String nameContains,
Expand All @@ -29,40 +30,40 @@ public static EventFilter emptyFilter() {
return new EventFilter(null, null, null, null, null, null, null, null);
}

public EventFilter withGroup(String group) {
return new EventFilter(group, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
public EventFilter withGroups(List<String> groups) {
return new EventFilter(groups, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
}

public EventFilter withEarliestStartAt(LocalDate earliestStartAt) {
return new EventFilter(group, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
return new EventFilter(groups, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
}

public EventFilter withLatestStartAt(LocalDate latestStartAt) {
return new EventFilter(group, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
return new EventFilter(groups, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
}

public EventFilter withNameContains(String nameContains) {
return new EventFilter(group, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
return new EventFilter(groups, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
}

public EventFilter withEventType(CeviEventType eventType) {
return new EventFilter(group, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
return new EventFilter(groups, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
}

public EventFilter withKursart(String kursart) {
return new EventFilter(group, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
return new EventFilter(groups, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
}

public EventFilter withHasAvailablePlaces(boolean hasAvailablePlaces) {
return new EventFilter(group, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
return new EventFilter(groups, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
}

public EventFilter withIsApplicationOpen(boolean isApplicationOpen) {
return new EventFilter(group, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
return new EventFilter(groups, earliestStartAt, latestStartAt, nameContains, eventType, kursart, hasAvailablePlaces, isApplicationOpen);
}

public boolean match(CeviEvent event) {
return (group() == null || event.group().equals(group())) &&
return (groups() == null || groups.contains(event.group())) &&
(earliestStartAt() == null || event.startsAt().toLocalDate().isEqual(earliestStartAt()) || event.startsAt().toLocalDate().isAfter(earliestStartAt())) &&
(latestStartAt() == null || event.startsAt().toLocalDate().isEqual(latestStartAt()) || event.startsAt().toLocalDate().isBefore(latestStartAt())) &&
(nameContains() == null || event.name().toLowerCase(Locale.ROOT).contains(nameContains().toLowerCase(Locale.ROOT))) &&
Expand Down
29 changes: 27 additions & 2 deletions backend/src/test/java/ch/cevi/db/adapter/EventControllerTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ void should_retrieve_events() throws Exception {
void should_filter_group() throws Exception {
String content = mockMvc.perform(
post("/events")
.content(objectMapper.writeValueAsString(EventFilter.emptyFilter().withGroup("Fachgruppen")))
.content(objectMapper.writeValueAsString(EventFilter.emptyFilter().withGroups(List.of("Fachgruppen"))))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
Expand All @@ -133,7 +133,7 @@ void should_filter_group() throws Exception {

content = mockMvc.perform(
post("/events")
.content(objectMapper.writeValueAsString(EventFilter.emptyFilter().withGroup("Cevi Regionalverband AG-SO-LU-ZG")))
.content(objectMapper.writeValueAsString(EventFilter.emptyFilter().withGroups(List.of("Cevi Regionalverband AG-SO-LU-ZG"))))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
Expand All @@ -150,6 +150,31 @@ void should_filter_group() throws Exception {
assertThat(firstGlk.eventType()).isEqualTo(CeviEventType.COURSE);
}

@Test
void should_filter_groups() throws Exception {
String content = mockMvc.perform(
post("/events")
.content(objectMapper.writeValueAsString(EventFilter.emptyFilter().withGroups(List.of("Fachgruppen", "Cevi Regionalverband AG-SO-LU-ZG"))))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
List<CeviEvent> events = objectMapper.readValue(content, new TypeReference<>() {});
assertThat(events).hasSize(3);

// an event from FGI
var ga = events.stream().filter(e -> e.id().equals("3213")).findFirst().orElseThrow();
assertThat(ga.name()).isEqualTo("European YWCA General Assembly 2030");
assertThat(ga.eventType()).isEqualTo(CeviEventType.EVENT);

// a course with multiple occurrences
var glk = events.stream().filter(e -> e.id().equals("3208")).toList();
assertThat(glk).hasSize(2);
var firstGlk = glk.getFirst();
assertThat(firstGlk.id()).isEqualTo("3208");
assertThat(firstGlk.name()).isEqualTo("Gruppenleiterkurs GLK 2030");
assertThat(firstGlk.eventType()).isEqualTo(CeviEventType.COURSE);
}

@Test
void should_filter_earliest_start_at() throws Exception {
String content = mockMvc.perform(
Expand Down
14 changes: 14 additions & 0 deletions frontend/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## Version 1.0.13, 24.11.2024

- feature: multi select for organisations
- chore: bump @angular-eslint/eslint-plugin to 18.3.1
- chore: bump typescript-eslint to 8.6.0
- chore: bump @typescript/eslint-plugin to 8.14.0
- chore: bump @typescript-eslint/parser to 8.14.0
- chore: npm update
- chore: bump Angular to 18.2.12
- chore: bump Angular Material to 18.2.13
- chore: bump eslint to 9.15.0
- chore: bump jasmine-core to 5.4.0
- chore: bump tslib to 2.8.1

## Version 1.0.12, 23.09.2024

- chore: bump angular to 18.2.4
Expand Down
1 change: 1 addition & 0 deletions frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"sourceMap": true
},
"int": {
"optimization": false,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/app/core/components/select-check-all.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormControl } from '@angular/forms';
import {
MatCheckboxChange,
MatCheckboxModule,
} from '@angular/material/checkbox';

// adapted from https://onthecode.co.uk/blog/select-all-option-mat-select
// needs a style in the global stylesheet. search for .app-select-check-all
@Component({
selector: 'app-select-check-all',
standalone: true,
imports: [MatCheckboxModule],
template: `
<mat-checkbox
class="app-select-check-all"
[indeterminate]="isIndeterminate()"
[checked]="isChecked()"
(click)="$event.stopPropagation()"
(change)="toggleSelection($event)">
{{ text }}
</mat-checkbox>
`,
})
export class SelectCheckAllComponent {
@Input()
model!: FormControl;
@Input() values: string[] = [];
@Input() text = 'Alle';
@Output() changeEvent = new EventEmitter<boolean>();

isChecked(): boolean {
return (
this.model.value &&
this.values.length &&
this.model.value.length === this.values.length
);
}

isIndeterminate(): boolean {
return (
this.model.value &&
this.values.length &&
this.model.value.length &&
this.model.value.length < this.values.length
);
}

toggleSelection(change: MatCheckboxChange): void {
if (change.checked) {
this.model.setValue(this.values);
} else {
this.model.setValue([]);
}
this.changeEvent.emit(change.checked);
}
}
11 changes: 7 additions & 4 deletions frontend/src/app/event/components/eventlist.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
<mat-form-field>
<mat-label>Organisation</mat-label>
<mat-select
(selectionChange)="filterByOrganisation($event)"
[value]="this.filter.group">
<mat-option [value]="null">Alle</mat-option>
multiple
[formControl]="this.organisationFilter"
[value]="this.filter.groups">
<app-select-check-all
[model]="this.organisationFilter"
[values]="this.organisations"></app-select-check-all>
@for (organisation of organisations; track organisation) {
<mat-option [value]="organisation">{{ organisation }}</mat-option>
}
Expand Down Expand Up @@ -76,7 +79,7 @@
nochmal.
</p>
} @else if (!this.isLoading) {
<table mat-table [dataSource]="events" matSort class="mat-elevation-z8">
<table mat-table [dataSource]="data" matSort class="mat-elevation-z8">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
<td mat-cell *matCellDef="let element">{{ element.name }}</td>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/event/components/eventlist.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('EventlistComponent', () => {
expect(sut.types.length).toEqual(1);
expect(sut.isLoading).toEqual(false);
expect(sut.isLoadingMasterdata).toEqual(false);
expect(sut.events.data.length).toEqual(1);
expect(sut.data.data.length).toEqual(1);
});
it('translateEventTypes', () => {
let result = sut.translateEventTypes('COURSE');
Expand All @@ -88,7 +88,7 @@ describe('EventlistComponent', () => {
);
sut.filterByOrganisation({ value: 'Cevi Alpin' } as MatSelectChange);
expect(fnc).toHaveBeenCalledWith({
group: 'Cevi Alpin',
groups: ['Cevi Alpin'],
} as CeviEventFilter);
});
it('filterByEventType', () => {
Expand Down
27 changes: 20 additions & 7 deletions frontend/src/app/event/components/eventlist.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ import { MatInputModule } from '@angular/material/input';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { parseIsoDate } from '../../util/date.util';
import { ActivatedRoute } from '@angular/router';
import { SelectCheckAllComponent } from '../../core/components/select-check-all.component';

@Component({
selector: 'app-event-list',
standalone: true,
imports: [
CommonModule,
SelectCheckAllComponent,
MatProgressSpinnerModule,
MatTableModule,
MatSortModule,
Expand All @@ -46,8 +48,7 @@ import { ActivatedRoute } from '@angular/router';
styleUrls: ['./eventlist.component.scss'],
})
export class EventListComponent implements OnInit {
title = 'eventoverview';
events = new MatTableDataSource([] as CeviEvent[]);
data = new MatTableDataSource([] as CeviEvent[]);
organisations = [] as string[];
kursarten = [] as string[];
types = [] as string[];
Expand All @@ -67,18 +68,19 @@ export class EventListComponent implements OnInit {
'link',
];
public nameFilter!: FormControl;
public organisationFilter!: FormControl;

private sort!: MatSort;
private paginator!: MatPaginator;

@ViewChild(MatSort) set matSort(ms: MatSort) {
this.sort = ms;
this.events.sort = this.sort;
this.data.sort = this.sort;
}

@ViewChild(MatPaginator) set matPaginator(mp: MatPaginator) {
this.paginator = mp;
this.events.paginator = this.paginator;
this.data.paginator = this.paginator;
}

constructor(
Expand All @@ -87,6 +89,12 @@ export class EventListComponent implements OnInit {
private masterdataService: MasterdataService
) {
this.nameFilter = new FormControl('');
this.organisationFilter = new FormControl(this.organisations);
this.organisationFilter.valueChanges.subscribe(value => {
this.filter.groups = value;
this.loadEventsWithFilter();
});

this.nameFilter.valueChanges
.pipe(debounceTime(400), distinctUntilChanged())
.subscribe(value => {
Expand All @@ -98,7 +106,7 @@ export class EventListComponent implements OnInit {
ngOnInit(): void {
this.route.queryParamMap.subscribe(params => {
if (params.has('organisation')) {
this.filter.group = params.get('organisation');
this.filter.groups = params.getAll('organisation');
} else if (params.has('type')) {
this.filter.eventType = params.get('type') as CeviEventType;
} else if (params.has('text')) {
Expand Down Expand Up @@ -139,8 +147,13 @@ export class EventListComponent implements OnInit {
}
}

filterByOrganisationForm() {
this.filter.groups = this.organisationFilter.value;
this.loadEventsWithFilter();
}

filterByOrganisation($event: MatSelectChange) {
this.filter.group = $event.value;
this.filter.groups = $event.value;
this.loadEventsWithFilter();
}

Expand All @@ -167,7 +180,7 @@ export class EventListComponent implements OnInit {
loadEventsWithFilter() {
this.service.getEventsWithFilter(this.filter).subscribe({
next: (data: CeviEvent[]) => {
this.events.data = data;
this.data.data = data;
this.isLoading = false;
},
error: () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/event/services/event.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('EventService', () => {
});
it('getEventsWithFilter', (done: DoneFn) => {
const filter = {
group: 'Cevi Region Zürich',
groups: ['Cevi Region Zürich'],
eventType: 'COURSE',
nameContains: 'GLK',
kursart: 'J+S-Leiter*innenkurs LS/T Jugendliche',
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/event/services/event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface CeviEvent {
}

export interface CeviEventFilter {
group: string | null;
groups: string[] | null;
earliestStartAt: Date | null;
latestStartAt: Date | null;
nameContains: string | null;
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,10 @@ label {
span {
font-weight: 200;
}

// this is required to make the select all option selectable over the whole width
.app-select-check-all,
.app-select-check-all .mdc-form-field,
.app-select-check-all label {
width: 100% !important;
}

0 comments on commit 548e659

Please sign in to comment.