Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/nativescript-demo-ng/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Routes } from '@angular/router';
import { ItemDetailComponent } from './item/item-detail.component';
import { ItemsComponent } from './item/items.component';
import { InputBindingDemoComponent } from './input-binding-demo/input-binding-demo.component';
import { ListViewStickyComponent } from './list-view-sticky/list-view-sticky.component';
import { SPLIT_VIEW_ROUTES } from './split-view-demo/split-view.routes';
// import { HomeComponent } from './home/home.component';
Expand All @@ -10,6 +11,14 @@ export const routes: Routes = [
{ path: '', redirectTo: '/rootlazy', pathMatch: 'full' },
{ path: 'items', component: ItemsComponent },
{ path: 'item/:id', component: ItemDetailComponent },
{
path: 'input-binding-demo/:name',
component: InputBindingDemoComponent,
data: { title: 'Demo Page' },
resolve: {
timestamp: () => new Date().toISOString(),
},
},
{ path: 'item2', loadChildren: () => import('./item2/item2.routes').then((m) => m.ITEM2_ROUTES) },
{ path: 'rootlazy', loadChildren: () => import('./item3/item3.module').then((m) => m.Item3Module) },
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Component, input, NO_ERRORS_SCHEMA } from '@angular/core';
import { NativeScriptCommonModule } from '@nativescript/angular';

@Component({
selector: 'ns-input-binding-demo',
template: `
<ActionBar title="Input Binding Demo" class="action-bar"></ActionBar>
<StackLayout class="p-4">
<Label text="Route Input Binding Demo" class="text-2xl font-bold text-center"></Label>

<Label class="mt-4 text-lg font-bold" text="Route Param:"></Label>
<Label class="text-base" [text]="'name = ' + name()"></Label>

<Label class="mt-4 text-lg font-bold" text="Query Param:"></Label>
<Label class="text-base" [text]="'language = ' + language()"></Label>

<Label class="mt-4 text-lg font-bold" text="Resolver Data:"></Label>
<Label class="text-base" [text]="'timestamp = ' + timestamp()"></Label>

<Label class="mt-4 text-lg font-bold" text="Static Route Data:"></Label>
<Label class="text-base" [text]="'title = ' + title()"></Label>
</StackLayout>
`,
imports: [NativeScriptCommonModule],
schemas: [NO_ERRORS_SCHEMA],
})
export class InputBindingDemoComponent {
name = input<string>();
language = input<string>();
timestamp = input<string>();
title = input<string>();
}
10 changes: 10 additions & 0 deletions apps/nativescript-demo-ng/src/app/item3/items.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@
backgroundColor="#00d2ff"
class="text-white mt-2 w-full font-bold h-[50]"
></Button>
<Button
[nsRouterLink]="['/input-binding-demo', 'Angular']"
[queryParams]="{ language: 'en' }"
text="Input Binding Demo"
[borderRadius]="borderRadius"
[fontSize]="fontSize"
padding="0"
backgroundColor="#4CAF50"
class="text-white mt-2 w-full font-bold h-[50]"
></Button>
</StackLayout>
<ListView row="1" [items]="items" backgroundColor="#efefef">
<ng-template let-item="item">
Expand Down
2 changes: 2 additions & 0 deletions apps/nativescript-demo-ng/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
provideNativeScriptNgZone,
provideNativeScriptRouter,
runNativeScriptAngularApp,
withComponentInputBinding,
} from '@nativescript/angular';
import { Trace, Utils, SplitView } from '@nativescript/core';

Expand Down Expand Up @@ -38,6 +39,7 @@ runNativeScriptAngularApp({
providers: [
provideNativeScriptHttpClient(withInterceptorsFromDi()),
provideNativeScriptRouter(routes),
withComponentInputBinding(),
// provideNativeScriptRouter(SPLIT_VIEW_ROUTES),
ZONELESS ? provideZonelessChangeDetection() : provideNativeScriptNgZone(),
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { Component, Input, input } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { RoutedComponentInputBinder } from '@nativescript/angular/lib/legacy/router/router-component-input-binder';
import type { PageRouterOutlet } from '@nativescript/angular/lib/legacy/router/page-router-outlet';

@Component({ template: '', standalone: true })
class TestComponent {
@Input() name: string;
@Input() id: string;
@Input() language: string;
}

@Component({ template: '', standalone: true })
class SignalInputComponent {
name = input<string>();
id = input<string>();
}

function createMockOutlet(
component: any,
options?: {
params?: Record<string, string>;
queryParams?: Record<string, string>;
data?: Record<string, any>;
},
) {
const params$ = new BehaviorSubject(options?.params ?? {});
const queryParams$ = new BehaviorSubject(options?.queryParams ?? {});
const data$ = new BehaviorSubject(options?.data ?? {});

const setInputSpy = jasmine.createSpy('setInput');
const activatedRoute = {
params: params$.asObservable(),
queryParams: queryParams$.asObservable(),
data: data$.asObservable(),
component,
};

const outlet = {
isActivated: true,
activatedRoute,
activatedComponentRef: { setInput: setInputSpy },
} as unknown as PageRouterOutlet;

return { outlet, params$, queryParams$, data$, setInputSpy, activatedRoute };
}

describe('RoutedComponentInputBinder', () => {
it('binds route params to component inputs', () => {
const binder = new RoutedComponentInputBinder({ queryParams: true });
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
params: { name: 'test-name' },
});

binder.bindActivatedRouteToOutletComponent(outlet);

expect(setInputSpy).toHaveBeenCalledWith('name', 'test-name');
});

it('binds query params to component inputs', () => {
const binder = new RoutedComponentInputBinder({ queryParams: true });
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
queryParams: { id: '42' },
});

binder.bindActivatedRouteToOutletComponent(outlet);

expect(setInputSpy).toHaveBeenCalledWith('id', '42');
});

it('binds route data to component inputs', () => {
const binder = new RoutedComponentInputBinder({ queryParams: true });
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
data: { name: 'from-data' },
});

binder.bindActivatedRouteToOutletComponent(outlet);

expect(setInputSpy).toHaveBeenCalledWith('name', 'from-data');
});

