Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ngrx-hateoas.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 26.x

# Restore dependencies
- name: Restore dependencies
Expand Down
3 changes: 1 addition & 2 deletions apps/playground/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClient, withXhr } from '@angular/common/http';
import { provideHateoas } from '@angular-architects/ngrx-hateoas';

export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideRouter(routes),
provideHttpClient(),
provideHateoas()
]
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@ <h5 class="card-title">Connection</h5>
<form>
<div class="mb-3">
<label for="from" class="form-label">From</label>
<input class="form-control" id="from" [field]="connection().from">
<input class="form-control" id="from" [formField]="connection().from">
</div>
<div class="mb-3">
<label for="to" class="form-label">To</label>
<input class="form-control" id="to" [field]="connection().to">
<input class="form-control" id="to" [formField]="connection().to">
</div>
<div class="mb-3">
<label for="icaoFrom" class="form-label">ICAO From</label>
<input class="form-control" id="icaoFrom" [field]="connection().icaoFrom">
<input class="form-control" id="icaoFrom" [formField]="connection().icaoFrom">
</div>
<div class="mb-3">
<label for="icaoTo" class="form-label">ICAO To</label>
<input class="form-control" id="icaoTo" [field]="connection().icaoTo">
<input class="form-control" id="icaoTo" [formField]="connection().icaoTo">
</div>
</form>
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Component, input } from '@angular/core';
import { FlightConnection } from '../../flight.entities';
import { Field, FieldTree } from '@angular/forms/signals';
import { FormField, FieldTree } from '@angular/forms/signals';

@Component({
selector: 'app-flight-connection-form',
imports: [Field],
imports: [FormField],
templateUrl: './flight-connection-form.component.html'
})
export class FlightConnectionFormComponent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ <h5 class="card-title">Operator</h5>
<form>
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input class="form-control" id="name" [field]="operator().name">
<input class="form-control" id="name" [formField]="operator().name">
</div>
<div class="mb-3">
<label for="shortName" class="form-label">Short Name</label>
<input class="form-control" id="shortName" [field]="operator().shortName">
<input class="form-control" id="shortName" [formField]="operator().shortName">
</div>
<select class="form-select form-select-lg mb-3" [field]="operator().aircraftId">
<select class="form-select form-select-lg mb-3" [formField]="operator().aircraftId">
@for(aircraft of aircrafts(); track aircraft.id) {
<option [value]="aircraft.id">{{aircraft.registration}} - {{aircraft.type}}</option>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Component, input } from '@angular/core';
import { Field, FieldTree } from '@angular/forms/signals';
import { FormField, FieldTree } from '@angular/forms/signals';
import { Aircraft, FlightOperator } from '../../flight.entities';

@Component({
selector: 'app-flight-operator-form',
imports: [Field],
imports: [FormField],
templateUrl: './flight-operator-form.component.html'
})
export class FlightOperatorFormComponent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ <h5 class="card-title">Price</h5>
<form>
<div class="mb-3">
<label for="basePrice" class="form-label">Base Price</label>
<input class="form-control" type="number" id="amount" [field]="price().basePrice">
<input class="form-control" type="number" id="amount" [formField]="price().basePrice">
</div>
<div class="mb-3">
<label for="seatReservationSurcharge" class="form-label">Seat Reservation Surcharge</label>
<input class="form-control" type="number" id="amount" [field]="price().seatReservationSurcharge">
<input class="form-control" type="number" id="amount" [formField]="price().seatReservationSurcharge">
</div>
</form>
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Component, input } from '@angular/core';
import { Field, FieldTree } from '@angular/forms/signals';
import { FormField, FieldTree } from '@angular/forms/signals';
import { FlightPrice } from '../../flight.entities';

@Component({
selector: 'app-flight-price-form',
imports: [Field],
imports: [FormField],
templateUrl: './flight-price-form.component.html'
})
export class FlightPriceFormComponent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ <h5 class="card-title">Times</h5>
<form>
<div class="mb-3">
<label for="takeOff" class="form-label">Take Off</label>
<input class="form-control" type="datetime" id="takeOff" [field]="times().takeOff">
<input class="form-control" type="datetime" id="takeOff" [formField]="times().takeOff">
</div>
<div class="mb-3">
<label for="landing" class="form-label">Landing</label>
<input class="form-control" type="datetime" id="landing" [field]="times().landing">
<input class="form-control" type="datetime" id="landing" [formField]="times().landing">
</div>
</form>
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Component, input } from '@angular/core';
import { Field, FieldTree } from '@angular/forms/signals';
import { FormField, FieldTree } from '@angular/forms/signals';
import { FlightTimes } from '../../flight.entities';

