diff --git a/docs/1.architecture.md b/docs/1.architecture.md index f26881d..156b1ed 100644 --- a/docs/1.architecture.md +++ b/docs/1.architecture.md @@ -55,7 +55,7 @@ We'll dig into these in more detail in later documents. > [!NOTE] > -> Think of **smart components** as more involved components that have access and awareness of the broader application state and logic, via stores (and other services). They don't function just as black boxes and are not usually reusable across different parts of the app. +> Think of **smart components** as more involved components that have access and awareness of the broader application state and logic, via stores and other services. They don't function just as black boxes and are not usually reusable across different parts of the app. > > Think of **presentational components** as simple and naive components that only know their inputs and outputs, making no assumptions of the overall application state and structure. They should be easy to test (as a black box) and easy to reuse. @@ -67,7 +67,7 @@ We'll dig into these in more detail in later documents. > > As a reminder, all the Angular components (including ones generated through the Angular CLI) have been configured to use the [`OnPush` change detection strategy](https://angular.dev/best-practices/skipping-subtrees#using-onpush) by default. > -> This is a more performant approach that [works well with Angular's signals](https://angular.dev/guide/signals#reading-signals-in-onpush-components), and since we use NgRx SignalStore you are unlikely to hit the cases where change detection is not triggered when it should be. +> This is a more performant approach that [works well with Angular's signals](https://angular.dev/guide/signals#reading-signals-in-onpush-components), and since we use NgRx SignalStore and Angular's signals to manage most application state you are unlikely to hit the cases where change detection is not triggered when it should be. > > With the caveat that forms _sometimes_ don't behave well with OnPush change detection, so in rare cases you'd need to use the `ChangeDetectorRef` to manually mark a component for change detection. > @@ -96,7 +96,7 @@ sequenceDiagram (Ignore the time-ordering of this sequence diagram, it's just a way to visualize the data flow — it's not a strict sequence of events.) - Use Angular services to wrap ALL access to databases and external services. -- Use state management "stores" to encapsulate as much of the app's state and behavior as possible, leaving components to focus on UI needs and responding to state changes. +- Use state management "stores" to encapsulate as much of the app's state and behavior as possible, leaving components to focus on UI needs, triggering store behaviors and responding to state changes. - Use smart components to interact with stores to bind state and trigger application logic. - Use presentational components (within the template of smart components) to abstract out UI presentation and logic in a way that does not need to know about the overall application state and structure, communicating all actions/events back to the parent smart component. diff --git a/docs/2.routes-and-shell.md b/docs/2.routes-and-shell.md index 81ff0bc..ab43f9d 100644 --- a/docs/2.routes-and-shell.md +++ b/docs/2.routes-and-shell.md @@ -6,7 +6,7 @@ The base template comes with a `website` feature folder (within the `app`) where the static pages live. We could've added more pages and components here to build our logbook app, but it's better to separate it out into a dedicated feature folder ([`app/src/app/logbook/`](../app/src/app/logbook/)) and lazily load it only when the user navigates to a particular URL — `/logbook` in this case — as registered in the top-level app routes file: - + Here, the use of an `import` for the `loadChildren` property tells Angular to separate out the code for the logbook feature into its own bundle and only load it when the user navigates to `/logbook`. @@ -16,7 +16,7 @@ Here, the use of an `import` for the `loadChildren` property tells Angular to se Let's now look at the routes for the logbook feature itself: - + - We define a parent route that will load the `LogbookShellComponent`, with child routes defined within. - This shell component has a `` in its template where a matching child route will have it's component placed in to. diff --git a/docs/3.data-model-and-access.md b/docs/3.data-model-and-access.md index ca2f5d6..4a0f556 100644 --- a/docs/3.data-model-and-access.md +++ b/docs/3.data-model-and-access.md @@ -64,7 +64,7 @@ erDiagram Given the design decision to store all entries in a single collection, we first set up the security rules to ensure proper access control: - + - This is a special "domain specific language" (DSL) that Firestore uses to define access control rules in the [`firebase/firestore.rules`](../firebase/firestore.rules) file ([docs](https://firebase.google.com/docs/firestore/security/get-started)). - `isAuthed()` and `matchesAuthedUser(userId)` are helper functions we've defined to allow easy reuse in multiple rules. @@ -104,7 +104,7 @@ Both Angular and the Firebase JavaScript SDK have first class support for TypeSc When loading entries from Firestore we want to assume they take a particular _shape_ — the `EntryDoc` type — which the TypeScript (and VS Code) tooling can then use to look for type errors and provide code completion. - + - This uses the `Readonly` TypeScript utility type to mark the whole object as readonly. - We build on the `WithId` type from the [`firebase/common/models.ts`](../firebase/common/models.ts) file, which adds the `id` field to the object. @@ -122,7 +122,7 @@ When loading entries from Firestore we want to assume they take a particular _sh In the same file we also define a special `NewOrUpdatedEntryInput` type for the data we send to Firestore when creating or updating an entry: - + Here we're picking a subset of the fields from the `EntryDoc` type — we only want to allow the user to set or update these fields. @@ -150,11 +150,11 @@ Let's walk through this service: First, we inject the Firestore client using the helper provided from the base template: - + Next, we define a special converter object that the Firestore JavaScript library understands. We also define a reference to the collection (which we use in the actual data access methods): - + [Firestore converters](https://firebase.google.com/docs/firestore/query-data/get-data#custom_objects) are a first class way to convert Firestore document data into strongly typed objects, and back. A converter is an object that conforms to the `FirestoreDataConverter` type, which requires two methods: @@ -299,13 +299,13 @@ Because Realtime Database doesn't natively support arrays we have to model the c We then have some very basic security rules to allow anyone to read but no one to write (from the client-side): - + We add some tests in [`firebase/test/rtdb/rtdb-rules.spec.ts`](../firebase/test/rtdb/rtdb-rules.spec.ts) to ensure that these work as expected. Finally, we have a service that wraps access to this config object (and thus the underlying categories): [`ConfigService`](../app/src/app/logbook/data/db/config.service.ts). The key line in this service is where we flatten the `categories` object into an array of strings: - + Next we look at how we build on top of these data access services to drive state management and app logic using stores. diff --git a/docs/4.logbook-stores.md b/docs/4.logbook-stores.md index a63148b..cc00c2a 100644 --- a/docs/4.logbook-stores.md +++ b/docs/4.logbook-stores.md @@ -38,7 +38,7 @@ Let's look at how this done in the `ConfigStore` as an example (the same princip First, we model the TypeScript types for our state: - + - There are four possible states: `disconnected` (the initial state), `connecting`, `connected` and `error`, as defined by the possible `status` property values. - It's possible to have more, but these are the minimum required to manage a stream. @@ -51,10 +51,10 @@ First, we model the TypeScript types for our state: | :-- | | Use [TypeScript's discriminated unions](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions) to separate out the different possible states and their associated data, based on a `status` property. This allows us to be quite specific and intentional about all the possible states we can legitimately expect the store to have, and we can lean on the TypeScript compiler to detect unexpected states when updating the state. This also serves as useful _self-describing_ code for future reference. | -NgRx SignalStore gives us the `signalStore` factory function to define a new store. The arguments to this function are the results of "feature factories" that _compose_ the store's state and behavior. For the `ConfigStore` we have the following: +NgRx SignalStore gives us the `signalStore` factory function to define a new store. The arguments to this function are the results of "feature factories" that _compose_ the store's state and behavior. For the `ConfigStore` we have the following structure: ```ts -signalStore( +export const ConfigStore = signalStore( withState(initialState), withMethods(...), withHooks({ @@ -76,21 +76,16 @@ Collectively, these allow us to define and set up the state, define methods on t We declare our store as: ```typescript -const _ConfigStore = signalStore(...); +export type ConfigStore = InstanceType; -@Injectable() -export class ConfigStore extends _ConfigStore {} +export const ConfigStore = signalStore(…); ``` -So the publicly exported version is the explicit class wrapper over the store returned from `signalStore`. This is not strictly necessary but highly recommended (by us). - -| **:white_check_mark: Pattern** | -| :-- | -| For SignalStore stores, always declare a class that extends the actual `signalStore` factory output instance and export that instead. This is not strictly necessary, but we've found this currently plays better with TypeScript. E.g. the result of `ngMocks.get(MyStore)` has a type of `any` if you don’t.

It also allows you to extend the store in ways not supported by SignalStore, e.g. see the bottom of the [`AuthStore`](../app/src/app/shared/auth/data/auth.store.ts) where we provide observables from some of the signal properties in the store. | +Note how the type declaration has the same name as the store constant being exported. This additional type declaration is useful when referring to the instance type of the store elsewhere. You'll especially see these instance types used in tests, e.g. `ngMocks.get(MyStore)` but they can be used anywhere you need to pass an instance of a store (e.g. into a function). > [!NOTE] > -> Since the `ConfigStore` is a feature-level store (provided in the lazily loaded route) we don't mark the `@Injectable()` with `providedIn: 'root'` (like the auth store has, which is global). This tells Angular to only every instantiate it when it's explicitly provided somewhere first (and then the instance is only actually created when it's injected somewhere). +> Since the `ConfigStore` is a feature-level store (provided in the lazily loaded route) we don't configure the `signalStore` definition with `{ providedIn: 'root' }` (like the [`AuthStore`](../app/src/app/shared/auth/data/auth.store.ts) has, which is global). This tells Angular to only ever instantiate it when it's explicitly provided somewhere first (and then the instance is only actually created when it's injected somewhere, and only available within that provider context). All the bits in the `ConfigStore` are geared towards managing and storing the stream of config data from Realtime Database. To trigger this, we call `store.manageStream('connect');` in the `onInit` hook to connect the stream (`manageStream` is a method defined in the `withMethods` factory, which we'll cover below). @@ -116,13 +111,13 @@ All the bits in the `ConfigStore` are geared towards managing and storing the st Aside: note how we log state changes in the `onInit` hook: - + This allows us to see the state changes in the console, which can be very useful for debugging and understanding how the store is behaving. Note that it doesn't include state changes from computed signals in the store. -Back to managing the stream: let's look at all the set up bits we do in the `withMethods` factory function (for the `ConfigStore`): +Back to managing the stream: let's look at all the set-up bits we do in the `withMethods` factory function (for the `ConfigStore`): - + - The factory function is passed in an instance of the `store` that contains everything defined up to that point in the definition of the signal store. - We'll look at how this matters in the patterns example app (coming soon), where we layer different `withComputed` and `withMethods` factories that rely on values set in previous ones. @@ -137,9 +132,9 @@ Back to managing the stream: let's look at all the set up bits we do in the `wit | :-- | | Never use `patchState` outside a SignalStore. Whilst it's currently possible to do so it's not recommended — we want to keep updates to the state centralized to only within the store, either through store methods or side effects from RxJS streams. The latter should ideally call internal updater functions like the ones defined above. | -Finally, let's look at the implementation of the `manageStream` store method (the only public method defined in this store): +Finally, let's look at the implementation of the `manageStream` store method — the only public method defined in this store: - + | **:white_check_mark: Pattern** | | :-- | @@ -166,7 +161,7 @@ Finally, let's look at the implementation of the `manageStream` store method (th We've chosen to break out the connected and disconnected streams into separate internal helper functions (as we saw earlier, and repeated below): - + This makes it easier to reason about what happens in the `manageStream` method (i.e. doesn't clutter it with more implementation details). @@ -214,7 +209,7 @@ There is one important caveat to the way we've handled errors in the `ConfigStor The `EntriesStore` is similar to the `ConfigStore` in that it manages a stream of data, but it has a few key differences: - The connecting of the data stream is more involved as it needs the user ID of the logged in user before it can connect the stream of entries from Firestore. -- The list of entries are managed in the state using SignalStore's [entity management](https://ngrx.io/guide/signals/signal-store/entity-management) capabilities. +- The list of entries is managed in the state using SignalStore's [entity management](https://ngrx.io/guide/signals/signal-store/entity-management) capabilities. - We have some extra computed fields to derive state from the base state. - It provides more capabilities like pagination and filtering. @@ -222,7 +217,7 @@ The `EntriesStore` is similar to the `ConfigStore` in that it manages a stream o Before we get a connected stream of entries from Firestore we need to know the user ID of the logged in user. Here is the implementation of the `manageStream` method in the `EntriesStore`: - + - We are now `switchMap`-ing twice, first on the `user$` observable from the `AuthStore` (which is injected earlier in the `withMethods` factory function) and then — as long as we have a non-null user — on the data stream observable from `EntriesService`. - This is so we can listen out for when the user changes (i.e. logs in or out) and connect or disconnect the stream accordingly — if the user becomes `null` then we have to disconnect the entries stream. @@ -231,13 +226,25 @@ Before we get a connected stream of entries from Firestore we need to know the u ### Entries entity management -You may notice the state object of the `EntriesStore` doesn't define the list of entries as part of its type. This is because we use SignalStore's entity management to layer in the list of entries into the state: +You may notice the state object of the `EntriesStore` doesn't define the list of entries as part of its type. This is because we use [SignalStore's entity management](https://ngrx.io/guide/signals/signal-store/entity-management) to layer in the list of entries into the state, managed using a special optimized entity management system. - +We start with a config object that will be reused whenever we need to refer to the entity collection: -As mentioned in the docs, the `withEntities()` factory function tells SignalStore to add three properties to the state: `ids`, `entities` and `entityMap`, where `entities` is computed from the other two. This ensures the entries are _normalized_, making it faster to update the list, which is a good pattern to follow when dealing with lists of data. We then use various entity management helper functions in our internal updater functions: + - +Then, in the signal store definition, we use the `withEntities` factory function to add the entity management for the entries collection to the state: + + + +As mentioned in the docs, the `withEntities(…)` factory function tells SignalStore to add three properties to the state: `ids`, `entities` and `entityMap` (which may be named differently based on the specified name in the config object), where `entities` is computed from the other two. This ensures the entries are _normalized_, making it faster to update the list, which is a good pattern to follow when dealing with lists of data. + +> [!NOTE] +> +> Here, we are using SignalStore's special syntax for _private_ members to ensure that the whole entity collection is private and hidden to consumers of the store. We expose the actual list of `entries` in the state as a computed signal (which we'll cover below). + +We then use various entity management helper functions in our internal updater functions to manage the entity collection together with the rest of the state: + + Notice the functions `removeAllEntities` and `setAllEntities`, and how we chain these with the updating of the other state, in the `patchState` call. @@ -245,16 +252,16 @@ It's also possible to do finer-grained additions, updates and removals from the | **:white_check_mark: Pattern** | | :-- | -| Try to always use SignalStore's entity management when storing a list of items in the state (unless the list is small and you know it won't grow unbounded).

You can even use it multiple times for different lists in the same store, as described [in the official docs](https://ngrx.io/guide/signals/signal-store/entity-management#multiple-entity-types--named-entities) | +| Try to always use SignalStore's entity management when storing a list of items in the state (unless the list is small and you know it won't grow unbounded).

You can even use it multiple times for different lists in the same store, as described [in the official docs](https://ngrx.io/guide/signals/signal-store/entity-management#named-entity-collections) | ### Computed state We use the `withComputed` factory function to derive state from the base state in the store: - + - As you can see, this uses Angular signal's `computed` function, which only recalculates when any dependent signals change. -- As mentioned in the entity management, above, there is already an `entities` signal in the state that could be used to access the entries loaded. However, we want to provide a better named property — `entries`, and we implement a bit of a trick to make pagination work properly, so we define our own `entries` signal here which is the one that consumers of the store should use. +- As mentioned in the entity management section, above, there is an internal entities collection signal (in our case `_entriesEntities`) in the state that could be used to access the entries loaded. However, we want to provide a better named property — `entries`, and we implement a bit of a trick to make pagination work properly, so we define our own `entries` signal here which is the one that consumers of the store should use. - We'll cover this pagination trick, and the `hasPreviousPage` and `hasNextPage` computed signals, below. ### Pagination @@ -281,17 +288,17 @@ As part of the spec of the simple example app we want to show how pagination can Recall that we are working with a live data stream of entries, which the backend updates for us in realtime. We need to switch this data stream to a particular paginated (and filtered) query every time a pagination parameter (or filter) is changed by the user. -First, we capture the pagination state in the store, using the properties `currentPage` and `pageCursor`: +First, we capture the pagination state in the store, using the properties `currentPage` and `_pageCursor`: - + - The `currentPage` is what gets updated to trigger back and forth between pages of entries, via store methods (below). -- We use the `pageCursor` to store the `startAt` and `endAt` values for the Firestore query. +- We use the `_pageCursor` private property to store the `startAt` and `endAt` values for the Firestore query. - We're leveraging TypeScript types to strongly define what the expected values are for these properties in the different possible states. -Here are the relevant type definitions used for the `pageCursor` property: +Here are the relevant type definitions used for the `_pageCursor` property: - + > [!NOTE] > @@ -303,13 +310,11 @@ Here are the relevant type definitions used for the `pageCursor` property: > [!NOTE] > -> The `pageCursor` property is an example of state that is internal to a store — it's not expected to be used by consumers of the store. Currently, it's not possible to mark state like this as private (in a SignalStore). But there are ways to do so (e.g. use an internal variable in the module file). -> -> For the purposes of this simple example app we've kept it, err, simple. But in the patterns example app (coming soon) we show you how to hide this kind of state from consumers of the store. +> The `_pageCursor` property is an example of state that is internal to a store — it's not expected to be used by consumers of the store. We make use of [SignalStore's private members syntax](https://ngrx.io/guide/signals/signal-store/private-store-members) to ensure this. For now, we're hardcoding the page size as an internal constant of the store module file (towards the top of the file): - + > [!TIP] > @@ -321,9 +326,9 @@ This is where we use a common "trick" (as alluded to earlier): we always try to Let's revisit the keys bits of the `manageStream` method in the `EntriesStore`, for pagination (and filtering): - + -- We convert the store data signals for `pageCursor` (and `filters`) into observables so we can listen out for any changes to these values and re-trigger the query. +- We convert the store state signals for `_pageCursor` and `filters` into observables so we can listen out for any changes to these values and re-trigger the query. - We use the [`toObservable` helper function](https://angular.dev/guide/signals/rxjs-interop#toobservable) provided by the Angular signals' RxJS Interop package. - We use the `combineLatestWith` RxJS operator to combine the original input in the chain (the user ID) with the data from these observables and listen out for changes to all three – _any_ change to any of these will trigger a new emission of this stream, and thus re-trigger the query. - We fetch N+1 items by incrementing the page size we send to the query by 1. @@ -346,12 +351,12 @@ To determine whether there is a previous or next page of entries to show, we def We then provide store methods to go back and forth between pages (these are defined within the `withMethods` factory function): - + - We use the computed signals mentioned above to determine if we can go back or forth. - If we can't, we just ignore the method call. -- We update the `currentPage` and `pageCursor` properties using the `patchState` helper function. - - We get the relevant `timestamp` value from the first or last document in the list of **entities**, and use this to set the `pageCursor` property. +- We update the `currentPage` and `_pageCursor` properties using the `patchState` helper function. + - We get the relevant `timestamp` value from the first or last document in the list of **entities**, and use this to set the `_pageCursor` property. - Recall that in the `manageStream` method we are listening out for any changes to these state properties, and re-triggering the query when they change. These methods are used by consumers of the `EntriesStore` to drive pagination in the UI (as we'll see in a later document). @@ -364,7 +369,7 @@ We have a `filters` object in the state to capture the filter state, as shown pr The relevant types for this property are: - + | **:brain: Design decision** | | :-- | @@ -372,7 +377,7 @@ The relevant types for this property are: Given these filtering states, it's important that the underlying data is validated and stored properly — ideally, we never want the `category` field of the `entries` documents (in Firestore) to be an invalid category. If no category is set, the expectation is that an empty category is ALWAYS `null`, not missing and not an empty string. This is handled at the data service level, by ensuring we normalize the value stored for the `category` field of an entry: - + In a later document we'll see how the frontend ensures that only a category from the config is used on an entry. @@ -386,7 +391,7 @@ We've seen in the pagination section (above) how we listen out for changes to th We provide a store method to set this filter (defined within the `withMethods` factory function): - + - We're using `undefined` as a special value to indicate no filter. - We either clear out the whole `filters` state, or set it to the category value passed in (`null` or `string`). @@ -424,14 +429,14 @@ Let's look at the `EntriesUpdateStore` step by step. The state is defined simply as: - + - We have a `processing` flag to indicate if an operation is in progress. - We have an `error` field to capture any error state. The `withMethods` factory function starts with: - + - We inject the `AuthStore` and `EntriesService` to be used in the methods. - Similar to the `EntriesStore`, we need the `AuthStore` to get the user ID, so we can perform the operations for a specific user only. @@ -440,7 +445,7 @@ The `withMethods` factory function starts with: Finally, we have the `create`, `update` and `delete` store methods, that consumers of the store can use to carry the operations out: - + - All of these are defined using the `rxMethod` factory function. - This gives us a single RxJS stream per operation (create + update + delete). diff --git a/docs/5.logbook-ui-and-flows.md b/docs/5.logbook-ui-and-flows.md index c34fb18..8e59816 100644 --- a/docs/5.logbook-ui-and-flows.md +++ b/docs/5.logbook-ui-and-flows.md @@ -65,7 +65,7 @@ graph TB To start with, let's look at the special `loading` signal that we compute to determine the _overall_ loading state of the page: - + Here, we take the two `status` values from the `ConfigStore` and `EntriesStore`, and if either is in the `connecting` state we set the loading state to `true`. This allows us to show a loading indicator to the user whilst we're fetching both the categories data and user's logbook entries — both of which are needed before anything else can be done. @@ -111,7 +111,7 @@ As required by the spec, when no entries are present in the logbook, the user is To achieve this, we need to maintain an `onboarding` flag in the component (which we also pass to any child components that need to know about it). This is defined as a signal with a boolean value, and we compute it based on other state: - + - We use Angular Signal's `effect` function to react to changes to signals used within the function passed. - Note: usually, for derived data like this you would use the `computed` function, but here we're using an `effect` because we want to disconnect this effect once the onboarding state is no longer needed (as explained below). @@ -154,13 +154,13 @@ The ability to create new entries is abstracted out to the [`NewEntryPanelCompon When in onboarding mode, we show an onboarding message and expand the entry form, so the user can immediately start creating their first entry. To enable this, we pass in the `onboarding` flag from the parent component (which is used directly in the template to determine whether the onboarding message is shown) and maintain a separate `expanded` flag within this component to control the form's visibility. We keep the `expanded` flag in sync with the passed in `onboarding` flag by using an `effect`: - + When not in onboarding mode, we show a more condensed experienced where the user gets to explicitly trigger the new entry form — we don't want the full entry form showing as it takes up a lot of space and the user may just be interested in browsing their logbook entries. Instead, we have a one-line input field that, when clicked or focused on, toggles the `expanded` flag to show the new entry form. Finally, we handle both the submission and cancellation of the form: - + - When the form is submitted, we take the data emitted and pass it to the `EntriesUpdateStore` to create a new entry, and then switch off the expanded state. - Recall that the parent component (the `LogbookPageComponent`) is already listening to the `processing` signal from the `EntriesUpdateStore` and showing a processing indicator when required. @@ -182,7 +182,7 @@ The [`EntryFormComponent`](../app/src/app/logbook/ui/entry-form.component.ts) is This component is designed to be reused for both _creating_ and _updating_ entries — a common need when designing forms. We pass in the processing state, categories list (for the select dropdown) and existing entry data (if applicable) as component signal inputs, and emit the new entry data (from the form) when it's submitted, or emit a cancellation if the user decides to cancel: - + This is essentially the public interface to the component. @@ -200,7 +200,7 @@ We use [Angular's Reactive Forms](https://angular.dev/guide/forms/reactive-forms The first step is to import the `ReactiveFormsModule` into the component's `imports` array. Then we can inject the `FormBuilder` and use it to declare the form structure and initial values: - + - Note how we use `this.#fb.nonNullable.group(...)` — this makes it so the fields are non-nullable by default in the form's data model. - Note: you still need to add the validators for the required fields. @@ -209,7 +209,7 @@ The first step is to import the `ReactiveFormsModule` into the component's `impo In the template, we declare the form: - + - We have to bind the HTML form to the reactive form instance we've created, using `[formGroup]="formGroup"`, which is a directive provided by Angular's Reactive Forms module. - We then bind this underlying reactive form instance to a variable, using `#form="ngForm"`, so we can access it in the submission. @@ -218,7 +218,7 @@ In the template, we declare the form: For the individual form fields, as an example: - + - We use Angular Material's components and directives to declare the form field, including label and error message. - We bind the property in the reactive form instance to the input element using `formControlName="title"`. @@ -228,7 +228,7 @@ For the `category` field, we use Angular Material's select component, which is a When the form is submitted (via the submit button), we pull out and emit the form data to the parent component (if the form is valid): - + Note here how we have both a `FormGroupDirective` instance (as passed in to the submit method in the template) and the form group instance (from the component) itself. These serve slightly different purposes and have subtly different behaviors (probably due to legacy reasons in Angular). Through some trial and error, we've ended up with this set up that works better. For example, elsewhere, we have experienced that it's better to reset the form using the `FormGroupDirective` instance, rather than the form group instance. @@ -250,7 +250,7 @@ We'll cover pagination and filtering in later sections. For now, let's look at h Back to the `LogbookPageComponent`, we build the list of entries in the UI with: - + - We iterate over the list of entries (from the `EntriesStore`) and pass each to an instance of the [`EntryItemComponent`](../app/src/app/logbook/feature/entry-item.component.ts) to render the whole item. - We use the `@empty` block to show a message when there are no entries found. @@ -281,7 +281,7 @@ As mentioned above, the [`EntryItemComponent`](../app/src/app/logbook/feature/en The delete button in the entry item template triggers the deletion process: - + We use the native web browser's prompt to ask the user to confirm the deletion. If they agree, we then call the appropriate method in the `EntriesUpdateStore` to delete the entry. @@ -291,7 +291,7 @@ In a previous document, we covered how the application logic and data access for We have the following state property signals from the store: - + … which allow us to build a simple UI with previous and next buttons, together with the current page number. The previous and next buttons are disabled whenever there are no entries to show in that direction, by inspecting the values of the `hasPreviousPage` and `hasNextPage` boolean signals. We also choose not to show any pagination UI at all if both of these signals evaluate to `false`, as the assumption is we don't have any more pages to show. @@ -311,7 +311,7 @@ In a previous document, we covered how the application logic and data access for To achieve this capability of having the `category` filter embedded in the URL query parameter (e.g. a URL like: ) we first set up an internal `selectedCategory` signal input in the component, which we use to track the currently selected category filter from the URL: - + This automatically contains the `category` query parameter from the URL because: @@ -324,13 +324,13 @@ This automatically contains the `category` query parameter from the URL because: Once we have this signal updating whenever the `category` query parameter updates in the URL, we then need to set it on the store, so the store can react accordingly and issue a new query to Firestore. To achieve this we set up an `effect`: - + Here, we call the appropriate method on the store to set the category filter. Note how we parse the `selectedCategory` value here — as covered in a previous document, a category with a `null` value means we still want to filter the entries but only match entries where no category is set. However, it's not possible to easily model `null` values in a URL query parameter, so we take the empty string `''` to mean `null` in this case (so the URL would look like: ). We also model this in the category filter dropdown options: - + You can see here how we use Angular Material again to render a select dropdown with the categories list, and we model the 3 types of options: @@ -340,7 +340,7 @@ You can see here how we use Angular Material again to render a select dropdown w When the user selects a category from this dropdown, we call a method on the component: - + Here we're using the router to set the category query parameter on the current URL, which will cause the `selectedCategory` signal to update, and then the `effect` we covered above will set the filter on the store. diff --git a/docs/6.testing.md b/docs/6.testing.md index e0a1ff9..7a43f0a 100644 --- a/docs/6.testing.md +++ b/docs/6.testing.md @@ -36,6 +36,10 @@ Here are some patterns to follow: | :-- | | Use [`MockRender`](https://ng-mocks.sudo.eu/api/MockRender) to render components and provide inputs.

Note: you will still need to wrap your components in a `TestComponent` wrapper to provide inputs and outputs (since it's not currently possible to easily test signal inputs and outputs with `MockRender`). See below for a concrete example. | +| **:white_check_mark: Pattern** | +| :-- | +| Use [`ngMocks.get`](https://ng-mocks.sudo.eu/api/ngMocks/get) to get the instance of a service. You may often need to supply a generic type param to make this strongly typed (and prevent ESLint from complaining), especially when fetching stores — e.g. `ngMocks.get(EntriesStore)` | + ## Testing client-side Firebase access Avoid having test suites for the data services that wrap Firebase functionality (e.g. the `ConfigService`). This is because it's hard to mock the Firebase JavaScript SDK functions and not worth the effort to test these data services directly, as long as the logic within them is simple enough. @@ -79,7 +83,7 @@ These files (and the whole test set up) is already provided by the base template Let's take a look at the `ConfigStore` test file as an example of how to test a store service, and how to use `ng-mocks` to simplify the process. - + - `MockInstance.scope();` is used at the beginning of the test suite to scope the mocked instances to just this file. - `MockBuilder` is used to set things up for the Angular testing environment. @@ -89,7 +93,8 @@ Let's take a look at the `ConfigStore` test file as an example of how to test a - In both test cases, we need to set up the config data that the mocked `ConfigService` will return. - We do this using `MockInstance` to provide a mock implementation of the `ConfigService`'s `getConfig$` method. - Note how this is returning an observable of the config data we want to test with (using the special `of` RxJS operator). -- We use `ngMocks.get(ConfigStore)` to get the instance of the `ConfigStore` to test. +- We use `ngMocks.get(ConfigStore)` to get the instance of the `ConfigStore` to test. + - Note how we're providing a generic type parameter to make this strongly typed. - In the first test case, we just want to check that we have a valid instance, and that it's in the correct state (`connected`) and with some categories (from the mocked `ConfigService`). - Recall that we connect the stream when the store is instantiated, so the test doesn't need to explicitly trigger the connect. - In the second test case, we want to test that disconnecting the stream works, so we explicitly call `store.manageStream('disconnect');` and check that the state is now `disconnected` and no categories are available. @@ -98,7 +103,7 @@ Let's take a look at the `ConfigStore` test file as an example of how to test a Let's take a look at the `EntryItemComponent` test file as an example of how to test a component with a `TestComponent` wrapper, and how to use `ng-mocks` to simplify the process. - + - We start by declaring a regular Angular component just for this test suite, naming it `TestComponent`, and importing the `EntryItemComponent` and using it in the template. - We declare an old-school `@Input` property for the `entry` input, which is then passed on the `EntryItemComponent` in the template.