-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
22 changed files
with
749 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`. |
60 changes: 60 additions & 0 deletions
60
...ts/events-students-study-course-detail/events-students-study-course-detail.component.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
31 changes: 31 additions & 0 deletions
31
...ts/events-students-study-course-detail/events-students-study-course-detail.component.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
172 changes: 172 additions & 0 deletions
172
...events-students-study-course-detail/events-students-study-course-detail.component.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.