Skip to content

Commit 30367a5

Browse files
committed
feat: add router input binding
1 parent fe87e28 commit 30367a5

8 files changed

Lines changed: 416 additions & 1 deletion

File tree

apps/nativescript-demo-ng/src/app/app.routes.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Routes } from '@angular/router';
22
import { ItemDetailComponent } from './item/item-detail.component';
33
import { ItemsComponent } from './item/items.component';
4+
import { InputBindingDemoComponent } from './input-binding-demo/input-binding-demo.component';
45
import { ListViewStickyComponent } from './list-view-sticky/list-view-sticky.component';
56
import { SPLIT_VIEW_ROUTES } from './split-view-demo/split-view.routes';
67
// import { HomeComponent } from './home/home.component';
@@ -10,6 +11,14 @@ export const routes: Routes = [
1011
{ path: '', redirectTo: '/rootlazy', pathMatch: 'full' },
1112
{ path: 'items', component: ItemsComponent },
1213
{ path: 'item/:id', component: ItemDetailComponent },
14+
{
15+
path: 'input-binding-demo/:name',
16+
component: InputBindingDemoComponent,
17+
data: { title: 'Demo Page' },
18+
resolve: {
19+
timestamp: () => new Date().toISOString(),
20+
},
21+
},
1322
{ path: 'item2', loadChildren: () => import('./item2/item2.routes').then((m) => m.ITEM2_ROUTES) },
1423
{ path: 'rootlazy', loadChildren: () => import('./item3/item3.module').then((m) => m.Item3Module) },
1524
{
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Component, input, NO_ERRORS_SCHEMA } from '@angular/core';
2+
import { NativeScriptCommonModule } from '@nativescript/angular';
3+
4+
@Component({
5+
selector: 'ns-input-binding-demo',
6+
template: `
7+
<ActionBar title="Input Binding Demo" class="action-bar"></ActionBar>
8+
<StackLayout class="p-4">
9+
<Label text="Route Input Binding Demo" class="text-2xl font-bold text-center"></Label>
10+
11+
<Label class="mt-4 text-lg font-bold" text="Route Param:"></Label>
12+
<Label class="text-base" [text]="'name = ' + name()"></Label>
13+
14+
<Label class="mt-4 text-lg font-bold" text="Query Param:"></Label>
15+
<Label class="text-base" [text]="'language = ' + language()"></Label>
16+
17+
<Label class="mt-4 text-lg font-bold" text="Resolver Data:"></Label>
18+
<Label class="text-base" [text]="'timestamp = ' + timestamp()"></Label>
19+
20+
<Label class="mt-4 text-lg font-bold" text="Static Route Data:"></Label>
21+
<Label class="text-base" [text]="'title = ' + title()"></Label>
22+
</StackLayout>
23+
`,
24+
imports: [NativeScriptCommonModule],
25+
schemas: [NO_ERRORS_SCHEMA],
26+
})
27+
export class InputBindingDemoComponent {
28+
name = input<string>();
29+
language = input<string>();
30+
timestamp = input<string>();
31+
title = input<string>();
32+
}

apps/nativescript-demo-ng/src/app/item3/items.component.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@
4444
backgroundColor="#00d2ff"
4545
class="text-white mt-2 w-full font-bold h-[50]"
4646
></Button>
47+
<Button
48+
[nsRouterLink]="['/input-binding-demo', 'Angular']"
49+
[queryParams]="{ language: 'en' }"
50+
text="Input Binding Demo"
51+
[borderRadius]="borderRadius"
52+
[fontSize]="fontSize"
53+
padding="0"
54+
backgroundColor="#4CAF50"
55+
class="text-white mt-2 w-full font-bold h-[50]"
56+
></Button>
4757
</StackLayout>
4858
<ListView row="1" [items]="items" backgroundColor="#efefef">
4959
<ng-template let-item="item">

apps/nativescript-demo-ng/src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
provideNativeScriptNgZone,
55
provideNativeScriptRouter,
66
runNativeScriptAngularApp,
7+
withComponentInputBinding,
78
} from '@nativescript/angular';
89
import { Trace, Utils, SplitView } from '@nativescript/core';
910