it('data takes priority over params, params over queryParams', () => {
const binder = new RoutedComponentInputBinder({ queryParams: true });
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
queryParams: { name: 'from-query' },
params: { name: 'from-params' },
data: { name: 'from-data' },
});

binder.bindActivatedRouteToOutletComponent(outlet);

const nameCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'name');
expect(nameCall[1]).toBe('from-data');
});

it('params take priority over queryParams', () => {
const binder = new RoutedComponentInputBinder({ queryParams: true });
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
queryParams: { name: 'from-query' },
params: { name: 'from-params' },
});

binder.bindActivatedRouteToOutletComponent(outlet);

const nameCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'name');
expect(nameCall[1]).toBe('from-params');
});

it('does not bind query params when queryParams option is false', () => {
const binder = new RoutedComponentInputBinder({ queryParams: false });
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
queryParams: { name: 'from-query' },
});

binder.bindActivatedRouteToOutletComponent(outlet);

const nameCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'name');
expect(nameCall[1]).toBeUndefined();
});

it('sets unmatched inputs to undefined with alwaysUndefined behavior (default)', () => {
const binder = new RoutedComponentInputBinder({});
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
params: { name: 'test' },
});

binder.bindActivatedRouteToOutletComponent(outlet);

const idCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'id');
expect(idCall).toBeTruthy();
expect(idCall[1]).toBeUndefined();
});

it('does not set unmatched inputs with undefinedIfStale when key was never seen', () => {
const binder = new RoutedComponentInputBinder({ unmatchedInputBehavior: 'undefinedIfStale' });
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
params: { name: 'test' },
});

binder.bindActivatedRouteToOutletComponent(outlet);

const idCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'id');
expect(idCall).toBeUndefined();
});

it('sets previously seen keys to undefined with undefinedIfStale behavior', async () => {
const binder = new RoutedComponentInputBinder({ unmatchedInputBehavior: 'undefinedIfStale' });
const { outlet, setInputSpy, params$ } = createMockOutlet(TestComponent, {
params: { name: 'test', id: '1' },
});

binder.bindActivatedRouteToOutletComponent(outlet);

const idCallBefore = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'id');
expect(idCallBefore[1]).toBe('1');

setInputSpy.calls.reset();
params$.next({ name: 'test' });
await Promise.resolve();

const idCallAfter = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'id');
expect(idCallAfter).toBeTruthy();
expect(idCallAfter[1]).toBeUndefined();
});

it('reacts to param changes', async () => {
const binder = new RoutedComponentInputBinder({});
const { outlet, setInputSpy, params$ } = createMockOutlet(TestComponent, {
params: { name: 'initial' },
});

binder.bindActivatedRouteToOutletComponent(outlet);
expect(setInputSpy).toHaveBeenCalledWith('name', 'initial');

setInputSpy.calls.reset();
params$.next({ name: 'updated' });
await Promise.resolve();

expect(setInputSpy).toHaveBeenCalledWith('name', 'updated');
});

it('unsubscribes from route data', () => {
const binder = new RoutedComponentInputBinder({});
const { outlet, setInputSpy, params$ } = createMockOutlet(TestComponent, {
params: { name: 'test' },
});

binder.bindActivatedRouteToOutletComponent(outlet);
setInputSpy.calls.reset();

binder.unsubscribeFromRouteData(outlet);
params$.next({ name: 'after-unsub' });

expect(setInputSpy).not.toHaveBeenCalled();
});

it('re-subscribes when bindActivatedRouteToOutletComponent is called again', () => {
const binder = new RoutedComponentInputBinder({});
const { outlet, setInputSpy, params$ } = createMockOutlet(TestComponent, {
params: { name: 'first' },
});

binder.bindActivatedRouteToOutletComponent(outlet);
setInputSpy.calls.reset();

params$.next({ name: 'second' });
binder.bindActivatedRouteToOutletComponent(outlet);

const nameCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'name');
expect(nameCall[1]).toBe('second');
});

it('unsubscribes when outlet is deactivated mid-stream', async () => {
const binder = new RoutedComponentInputBinder({});
const { outlet, setInputSpy, params$ } = createMockOutlet(TestComponent, {
params: { name: 'test' },
});

binder.bindActivatedRouteToOutletComponent(outlet);
setInputSpy.calls.reset();

(outlet as any).isActivated = false;
params$.next({ name: 'after-deactivate' });
await Promise.resolve();

expect(setInputSpy).not.toHaveBeenCalledWith('name', 'after-deactivate');
});

it('works with signal inputs', () => {
const binder = new RoutedComponentInputBinder({});
const { outlet, setInputSpy } = createMockOutlet(SignalInputComponent, {
params: { name: 'signal-test' },
});

binder.bindActivatedRouteToOutletComponent(outlet);

expect(setInputSpy).toHaveBeenCalledWith('name', 'signal-test');
});

it('combines values from all sources', () => {
const binder = new RoutedComponentInputBinder({ queryParams: true });
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
queryParams: { language: 'en' },
params: { name: 'test-name' },
data: { id: 'data-id' },
});

binder.bindActivatedRouteToOutletComponent(outlet);

expect(setInputSpy).toHaveBeenCalledWith('name', 'test-name');
expect(setInputSpy).toHaveBeenCalledWith('id', 'data-id');
expect(setInputSpy).toHaveBeenCalledWith('language', 'en');
});
});
Loading