Skip to content

Commit

Permalink
feat: add withConditional()
Browse files Browse the repository at this point in the history
`withConditional` activates a feature based on a given condition.

## Use Cases
- Conditionally activate features based on the **store state** or other criteria.
- Choose between **two different implementations** of a feature.

## Type Constraints
Both features must have **exactly the same state, props, and methods**.
Otherwise, a type error will occur.

## Usage

```typescript
const withUser = signalStoreFeature(
  withState({ id: 1, name: 'Konrad' }),
  withHooks(store => ({
    onInit() {
      // user loading logic
    }
  }))
);

function withFakeUser() {
  return signalStoreFeature(
    withState({ id: 0, name: 'anonymous' })
  );
}

signalStore(
  withMethods(() => ({
    useRealUser: () => true
  })),
  withConditional((store) => store.useRealUser(), withUser, withFakeUser)
)
```
  • Loading branch information
rainerhahnekamp authored Feb 9, 2025
1 parent f5039aa commit bef1528
Show file tree
Hide file tree
Showing 10 changed files with 381 additions and 0 deletions.
22 changes: 22 additions & 0 deletions apps/demo/e2e/conditional.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test, expect } from '@playwright/test';

test.describe('conditional', () => {
test.beforeEach(async ({ page }) => {
await page.goto('');
await page.getByRole('link', { name: 'withConditional' }).click();
});

test(`uses real user`, async ({ page }) => {
await page.getByRole('radio', { name: 'Real User' }).click();
await page.getByRole('button', { name: 'Toggle User Component' }).click();

await expect(page.getByText('Current User Konrad')).toBeVisible();
});

test(`uses fake user`, async ({ page }) => {
await page.getByRole('radio', { name: 'Fake User' }).click();
await page.getByRole('button', { name: 'Toggle User Component' }).click();

await expect(page.getByText('Current User Tommy Fake')).toBeVisible();
});
});
1 change: 1 addition & 0 deletions apps/demo/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<a mat-list-item routerLink="/reset">withReset</a>
<a mat-list-item routerLink="/immutable-state">withImmutableState</a>
<a mat-list-item routerLink="/feature-factory">withFeatureFactory</a>
<a mat-list-item routerLink="/conditional">withConditional</a>
</mat-nav-list>
</mat-drawer>
<mat-drawer-content>
Expand Down
7 changes: 7 additions & 0 deletions apps/demo/src/app/lazy-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,11 @@ export const lazyRoutes: Route[] = [
(m) => m.FeatureFactoryComponent
),
},
{
path: 'conditional',
loadComponent: () =>
import('./with-conditional/conditional.component').then(
(m) => m.ConditionalSettingComponent
),
},
];
110 changes: 110 additions & 0 deletions apps/demo/src/app/with-conditional/conditional.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Component, signal, inject, untracked, effect } from '@angular/core';
import {
patchState,
signalStore,
signalStoreFeature,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { FormsModule } from '@angular/forms';
import {
MatButtonToggle,
MatButtonToggleGroup,
} from '@angular/material/button-toggle';
import { withConditional } from '@angular-architects/ngrx-toolkit';
import { MatButton } from '@angular/material/button';

const withUser = signalStoreFeature(
withState({ id: 0, name: '' }),
withHooks((store) => ({
onInit() {
patchState(store, { id: 1, name: 'Konrad' });
},
}))
);

const withFakeUser = signalStoreFeature(
withState({ id: 0, name: 'Tommy Fake' })
);

const UserServiceStore = signalStore(
{ providedIn: 'root' },
withState({ implementation: 'real' as 'real' | 'fake' }),
withMethods((store) => ({
setImplementation(implementation: 'real' | 'fake') {
patchState(store, { implementation });
},
}))
);

const UserStore = signalStore(
withConditional(
() => inject(UserServiceStore).implementation() === 'real',
withUser,
withFakeUser
)
);

@Component({
selector: 'demo-conditional-user',
template: `<p>Current User {{ userStore.name() }}</p>`,
providers: [UserStore],
})
class ConditionalUserComponent {
protected readonly userStore = inject(UserStore);

constructor() {
console.log('log geht es');
}
}

@Component({
template: `
<h2>
<pre>withConditional</pre>
</h2>
<mat-button-toggle-group
aria-label="User Feature"
[(ngModel)]="userFeature"
>
<mat-button-toggle value="real">Real User</mat-button-toggle>
<mat-button-toggle value="fake">Fake User</mat-button-toggle>
</mat-button-toggle-group>
<div>
<button mat-raised-button (click)="toggleUserComponent()">
Toggle User Component
</button>
</div>
@if (showUserComponent()) {
<demo-conditional-user />
}
`,
imports: [
FormsModule,
MatButtonToggle,
MatButtonToggleGroup,
ConditionalUserComponent,
MatButton,
],
})
export class ConditionalSettingComponent {
showUserComponent = signal(false);

toggleUserComponent() {
this.showUserComponent.update((show) => !show);
}
userService = inject(UserServiceStore);
protected readonly userFeature = signal<'real' | 'fake'>('real');

effRef = effect(() => {
const userFeature = this.userFeature();

untracked(() => {
this.userService.setImplementation(userFeature);
this.showUserComponent.set(false);
});
});
}
1 change: 1 addition & 0 deletions docs/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The NgRx Toolkit is a set of extensions to the NgRx SignalsStore.
It offers extensions like:

- [⭐️ Devtools](./with-devtools): Integration into Redux Devtools
- [Conditional Features](./with-conditional): Allows adding features to the store conditionally
- [DataService](./with-data-service): Builds on top of `withEntities` and adds the backend synchronization to it
- [Feature Factory](./with-feature-factory): Allows passing properties, methods, or signals from a SignalStore to a custom feature (`signalStoreFeature`).
- [Immutable State Protection](./with-immutable-state): Protects the state from being mutated outside or inside the Store.
Expand Down
39 changes: 39 additions & 0 deletions docs/docs/with-conditional.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
title: withConditional()
---

`withConditional` activates a feature based on a given condition.

## Use Cases

- Conditionally activate features based on the **store state** or other criteria.
- Choose between **two different implementations** of a feature.

## Type Constraints

Both features must have **exactly the same state, props, and methods**.
Otherwise, a type error will occur.

## Usage

```typescript
const withUser = signalStoreFeature(
withState({ id: 1, name: 'Konrad' }),
withHooks((store) => ({
onInit() {
// user loading logic
},
}))
);

function withFakeUser() {
return signalStoreFeature(withState({ id: 0, name: 'anonymous' }));
}

signalStore(
withMethods(() => ({
useRealUser: () => true,
})),
withConditional((store) => store.useRealUser(), withUser, withFakeUser)
);
```
1 change: 1 addition & 0 deletions docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const sidebars: SidebarsConfig = {
'with-undo-redo',
'with-immutable-state',
'with-feature-factory',
'with-conditional',
],
reduxConnectorSidebar: [
{
Expand Down
1 change: 1 addition & 0 deletions libs/ngrx-toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export * from './lib/with-pagination';
export { withReset, setResetState } from './lib/with-reset';
export { withImmutableState } from './lib/immutable-state/with-immutable-state';
export { withFeatureFactory } from './lib/with-feature-factory';
export { withConditional, emptyFeature } from './lib/with-conditional';
125 changes: 125 additions & 0 deletions libs/ngrx-toolkit/src/lib/with-conditional.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {
getState,
patchState,
signalStore,
signalStoreFeature,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { emptyFeature, withConditional } from './with-conditional';
import { inject, InjectionToken } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { withDevtools } from './devtools/with-devtools';

describe('withConditional', () => {
const withUser = signalStoreFeature(
withState({ id: 0, name: '' }),
withHooks((store) => ({
onInit() {
patchState(store, { id: 1, name: 'Konrad' });
},
}))
);

const withFakeUser = signalStoreFeature(
withState({ id: 0, name: 'Tommy Fake' })
);

for (const isReal of [true, false]) {
it(`should ${isReal ? '' : 'not '} enable withUser`, () => {
const REAL_USER_TOKEN = new InjectionToken('REAL_USER', {
providedIn: 'root',
factory: () => isReal,
});
const UserStore = signalStore(
{ providedIn: 'root' },
withConditional(() => inject(REAL_USER_TOKEN), withUser, withFakeUser)
);
const userStore = TestBed.inject(UserStore);

if (isReal) {
expect(getState(userStore)).toEqual({ id: 1, name: 'Konrad' });
} else {
expect(getState(userStore)).toEqual({ id: 0, name: 'Tommy Fake' });
}
});
}

it(`should access the store`, () => {
const UserStore = signalStore(
{ providedIn: 'root' },
withMethods(() => ({
useRealUser: () => true,
})),
withConditional((store) => store.useRealUser(), withUser, withFakeUser)
);
const userStore = TestBed.inject(UserStore);

expect(getState(userStore)).toEqual({ id: 1, name: 'Konrad' });
});

it('should be used inside a signalStoreFeature', () => {
const withConditionalUser = (activate: boolean) =>
signalStoreFeature(
withConditional(() => activate, withUser, withFakeUser)
);

const UserStore = signalStore(
{ providedIn: 'root' },
withConditionalUser(true)
);
const userStore = TestBed.inject(UserStore);

expect(getState(userStore)).toEqual({ id: 1, name: 'Konrad' });
});

it('should ensure that both features return the same type', () => {
const withUser = signalStoreFeature(
withState({ id: 0, name: '' }),
withHooks((store) => ({
onInit() {
patchState(store, { id: 1, name: 'Konrad' });
},
}))
);

const withFakeUser = signalStoreFeature(
withState({ id: 0, firstname: 'Tommy Fake' })
);

// @ts-expect-error withFakeUser has a different state shape
signalStore(withConditional(() => true, withUser, withFakeUser));
});

it('should also work with empty features', () => {
signalStore(
withConditional(
() => true,
withDevtools('dummy'),
signalStoreFeature(withState({}))
)
);
});

it('should work with `emptyFeature` if falsy is skipped', () => {
signalStore(
withConditional(
() => true,
signalStoreFeature(withState({})),
emptyFeature
)
);
});

it('should not work with `emptyFeature` if feature is not empty', () => {
signalStore(
withConditional(
() => true,
// @ts-expect-error feature is not empty
() => signalStoreFeature(withState({ x: 1 })),
emptyFeature
)
);
});
});
Loading

0 comments on commit bef1528

Please sign in to comment.