Skip to content

Commit

Permalink
feat(composables): add ngxs composables (#15)
Browse files Browse the repository at this point in the history
  • Loading branch information
homj authored Nov 20, 2023
1 parent bff8da2 commit 2c42140
Show file tree
Hide file tree
Showing 12 changed files with 209 additions and 11 deletions.
7 changes: 0 additions & 7 deletions libs/composables/attribute/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
# @bynary/composables/attribute

Secondary entry point of `@bynary/composables`. It can be used by importing from `@bynary/composables/attribute`.

## Composables:

| Name | Purpose |
|-------------------------------------------------------------|------------------------------------------------------|
| [`Attribute`](docs/attribute.composable.md) | Get and set the value of an attribute of an element. |
| [`Boolean Attribute`](docs/boolean-attribute.composable.md) | Get and set the value of a boolean attribute. |
3 changes: 3 additions & 0 deletions libs/composables/ngxs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @bynary/composables/ngxs

Secondary entry point of `@bynary/composables`. It can be used by importing from `@bynary/composables/ngxs`.
5 changes: 5 additions & 0 deletions libs/composables/ngxs/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
1 change: 1 addition & 0 deletions libs/composables/ngxs/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './public-api';
29 changes: 29 additions & 0 deletions libs/composables/ngxs/src/public-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @packageDocumentation
* Composables for [NGXS](https://www.ngxs.io/).
*
* @example
* ```ts
* @Component({
* template: `
* My books:
* <ul>
* <li *ngFor="let book of books()>{{ book }}</li>
* </ul>
* `
* })
* class BooksComponent {
*
* // legacy way using an Observable
* @Select(BooksState.books)
* books$: Observable<string[]>;
*
* // new way using a signal
* books = useSelect(BooksState.books);
* }
* ```
*
* @module @bynary/composables/attribute
*/

export * from './select.composable';
92 changes: 92 additions & 0 deletions libs/composables/ngxs/src/select.composable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { inject, Injectable, isSignal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Action, NgxsModule, Selector, State, StateContext, Store } from '@ngxs/store';

import { useSelect } from './select.composable';

interface IBookStateModel {
books: string[];
}

class AddBook {
static readonly type = '[Books] Add';

constructor(public book: string) {
}
}

@State<IBookStateModel>({
name: 'books',
defaults: {
books: [ 'The Hobbit' ]
}
})
@Injectable()
class BooksState {

@Selector()
static books(state: IBookStateModel) {
return state.books;
}

@Action(AddBook)
addBook(ctx: StateContext<IBookStateModel>, action: AddBook) {
ctx.patchState({ books: [ ...ctx.getState().books, action.book ] });
}
}

describe('select composable', () => {

describe('useSelect', () => {

beforeEach(() => {
TestBed.configureTestingModule({
imports: [ NgxsModule.forRoot([ BooksState ]) ]
});
});

it('should be contained in state.utils', async () => {
const module = await import('./select.composable');

expect(module).toHaveProperty('useSelect');
expect(typeof module.useSelect).toBe('function');
});

it('should return a Signal', async () => {
TestBed.runInInjectionContext(() => {
const result = useSelect(BooksState.books);

expect(isSignal(result)).toBe(true);
expect(result()).toEqual([ 'The Hobbit' ]);
});
});

it('should pass the selector to Store.select', async () => {
TestBed.runInInjectionContext(() => {
const store = inject(Store);
const selectSpy = jest.spyOn(store, 'select');

useSelect(BooksState.books);

expect(selectSpy).toHaveBeenCalledTimes(1);
expect(selectSpy).toHaveBeenCalledWith(BooksState.books);

selectSpy.mockReset();
});
});

it('the returned Signal should update when the state changes', async () => {
TestBed.runInInjectionContext(() => {
const store = inject(Store);

const result = useSelect(BooksState.books);

expect(result()).toEqual([ 'The Hobbit' ]);

store.dispatch(new AddBook('The Lord of the Rings'));

expect(result()).toEqual([ 'The Hobbit', 'The Lord of the Rings' ]);
});
});
});
});
43 changes: 43 additions & 0 deletions libs/composables/ngxs/src/select.composable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { inject, Signal, Type } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngxs/store';
import { StateToken } from '@ngxs/store/src/state-token/state-token';

/* eslint-disable @typescript-eslint/no-explicit-any */
export function useSelect<T>(selector: (state: any, ...states: any[]) => T): Signal<T>;
export function useSelect<T = any>(selector: string | Type<any>): Signal<T>;
export function useSelect<T>(selector: StateToken<T>): Signal<T>;
/**
* Selects a slice of data from the store.
* Uses {@link Store#select} internally and converts its observable to a signal.
*
* @example
* ```ts
* @Component({
* template: `
* My books:
* <ul>
* <li *ngFor="let book of books()>{{ book }}</li>
* </ul>
* `
* })
* class BooksComponent {
*
* // legacy way using an Observable
* @Select(BooksState.books)
* books$: Observable<string[]>;
*
* // new way using a signal
* books = useSelect(BooksState.books);
* }
* ```
*
* @param selector The selector function or key
*/
export function useSelect<T>(selector: any) {
const store = inject(Store);

return toSignal(store.select<T>(selector));
}

/* eslint-enable @typescript-eslint/no-explicit-any */
1 change: 1 addition & 0 deletions libs/composables/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@angular/core": ">=16.0.0 <18.0.0",
"@angular/cdk": ">=16.0.0 <18.0.0",
"@angular/platform-browser": ">=16.0.0 <18.0.0",
"@ngxs/store": "^3.8.1",
"rxjs": ">=7.0.0"
},
"dependencies": {},
Expand Down
4 changes: 3 additions & 1 deletion libs/composables/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@
"libs/composables/title/**/*.ts",
"libs/composables/title/**/*.html",
"libs/composables/theme/**/*.ts",
"libs/composables/theme/**/*.html"
"libs/composables/theme/**/*.html",
"libs/composables/ngxs/**/*.ts",
"libs/composables/ngxs/**/*.html"
]
}
}
Expand Down
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@angular/platform-browser": "16.2.9",
"@angular/platform-browser-dynamic": "16.2.9",
"@angular/router": "16.2.9",
"@ngxs/store": "^3.8.1",
"@nx/angular": "17.0.2",
"@nx/devkit": "17.0.2",
"@swc/helpers": "0.5.3",
Expand Down
17 changes: 14 additions & 3 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
"importHelpers": true,
"target": "es2015",
"module": "esnext",
"lib": ["es2020", "dom"],
"lib": [
"es2020",
"dom"
],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"baseUrl": ".",
Expand All @@ -21,14 +24,22 @@
"@bynary/composables/class": [
"libs/composables/class/src/index.ts"
],
"@bynary/composables/ngxs": [
"libs/composables/ngxs/src/index.ts"
],
"@bynary/composables/observer": [
"libs/composables/observer/src/index.ts"
],
"@bynary/composables/storage": [
"libs/composables/storage/src/index.ts"
],
"@bynary/composables/title": ["libs/composables/title/src/index.ts"]
"@bynary/composables/title": [
"libs/composables/title/src/index.ts"
]
}
},
"exclude": ["node_modules", "tmp"]
"exclude": [
"node_modules",
"tmp"
]
}

0 comments on commit 2c42140

Please sign in to comment.