From 122bef6c2ddaaa809f22938f358b3e22751e8ee4 Mon Sep 17 00:00:00 2001 From: Jits Date: Sun, 9 Mar 2025 15:14:45 +0000 Subject: [PATCH 1/2] Update docs --- README.md | 14 +------ docs/1.architecture.md | 24 +++--------- docs/2.routes-and-shell.md | 8 +--- docs/3.data-model-and-access.md | 33 ++++++++-------- docs/4.logbook-stores.md | 59 +++++++++++++---------------- docs/5.logbook-ui-and-flows.md | 38 +++++++++---------- docs/6.testing.md | 10 ++--- docs/7.deployment-and-monitoring.md | 2 +- 8 files changed, 72 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 94d053a..cdbf04f 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,10 @@ -> [!CAUTION] -> -> **Note for future Jits:** you put this particular part of the tech stack on ice in Feb 2025 ([ref](https://www.notion.so/jits/v2-On-Ice-Maintenance-1ab2a607a9ea807b92c3ff391ca91ac4?pvs=4)). You _did_ complete the migration to Angular v19 etc. ([ref](https://www.notion.so/jits/Angular-v19-etc-upgrades-1272a607a9ea80d48ca1e57c291a4836?pvs=4)) but didn't update the docs (because, why bother?). Just bear this mind if you ever resurrect this repo. - # The [FullStacksDev](https://fullstacks.dev) Angular and Firebase simple example app Part of the curated [**FullStacksDev Angular and Firebase tech stack**](https://fullstacks.dev/#angular-and-firebase). For solo devs and very small teams. This is a fairly simple **Logbook app** — to keep a single time-ordered log of text entries — focused on showcasing and learning the tech stack, built from the [base template](https://github.com/FullStacksDev/angular-and-firebase-template). -You can read more about the [purpose and specs of the example apps](https://fullstacks.dev/example-apps-and-patterns) on our website. - -> [!IMPORTANT] -> -> This is currently in **beta**. We're actively working on it and will be making regular updates — expect big changes and improvements until it gets to a stable release. Feel free to give your feedback and suggestions via the Issues tab. +You can read more about the [purpose and specs of the example apps](TODO) on our website. ## Running the app locally @@ -76,7 +68,3 @@ To make the information skimmable and easier to understand, you'll see the follo > [!CAUTION] > > More severe gotchas and things to watch out for. - -## A note about the patterns example app - -Throughout the docs we reference the patterns example app (coming soon). This is a dedicated place for showcasing even more in-depth capabilities and patterns to bring out the best in this tech stack. It's a perfect next step to take after you've understood and learnt from this simple example app. diff --git a/docs/1.architecture.md b/docs/1.architecture.md index 156b1ed..5b86102 100644 --- a/docs/1.architecture.md +++ b/docs/1.architecture.md @@ -15,18 +15,10 @@ This simple example app is frontend-heavy in that the majority of the functional - Updated routes in the [`app/src/app/app.routes.ts`](../app/src/app/app.routes.ts) file to lazily load the logbook feature routes. - A link to "Open logbook" in the navigation of the website's static pages (i.e. in [`app/src/app/website/website-shell.component.ts`](../app/src/app/website/website-shell.component.ts)). -| **:white_check_mark: Pattern** | -| :-- | -| We don't use [Angular modules (i.e. `@Module`)](https://angular.dev/guide/ngmodules) for our own code — we've chosen to go all-in on [Angular's recent **standalone** approach](https://angular.dev/guide/components/importing#standalone-components). So we only ever define (and prefer to import) standalone components, directives, etc.

The base template has configured the Angular CLI generator to always set the `standalone: true` flag on any components, directives, etc. you generate. | - | **:brain: Design decision** | | :-- | | Since this app is frontend-heavy we've decided to put all the data model types (and other useful types) in a file within the `app` folder, as opposed to putting these in [`firebase/common/models.ts`](../firebase/common/models.ts) (as provided by the base template) where they would be available to both the frontend and backend. | -> [!NOTE] -> -> In the patterns example app (coming soon) we show you how to better share data model types between the frontend and backend. - ### The logbook feature ```text @@ -61,7 +53,7 @@ We'll dig into these in more detail in later documents. | **:white_check_mark: Pattern** | | :-- | -| As mentioned in the base template, we highly recommend separating the code within the top-level feature folders into the following subfolders: **`data`**, **`feature`**, **`ui`** and **`util`**. And trying to keep both the top-level feature folders and these subfolders at one hierarchical level. We've found that this is a great starting folder structure (and general architecture) which helps you quickly find stuff, whilst spending minimal time on figuring out what goes where.

The `data` folder is for (most) state management and data access services. Page and smart components go in the `feature` folder, whilst presentational components go in the `ui`folder. And the `util` folder is for standalone utilities.

This is a recommended folder structure based on [Nx's suggested library types](https://nx.dev/concepts/more-concepts/library-types).

For features within the `shared` folder you should follow the same structure, except you probably won't need a `feature` subfolder within each shared feature since these are shared bits of code for use elsewhere.

As things grow you may need to adapt and tweak this structure (e.g. to add another level in the hierarchy) — we'll see how to tackle this in the patterns example app (coming soon). | +| As mentioned in the base template, we highly recommend separating the code within the top-level feature folders into the following subfolders: **`data`**, **`feature`**, **`ui`** and **`util`**. And trying to keep both the top-level feature folders and these subfolders at one hierarchical level. We've found that this is a great starting folder structure (and general architecture) which helps you quickly find stuff, whilst spending minimal time on figuring out what goes where.

The `data` folder is for (most) state management and data access services. Page and smart components go in the `feature` folder, whilst presentational components go in the `ui`folder. And the `util` folder is for standalone utilities.

This is a recommended folder structure based on [Nx's suggested library types](https://nx.dev/concepts/decisions/project-dependency-rules).

For features within the `shared` folder you should follow the same structure, except you probably won't need a `feature` subfolder within each shared feature since these are shared bits of code for use elsewhere.

As things grow you may need to adapt and tweak this structure (e.g. to add another level in the hierarchy). | > [!IMPORTANT] > @@ -71,11 +63,11 @@ We'll dig into these in more detail in later documents. > > 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. > -> As long as you stick to the approaches promoted in the example apps you should not encounter any change detection issues (i.e. where underlying data changes but the UI does not update). +> As long as you stick to the approaches promoted in this example app you should not encounter any change detection issues (i.e. where underlying data changes but the UI does not update). ### Data flows, app logic and UI components architecture -Within the frontend app, it's important to have an architecture in place for reasoning about data flows, app logic and UI components with some "rules" to make things predictable and easy to scale up with more features, and manage growing complexity. Thus, knowing where things go and how data flows between backend, services, and components is crucial. +Within the frontend app, it's important to have an architecture in place for reasoning about data flows, app logic and UI components with some rules to make things predictable and easy to scale up with more features, and manage growing complexity. Thus, knowing where things go and how data flows between backend, services, and components is crucial. We highly recommend the following generalized data and logic flows — we follow this extensively in the example apps: @@ -98,7 +90,7 @@ sequenceDiagram - 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, 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. +- 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. We'll cover these in more detail, in the context of the simple logbook feature, in later documents. @@ -116,7 +108,7 @@ We'll cover these in more detail, in the context of the simple logbook feature, > > … think: > -> _"How do these actions and events change the state of the application? How can I use this state to drive UI and flows, or what additional state do I need to model?"_ +> _"How do these actions and events change the state of the application? How can I use this state to drive UI and flows, and what additional state do I need to model?"_ There are different levels of stores, scoped to particular contexts: @@ -151,14 +143,10 @@ We don't make use of Firebase Functions in this simple example app, partly so it > [!IMPORTANT] > -> If you're used to a more traditional client-server access model where the server controls all access to a database via an API then Firebase's approach of making direct database calls from the client-side may seem counter-intuitive (and even scary) at first. If it helps, consider that there is still _some form of an API_ that these database calls go through — the security rules, which essentially encode the business logic on what can and cannot be accessed, in its own domain specific language (albeit limited to access and basic validation). +> If you're used to a more traditional client-server access model where the server controls all access to a database via an API then Firebase's approach of making direct database calls from the client-side may seem counter-intuitive (and even scary) at first. If it helps, consider that there is still _some form of an API_ that these database calls go through — i.e. the security rules, which essentially encode the business logic on what can and cannot be accessed, in its own domain specific language (albeit limited to access and basic validation). > > You can still achieve a more traditional server-side API style with Firebase Functions and the Firebase Admin SDK, but we don't use that in this simple example app. -> [!NOTE] -> -> We use Firebase functions extensively in the patterns example app (coming soon), for capabilities that need proper backend support and can't be achieved with just client side code and Firebase's security rules. - > [!WARNING] > > It's best to have as much encoded in the codebase as possible, especially the security rules and indexes, in their relevant files, which will be pushed to Firebase as part of the deployment run. diff --git a/docs/2.routes-and-shell.md b/docs/2.routes-and-shell.md index ab43f9d..352ad4e 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. @@ -51,10 +51,6 @@ At the moment, the [`app/src/app/logbook/logbook-shell.component.ts`](../app/src In the next few documents we dig into how the feature is built. -> [!NOTE] -> -> In the patterns example app (coming soon) we showcase a bigger feature with multiple pages and a shell component that provides a common UI and navigation elements. All of this uses a more complex route structure with multiple child routes and route parameters. - --- | ← Previous | ↑ | Next → | diff --git a/docs/3.data-model-and-access.md b/docs/3.data-model-and-access.md index 4a0f556..662c946 100644 --- a/docs/3.data-model-and-access.md +++ b/docs/3.data-model-and-access.md @@ -6,7 +6,7 @@ The backbone of most apps is the data model, and how it's accessed and updated. -Revisiting the [spec of the simple example app](https://fullstacks.dev/example-apps-and-patterns#the-simple-example-app) we see that only one logbook is required per logged-in user, and a logbook consists of a flat list of time-ordered entries. This is a fairly simple and common data model for many apps and can be modeled as a single "table" (or "collection", in Firestore) of data items differentiated per user. +Revisiting the [spec of the simple example app](TODO) we see that only one logbook is required per logged-in user, and a logbook consists of a flat list of time-ordered _entries_. This is a fairly simple and common data model for many apps and can be modeled as a single "table" (or "collection", in Firestore) of data items differentiated per user. This list of entries is stored in Firestore in a single collection called `entries`. Each entry is associated with a user via a `userId` field, which matches their Firebase Auth user ID. We then use security rules to control access — i.e. to ensure that a user can only retrieve and modify their own entries. We access all data from the frontend (so no server-side access in this case), using the Firebase JavaScript SDK, where we convert data from Firestore into strongly typed objects using TypeScript, within an Angular service that wraps all access to Firestore. @@ -14,7 +14,7 @@ We'll cover how this is then used to enable the logbook feature, later in a sepa | **:white_check_mark: Pattern** | | :-- | -| Whilst Firestore is not a relational database (like PostgreSQL or MySQL), it's still a good idea to _start_ from the principles of relational database modelling. In particular: breaking down your domain model into separate database tables connected by foreign keys (known as "normalization"). Once you have this, you can then consider how to denormalize the data to better fit Firestore's document-based model and to optimize for the queries you need to make.

In the case of the simple example app, we don't need to denormalize the data as the data model is already flat and simple enough. In the patterns example app (coming soon), we'll see how to denormalize the data to make better use of Firestore's capabilities and cost model. | +| Whilst Firestore is not a relational database (like PostgreSQL or MySQL), it's still a good idea to _start_ from the principles of relational database modelling. In particular: breaking down your domain model into separate database tables connected by foreign keys (known as "normalization"). Once you have this, you can then consider how to denormalize the data to better fit Firestore's document-based model and to optimize for the queries you need to make.

In the case of this simple example app, we don't need to denormalize the data as the data model is already flat and simple enough. | ## Firestore data model @@ -36,7 +36,7 @@ We'll cover how this is then used to enable the logbook feature, later in a sepa | **:brain: Design decision** | | :-- | -| We've chosen to store all entries — for all users — in one Firestore collection, controlling access using security rules.

Another approach could've been to store a user's entries in a _subcollection_ of a "user" or "profile" parent document (where each user has one of these parent documents, and everything in the subcollections of that parent belong to that user). We cover this in the patterns example app (coming soon) where a more complex data model benefits from this approach. | +| We've chosen to store all entries — for all users — in one Firestore collection, controlling access using security rules.

Another approach could've been to store a user's entries in a _subcollection_ of a "user" or "profile" parent document (where each user has one of these parent documents, and everything in the subcollections of that parent belong to that user). | The core of the data model for the simple example app is: @@ -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. @@ -88,7 +88,8 @@ We add some fairly extensive tests in [`firebase/test/firestore/firestore-rules. In [`firebase/firestore.indexes.json`](../firebase/firestore.indexes.json) we: 1. Add a compound index on the `userId` and `timestamp` fields, as we're always filtering entries by `userId` and ordering by `timestamp`. -1. Disable the automatic indexes on the `title` and `text` fields, as we don't need to filter or order by these. +1. Add a compound index on the `userId`, `category`, and `timestamp` fields, as we're filtering entries by `userId` and `category`, and ordering by `timestamp`. +1. Disable the automatic single-field indexes on the `title` and `text` fields, as we don't need to filter or order by these. > [!NOTE] > @@ -104,7 +105,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 +123,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 +151,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: @@ -164,8 +165,6 @@ Next, we define a special converter object that the Firestore JavaScript library > [!TIP] > > It's possible to perform data validation in these converter methods to further check and constrain what goes in and out of the database (though only client-side, here). -> -> We don't do this in the simple example app but do explore this in the patterns example app (coming soon). The collection reference we define then uses this converter. So anything that builds off this reference will automatically have the converter applied. @@ -185,9 +184,9 @@ Finally, we have the actual data access and update methods in the service (which > [!NOTE] > -> Both Firestore and Realtime Database provide the ability to fetch data and then listen for any new changes. This allows us to build realtime applications, and we use them for the example apps for this tech stack. +> Both Firestore and Realtime Database provide the ability to fetch data and then listen for any new changes. This allows us to build realtime applications, as we show you in this example app. > -> For Firestore, it's important to be aware of [the costs of running these long-lived listeners](https://firebase.google.com/docs/firestore/pricing#listens) since Firestore charges for a read _per document_. This is where pagination is very important. +> For Firestore, it's important to be aware of [the costs of running these long-lived listeners](https://firebase.google.com/docs/firestore/pricing#listens) since Firestore charges for a read _per document_. This is where pagination is very important, as we'll cover in a later chapter. Here is an example data flow between the methods to show how they interact together (time-ordered from top to bottom): @@ -225,7 +224,7 @@ As you can see, _reads_ are distinctly separated from _updates_ (loosely based o | **:brain: Design decision** | | :-- | -| Even with the advent of signals, RxJS stream processing is still an important tool in our toolbox as it is a powerful way to work with _changes over time_, and has very good interoperability with Angular's signals. We use it extensively in the example apps to manage these streams of data from Firestore and Realtime Database (and more).

To bridge the gap between the Firebase JavaScript SDK and RxJS we use [`rxfire`](https://github.com/FirebaseExtended/rxfire) — a third-party library that provides a set of functions that wrap the SDK and return observables. We, in turn, alias these functions in the relevant Firebase helper module (e.g. [`app/src/app/shared/firebase/firestore.ts`](../app/src/app/shared/firebase/firestore.ts)) so we can centralize these functions and do minor things like add a `$` at the end of the function name (to denote that the function returns an observable). | +| Even with the advent of signals, RxJS-based stream processing is still an important tool in our toolbox as it is a powerful way to work with _changes over time_, and has very good interoperability with Angular's signals. We use it extensively in the example apps to manage these streams of data from Firestore and Realtime Database (and more).

To bridge the gap between the Firebase JavaScript SDK and RxJS we use [`rxfire`](https://github.com/FirebaseExtended/rxfire) — a third-party library that provides a set of functions that wrap the SDK and return observables. We, in turn, alias these functions in the relevant Firebase helper module (e.g. [`app/src/app/shared/firebase/firestore.ts`](../app/src/app/shared/firebase/firestore.ts)) so we can centralize these functions and do minor things like add a `$` at the end of the function name (to denote that the function returns an observable). | | **:white_check_mark: Pattern** | | :-- | @@ -294,18 +293,16 @@ Because Realtime Database doesn't natively support arrays we have to model the c > [!NOTE] > > We could’ve implemented a more elaborate data structure for these categories, with a unique ID, description, CSS class names (e.g. for color customization), etc. But then it would be more error prone to make edits via the Firebase Console (as you can’t have data validation there). You would need to build a dedicated admin panel, or use a proper content management system (CMS), to make those edits. And at that point it’s worth reconsidering how best to implement an app-wide config system, with proper versioning etc. (or even using Firebase's Remote Config, though that’s not currently supported in local emulators). -> -> We touch on these aspects some more in the patterns example app (coming soon). 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 cc00c2a..c98dc8a 100644 --- a/docs/4.logbook-stores.md +++ b/docs/4.logbook-stores.md @@ -30,7 +30,7 @@ Many of the state management examples you see on the web will (rightly) be simpl > [!IMPORTANT] > -> Since we use the realtime capabilities of Firestore and Realtime Database we have to think about, and build, a different model of state management: one that _manages_ a stream of data (i.e. connecting and disconnecting it) and syncs the local state with new data as it comes in. Whilst completely separating out _reads_ and _updates_ (loosely based on [the Command-query separation (CQS) principle](https://en.wikipedia.org/wiki/Command%E2%80%93query_separation#Command_Query_Responsibility_Separation)) and relying on the stream to be updated by the backend when relevant updates are made. This is at the core of both the `ConfigStore` and `EntriesStore` in this simple example app, and something we'll explore in more depth in the patterns example app (coming soon). +> Since we use the realtime capabilities of Firestore and Realtime Database we have to think about, and build, a different model of state management: one that _manages_ a stream of data (i.e. connecting and disconnecting it) and syncs the local state with new data as it comes in. Whilst completely separating out _reads_ and _updates_ (loosely based on [the Command-query separation (CQS) principle](https://en.wikipedia.org/wiki/Command%E2%80%93query_separation#Command_Query_Responsibility_Separation)) and relying on the stream to be updated by the backend when relevant updates are made. This is at the core of both the `ConfigStore` and `EntriesStore` in this simple example app. We can rely on the underlying data access services (covered in a previous document) to always provide **long-lived observables** for the fetched data, which we use to connect to a store's state and manage the overall status of the stream (i.e. disconnected, connected, error, etc.) @@ -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. @@ -111,16 +111,15 @@ 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`): - + - 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. - We inject the data access service `ConfigService` (covered in a previous document), to be used later on in the `manageStream` method. - We define some internal functions _within_ the `withMethods` factory function body, to help us set the store to a particular state. - These functions are only accessible to this block of code and are not exposed outside the store. @@ -134,7 +133,7 @@ Back to managing the stream: let's look at all the set-up bits we do in the `wit Finally, let's look at the implementation of the `manageStream` store method — the only public method defined in this store: - + | **:white_check_mark: Pattern** | | :-- | @@ -161,7 +160,7 @@ Finally, let's look at the implementation of the `manageStream` store method — 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). @@ -186,10 +185,6 @@ Good error handling is key to a robust app and smooth user experience. Let's fac In this simple example app, we provide basic and minimal error handling, resorting to generic error messages or surfacing lower level errors. This is good enough for now, but in a real-world app it's important to consider more advanced error handling patterns like mapping errors to useful user messages, retrying operations, exponential backoff, etc., depending on needs. -> [!NOTE] -> -> In the patterns example app (coming soon) we cover more in-depth error handling. - | **:white_check_mark: Pattern** | | :-- | | A key minimum in error handling when using RxJS observables is: ALWAYS catch errors in the `pipe` chain — either using the `catchError` operator directly or the previously mentioned `tapResponse` operator. Otherwise, observables will unexpectedly finish and stop emitting any new values, which is often not what's intended. | @@ -217,7 +212,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. @@ -230,11 +225,11 @@ You may notice the state object of the `EntriesStore` doesn't define the list of We start with a config object that will be reused whenever we need to refer to the entity collection: - + 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. @@ -244,11 +239,11 @@ As mentioned in the docs, the `withEntities(…)` factory function tells SignalS 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. -It's also possible to do finer-grained additions, updates and removals from the list, which is where this entity management really shines — we cover this in the patterns example app (coming soon). +It's also possible to do finer-grained additions, updates and removals from the list, which is where this entity management really shines. | **:white_check_mark: Pattern** | | :-- | @@ -258,7 +253,7 @@ It's also possible to do finer-grained additions, updates and removals from the 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 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. @@ -290,7 +285,7 @@ Recall that we are working with a live data stream of entries, which the backend 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` private property to store the `startAt` and `endAt` values for the Firestore query. @@ -298,7 +293,7 @@ First, we capture the pagination state in the store, using the properties `curre Here are the relevant type definitions used for the `_pageCursor` property: - + > [!NOTE] > @@ -314,7 +309,7 @@ Here are the relevant type definitions used for the `_pageCursor` property: For now, we're hardcoding the page size as an internal constant of the store module file (towards the top of the file): - + > [!TIP] > @@ -326,7 +321,7 @@ 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 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. @@ -351,7 +346,7 @@ 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. @@ -369,7 +364,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** | | :-- | @@ -377,7 +372,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. @@ -391,7 +386,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`). @@ -421,22 +416,22 @@ It might seem overkill to use a state management library to wrap create, update > > In reality, this is only a problem if you a) have multiple components reacting to the `processing` and `error` flags and causing excess UI updates, or b) if operations are expected to happen very frequently and thus run in to timing and race conditions, which we don't do or have in this simple example app. -> [!NOTE] +> [!TIP] > -> In the patterns example app (coming soon) we split the separate operations into individual stores, so we can have finer grained control (e.g. separate instances per component, and separate processing flags and error messages). We also model the state in a more advanced way in some cases, using discriminated union types, to show where this could be useful for this kind of store. +> An alternative is to split the separate operations into individual stores, so we can have finer grained control (e.g. separate instances per component, and separate processing flags and error messages). You can then also model the state in a more advanced way, e.g. using discriminated union types. 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. @@ -445,7 +440,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). @@ -459,13 +454,13 @@ Finally, we have the `create`, `update` and `delete` store methods, that consume > > Firestore operations from the client-side (i.e. the JavaScript SDK) use [optimistic concurrency control](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) (aka **optimistic updates**) — they assume that the operation will succeed and update data in memory first, before the operation has actually been confirmed by the server. This means the UI gets updated before the operation is actually confirmed to be successful. It's important to be aware of this — it's possible for the operation to fail, in which case the data in memory is reverted, and the operation is essentially reversed. > -> For the simple example app, we don't do anything special to handle this, given the simple nature of the app. In the patterns example app (coming soon) we show potentially better ways to handle this. +> For the simple example app, we don't do anything special to handle this, given the simple nature of the app. > [!NOTE] > > We only make use of the `concatMap` operator in this particular case because it's the safest option: previous operations never get cancelled and always run in order, preventing race conditions (e.g. where the error state gets updated by different operations but out of order). > -> However, there are other flattening RxJS operators — we've used `switchMap` elsewhere (which cancels the previous stream) and there's also `mergeMap` and `exhaustMap`. We'll cover these in the patterns example app (coming soon) where we have more complex use cases. For this simple example, it would be overkill to use these as it's highly unlikely that any of these operations will be normally carried out by the user in such quick succession that race conditions hit. `concatMap` is a good default choice for operations like these. +> However, there are other flattening RxJS operators — we've used `switchMap` elsewhere (which cancels the previous stream) and there's also `mergeMap` and `exhaustMap`. For this simple example, it would be overkill to use these as it's highly unlikely that any of these operations will be normally carried out by the user in such quick succession that race conditions hit. `concatMap` is a good default choice for operations like these. > [!TIP] > diff --git a/docs/5.logbook-ui-and-flows.md b/docs/5.logbook-ui-and-flows.md index 8e59816..3bcb5c6 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. @@ -78,8 +78,6 @@ Here, we take the two `status` values from the `ConfigStore` and `EntriesStore`, > Instead of putting derived state (and logic) like this directly in the component, we could have introduced a **component-level store** to manage all state and logic specifically for that component (including wrapping all global and feature-level stores). This would have kept the component more focused on rendering and handling user interactions. This isn't a hard and fast rule though, and for simpler cases it's easier to keep things in the component and use global or feature-level stores directly. > > You could certainly argue that the `LogbookPageComponent` really should have its own component-level store, given everything going on in it (as we'll see more of below). We can leave this as an exercise for the reader. -> -> In the patterns example app (coming soon) we show extensive uses of component-level stores. | **:white_check_mark: Pattern** | | :-- | @@ -111,7 +109,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). @@ -152,15 +150,15 @@ The ability to create new entries is abstracted out to the [`NewEntryPanelCompon > > So for the `LogbookPageComponent` to use the `NewEntryPanelComponent`, we add `NewEntryPanelComponent` to the `imports` array in the `@Component` decorator of the `LogbookPageComponent`. Then we can use the component (as many times as needed) in the template using its selector (in this case ``). -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 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 (just another signal) within this component to control the form's visibility. We keep the `expanded` flag in sync with the passed in `onboarding` flag by using a [`linkedSignal`](https://angular.dev/guide/signals/linked-signal), which allows us to change it within the component but also have it react if/when the `onboarding` signal changes: - + 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. @@ -173,8 +171,6 @@ Finally, we handle both the submission and cancellation of the form: > This is a judgement call as the chance of this operation erroring is quite slim (and more complicated to handle given Firestore's optimistic updates). This simplifies the implementation a fair bit, as otherwise we would need to model the whole flow state of the create operation, not just `processing` but up to completion, and handling when data is rolled back due to a Firestore server-side error (and then reset the whole state for newer operations, since we only use one store instance throughout). > > We also don't handle offline situations particularly well here — Firestore operations will wait for the network connection to come back up before completing the operation (or failing). -> -> We show a more granular approach that is offline and UX friendly in the patterns example app (coming soon) where errors are more likely to occur in those operations. ## The entry form @@ -182,7 +178,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 +196,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 +205,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 +214,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 +224,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 +246,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 +277,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 +287,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 +307,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 +320,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 +336,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 7a43f0a..41b3a74 100644 --- a/docs/6.testing.md +++ b/docs/6.testing.md @@ -8,10 +8,6 @@ In this simple example app we've placed a lesser emphasis on having comprehensiv This isn't to say that automated testing is not important — it can be vital to the long-term growth and necessary refactoring of an app (especially if you have good test coverage of the important flows and app logic). In an ideal situation we would have a comprehensive suite of tests covering all aspects of the app. In reality, we have to balance the time and effort spent on testing, especially at very early stages of a project when the concept and idea is still forming, and we need a much faster iterative approach closer to functional prototyping. Once the app is more mature, then having comprehensive tests becomes more important, and very useful in the longer run, though still requires a judgement call on where to spend the most time testing. -> [!NOTE] -> -> In the patterns example app (coming soon) we go in depth into testing and provide more comprehensive test examples, discussing how best to write tests for this tech stack. - > [!NOTE] > > You may be familiar with [test-driven development (TDD)](https://en.wikipedia.org/wiki/Test-driven_development) or [behavior-driven development (BDD)](https://en.wikipedia.org/wiki/Behavior-driven_development) — these can be useful methodologies to follow when building your app. Whilst we don't directly promote using these methodologies in the example apps they are worth looking into if you're not already familiar with them. Our advice is to be pragmatic: if you're familiar with TDD/BDD and you find it helps you to flesh out the implementation of your app as you go along then by all means use it. But if you're not familiar with these methodologies, or you find them too restrictive, then don't feel like you have to follow them. As long as you introduce automated tests at some point in your app's development and growth (and maintain them!), you're on the right track. @@ -48,7 +44,7 @@ Avoid having test suites for the data services that wrap Firebase functionality > > If you find the logic within these data services are getting more complex, then you should consider breaking them out into separate services or modules that you can test on their own. -Instead, use manual testing to test that actual Firebase usage works, or write an integration or end-to-end test (we show you how to do the latter in the patterns example app (coming soon)). +Instead, use manual testing to test that actual Firebase usage works, or write an integration or end-to-end test. > [!NOTE] > @@ -83,7 +79,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. @@ -103,7 +99,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. diff --git a/docs/7.deployment-and-monitoring.md b/docs/7.deployment-and-monitoring.md index 037236e..509e587 100644 --- a/docs/7.deployment-and-monitoring.md +++ b/docs/7.deployment-and-monitoring.md @@ -18,7 +18,7 @@ Then, it's good to get into the habit of deploying often, whenever you get to a > [!IMPORTANT] > -> The base template is set up to run things locally and then deploy to a "live" environment — the one your users will use. You may be used to projects where there's at least one more environment in between (e.g. a "staging" environment). This is something we set up in the patterns example app (coming soon), together with continuous deployment (CD). +> The base template is set up to run things locally and then deploy to a "live" environment — the one your users will use. You may be used to projects where there's at least one more environment in between (e.g. a "staging" environment). > > This will depend on how much risk you can take — if you don't have many users, you can probably deploy (and potentially break things) often to a live environment, as long as you address the issues soon. If you have a lot of users — especially ones where breakages are a bigger deal — you'll want to be more cautious, with an intermediate staging environment where you test out all changes first, before deploying live. Also in this case, having more comprehensive automated testing and monitoring in place is important. From f1bfde302cb9896f6c4fc892bd34d707781ba90d Mon Sep 17 00:00:00 2001 From: Jits Date: Sun, 9 Mar 2025 17:55:26 +0000 Subject: [PATCH 2/2] Add licenses and copyright statement --- LICENSE | 17 +++++++++++++++++ README.md | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f9d5875 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +All code is licensed under: + +MIT License + +Copyright (c) 2024 FullStacksDev / TidyBrains Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +All learning content (within the /docs folder) is licensed under: + +Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License — diff --git a/README.md b/README.md index cdbf04f..4789d39 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,23 @@ To make the information skimmable and easier to understand, you'll see the follo > [!CAUTION] > > More severe gotchas and things to watch out for. + +## Licenses + +> [!IMPORTANT] +> +> This example app and all code and content is provided "as is" and with no warranty nor liability. Please make sure you keep a close eye on any costs incurred as you'll be liable for these and anything else that arises from using this template. We recommend you review the code and architecture carefully, adapt it to your needs and thoroughly test your solutions out, before deploying to a live project, paying close attention to [Firebase's pricing model](https://firebase.google.com/pricing). + +### Code + +All code here is licensed under the MIT License — see the [LICENSE](./LICENSE) file for details. + +### Content + +

All learning content within the `/docs` folder is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International

+ +> [!IMPORTANT] +> +> This means you cannot use the learning content for commercial purposes (but you can use the code for commercial apps), and if you reproduce the learning content anywhere you must attribute it to FullStacksDev and you must share any derivative works under the same license. + +©️ 2025 [FullStacksDev / TidyBrains Ltd.](https://fullstacks.dev/)