@@ -38,6 +39,7 @@ runNativeScriptAngularApp({
3839
providers: [
3940
provideNativeScriptHttpClient(withInterceptorsFromDi()),
4041
provideNativeScriptRouter(routes),
42+
withComponentInputBinding(),
4143
// provideNativeScriptRouter(SPLIT_VIEW_ROUTES),
4244
ZONELESS ? provideZonelessChangeDetection() : provideNativeScriptNgZone(),
4345
],
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { Component, Input, input } from '@angular/core';
2+
import { BehaviorSubject } from 'rxjs';
3+
import { RoutedComponentInputBinder } from '@nativescript/angular/lib/legacy/router/router-component-input-binder';
4+
import type { PageRouterOutlet } from '@nativescript/angular/lib/legacy/router/page-router-outlet';
5+
6+
@Component({ template: '', standalone: true })
7+
class TestComponent {
8+
@Input() name: string;
9+
@Input() id: string;
10+
@Input() language: string;
11+
}
12+
13+
@Component({ template: '', standalone: true })
14+
class SignalInputComponent {
15+
name = input<string>();
16+
id = input<string>();
17+
}
18+
19+
function createMockOutlet(
20+
component: any,
21+
options?: {
22+
params?: Record<string, string>;
23+
queryParams?: Record<string, string>;
24+
data?: Record<string, any>;
25+
},
26+
) {
27+
const params$ = new BehaviorSubject(options?.params ?? {});
28+
const queryParams$ = new BehaviorSubject(options?.queryParams ?? {});
29+
const data$ = new BehaviorSubject(options?.data ?? {});
30+
31+
const setInputSpy = jasmine.createSpy('setInput');
32+
const activatedRoute = {
33+
params: params$.asObservable(),
34+
queryParams: queryParams$.asObservable(),
35+
data: data$.asObservable(),
36+
component,
37+
};
38+
39+
const outlet = {
40+
isActivated: true,
41+
activatedRoute,
42+
activatedComponentRef: { setInput: setInputSpy },
43+
} as unknown as PageRouterOutlet;
44+
45+
return { outlet, params$, queryParams$, data$, setInputSpy, activatedRoute };
46+
}
47+
48+
describe('RoutedComponentInputBinder', () => {
49+
it('binds route params to component inputs', () => {
50+
const binder = new RoutedComponentInputBinder({ queryParams: true });
51+
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
52+
params: { name: 'test-name' },
53+
});
54+
55+
binder.bindActivatedRouteToOutletComponent(outlet);
56+
57+
expect(setInputSpy).toHaveBeenCalledWith('name', 'test-name');
58+
});
59+
60+
it('binds query params to component inputs', () => {
61+
const binder = new RoutedComponentInputBinder({ queryParams: true });
62+
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
63+
queryParams: { id: '42' },
64+
});
65+
66+
binder.bindActivatedRouteToOutletComponent(outlet);
67+
68+
expect(setInputSpy).toHaveBeenCalledWith('id', '42');
69+
});
70+
71+
it('binds route data to component inputs', () => {
72+
const binder = new RoutedComponentInputBinder({ queryParams: true });
73+
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
74+
data: { name: 'from-data' },
75+
});
76+
77+
binder.bindActivatedRouteToOutletComponent(outlet);
78+
79+
expect(setInputSpy).toHaveBeenCalledWith('name', 'from-data');
80+
});
81+
82+
it('data takes priority over params, params over queryParams', () => {
83+
const binder = new RoutedComponentInputBinder({ queryParams: true });
84+
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
85+
queryParams: { name: 'from-query' },
86+
params: { name: 'from-params' },
87+
data: { name: 'from-data' },
88+
});
89+
90+
binder.bindActivatedRouteToOutletComponent(outlet);
91+
92+
const nameCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'name');
93+
expect(nameCall[1]).toBe('from-data');
94+
});
95+
96+
it('params take priority over queryParams', () => {
97+
const binder = new RoutedComponentInputBinder({ queryParams: true });
98+
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
99+
queryParams: { name: 'from-query' },
100+
params: { name: 'from-params' },
101+
});
102+
103+
binder.bindActivatedRouteToOutletComponent(outlet);
104+
105+
const nameCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'name');
106+
expect(nameCall[1]).toBe('from-params');
107+
});
108+
109+
it('does not bind query params when queryParams option is false', () => {
110+
const binder = new RoutedComponentInputBinder({ queryParams: false });
111+
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
112+
queryParams: { name: 'from-query' },
113+
});
114+
115+
binder.bindActivatedRouteToOutletComponent(outlet);
116+
117+
const nameCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'name');
118+
expect(nameCall[1]).toBeUndefined();
119+
});
120+
121+
it('sets unmatched inputs to undefined with alwaysUndefined behavior (default)', () => {
122+
const binder = new RoutedComponentInputBinder({});
123+
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
124+
params: { name: 'test' },
125+
});
126+
127+
binder.bindActivatedRouteToOutletComponent(outlet);
128+
129+
const idCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'id');
130+
expect(idCall).toBeTruthy();
131+
expect(idCall[1]).toBeUndefined();
132+
});
133+
134+
it('does not set unmatched inputs with undefinedIfStale when key was never seen', () => {
135+
const binder = new RoutedComponentInputBinder({ unmatchedInputBehavior: 'undefinedIfStale' });
136+
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
137+
params: { name: 'test' },
138+
});
139+
140+
binder.bindActivatedRouteToOutletComponent(outlet);
141+
142+
const idCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'id');
143+
expect(idCall).toBeUndefined();
144+
});
145+
146+
it('sets previously seen keys to undefined with undefinedIfStale behavior', async () => {
147+
const binder = new RoutedComponentInputBinder({ unmatchedInputBehavior: 'undefinedIfStale' });
148+
const { outlet, setInputSpy, params$ } = createMockOutlet(TestComponent, {
149+
params: { name: 'test', id: '1' },
150+
});
151+
152+
binder.bindActivatedRouteToOutletComponent(outlet);
153+
154+
const idCallBefore = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'id');
155+
expect(idCallBefore[1]).toBe('1');
156+
157+
setInputSpy.calls.reset();
158+
params$.next({ name: 'test' });
159+
await Promise.resolve();
160+
161+
const idCallAfter = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'id');
162+
expect(idCallAfter).toBeTruthy();
163+
expect(idCallAfter[1]).toBeUndefined();
164+
});
165+
166+
it('reacts to param changes', async () => {
167+
const binder = new RoutedComponentInputBinder({});
168+
const { outlet, setInputSpy, params$ } = createMockOutlet(TestComponent, {
169+
params: { name: 'initial' },
170+
});
171+
172+
binder.bindActivatedRouteToOutletComponent(outlet);
173+
expect(setInputSpy).toHaveBeenCalledWith('name', 'initial');
174+
175+
setInputSpy.calls.reset();
176+
params$.next({ name: 'updated' });
177+
await Promise.resolve();
178+
179+
expect(setInputSpy).toHaveBeenCalledWith('name', 'updated');
180+
});
181+
182+
it('unsubscribes from route data', () => {
183+
const binder = new RoutedComponentInputBinder({});
184+
const { outlet, setInputSpy, params$ } = createMockOutlet(TestComponent, {
185+
params: { name: 'test' },
186+
});
187+
188+
binder.bindActivatedRouteToOutletComponent(outlet);
189+
setInputSpy.calls.reset();
190+
191+
binder.unsubscribeFromRouteData(outlet);
192+
params$.next({ name: 'after-unsub' });
193+
194+
expect(setInputSpy).not.toHaveBeenCalled();
195+
});
196+
197+
it('re-subscribes when bindActivatedRouteToOutletComponent is called again', () => {
198+
const binder = new RoutedComponentInputBinder({});
199+
const { outlet, setInputSpy, params$ } = createMockOutlet(TestComponent, {
200+
params: { name: 'first' },
201+
});
202+
203+
binder.bindActivatedRouteToOutletComponent(outlet);
204+
setInputSpy.calls.reset();
205+
206+
params$.next({ name: 'second' });
207+
binder.bindActivatedRouteToOutletComponent(outlet);
208+
209+
const nameCall = setInputSpy.calls.allArgs().find((args: any[]) => args[0] === 'name');
210+
expect(nameCall[1]).toBe('second');
211+
});
212+
213+
it('unsubscribes when outlet is deactivated mid-stream', async () => {
214+
const binder = new RoutedComponentInputBinder({});
215+
const { outlet, setInputSpy, params$ } = createMockOutlet(TestComponent, {
216+
params: { name: 'test' },
217+
});
218+
219+
binder.bindActivatedRouteToOutletComponent(outlet);
220+
setInputSpy.calls.reset();
221+
222+
(outlet as any).isActivated = false;
223+
params$.next({ name: 'after-deactivate' });
224+
await Promise.resolve();
225+
226+
expect(setInputSpy).not.toHaveBeenCalledWith('name', 'after-deactivate');
227+
});
228+
229+
it('works with signal inputs', () => {
230+
const binder = new RoutedComponentInputBinder({});
231+
const { outlet, setInputSpy } = createMockOutlet(SignalInputComponent, {
232+
params: { name: 'signal-test' },
233+
});
234+
235+
binder.bindActivatedRouteToOutletComponent(outlet);
236+
237+
expect(setInputSpy).toHaveBeenCalledWith('name', 'signal-test');
238+
});
239+
240+
it('combines values from all sources', () => {
241+
const binder = new RoutedComponentInputBinder({ queryParams: true });
242+
const { outlet, setInputSpy } = createMockOutlet(TestComponent, {
243+
queryParams: { language: 'en' },
244+
params: { name: 'test-name' },
245+
data: { id: 'data-id' },
246+
});
247+
248+
binder.bindActivatedRouteToOutletComponent(outlet);
249+
250+
expect(setInputSpy).toHaveBeenCalledWith('name', 'test-name');
251+
expect(setInputSpy).toHaveBeenCalledWith('id', 'data-id');
252+
expect(setInputSpy).toHaveBeenCalledWith('language', 'en');
253+
});
254+
});

0 commit comments

Comments
 (0)