Skip to content

Commit

Permalink
Add study course detail page #716
Browse files Browse the repository at this point in the history
  • Loading branch information
caebr authored and hupf committed Dec 19, 2024
1 parent e1cd211 commit 01694b6
Show file tree
Hide file tree
Showing 22 changed files with 749 additions and 18 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ Although it can be used standalone during development, the _webapp-schulverwaltu
- [Internationalization (i18n)](doc/i18n.md) – Translating texts
- [Browser Testing](doc/browser-testing.md) – Support & BrowserStack.com
- [Data Decoding with io-ts](doc/io-ts.md) – API data contract
- [Reactivity](doc/reactivity.md) – Dos and don'ts when using signals & observables
17 changes: 17 additions & 0 deletions doc/reactivity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[back](../README.md)

# Reactivity

Previously the go-to tool for reactivity has been RxJS and observables. With Angular 17 a new reactive building block has been introduced: signals. With this change Angular is shifting towards signal-based APIs and patterns. But as of 2025 this is still "work in progress" and there are dos and don'ts to consider:

- For data fetching, we still use the observable-based `HttpClient` API for now, since with signals a pattern still has to emerge.
- In terms of the ergonomics it is desirable to work with signals in components/templates. They allow to always read the current value (also for `computed`s, no `value$.pipe(take(1)).subscribe(value => ...)`) and it is easy to define derived values with `computed(() => ...)`.
- Observables can be converted to signals using `toSignal`, but there are important things to note:
- `toSignal` is not lazy, it subscribes to the observable (and causes the fetching of the data) no matter if the signal is read or not. The behavior can be compared to a "hot" observable.
- This project includes a custom `toLazySignal` that only subscribes to the observable, when the signal is read.
- Both `toSignal` and `toLazySignal` only unsubscribe when the injector is destroyed (i.e. the component is destroyed), they won't unsubscribe if the component stops using the signal due to a condition (`@if`) in the template.
- Global services with `{ providedIn: "root" }` should not use `toSignal` and `toLazySignal`, since these observables will never get unsubscribed, unless a "hot" observable is the desired behavior.
- Local services (provided in the context of a component or route) should always use `toLazySignal` when data fetching is involved.
- Be aware, that converting signals created with `toLazySignal` back with `toObservable` will cause them to be not lazy anymore.
- `input` and `model` signals are preferred over `@Input` since they allow to integrate in the reactive world nicely (no more `Subject` that is `next`ed in `ngOnChanges`). And also, they allow to mark inputs as `input.required`.
- Function-based `output` is preferred over `@Output`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<div class="bkd-container bkd-container-limited">
<bkd-backlink link="../.." [params]="backLink()"></bkd-backlink>

@let personData = person();
@if (personData) {
<h1>{{ personData.FullName }}</h1>
<span class="additional-info">
@if (personData.Birthdate) {
{{ personData.Birthdate | date: "dd.MM.yyyy" }}
}
@if (personData.Gender) {
({{ personData.Gender }})
}
</span>
<address>
@if (personData.AddressLine1) {
{{ personData.AddressLine1 }}<br />
}
@if (personData.Zip && personData.Location) {
{{ personData.Zip }} {{ personData.Location }}<br />
}
@if (personData.PhonePrivate) {
<a href="tel:{{ personData.PhonePrivate }}">{{
personData.PhonePrivate
}}</a
><br />
}
@if (personData.PhoneMobile) {
<a href="tel:{{ personData.PhoneMobile }}">{{
personData.PhoneMobile
}}</a>
}
</address>
}

@let status = subscription()?.Status;
@if (status) {
<div class="status">
{{ "events-students.study-course-detail.status" | translate }}:
<span class="ms-2">{{ status }}</span>
</div>
}

@for (detail of subscriptionDetails(); track detail.id) {
@if (detail.value) {
<div class="detail">
<div>{{ detail.label }}</div>
@if (detail.file !== null) {
<a href="{{ detail.file }}" target="_blank">{{ detail.value }}</a>
} @else {
{{ detail.value }}
}
</div>
}
}

@if (loading()) {
<bkd-spinner></bkd-spinner>
}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
@import "../../../../bootstrap-variables";

h1 {
margin-bottom: 0;
}

.additional-info {
color: $gray-500;
}

address {
margin: $spacer 0;
}

.status {
display: flex;
align-items: center;
padding: $spacer 0;
border-top: 1px solid $border-color;
border-bottom: 1px solid $border-color;
}