@Component({
selector: 'app-flight-times-form',
imports: [Field],
imports: [FormField],
templateUrl: './flight-times-form.component.html'
})
export class FlightTimesFormComponent {
Expand Down
10 changes: 9 additions & 1 deletion apps/playground/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,13 @@
],
"include": [
"src/**/*.d.ts"
]
],
"angularCompilerOptions": {
"extendedDiagnostics": {
"checks": {
"nullishCoalescingNotNullable": "suppress",
"optionalChainNotNullable": "suppress"
}
}
}
}
6 changes: 3 additions & 3 deletions libs/ngrx-hateoas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"name": "@angular-architects/ngrx-hateoas",
"version": "22.0.0-beta.0",
"peerDependencies": {
"@angular/common": "^21.0.0",
"@angular/core": "^21.0.0",
"@ngrx/signals": "^21.0.0"
"@angular/common": "^22.0.0",
"@angular/core": "^22.0.0",
"@ngrx/signals": "^22.0.0-beta.0"
},
"dependencies": {
"tslib": "^2.3.0"
Expand Down
37 changes: 36 additions & 1 deletion libs/ngrx-hateoas/src/lib/provide.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ describe('provideHateaos', () => {

describe('withAntiForgery', () => {

it('registers default anti forgery options', () => {
TestBed.configureTestingModule({ providers: [provideHateoas(withAntiForgery())] });

expect(TestBed.inject(HATEOAS_ANTI_FORGERY)).toEqual({ cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN' });
});

it('registers custom anti forgery options in injection context', () => {

const dummyAntiForgeryOptions: AntiForgeryOptions = {
Expand All @@ -29,6 +35,12 @@ describe('provideHateaos', () => {

describe('withLoginRedirect', () => {

it('registers default login redirect options', () => {
TestBed.configureTestingModule({ providers: [provideHateoas(withLoginRedirect())] });

expect(TestBed.inject(HATEOAS_LOGIN_REDIRECT)).toEqual({ loginUrl: '/login', redirectUrlParamName: 'redirectUrl' });
});

it('registers custom login redirect options in injection context', () => {

const dummyLoginRedirectOptions: LoginRedirectOptions = {
Expand All @@ -47,6 +59,12 @@ describe('provideHateaos', () => {

describe('withCustomHeaders', () => {

it('registers empty default headers', () => {
TestBed.configureTestingModule({ providers: [provideHateoas(withCustomHeaders())] });

expect(TestBed.inject(HATEOAS_CUSTOM_HEADERS).headers).toEqual({});
});

it('registers custom header options in injection context', () => {

const dummyCustomHeaderOptions: CustomHeadersOptions = {
Expand All @@ -65,6 +83,23 @@ describe('provideHateaos', () => {

describe('withMedatadaProvider', () => {

it('collects valid default metadata and ignores invalid entries', () => {
TestBed.configureTestingModule({ providers: [provideHateoas()] });
const provider = TestBed.inject(HATEOAS_METADATA_PROVIDER);
const resource = {
_links: { self: { href: '/self' }, invalid: null },
_actions: { save: { href: '/save', method: 'PUT' }, invalid: { href: '/invalid' } },
_sockets: { changes: { href: '/changes', event: 'changed' }, invalid: { href: '/invalid' } }
};

expect(provider.getAllLinks(resource)).toEqual([{ rel: 'self', href: '/self' }]);
expect(provider.getAllActions(resource)).toEqual([{ rel: 'save', href: '/save', method: 'PUT' }]);
expect(provider.getAllSockets(resource)).toEqual([{ rel: 'changes', href: '/changes', event: 'changed' }]);
expect(provider.getAllLinks(null)).toEqual([]);
expect(provider.getAllActions({ _actions: null })).toEqual([]);
expect(provider.getAllSockets({ _sockets: 'invalid' })).toEqual([]);
});

it('registers custom metadataprovider in injection context', () => {

const dummyMetadataProvider: MetadataProvider = {
Expand Down Expand Up @@ -105,4 +140,4 @@ describe('provideHateaos', () => {

});

});
});
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ describe('withHypermediaAction', () => {
httpTestingController = TestBed.inject(HttpTestingController);
});

async function loadAvailableAction(method = 'PUT') {
const loadPromise = store.loadTestModelFromUrl('/api/test-model');
httpTestingController.expectOne('/api/test-model').flush({
name: 'foobar',
_actions: { doSomething: { href: '/api/do-something', method } }
} satisfies TestModel);
await loadPromise;
await firstValueFrom(timer(0));
}

it('sets correct initial resource state', () => {
expect(store.doSomethingState.href()).toBe('');
expect(store.doSomethingState.method()).toBe('');
Expand All @@ -62,12 +72,7 @@ describe('withHypermediaAction', () => {
});

it('executes an action successfully after it is available', async () => {

const testModel = store.getTestModelAsPatchable();
testModel.name.patch('foobar');
testModel._actions.patch({ doSomething: { href: '/api/do-something', method: 'PUT' } });

await firstValueFrom(timer(0));
await loadAvailableAction();

expect(store.doSomethingState.href()).toBe('/api/do-something');
expect(store.doSomethingState.method()).toBe('PUT');
Expand Down Expand Up @@ -105,4 +110,42 @@ describe('withHypermediaAction', () => {
expect(store.doSomethingState.error()).toBeNull();
});

it('stores an HTTP action error and rejects', async () => {
await loadAvailableAction();

const actionPromise = store.doSomething();
httpTestingController.expectOne('/api/do-something').flush('failed', { status: 500, statusText: 'Server Error' });

await expectAsync(actionPromise).toBeRejected();
expect(store.doSomethingState.isExecuting()).toBeFalse();
expect(store.doSomethingState.hasError()).toBeTrue();
expect(store.doSomethingState.error()).toBeDefined();
});

it('sends no body for DELETE actions', async () => {
await loadAvailableAction('DELETE');

const actionPromise = store.doSomething();
const request = httpTestingController.expectOne('/api/do-something');
expect(request.request.body).toBeNull();
request.flush(null);

await actionPromise;
});

it('resets availability when the action disappears or becomes invalid', async () => {
await loadAvailableAction();

for (const [url, actions] of [
['/api/without-action', {}],
['/api/with-invalid-action', { doSomething: { href: '', method: 'TRACE' } }]
] as const) {
const loadPromise = store.loadTestModelFromUrl(url);
httpTestingController.expectOne(url).flush({ name: 'updated', _actions: actions });
await loadPromise;
await firstValueFrom(timer(0));
expect(store.doSomethingState.isAvailable()).toBeFalse();
}
});

});
Loading
Loading