Skip to content

Commit

Permalink
feat: add angular store
Browse files Browse the repository at this point in the history
  • Loading branch information
nartc committed Feb 9, 2024
1 parent 59953ba commit 9c89ef9
Show file tree
Hide file tree
Showing 14 changed files with 4,337 additions and 86 deletions.
23 changes: 23 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,29 @@
]
}
]
},
{
"framework": "angular",
"menuItems": [
{
"label": "Getting Started",
"children": [
{
"label": "Quick Start",
"to": "framework/angular/quick-start"
}
]
},
{
"label": "API Reference",
"children": [
{
"label": "injectStore",
"to": "framework/angular/reference/injectStore"
}
]
}
]
}
]
}
73 changes: 73 additions & 0 deletions docs/framework/angular/quick-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: Quick Start
id: quick-start
---

The basic angular app example to get started with the Tanstack angular-store.

**app.component.ts**
```html
<h1>How many of your friends like cats or dogs?</h1>
<p>Press one of the buttons to add a counter of how many of your friends like cats or dogs</p>
<app-increment animal="dogs" />
<app-display animal="dogs" />
<app-increment animal="cats" />
<app-display animal="cats" />
```

**store.ts**
```js
import { Store } from '@tanstack/store';

// You can use @tanstack/store outside of App components too!
export const store = new Store({
dogs: 0,
cats: 0,
});

export function updateState(animal: 'dogs' | 'cats') {
store.setState((state) => {
return {
...state,
[animal]: state[animal] + 1,
};
});
}
```

**display.component.ts**
```typescript
import { injectStore } from '@tanstack/angular-store';
import { store } from './store';

@Component({
selector: 'app-display',
template: `
<!-- This will only re-render when animal changes. If an unrelated store property changes, it won't re-render -->
<div>{{ animal() }}: {{ count() }}</div>
`,
standalone: true
})
export class Display {
animal = input.required<string>();
count = injectStore(store, (state) => state[this.animal()]);
}
```

**increment.component.ts**
```typescript
import { injectStore } from '@tanstack/angular-store';
import { store, updateState } from './store';

@Component({
selector: 'app-increment',
template: `
<button (click)="updateState(animal())">My Friend Likes {{ animal() }}</button>
`,
standalone: true
})
export class Increment {
animal = input.required<string>();
updateState = injectStore(store, updateState);
}
```
6 changes: 6 additions & 0 deletions docs/framework/angular/reference/injectStore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: Inject Store
id: injectStore
---

