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
30 changes: 30 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# AGENTS Notes — ngrx-hateoas workspace

Purpose
- This workspace contains an Angular library, `@angular-architects/ngrx-hateoas`, built on top of the NgRx Signal Store. The library helps load hypermedia JSON resources into a reactive signal-based store, mutate state locally and execute hypermedia-driven actions.

Key locations
- Library source: `libs/ngrx-hateoas`
- Documentation (user guide): `doc/docs/guide` (see Getting Started, Concept, Pipes, Configuration, Loading Features, State Mutation Features, Action Features)
- Playground app (demo + local server): `apps/playground` (contains `server.js` and `db.json` for local demonstration)

How tests are written (libs/ngrx-hateoas)
- Location: `libs/ngrx-hateoas/src/lib/**/*.spec.ts`.
- Framework / runner: Angular unit tests using Jasmine (see `tsconfig.spec.json` types: `jasmine`) and Angular TestBed utilities.
- Common patterns:
- Use `TestBed.configureTestingModule(...)` with providers such as `provideZonelessChangeDetection()`, `provideHttpClient()` and `provideHttpClientTesting()` where needed.
- Services that call HTTP use `HttpTestingController` and `expectOne(...).flush(...)` to simulate server responses and assert request properties (method, body, headers).
- Many tests assert metastate signals (e.g., `isLoading`, `isLoaded`, `isAvailable`) and verify reactive behavior (cancellation of previous requests, reactive reloads when a `Signal` changes) via `signalStore`, `signal()` and `TestBed.flushEffects()` where applicable.
- Tests use `async/await` or `expectAsync` for promise-based operations and standard Jasmine matchers (`toBeTrue`, `toBeFalse`, `toEqual`, `toBe`, `toBeDefined`, etc.).
- Tests cover pipes, services (request/hateoas), and store-features (loading, writable copies, actions), focusing on isolated unit behavior.

Playground / local demo
- `apps/playground` contains a minimal Angular app and local server artifacts (`db.json`, `server.js`) useful to run the library locally against a fake REST API for manual testing and demos.

Rules for agents (editing / extending)
- When changing features, update matching docs in `doc/docs/guide/*` and add/adjust unit tests under `libs/ngrx-hateoas/src/lib/**`.
- For HTTP-related behavior, follow existing test patterns using `provideHttpClientTesting()` and `HttpTestingController`.
- Use `provideZonelessChangeDetection()` in TestBed providers to match existing test environment.

References
- See `doc/docs/guide/01-getting-started.md` and `doc/docs/guide/02-concept.md` for conceptual details and examples.
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,19 @@