.detail {
max-width: 70ch;
padding-top: $spacer;

div {
color: $gray-dark;
font-size: $font-size-sm;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute } from "@angular/router";
import { of } from "rxjs";
import { Person } from "src/app/shared/models/person.model";
import {
Subscription,
SubscriptionDetail,
} from "src/app/shared/models/subscription.model";
import { PersonsRestService } from "src/app/shared/services/persons-rest.service";
import { StorageService } from "src/app/shared/services/storage.service";
import { SubscriptionsRestService } from "src/app/shared/services/subscriptions-rest.service";
import {
buildPerson,
buildSubscription,
buildSubscriptionDetail,
} from "src/spec-builders";
import { buildTestModuleMetadata } from "../../../../spec-helpers";
import { EventsStudentsStudyCourseDetailComponent } from "./events-students-study-course-detail.component";

describe("EventsStudentsStudyCourseDetailComponent", () => {
let fixture: ComponentFixture<EventsStudentsStudyCourseDetailComponent>;
let element: HTMLElement;
let personsServiceMock: jasmine.SpyObj<PersonsRestService>;
let subscriptionsServiceMock: jasmine.SpyObj<SubscriptionsRestService>;
let person: Person;
let subscription: Subscription;
let details: ReadonlyArray<SubscriptionDetail>;

beforeEach(async () => {
person = buildPerson(42);
person.FullName = "Lennon John";
person.Birthdate = new Date(1970, 0, 1);
person.Gender = "F";
person.AddressLine1 = "3 Abbey Road";
person.Zip = "NW8 9AY";
person.Location = "London";
person.PhonePrivate = "031 123 45 67";
person.PhoneMobile = "079 123 45 67";

subscription = buildSubscription(100, 1, 42);
subscription.Status = "Aufgenommen";

details = [];

await TestBed.configureTestingModule(
buildTestModuleMetadata({
imports: [EventsStudentsStudyCourseDetailComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
paramMap: of(new Map([["id", "42"]])),
parent: {
paramMap: of(new Map([["id", "1"]])),
},
queryParams: of({ returnparams: "foo=bar&baz=123" }),
},
},
{
provide: PersonsRestService,
useFactory() {
personsServiceMock = jasmine.createSpyObj("PersonsRestService", [
"get",
]);
personsServiceMock.get.and.callFake(() => of(person));
return personsServiceMock;
},
},
{
provide: SubscriptionsRestService,
useFactory() {
subscriptionsServiceMock = jasmine.createSpyObj("", [
"getSubscriptionsByCourse",
"getSubscriptionDetailsById",
]);
subscriptionsServiceMock.getSubscriptionsByCourse.and.callFake(
() => of([subscription]),
);
subscriptionsServiceMock.getSubscriptionDetailsById.and.callFake(
() => of(details),
);
return subscriptionsServiceMock;
},
},
{
provide: StorageService,
useValue: { getAccessToken: () => "eyABCDEFG" },
},
],
}),
).compileComponents();

fixture = TestBed.createComponent(EventsStudentsStudyCourseDetailComponent);
element = fixture.debugElement.nativeElement;
});

it("fetches & renders person data", () => {
fixture.detectChanges();
expect(personsServiceMock.get).toHaveBeenCalledWith(42);
expect(element.querySelector("h1")?.textContent).toContain("Lennon John");
expect(element.textContent).toContain("01.01.1970");
expect(element.textContent).toContain("(F)");
expect(element.textContent).toContain("3 Abbey Road");
expect(element.textContent).toContain("NW8 9AY");
expect(element.textContent).toContain("London");
expect(element.textContent).toContain("031 123 45 67");
expect(element.textContent).toContain("079 123 45 67");
});

it("fetches & renders subscription status", () => {
fixture.detectChanges();
expect(
subscriptionsServiceMock.getSubscriptionsByCourse,
).toHaveBeenCalledWith(1, { "filter.PersonId": "=42" });
expect(element.textContent).toContain("Aufgenommen");
});

describe("subscription details", () => {
it("fetches & renders text entries", () => {
const detail1 = buildSubscriptionDetail(1001, "Lorem ipsum");
detail1.Id = "1001";
detail1.VssDesignation = "Bemerkung";

const detail2 = buildSubscriptionDetail(1002, "2401");
detail2.Id = "1002";
detail2.VssDesignation = "Eintritt in Semester";

details = [detail1, detail2];

fixture.detectChanges();
expect(
subscriptionsServiceMock.getSubscriptionDetailsById,
).toHaveBeenCalledWith(100);
expect(element.textContent).toContain("Bemerkung");
expect(element.textContent).toContain("Lorem ipsum");
expect(element.textContent).toContain("Eintritt in Semester");
expect(element.textContent).toContain("2401");
});

it("renders document file entry", () => {
const detail = buildSubscriptionDetail(1001, "document.pdf");
detail.Id = "1001";
detail.VssDesignation = "Upload PDF-Datei 1";
detail.VssStyle = "PD";
details = [detail];

fixture.detectChanges();
expect(element.textContent).toContain("Upload PDF-Datei 1");
const link = element.querySelector(
"a[href='https://eventotest.api/Files/SubscriptionDetails/1001?token=eyABCDEFG']",
);
expect(link).not.toBeNull();
expect(link?.textContent).toBe("document.pdf");
});

it("renders foto file entry", () => {
const detail = buildSubscriptionDetail(1002, "foto.jpg");
detail.Id = "1002";
detail.VssDesignation = "Upload Foto";
detail.VssStyle = "PF";
details = [detail];

fixture.detectChanges();
expect(element.textContent).toContain("Upload Foto");
const link = element.querySelector(
"a[href='https://eventotest.api/Files/SubscriptionDetails/1002?token=eyABCDEFG']",
);
expect(link).not.toBeNull();
expect(link?.textContent).toBe("foto.jpg");
});
});
});
Loading

0 comments on commit 01694b6

Please sign in to comment.