Please see [/packages/angular-store/src/index.ts](https://github.com/tanstack/store/tree/main/packages/angular-store/src/index.ts)
8 changes: 8 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,11 @@ npm install @tanstack/vue-store
```

TanStack Store is compatible with Vue 2 and 3.

## Angular

```sh
npm install @tanstack/angular-store
```

TanStack Store is compatible with Angular 16+
2 changes: 1 addition & 1 deletion docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ title: Overview
id: overview
---

TanStack Store is a framework agnostic data store that ships with framework specific adapters for major frameworks like React, Solid, Vue and Svelte.
TanStack Store is a framework agnostic data store that ships with framework specific adapters for major frameworks like React, Solid, Vue, Angular, and Svelte.

TanStack Store is primarily used for state management internally for most framework agnostic TanStack libraries. It can also be used as a standalone library for any framework or application.
11 changes: 11 additions & 0 deletions packages/angular-store/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// @ts-check

/** @type {import('eslint').Linter.Config} */
const config = {
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json',
},
}

module.exports = config
69 changes: 69 additions & 0 deletions packages/angular-store/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"name": "@tanstack/angular-store",
"author": "Tanner Linsley",
"version": "0.3.1",
"license": "MIT",
"repository": "tanstack/store",
"homepage": "https://tanstack.com/store",
"description": "",
"scripts": {
"clean": "rimraf ./dist && rimraf ./coverage",
"test:eslint": "eslint --ext .ts,.tsx ./src",
"test:types": "tsc",
"test:lib": "vitest",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict",
"build": "vite build"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"keywords": [
"store",
"typescript",
"angular"
],
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"type": "module",
"types": "dist/esm/index.d.ts",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.js",
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"dist",
"src"
],
"dependencies": {
"@tanstack/store": "workspace:*"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "^0.2.32",
"@angular/core": "^17.1.2",
"@angular/common": "^17.1.2",
"@angular/compiler": "^17.1.2",
"@angular/compiler-cli": "^17.1.2",
"@angular/platform-browser": "^17.1.2",
"@angular/platform-browser-dynamic": "^17.1.2",
"zone.js": "^0.14.3"
},
"peerDependencies": {
"@angular/core": ">=16 < 18",
"@angular/common": ">=16 < 18"
}
}
76 changes: 76 additions & 0 deletions packages/angular-store/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
effect,
signal,
type CreateSignalOptions,
Injector,
assertInInjectionContext,
inject,
runInInjectionContext,
} from '@angular/core'
import type { AnyUpdater, Store } from '@tanstack/store'

type NoInfer<T> = [T][T extends any ? 0 : never]

export function injectStore<
TState,
TSelected = NoInfer<TState>,
TUpdater extends AnyUpdater = AnyUpdater,
>(
store: Store<TState, TUpdater>,
selector: (state: NoInfer<TState>) => TSelected = (d) => d as TSelected,
options: CreateSignalOptions<TSelected> & { injector?: Injector } = {
equal: shallow,
},
) {
!options.injector && assertInInjectionContext(injectStore)

if (!options.injector) {
options.injector = inject(Injector)
}

return runInInjectionContext(options.injector, () => {
const slice = signal(selector(store.state), options)

effect(
(onCleanup) => {
const unsub = store.subscribe(() => {
slice.set(selector(store.state))
})
onCleanup(unsub)
},
{ allowSignalWrites: true },
)

return slice.asReadonly()
})
}

function shallow<T>(objA: T, objB: T) {
if (Object.is(objA, objB)) {
return true
}

if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false
}

const keysA = Object.keys(objA)
if (keysA.length !== Object.keys(objB).length) {
return false
}

for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) ||
!Object.is(objA[keysA[i] as keyof T], objB[keysA[i] as keyof T])
) {
return false
}
}
return true
}
94 changes: 94 additions & 0 deletions packages/angular-store/src/tests/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Component, effect } from '@angular/core'
import { TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { Store } from '@tanstack/store'
import { injectStore } from '../index'

describe('injectStore', () => {
test(`allows us to select state using a selector`, () => {
const store = new Store({ select: 0, ignored: 1 })

@Component({
template: `<p>Store: {{ storeVal() }}</p>`,
standalone: true,
})
class MyCmp {
storeVal = injectStore(store, (state) => state.select)
}

const fixture = TestBed.createComponent(MyCmp)
fixture.detectChanges()

const element = fixture.nativeElement
expect(element.textContent).toContain('Store: 0')
})

test('only triggers a re-render when selector state is updated', async () => {
const store = new Store({ select: 0, ignored: 1 })
let count = 0

@Component({
template: `
<div>
<p>Store: {{ storeVal() }}</p>
<button id="updateSelect" (click)="updateSelect()">
Update select
</button>
<button id="updateIgnored" (click)="updateIgnored()">
Update ignored
</button>
</div>
`,
standalone: true,
})
class MyCmp {
storeVal = injectStore(store, (state) => state.select)

constructor() {
effect(() => {
console.log(this.storeVal())
count++
})
}

updateSelect() {
store.setState((v) => ({
...v,
select: 10,
}))
}

updateIgnored() {
store.setState((v) => ({
...v,
ignored: 10,
}))
}
}

const fixture = TestBed.createComponent(MyCmp)
fixture.detectChanges()

const element = fixture.nativeElement
const debugElement = fixture.debugElement

expect(element.textContent).toContain('Store: 0')
expect(count).toEqual(1)

debugElement
.query(By.css('button#updateSelect'))
.triggerEventHandler('click', null)

fixture.detectChanges()
expect(element.textContent).toContain('Store: 10')
expect(count).toEqual(2)

debugElement
.query(By.css('button#updateIgnored'))
.triggerEventHandler('click', null)

fixture.detectChanges()
expect(element.textContent).toContain('Store: 10')
expect(count).toEqual(2)
})
})
12 changes: 12 additions & 0 deletions packages/angular-store/src/tests/test-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import '@analogjs/vite-plugin-angular/setup-vitest'

import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing'
import { getTestBed } from '@angular/core/testing'

getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(),
)
Loading

0 comments on commit 9c89ef9

Please sign in to comment.