<div class="row">
<div class="col">
<app-action-card #connection
[disabled]="!store.updateFlightConnectionState.isAvailable()"
(save)="onUpdateConnection()">
<app-action-card #connection [disabled]="!store.updateFlightConnectionState.isAvailable()" (save)="onUpdateConnection()">
<app-flight-connection-form [connection]="flightForm.connection" />
</app-action-card>
<app-action-card #times
[disabled]="!store.updateFlightTimesState.isAvailable()"
(save)="onUpdateTimes()">
<app-action-card #times [disabled]="!store.updateFlightTimesState.isAvailable()" (save)="onUpdateTimes()">
<app-flight-times-form [times]="flightForm.times" />
</app-action-card>
<app-action-card #operator
[disabled]="!store.updateFlightOperatorState.isAvailable()"
(save)="onUpdateOperator()">
<app-action-card #operator [disabled]="!store.updateFlightOperatorState.isAvailable()" (save)="onUpdateOperator()">
<app-flight-operator-form [aircrafts]="store.flightEditVm.aircrafts()" [operator]="flightForm.operator" />
</app-action-card>
@if(flightForm.price().value(); as flightPriceValue) {
<app-action-card #price
[disabled]="!store.updateFlightPriceState.isAvailable()"
(save)="onUpdatePrice()">
<app-flight-price-form [price]="$any(flightForm.price)" />
</app-action-card>
<app-action-card #price [disabled]="!store.updateFlightPriceState.isAvailable()" (save)="onUpdatePrice()">
<app-flight-price-form [price]="$any(flightForm.price)" />
</app-action-card>
}
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class FlightEditComponent {
_operatorCard = viewChild.required<ActionCardComponent>('operator');
_priceCard = viewChild<ActionCardComponent>('price');


async onUpdateConnection() {
try {
await this.store.updateFlightConnection();
Expand All @@ -31,27 +32,27 @@ export class FlightEditComponent {
}
}

onUpdateTimes() {
async onUpdateTimes() {
try {
this.store.updateFlightTimes();
await this.store.updateFlightTimes();
this._timesCard().showSuccess();
} catch {
this._timesCard().showError();
}
}

onUpdateOperator() {
async onUpdateOperator() {
try {
this.store.updateFlightOperator();
await this.store.updateFlightOperator();
this._operatorCard().showSuccess();
} catch {
this._operatorCard().showError();
}
}

onUpdatePrice() {
async onUpdatePrice() {
try {
this.store.updateFlightPrice();
await this.store.updateFlightPrice();
this._priceCard()?.showSuccess();
} catch {
this._priceCard()?.showError();
Expand Down
27 changes: 15 additions & 12 deletions apps/playground/src/app/flight/flight-edit/flight-edit.store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { withHypermediaResource, withHypermediaAction, withWritableStateCopy, withExperimentalDeepWritableStateCopy, withExperimentalDeepWritableStateDelegate } from "@angular-architects/ngrx-hateoas";
import { signalStore } from "@ngrx/signals";
import { signalStore, withMethods } from "@ngrx/signals";
import { Aircraft, Flight, initialFlight } from "../flight.entities";

export type FlightEditVm = {
Expand All @@ -15,39 +15,42 @@ export const initialFlightEditVm: FlightEditVm = {
export const FlightEditStore = signalStore(
{ providedIn: 'root' },
withHypermediaResource('flightEditVm', initialFlightEditVm),
withWritableStateCopy(store => ({
localFlight: store.flightEditVm.flight
})),
withWritableStateCopy(store => ({ localFlight: store.flightEditVm.flight })),
withHypermediaAction('updateFlightConnection', store => store.localFlight.connection, 'update'),
withHypermediaAction('updateFlightTimes', store => store.localFlight.times, 'update'),
withHypermediaAction('updateFlightOperator', store => store.localFlight.operator, 'update'),
withHypermediaAction('updateFlightPrice', store => store.localFlight.price, 'update')
withHypermediaAction('updateFlightPrice', store => store.localFlight.price, 'update'),
withMethods(store => ({
reset() {
store.localFlight.set(store.flightEditVm.flight());
}
}))
);


// Option 1: Reading a resource from server, creating a writable copy, and sending back the copy to the server
export const FlightEditViewStore1 = signalStore(
{ providedIn: 'root' },
withHypermediaResource('flightEditVm', initialFlightEditVm),
withWritableStateCopy(store => ({ writableFlightConnection: store.flightEditVm.flight.connection })),
withHypermediaAction('updateFlightConnection', store => store.writableFlightConnection, 'update'),
withWritableStateCopy(store => ({ writableFlightConnectionCopy: store.flightEditVm.flight.connection })),
withHypermediaAction('updateFlightConnection', store => store.writableFlightConnectionCopy, 'update'),
);

// Option 2: Reading a resource from server, creating a deep writable copy (suited for template driven forms),
// and sending back the copy to the server
export const FlightEditViewStore2 = signalStore(
{ providedIn: 'root' },
withHypermediaResource('flightEditVm', initialFlightEditVm),
withExperimentalDeepWritableStateCopy(store => ({ deepWritableFlightConnection: store.flightEditVm.flight.connection })),
withHypermediaAction('updateFlightConnection', store => store.deepWritableFlightConnection, 'update'),
withExperimentalDeepWritableStateCopy(store => ({ deepWritableFlightConnectionCopy: store.flightEditVm.flight.connection })),
withHypermediaAction('updateFlightConnection', store => store.deepWritableFlightConnectionCopy, 'update'),
);

// Option 3: Reading a resource from server, creating a delegate that directly modifies the "main" state of the store,
// and sending back parts of the main state to the server
export const FlightEditViewStore = signalStore(
export const FlightEditViewStore3 = signalStore(
{ providedIn: 'root' },
withHypermediaResource('flightEditVm', initialFlightEditVm),
withExperimentalDeepWritableStateDelegate(store => ({ delegatedDeepWritableFlightConnection: store.flightEditVm.flight.connection })),
withHypermediaAction('updateFlightConnection', store => store.delegatedDeepWritableFlightConnection, 'update'),
withExperimentalDeepWritableStateDelegate(store => ({ deepWritableFlightConnectionDelegate: store.flightEditVm.flight.connection })),
withHypermediaAction('updateFlightConnection', store => store.flightEditVm.flight.connection, 'update'),
);

10 changes: 6 additions & 4 deletions doc/docs/guide/04-loading_features/01-withHypermediaResource.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ With this feature the following methods are added to the interface of the store:
### Load the Resource from an URL
```ts
load<resourceName>FromUrl(url: string | null, fromCache?: boolean): Promise<void>
load<resourceName>FromUrl(url: Signal<string | null>): EffectRef
```
Loads the resource from the provided URL.
Loads the resource from the provided URL. Two overloads are available:

* **url**: The URL from which the resource should be loaded. If `null` is provided the resource is reset to its initial value.
* **fromCache**: Whether to load the resource even if the the current state is loaded from the same URL. If not provided, defaults to `false`.
* When called with a **plain value** (`string | null`): loads the resource immediately and returns a `Promise<void>` that resolves when the request completes. Passing `null` resets the resource to its initial value.
* **fromCache**: When `true`, skips loading if the resource is already loaded from the same URL. Defaults to `false`.
* When called with a **`Signal<string | null>`**: sets up a reactive effect that automatically reloads the resource whenever the signal's value changes, and returns an `EffectRef` that can be used to destroy the effect.

### Load the Resource from a Link
```ts
Expand All @@ -64,4 +66,4 @@ Loads the resource from the provided URL.
```ts
reload<resourceName>(): Promise<void>
```
Reloads the resource from the last used URL.
Reloads the resource from the last used URL.
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ Creates a resource in the store which gets automatically loaded when an instance
## API
```ts
function withInitialHypermediaResource<ResourceName extends string, TResource>(
resourceName: ResourceName, initialValue: TResource, url: string | (() => string)): SignalStoreFeature;
resourceName: ResourceName, initialValue: TResource, url: string | Promise<string> | (() => string | Promise<string>)): SignalStoreFeature;
```

* **resourceName**: The name of how the resource will be declared in the store.
* **initialValue**: The initial value of the resource before it is loaded from a URL.
* **url**: The URL from which the resource will be loaded. Can also be a function returning a string. If a function is provided, it will be executed at the time the store is created.
* **url**: The URL from which the resource will be loaded. Accepts four forms:
- A `string`: the URL is used directly at store creation time.
- A `Promise<string>`: the promise is awaited and the resolved value is used as the URL.
- A function returning a `string`: the function is called at store creation time, allowing injection of Angular services (e.g. `() => inject(MyToken)`).
- A function returning a `Promise<string>`: the function is called at store creation time and the resulting promise is awaited before loading. Useful for asynchronous URL resolution via injected services.

## State
With this feature the following state properties are added to the interface of the store:
Expand Down Expand Up @@ -46,11 +50,13 @@ With this feature the following methods are added to the interface of the store:
### Load the Resource from an URL
```ts
load<resourceName>FromUrl(url: string | null, fromCache?: boolean): Promise<void>
load<resourceName>FromUrl(url: Signal<string | null>): EffectRef
```
Loads the resource from the provided URL.
Loads the resource from the provided URL. Two overloads are available:

* **url**: The URL from which the resource should be loaded. If `null` is provided the resource is reset to its initial value.
* **fromCache**: Whether to load the resource even if the the current state is loaded from the same URL. If not provided, defaults to `false`.
* When called with a **plain value** (`string | null`): loads the resource immediately and returns a `Promise<void>` that resolves when the request completes. Passing `null` resets the resource to its initial value.
* **fromCache**: When `true`, skips loading if the resource is already loaded from the same URL. Defaults to `false`.
* When called with a **`Signal<string | null>`**: sets up a reactive effect that automatically reloads the resource whenever the signal's value changes, and returns an `EffectRef` that can be used to destroy the effect.

### Load the Resource from a Link
```ts
Expand Down
1 change: 0 additions & 1 deletion doc/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const config: Config = {
projectName: 'ngrx-hateoas', // Usually your repo name.

onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',

// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
Expand Down
Loading
Loading