-
Notifications
You must be signed in to change notification settings - Fork 93
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(signal-slice): add signalSlice (#135)
* feat(signal-slice): create entry point * refactor(connect): export PartialOrValue and Reducer types * feat(signal-slice): add signal slice implementation * feat(signal-slice): add tests * docs(signal-slice): add docs * docs(signal-slice): add comma Co-authored-by: Chau Tran <[email protected]> * docs(signal-slice): improve docs --------- Co-authored-by: Chau Tran <[email protected]>
- Loading branch information
1 parent
b17ae1d
commit 76fcfad
Showing
9 changed files
with
434 additions
and
2 deletions.
There are no files selected for viewing
136 changes: 136 additions & 0 deletions
136
docs/src/content/docs/utilities/Signals/signal-slice.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
--- | ||
title: signalSlice | ||
description: ngxtension/signalSlice | ||
--- | ||
|
||
`signalSlice` is loosely inspired by the `createSlice` API from Redux Toolkit. The general idea is that it allows you to declaratively create a "slice" of state. This state will be available as a **readonly** signal. | ||
|
||
The key motivation, and what makes this declarative, is that all the ways for updating this signal are declared upfront with `sources` and `reducers`. It is not possible to imperatively update the state. | ||
|
||
## Basic Usage | ||
|
||
```ts | ||
import { signalSlice } from 'ngxtension/signal-slice'; | ||
``` | ||
|
||
```ts | ||
private initialState: ChecklistsState = { | ||
checklists: [], | ||
loaded: false, | ||
error: null, | ||
}; | ||
|
||
state = signalSlice({ | ||
initialState: this.initialState, | ||
}); | ||
``` | ||
|
||
The returned `state` object will be a standard **readonly** signal, but it will also have properties attached to it that will be discussed below. | ||
|
||
You can access the state as you would with a typical signal: | ||
|
||
```ts | ||
this.state().loaded; | ||
``` | ||
|
||
However, by default `computed` selectors will be created for each top-level property in the initial state: | ||
|
||
```ts | ||
this.state.loaded(); | ||
``` | ||
|
||
## Sources | ||
|
||
One way to update state is through the use of `sources`. These are intended to be used for "auto sources" — as in, observable streams that will emit automatically like an `http.get()`. Although it will work with a `Subject` that you `next` as well, it is recommended that you use a **reducer** for these imperative style state updates. | ||
|
||
You can supply a source like this: | ||
|
||
```ts | ||
loadChecklists$ = this.checklistsLoaded$.pipe(map((checklists) => ({ checklists, loaded: true }))); | ||
|
||
state = signalSlice({ | ||
initialState: this.initialState, | ||
sources: [this.loadChecklists$], | ||
}); | ||
``` | ||
|
||
The `source` should be mapped to a partial of the `initialState`. In the example above, when the source emits it will update both the `checklists` and the `loaded` properties in the state signal. | ||
|
||
## Reducers and Actions | ||
|
||
Another way to update the state is through `reducers` and `actions`. This is good for situations where you need to manually/imperatively trigger some action, and then use the current state in some way in order to calculate the new state. | ||
|
||
When you supply a `reducer`, it will automatically create an `action` that you can call. Reducers can be created like this: | ||
|
||
```ts | ||
state = signalSlice({ | ||
initialState: this.initialState, | ||
reducers: { | ||
add: (state, checklist: AddChecklist) => ({ | ||
checklists: [...state.checklists, checklist], | ||
}), | ||
remove: (state, id: RemoveChecklist) => ({ | ||
checklists: state.checklists.filter((checklist) => checklist.id !== id), | ||
}), | ||
}, | ||
}); | ||
``` | ||
|
||
You can supply a reducer function that has access to the previous state, and whatever payload the action was just called with. Actions are created automatically and can be called like this: | ||
|
||
```ts | ||
this.state.add(checklist); | ||
``` | ||
|
||
It is also possible to have a reducer/action without any payload: | ||
|
||
```ts | ||
state = signalSlice({ | ||
initialState: this.initialState, | ||
reducers: { | ||
toggleActive: (state) => ({ | ||
active: !state.active, | ||
}), | ||
}, | ||
}); | ||
``` | ||
|
||
The associated action can then be triggered with: | ||
|
||
```ts | ||
this.state.toggleActive(); | ||
``` | ||
|
||
## Action Streams | ||
|
||
The source/stream for each action is also exposed on the state object. That means that you can access: | ||
|
||
```ts | ||
this.state.add$; | ||
``` | ||
|
||
Which will allow you to react to the `add` action/reducer being called. | ||
|
||
## Selectors | ||
|
||
By default, all of the top-level properties from the initial state will be exposed as selectors which are `computed` signals on the state object. | ||
|
||
It is also possible to create more selectors simply using `computed` and the values of the signal created by `signalSlice`, however, it is awkward to have some selectors available directly on the state object (our default selectors) and others defined outside of the state object. | ||
|
||
It is therefore recommended to define all of your selectors using the `selectors` config of `signalSlice`: | ||
|
||
```ts | ||
state = signalSlice({ | ||
initialState: this.initialState, | ||
selectors: (state) => ({ | ||
loadedAndError: () => state().loaded && state().error, | ||
whatever: () => 'hi', | ||
}), | ||
}); | ||
``` | ||
|
||
This will also make these additional computed values available on the state object: | ||
|
||
```ts | ||
this.state.loadedAndError(); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# ngxtension/signal-slice | ||
|
||
Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/signal-slice`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"lib": { | ||
"entryFile": "src/index.ts" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
{ | ||
"name": "ngxtension/signal-slice", | ||
"$schema": "../../../node_modules/nx/schemas/project-schema.json", | ||
"projectType": "library", | ||
"sourceRoot": "libs/ngxtension/signal-slice/src", | ||
"targets": { | ||
"test": { | ||
"executor": "@nx/jest:jest", | ||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"], | ||
"options": { | ||
"jestConfig": "libs/ngxtension/jest.config.ts", | ||
"testPathPattern": ["signal-slice"], | ||
"passWithNoTests": true | ||
}, | ||
"configurations": { | ||
"ci": { | ||
"ci": true, | ||
"codeCoverage": true | ||
} | ||
} | ||
}, | ||
"lint": { | ||
"executor": "@nx/eslint:lint", | ||
"outputs": ["{options.outputFile}"], | ||
"options": { | ||
"lintFilePatterns": [ | ||
"libs/ngxtension/signal-slice/**/*.ts", | ||
"libs/ngxtension/signal-slice/**/*.html" | ||
] | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './signal-slice'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
import { TestBed } from '@angular/core/testing'; | ||
import { Subject } from 'rxjs'; | ||
import { SignalSlice, signalSlice } from './signal-slice'; | ||
|
||
describe(signalSlice.name, () => { | ||
const initialState = { | ||
user: { | ||
firstName: 'josh', | ||
lastName: 'morony', | ||
}, | ||
age: 30, | ||
likes: ['angular', 'typescript'], | ||
}; | ||
|
||
describe('initialState', () => { | ||
let state: SignalSlice<typeof initialState, any, any>; | ||
|
||
beforeEach(() => { | ||
TestBed.runInInjectionContext(() => { | ||
state = signalSlice({ | ||
initialState, | ||
}); | ||
}); | ||
}); | ||
|
||
it('should create a signal of initialState', () => { | ||
expect(state().user.firstName).toEqual(initialState.user.firstName); | ||
}); | ||
|
||
it('should create default selectors', () => { | ||
expect(state.age()).toEqual(initialState.age); | ||
}); | ||
}); | ||
|
||
describe('sources', () => { | ||
const testSource$ = new Subject<Partial<typeof initialState>>(); | ||
const testSource2$ = new Subject<Partial<typeof initialState>>(); | ||
|
||
let state: SignalSlice<typeof initialState, any, any>; | ||
|
||
beforeEach(() => { | ||
TestBed.runInInjectionContext(() => { | ||
state = signalSlice({ | ||
initialState, | ||
sources: [testSource$], | ||
}); | ||
}); | ||
}); | ||
|
||
it('should be initial value initially', () => { | ||
expect(state().user.firstName).toEqual(initialState.user.firstName); | ||
}); | ||
|
||
it('should update with value from source after emission', () => { | ||
const testUpdate = { user: { firstName: 'chau', lastName: 'tran' } }; | ||
testSource$.next(testUpdate); | ||
expect(state().user.firstName).toEqual(testUpdate.user.firstName); | ||
}); | ||
|
||
it('should work with multiple sources', () => { | ||
TestBed.runInInjectionContext(() => { | ||
state = signalSlice({ | ||
initialState, | ||
sources: [testSource$, testSource2$], | ||
}); | ||
}); | ||
|
||
const testUpdate = { user: { firstName: 'chau', lastName: 'tran' } }; | ||
const testUpdate2 = { age: 20 }; | ||
testSource$.next(testUpdate); | ||
testSource2$.next(testUpdate2); | ||
|
||
expect(state().user.firstName).toEqual(testUpdate.user.firstName); | ||
expect(state().age).toEqual(testUpdate2.age); | ||
}); | ||
}); | ||
|
||
describe('reducers', () => { | ||
it('should create action that updates signal', () => { | ||
TestBed.runInInjectionContext(() => { | ||
const state = signalSlice({ | ||
initialState, | ||
reducers: { | ||
increaseAge: (state, amount: number) => ({ | ||
age: state.age + amount, | ||
}), | ||
}, | ||
}); | ||
|
||
const amount = 1; | ||
state.increaseAge(amount); | ||
expect(state().age).toEqual(initialState.age + amount); | ||
}); | ||
}); | ||
|
||
it('should create action stream for reducer', () => { | ||
TestBed.runInInjectionContext(() => { | ||
const state = signalSlice({ | ||
initialState, | ||
reducers: { | ||
increaseAge: (state, amount: number) => ({ | ||
age: state.age + amount, | ||
}), | ||
}, | ||
}); | ||
expect(state.increaseAge$).toBeDefined(); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('selectors', () => { | ||
it('should add custom selectors to state object', () => { | ||
TestBed.runInInjectionContext(() => { | ||
const state = signalSlice({ | ||
initialState, | ||
selectors: (state) => ({ | ||
doubleAge: () => state().age * 2, | ||
}), | ||
}); | ||
|
||
expect(state.doubleAge()).toEqual(state().age * 2); | ||
}); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.