packages/__tests__/src/router-lite/resources/load.spec.ts
import { IRouteContext, IRouteViewModel, Params, route, RouteNode } from '@aurelia/router-lite';
import { CustomElement, customElement, ILocation, IPlatform } from '@aurelia/runtime-html';
import { assert, MockBrowserHistoryLocation } from '@aurelia/testing';
import { start } from '../_shared/create-fixture.js';
describe('router-lite/resources/load.spec.ts', function () {
function assertAnchors(anchors: HTMLAnchorElement[] | NodeListOf<HTMLAnchorElement>, expected: { href: string; active?: boolean }[], message: string = '', assertActive: boolean = true): void {
const len = anchors.length;
assert.strictEqual(len, expected.length, `${message} length`);
for (let i = 0; i < len; i++) {
const anchor = anchors[i];
const item = expected[i];
assert.strictEqual(anchor.href.endsWith(item.href), true, `${message} - #${i} href - actual: ${anchor.href} - expected: ${item.href}`);
if (!assertActive) continue;
assert.strictEqual(anchor.classList.contains('active'), !!item.active, `${message} - #${i} active`);
}
}
it('active status works correctly', async function () {
@customElement({ name: 'fo-o', template: '' })
class Foo { }
@route({
routes: [
{ id: 'foo', path: 'foo/:id', component: Foo }
]
})
@customElement({
name: 'ro-ot',
template: `
<a load="route:foo; params.bind:{id: 1}; active.bind:active1" active.class="active1"></a>
<a load="route:foo/2; active.bind:active2" active.class="active2"></a>
<au-viewport></au-viewport>`
})
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [Foo] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
const anchors = host.querySelectorAll('a');
const a1 = { href: 'foo/1', active: false };
const a2 = { href: 'foo/2', active: false };
assertAnchors(anchors, [a1, a2], 'round#1');
anchors[1].click();
await queue.yield();
a2.active = true;
assertAnchors(anchors, [a1, a2], 'round#2');
anchors[0].click();
await queue.yield();
a1.active = true;
a2.active = false;
assertAnchors(anchors, [a1, a2], 'round#3');
await au.stop(true);
});
it('adds activeClass when configured', async function () {
@customElement({ name: 'fo-o', template: '${instanceId} ${id}' })
class Foo {
private static instanceId: number = 0;
private readonly instanceId = ++Foo.instanceId;
private id: string;
public loading(params: Params) {
this.id = params.id;
}
}
@route({
transitionPlan: 'replace',
routes: [
{ id: 'foo', path: 'foo/:id', component: Foo }
]
})
@customElement({
name: 'ro-ot',
template: `
<a load="route:foo; params.bind:{id: 1}"></a>
<a load="route:foo/2"></a>
<au-viewport></au-viewport>`
})
class Root { }
const activeClass = 'au-rl-active';
const { au, host, container } = await start({ appRoot: Root, registrations: [Foo], activeClass });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
const anchors = host.querySelectorAll('a');
const a1 = { href: 'foo/1', active: false };
const a2 = { href: 'foo/2', active: false };
assertAnchorsWithClass(anchors, [a1, a2], activeClass, 'round#1');
anchors[1].click();
await queue.yield();
a2.active = true;
assertAnchorsWithClass(anchors, [a1, a2], activeClass, 'round#2');
assert.html.textContent(host, '1 2', 'round#2 - text');
anchors[1].click();
await queue.yield();
assertAnchorsWithClass(anchors, [a1, a2], activeClass, 'round#3');
assert.html.textContent(host, '1 2', 'round#3 - text');
anchors[0].click();
await queue.yield();
a1.active = true;
a2.active = false;
assertAnchorsWithClass(anchors, [a1, a2], activeClass, 'round#4');
assert.html.textContent(host, '2 1', 'round#4 - text');
anchors[0].click();
await queue.yield();
assertAnchorsWithClass(anchors, [a1, a2], activeClass, 'round#5');
assert.html.textContent(host, '2 1', 'round#5 - text');
await au.stop(true);
function assertAnchorsWithClass(anchors: HTMLAnchorElement[] | NodeListOf<HTMLAnchorElement>, expected: { href: string; active?: boolean }[], activeClass: string | null = null, message: string = ''): void {
const len = anchors.length;
assert.strictEqual(len, expected.length, `${message} length`);
for (let i = 0; i < len; i++) {
const anchor = anchors[i];
const item = expected[i];
assert.strictEqual(anchor.href.endsWith(item.href), true, `${message} - #${i} href - actual: ${anchor.href} - expected: ${item.href}`);
assert.strictEqual(anchor.classList.contains(activeClass), !!item.active, `${message} - #${i} active`);
}
}
});
it('does not add activeClass when not configured', async function () {
@customElement({ name: 'fo-o', template: '' })
class Foo { }
@route({
routes: [
{ id: 'foo', path: 'foo/:id', component: Foo }
]
})
@customElement({
name: 'ro-ot',
template: `
<a load="route:foo; params.bind:{id: 1}"></a>
<a load="route:foo/2"></a>
<au-viewport></au-viewport>`
})
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [Foo] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
const anchors = host.querySelectorAll('a');
const a1 = { href: 'foo/1', active: false };
const a2 = { href: 'foo/2', active: false };
assertAnchorsWithoutClass(anchors, [a1, a2], 'round#1');
anchors[1].click();
await queue.yield();
a2.active = true;
assertAnchorsWithoutClass(anchors, [a1, a2], 'round#2');
anchors[0].click();
await queue.yield();
a1.active = true;
a2.active = false;
assertAnchorsWithoutClass(anchors, [a1, a2], 'round#3');
await au.stop(true);
function assertAnchorsWithoutClass(anchors: HTMLAnchorElement[] | NodeListOf<HTMLAnchorElement>, expected: { href: string; active?: boolean }[], message: string = ''): void {
const len = anchors.length;
assert.strictEqual(len, expected.length, `${message} length`);
for (let i = 0; i < len; i++) {
const anchor = anchors[i];
const item = expected[i];
assert.strictEqual(anchor.href.endsWith(item.href), true, `${message} - #${i} href - actual: ${anchor.href} - expected: ${item.href}`);
}
}
});
it('un-configured parameters are added to the querystring', async function () {
@customElement({ name: 'fo-o', template: '' })
class Foo { }
@route({
routes: [
{ id: 'foo', path: 'foo/:id', component: Foo }
]
})
@customElement({
name: 'ro-ot',
template: `<a load="route:foo; params.bind:{id: 3, a: 2};"></a><au-viewport></au-viewport>`
})
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [Foo] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
const anchor = host.querySelector('a');
assert.match(anchor.href, /foo\/3\?a=2/);
await au.stop(true);
});
it('the most matched path is generated', async function () {
@customElement({ name: 'fo-o', template: 'foo' })
class Foo { }
@route({
routes: [
{ id: 'foo', path: ['foo/:id', 'foo/:id/bar/:a', 'bar/fizz'], component: Foo }
]
})
@customElement({
name: 'ro-ot',
template: `<a load="route:foo; params.bind:{id: 3, a: 2};"></a><a load="route:foo; params.bind:{id: 3, b: 2};"></a><a load="bar/fizz"></a><au-viewport></au-viewport>`
})
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [Foo] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
const anchors = Array.from(host.querySelectorAll('a'));
const hrefs = anchors.map(a => a.href);
assert.match(hrefs[0], /foo\/3\/bar\/2/);
assert.match(hrefs[1], /foo\/3\?b=2/); // this one ensures the rejection of non-monotonically increment in the parameter consumption
assert.match(hrefs[2], /bar\/fizz/);
anchors[2].click();
await queue.yield();
assert.html.textContent(host, 'foo');
await au.stop(true);
});
it('allow navigating to route defined in parent context using ../ prefix', async function () {
@customElement({ name: 'pro-duct', template: `product \${id} <a load="../products"></a>` })
class Product {
id: unknown;
public canLoad(params: Params, _next: RouteNode, _current: RouteNode): boolean {
this.id = params.id;
return true;
}
}
@customElement({ name: 'pro-ducts', template: `<a load="../product/1"></a><a load="../product/2"></a> products` })
class Products { }
@route({
routes: [
{ id: 'products', path: ['', 'products'], component: Products },
{ id: 'product', path: 'product/:id', component: Product },
]
})
@customElement({ name: 'ro-ot', template: '<au-viewport></au-viewport>' })
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [Products, Product] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
assert.html.textContent(host, 'products');
const anchors = Array.from(host.querySelectorAll('a'));
const hrefs = anchors.map(a => a.href);
assert.match(hrefs[0], /product\/1$/);
assert.match(hrefs[1], /product\/2$/);
anchors[0].click();
await queue.yield();
assert.html.textContent(host, 'product 1');
// go back
const back = host.querySelector<HTMLAnchorElement>('a');
assert.match(back.href, /products$/);
back.click();
await queue.yield();
assert.html.textContent(host, 'products');
// 2nd round
host.querySelector<HTMLAnchorElement>('a:nth-of-type(2)').click();
await queue.yield();
assert.html.textContent(host, 'product 2');
// go back
host.querySelector<HTMLAnchorElement>('a').click();
await queue.yield();
assert.html.textContent(host, 'products');
await au.stop(true);
});
it('allow navigating to route defined in parent context using ../ prefix - with parameters', async function () {
@customElement({ name: 'pro-duct', template: `product \${id} <a load="../products"></a>` })
class Product {
id: unknown;
public canLoad(params: Params, _next: RouteNode, _current: RouteNode): boolean {
this.id = params.id;
return true;
}
}
@customElement({ name: 'pro-ducts', template: `<a load="route:../product; params.bind:{id:'1'}"></a><a load="route:../product; params.bind:{id:'2'}"></a> products` })
class Products { }
@route({
routes: [
{ id: 'products', path: ['', 'products'], component: Products },
{ id: 'product', path: 'product/:id', component: Product },
]
})
@customElement({ name: 'ro-ot', template: '<au-viewport></au-viewport>' })
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [Products, Product] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
assert.html.textContent(host, 'products');
const anchors = Array.from(host.querySelectorAll('a'));
const hrefs = anchors.map(a => a.href);
assert.match(hrefs[0], /product\/1$/);
assert.match(hrefs[1], /product\/2$/);
anchors[0].click();
await queue.yield();
assert.html.textContent(host, 'product 1');
// go back
const back = host.querySelector<HTMLAnchorElement>('a');
assert.match(back.href, /products$/);
back.click();
await queue.yield();
assert.html.textContent(host, 'products');
// 2nd round
host.querySelector<HTMLAnchorElement>('a:nth-of-type(2)').click();
await queue.yield();
assert.html.textContent(host, 'product 2');
// go back
host.querySelector<HTMLAnchorElement>('a').click();
await queue.yield();
assert.html.textContent(host, 'products');
await au.stop(true);
});
it('allow navigating to route defined in parent context using explicit routing context', async function () {
@customElement({ name: 'pro-duct', template: `product \${id} <a load="route:products; context.bind:rtCtx.parent"></a>` })
class Product {
id: unknown;
private rtCtx: IRouteContext;
public canLoad(params: Params, next: RouteNode, _current: RouteNode): boolean {
this.id = params.id;
this.rtCtx = next.context;
return true;
}
}
@customElement({ name: 'pro-ducts', template: `<a load="route:product/1; context.bind:rtCtx.parent"></a><a load="route:product; params.bind:{id: 2}; context.bind:rtCtx.parent"></a> products` })
class Products {
private rtCtx: IRouteContext;
public canLoad(params: Params, next: RouteNode, _current: RouteNode): boolean {
this.rtCtx = next.context;
return true;
}
}
@route({
routes: [
{ id: 'products', path: ['', 'products'], component: Products },
{ id: 'product', path: 'product/:id', component: Product },
]
})
@customElement({ name: 'ro-ot', template: '<au-viewport></au-viewport>' })
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [Products, Product] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
assert.html.textContent(host, 'products');
const anchors = Array.from(host.querySelectorAll('a'));
const hrefs = anchors.map(a => a.href);
assert.match(hrefs[0], /product\/1$/);
assert.match(hrefs[1], /product\/2$/);
anchors[0].click();
await queue.yield();
assert.html.textContent(host, 'product 1');
// go back
const back = host.querySelector<HTMLAnchorElement>('a');
assert.match(back.href, /products$/);
back.click();
await queue.yield();
assert.html.textContent(host, 'products');
// 2nd round
host.querySelector<HTMLAnchorElement>('a:nth-of-type(2)').click();
await queue.yield();
assert.html.textContent(host, 'product 2');
// go back
host.querySelector<HTMLAnchorElement>('a').click();
await queue.yield();
assert.html.textContent(host, 'products');
await au.stop(true);
});
it('allow navigating to route defined in grand-parent context using ../../ prefix', async function () {
@customElement({ name: 'l-21', template: `l21 <a load="../../l12"></a>` })
class L21 { }
@customElement({ name: 'l-22', template: `l22 <a load="../../l11"></a>` })
class L22 { }
@route({
routes: [
{ id: 'l21', path: ['', 'l21'], component: L21 },
]
})
@customElement({ name: 'l-11', template: `l11 <au-viewport></au-viewport>` })
class L11 { }
@route({
routes: [
{ id: 'l22', path: ['', 'l22'], component: L22 },
]
})
@customElement({ name: 'l-12', template: `l12 <au-viewport></au-viewport>` })
class L12 { }
@route({
routes: [
{ id: 'l11', path: ['', 'l11'], component: L11 },
{ id: 'l12', path: 'l12', component: L12 },
]
})
@customElement({ name: 'ro-ot', template: '<au-viewport></au-viewport>' })
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [L11, L12, L21, L22] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
assert.html.textContent(host, 'l11 l21');
host.querySelector('a').click();
await queue.yield();
assert.html.textContent(host, 'l12 l22');
host.querySelector('a').click();
await queue.yield();
assert.html.textContent(host, 'l11 l21');
await au.stop(true);
});
it('allow navigating to route defined in grand-parent context using ../../ prefix - with parameters', async function () {
@customElement({ name: 'l-21', template: `l21 <a load="route:../../l12; params.bind:{id: '42'}"></a>` })
class L21 { }
@customElement({ name: 'l-22', template: `l22 <a load="route:../../l11; params.bind:{id: '42'}"></a>` })
class L22 { }
@route({
routes: [
{ id: 'l21', path: ['', 'l21'], component: L21 },
]
})
@customElement({ name: 'l-11', template: `l11 <au-viewport></au-viewport>` })
class L11 { }
@route({
routes: [
{ id: 'l22', path: ['', 'l22'], component: L22 },
]
})
@customElement({ name: 'l-12', template: `l12 <au-viewport></au-viewport>` })
class L12 { }
@route({
routes: [
{ path: '', redirectTo: 'l11/42' },
{ id: 'l11', path: 'l11/:id', component: L11 },
{ id: 'l12', path: 'l12/:id', component: L12 },
]
})
@customElement({ name: 'ro-ot', template: '<au-viewport></au-viewport>' })
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [L11, L12, L21, L22] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
assert.html.textContent(host, 'l11 l21');
host.querySelector('a').click();
await queue.yield();
assert.html.textContent(host, 'l12 l22');
host.querySelector('a').click();
await queue.yield();
assert.html.textContent(host, 'l11 l21');
await au.stop(true);
});
it('allow navigating to route defined in grand-parent context using explicit routing context', async function () {
@customElement({ name: 'l-21', template: `l21 <a load="route:l12; context.bind:rtCtx.parent.parent"></a>` })
class L21 {
private rtCtx: IRouteContext;
public canLoad(params: Params, next: RouteNode, _current: RouteNode): boolean {
this.rtCtx = next.context;
return true;
}
}
@customElement({ name: 'l-22', template: `l22 <a load="route:l11; context.bind:rtCtx.parent.parent"></a>` })
class L22 {
private rtCtx: IRouteContext;
public canLoad(params: Params, next: RouteNode, _current: RouteNode): boolean {
this.rtCtx = next.context;
return true;
}
}
@route({
routes: [
{ id: 'l21', path: ['', 'l21'], component: L21 },
]
})
@customElement({ name: 'l-11', template: `l11 <au-viewport></au-viewport>` })
class L11 { }
@route({
routes: [
{ id: 'l22', path: ['', 'l22'], component: L22 },
]
})
@customElement({ name: 'l-12', template: `l12 <au-viewport></au-viewport>` })
class L12 { }
@route({
routes: [
{ id: 'l11', path: ['', 'l11'], component: L11 },
{ id: 'l12', path: 'l12', component: L12 },
]
})
@customElement({ name: 'ro-ot', template: '<au-viewport></au-viewport>' })
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [L11, L12, L21, L22] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
assert.html.textContent(host, 'l11 l21');
host.querySelector('a').click();
await queue.yield();
assert.html.textContent(host, 'l12 l22');
host.querySelector('a').click();
await queue.yield();
assert.html.textContent(host, 'l11 l21');
await au.stop(true);
});
it('allow explicitly binding the routing context to null to perform navigation from root', async function () {
@customElement({ name: 'l-21', template: `l21 <a load="route:l22; context.bind:rtCtx.parent"></a>` })
class L21 {
private rtCtx: IRouteContext;
public canLoad(params: Params, next: RouteNode, _current: RouteNode): boolean {
this.rtCtx = next.context;
return true;
}
}
@customElement({ name: 'l-22', template: `l22 <a load="route:l12; context.bind:null"></a>` })
class L22 {
private rtCtx: IRouteContext;
public canLoad(params: Params, next: RouteNode, _current: RouteNode): boolean {
this.rtCtx = next.context;
return true;
}
}
@customElement({ name: 'l-23', template: `l23 <a load="route:l24; context.bind:rtCtx.parent"></a>` })
class L23 {
private rtCtx: IRouteContext;
public canLoad(params: Params, next: RouteNode, _current: RouteNode): boolean {
this.rtCtx = next.context;
return true;
}
}
@customElement({ name: 'l-24', template: `l24 <a load="route:l11; context.bind:null"></a>` })
class L24 {
private rtCtx: IRouteContext;
public canLoad(params: Params, next: RouteNode, _current: RouteNode): boolean {
this.rtCtx = next.context;
return true;
}
}
@route({
routes: [
{ id: 'l21', path: ['', 'l21'], component: L21 },
{ id: 'l22', path: 'l22', component: L22 },
]
})
@customElement({ name: 'l-11', template: `l11 <au-viewport></au-viewport>` })
class L11 { }
@route({
routes: [
{ id: 'l23', path: ['', 'l23'], component: L23 },
{ id: 'l24', path: 'l24', component: L24 },
]
})
@customElement({ name: 'l-12', template: `l12 <au-viewport></au-viewport>` })
class L12 { }
@route({
routes: [
{ id: 'l11', path: ['', 'l11'], component: L11 },
{ id: 'l12', path: 'l12', component: L12 },
]
})
@customElement({ name: 'ro-ot', template: '<au-viewport></au-viewport>' })
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [L11, L12, L21, L22, L23, L24] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
assert.html.textContent(host, 'l11 l21', 'init');
// l21 -> l22
host.querySelector('a').click();
await queue.yield();
assert.html.textContent(host, 'l11 l22', '#2 l21 -> l22');
// l22 -> l12
host.querySelector('a').click();
await queue.yield();
assert.html.textContent(host, 'l12 l23', '#3 l22 -> l12');
// l23 -> l24
host.querySelector('a').click();
await queue.yield();
assert.html.textContent(host, 'l12 l24', '#4 l23 -> l24');
// l24 -> l11
host.querySelector('a').click();
await queue.yield();
assert.html.textContent(host, 'l11 l21', '#5 l24 -> l11');
await au.stop(true);
});
it('adds hash correctly to the href when useUrlFragmentHash is set', async function () {
@customElement({ name: 'ce-one', template: `ce1` })
class CeOne { }
@customElement({ name: 'ce-two', template: `ce2` })
class CeTwo { }
@customElement({
name: 'ro-ot',
template: `
<a load="#ce-one"></a>
<a load="#ce-two"></a>
<a load="ce-two"></a>
<au-viewport></au-viewport>
`
})
@route({
routes: [
{
path: 'ce-one',
component: CeOne,
},
{
path: 'ce-two',
component: CeTwo,
},
]
})
class Root { }
const { au, host, container } = await start({ appRoot: Root, useHash: true, registrations: [CeOne, CeTwo] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
const anchors = Array.from(host.querySelectorAll('a'));
assert.deepStrictEqual(anchors.map(a => a.getAttribute('href')), ['/#/ce-one', '/#/ce-two', '/#/ce-two']);
anchors[1].click();
await queue.yield();
assert.html.textContent(host, 'ce2');
anchors[0].click();
await queue.yield();
assert.html.textContent(host, 'ce1');
anchors[2].click();
await queue.yield();
assert.html.textContent(host, 'ce2');
await au.stop(true);
});
class HrefGenerationForChildComponentTestData {
public constructor(
public readonly name: string,
public readonly templates: [root: string, c1: string, c2: string],
public readonly expectedHrefs: [expectations1: { href: string }[], expectations2: { href: string }[]],
) { }
}
function* getHrefGenerationForChildComponentTestData() {
yield new HrefGenerationForChildComponentTestData(
'using path',
[
`
<a load="c1">C1</a>
<a load="c2">C2</a>
<au-viewport></au-viewport>`,
`c1
<a load="gc11">gc11</a>
<a load="gc12">gc12</a>
<a load="route: c2; context.bind: parentCtx">c2</a>
<au-viewport></au-viewport>`,
`c2
<a load="gc21">gc21</a>
<a load="gc22">gc22</a>
<a load="route: c1; context.bind: parentCtx">c1</a>
<au-viewport></au-viewport>`
],
[
[
{ href: 'c1' },
{ href: 'c2' },
{ href: 'c1/gc11' },
{ href: 'c1/gc12' },
{ href: 'c2' },
],
[
{ href: 'c1' },
{ href: 'c2' },
{ href: 'c2/gc21' },
{ href: 'c2/gc22' },
{ href: 'c1' },
]
]
);
yield new HrefGenerationForChildComponentTestData(
'using route-id',
[
`
<a load="r1">C1</a>
<a load="r2">C2</a>
<au-viewport></au-viewport>`,
`c1
<a load="r1">gc11</a>
<a load="r2">gc12</a>
<a load="route: r2; context.bind: parentCtx">c2</a>
<au-viewport></au-viewport>`,
`c2
<a load="r1">gc21</a>
<a load="r2">gc22</a>
<a load="route: r1; context.bind: parentCtx">c1</a>
<au-viewport></au-viewport>`
],
[
[
{ href: 'r1' },
{ href: 'r2' },
{ href: 'c1/r1' },
{ href: 'c1/r2' },
{ href: 'r2' },
],
[
{ href: 'r1' },
{ href: 'r2' },
{ href: 'c2/r1' },
{ href: 'c2/r2' },
{ href: 'r1' },
]
]
);
}
for (const {
name,
templates: [rt, t1, t2],
expectedHrefs: [expectations1, expectations2]
} of getHrefGenerationForChildComponentTestData()) {
it(`href attribute value is correctly generated for child components - ${name}`, async function () {
@customElement({ name: 'gc-11', template: 'gc11' })
class GrandChildOneOne { }
@customElement({ name: 'gc-12', template: 'gc12' })
class GrandChildOneTwo { }
@route({
routes: [
{ path: '', redirectTo: 'gc11' },
{ id: 'r1', path: 'gc11', component: GrandChildOneOne },
{ id: 'r2', path: 'gc12', component: GrandChildOneTwo },
],
})
@customElement({ name: 'c-one', template: t1 })
class ChildOne {
private readonly parentCtx: IRouteContext;
public constructor(@IRouteContext ctx: IRouteContext) {
this.parentCtx = ctx.parent;
}
}
@customElement({ name: 'gc-21', template: 'gc21' })
class GrandChildTwoOne { }
@customElement({ name: 'gc-22', template: 'gc22' })
class GrandChildTwoTwo { }
@route({
routes: [
{ path: '', redirectTo: 'gc21' },
{ id: 'r1', path: 'gc21', component: GrandChildTwoOne },
{ id: 'r2', path: 'gc22', component: GrandChildTwoTwo },
],
})
@customElement({ name: 'c-two', template: t2 })
class ChildTwo {
private readonly parentCtx: IRouteContext;
public constructor(@IRouteContext ctx: IRouteContext) {
this.parentCtx = ctx.parent;
}
}
@route({
routes: [
{
path: '',
redirectTo: 'c1',
},
{
id: 'r1',
path: 'c1',
component: ChildOne,
},
{
id: 'r2',
path: 'c2',
component: ChildTwo,
},
],
})
@customElement({ name: 'my-app', template: rt })
class Root { }
const { au, host, container } = await start({ appRoot: Root });
const location = container.get<MockBrowserHistoryLocation>(ILocation);
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
let anchors = host.querySelectorAll('a');
assertAnchors(anchors, expectations1, 'round#1 - anchors', false);
assert.match(location.path, /c1\/gc11$/, 'round#1 - location.path');
anchors[3].click();
await queue.yield();
assertAnchors(anchors, expectations1, 'round#2 - anchors', false);
assert.match(location.path, /c1\/gc12$/, 'round#2 - location.path');
anchors[2].click();
await queue.yield();
assertAnchors(anchors, expectations1, 'round#3 - anchors', false);
assert.match(location.path, /c1\/gc11$/, 'round#3 - location.path');
anchors[1].click();
await queue.yield();
anchors = host.querySelectorAll('a');
assertAnchors(anchors, expectations2, 'round#4', false);
assert.match(location.path, /c2\/gc21$/, 'round#4 - location.path');
anchors[3].click();
await queue.yield();
assertAnchors(anchors, expectations2, 'round#5 - anchors', false);
assert.match(location.path, /c2\/gc22$/, 'round#5 - location.path');
anchors[2].click();
await queue.yield();
assertAnchors(anchors, expectations2, 'round#6 - anchors', false);
assert.match(location.path, /c2\/gc21$/, 'round#6 - location.path');
anchors[4].click();
await queue.yield();
anchors = host.querySelectorAll('a');
assertAnchors(anchors, expectations1, 'round#7', false);
assert.match(location.path, /c1\/gc11$/, 'round#7 - location.path');
await au.stop(true);
});
}
class HrefGenerationForChildComponentSiblingTestData {
public constructor(
public readonly name: string,
public readonly templates: [root: string, c1: string, c2: string],
public readonly expectedHrefs: [expectations1: { href: string }[], expectations2: { href: string }[]],
) { }
}
function* getHrefGenerationForChildComponentSiblingTestData() {
yield new HrefGenerationForChildComponentSiblingTestData(
'using path',
[
`
<a load="c1">C1</a>
<a load="c2">C2</a>
<au-viewport></au-viewport>`,
`c1
<a load="gc11+gc12">gc11+gc12</a>
<a load="gc12+gc13">gc12+gc13</a>
<a load="gc13+gc11">gc13+gc11</a>
<au-viewport default.bind="null"></au-viewport><au-viewport default.bind="null"></au-viewport>`,
`c2
<a load="gc21+gc22">gc21+gc22</a>
<a load="gc22+gc23">gc22+gc23</a>
<a load="gc23+gc21">gc23+gc21</a>
<au-viewport default.bind="null"></au-viewport><au-viewport default.bind="null"></au-viewport>`
],
[
[
{ href: 'c1' },
{ href: 'c2' },
{ href: 'c1/gc11+gc12' },
{ href: 'c1/gc12+gc13' },
{ href: 'c1/gc13+gc11' },
],
[
{ href: 'c1' },
{ href: 'c2' },
{ href: 'c2/gc21+gc22' },
{ href: 'c2/gc22+gc23' },
{ href: 'c2/gc23+gc21' },
]
]
);
yield new HrefGenerationForChildComponentSiblingTestData(
'using route-id',
[
`
<a load="r1">C1</a>
<a load="r2">C2</a>
<au-viewport></au-viewport>`,
`c1
<a load="r1+r2">gc11+gc12</a>
<a load="r2+r3">gc12+gc13</a>
<a load="r3+r1">gc13+gc11</a>
<au-viewport default.bind="null"></au-viewport><au-viewport default.bind="null"></au-viewport>`,
`c2
<a load="r1+r2">gc21+gc22</a>
<a load="r2+r3">gc22+gc23</a>
<a load="r3+r1">gc23+gc21</a>
<au-viewport default.bind="null"></au-viewport><au-viewport default.bind="null"></au-viewport>`
],
[
[
{ href: 'r1' },
{ href: 'r2' },
{ href: 'c1/r1+r2' },
{ href: 'c1/r2+r3' },
{ href: 'c1/r3+r1' },
],
[
{ href: 'r1' },
{ href: 'r2' },
{ href: 'c2/r1+r2' },
{ href: 'c2/r2+r3' },
{ href: 'c2/r3+r1' },
]
]
);
}
for (const {
name,
templates: [rt, t1, t2],
expectedHrefs: [expectations1, expectations2]
} of getHrefGenerationForChildComponentSiblingTestData()) {
it(`href attribute value is correctly generated for child components - sibling instructions - ${name}`, async function () {
@customElement({ name: 'gc-11', template: 'gc11' })
class GrandChildOneOne { }
@customElement({ name: 'gc-12', template: 'gc12' })
class GrandChildOneTwo { }
@customElement({ name: 'gc-13', template: 'gc13' })
class GrandChildOneThree { }
@route({
routes: [
{ id: 'r1', path: 'gc11', component: GrandChildOneOne },
{ id: 'r2', path: 'gc12', component: GrandChildOneTwo },
{ id: 'r3', path: 'gc13', component: GrandChildOneThree },
],
})
@customElement({ name: 'c-one', template: t1 })
class ChildOne {
private readonly parentCtx: IRouteContext;
public constructor(@IRouteContext ctx: IRouteContext) {
this.parentCtx = ctx.parent;
}
}
@customElement({ name: 'gc-21', template: 'gc21' })
class GrandChildTwoOne { }
@customElement({ name: 'gc-22', template: 'gc22' })
class GrandChildTwoTwo { }
@customElement({ name: 'gc-23', template: 'gc23' })
class GrandChildTwoThree { }
@route({
routes: [
{ id: 'r1', path: 'gc21', component: GrandChildTwoOne },
{ id: 'r2', path: 'gc22', component: GrandChildTwoTwo },
{ id: 'r3', path: 'gc23', component: GrandChildTwoThree },
],
})
@customElement({ name: 'c-two', template: t2 })
class ChildTwo {
private readonly parentCtx: IRouteContext;
public constructor(@IRouteContext ctx: IRouteContext) {
this.parentCtx = ctx.parent;
}
}
@route({
routes: [
{
path: '',
redirectTo: 'c1',
},
{
id: 'r1',
path: 'c1',
component: ChildOne,
},
{
id: 'r2',
path: 'c2',
component: ChildTwo,
},
],
})
@customElement({ name: 'my-app', template: rt })
class Root { }
const { au, host, container } = await start({ appRoot: Root });
const location = container.get<MockBrowserHistoryLocation>(ILocation);
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
let anchors = host.querySelectorAll('a');
assertAnchors(anchors, expectations1, 'round#1 - anchors', false);
assert.match(location.path, /c1$/, 'round#1 - location.path');
anchors[2].click();
await queue.yield();
assert.match(location.path, /c1\/gc11\+gc12$/, 'round#2 - location.path');
anchors[3].click();
await queue.yield();
assert.match(location.path, /c1\/gc12\+gc13$/, 'round#3 - location.path');
anchors[4].click();
await queue.yield();
assert.match(location.path, /c1\/gc13\+gc11$/, 'round#4 - location.path');
anchors[1].click();
await queue.yield();
anchors = host.querySelectorAll('a');
assertAnchors(anchors, expectations2, 'round#5 - anchors', false);
assert.match(location.path, /c2$/, 'round#5 - location.path');
anchors[2].click();
await queue.yield();
assert.match(location.path, /c2\/gc21\+gc22$/, 'round#6 - location.path');
anchors[3].click();
await queue.yield();
assert.match(location.path, /c2\/gc22\+gc23$/, 'round#7 - location.path');
anchors[4].click();
await queue.yield();
assert.match(location.path, /c2\/gc23\+gc21$/, 'round#8 - location.path');
anchors[0].click();
await queue.yield();
anchors = host.querySelectorAll('a');
assertAnchors(anchors, expectations1, 'round#9 - anchors', false);
assert.match(location.path, /c1$/, 'round#9 - location.path');
await au.stop(true);
});
}
it('supports routing instruction with parenthesized parameters', async function () {
@route('c1/:id1/:id2?')
@customElement({ name: 'c-1', template: 'c1 ${id1} ${id2}' })
class C1 implements IRouteViewModel {
private id1: string;
private id2: string;
public loading(params: Params, _next: RouteNode, _current: RouteNode): void | Promise<void> {
this.id1 = params.id1;
this.id2 = params.id2;
}
}
@route({ routes: [{ id: 'c1', component: C1 }] })
@customElement({ name: 'ro-ot', template: '<a load="c1(id1=1)"></a> <a load="c1(id1=2,id2=3)"></a> <au-viewport></au-viewport>' })
class Root { }
const { au, container, host } = await start({ appRoot: Root });
const queue = container.get(IPlatform).domWriteQueue;
assert.html.textContent(host, '', 'init');
host.querySelector('a').click();
await queue.yield();
assert.html.textContent(host, 'c1 1', 'round#1');
host.querySelector<HTMLAnchorElement>('a:nth-of-type(2)').click();
await queue.yield();
assert.html.textContent(host, 'c1 2 3', 'round#2');
await au.stop(true);
});
it('allow navigating to route defined in parent context using ../ prefix with replace transitionPlan and child viewport', async function () {
@customElement({ name: 'product-details', template: `product \${id} <a load="../../products"></a>` })
class Product {
id: unknown;
public canLoad(params: Params, _next: RouteNode, _current: RouteNode): boolean {
this.id = params.id;
return true;
}
}
@customElement({ name: 'product-init', template: `product init <a load="../product/1"></a><a load="../product/2"></a>` })
class ProductInit { }
@route({
routes: [
{ path: '', component: ProductInit },
{ path: 'product/:id', component: Product },
]
})
@customElement({ name: 'pro-ducts', template: `<au-viewport name="products"></au-viewport>` })
class Products { }
@route({
routes: [
{ id: 'products', path: ['', 'products'], component: Products },
],
transitionPlan: 'replace',
})
@customElement({ name: 'ro-ot', template: '<au-viewport name="root"></au-viewport>' })
class Root { }
const { au, host, container } = await start({ appRoot: Root, registrations: [Products, Product] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
assert.html.textContent(host, 'product init');
const anchors = Array.from(host.querySelectorAll('a'));
const hrefs = anchors.map(a => a.href);
assert.match(hrefs[0], /product\/1$/);
assert.match(hrefs[1], /product\/2$/);
anchors[0].click();
await queue.yield();
assert.html.textContent(host, 'product 1', 'round#1');
// go back
const back = host.querySelector<HTMLAnchorElement>('a');
assert.match(back.href, /products$/, 'round#1 - back - href');
back.click();
await queue.yield();
assert.html.textContent(host, 'product init', 'round#1 - back - text');
// 2nd round
host.querySelector<HTMLAnchorElement>('a:nth-of-type(2)').click();
await queue.yield();
assert.html.textContent(host, 'product 2', 'round#2');
// go back
host.querySelector<HTMLAnchorElement>('a').click();
await queue.yield();
assert.html.textContent(host, 'product init', 'round#2 - back - text');
await au.stop(true);
});
for (const value of [null, undefined]) {
it(`${value} value for a query-string param is ignored`, async function () {
@route('product')
@customElement({ name: 'pro-duct', template: `product` })
class Product {
public query: Readonly<URLSearchParams>;
public canLoad(_params: Params, _next: RouteNode, _current: RouteNode): boolean {
this.query = _next.queryParams;
return true;
}
}
@route({
routes: [
Product,
]
})
@customElement({ name: 'ro-ot', template: '<a load="route:product; params.bind: {id: value}"></a><au-viewport></au-viewport>' })
class Root {
private readonly value = value;
}
const { au, host, container } = await start({ appRoot: Root, registrations: [Product] });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
host.querySelector('a').click();
await queue.yield();
const product = CustomElement.for<Product>(host.querySelector('pro-duct')).viewModel;
const query = product.query;
assert.strictEqual(query.get('id'), null);
assert.deepStrictEqual(Array.from(query.keys()), []);
await au.stop(true);
});
}
it('respects constrained routes', async function () {
@route('nf')
@customElement({ name: 'not-found', template: `nf` })
class NotFound { }
@route({ id: 'product', path: 'product/:id{{^\\d+$}}' })
@customElement({ name: 'pro-duct', template: `product \${id}` })
class Product {
public id: unknown;
public canLoad(params: Params, _next: RouteNode, _current: RouteNode): boolean {
this.id = params.id;
return true;
}
}
@route({ routes: [Product, NotFound], fallback: 'nf' })
@customElement({
name: 'ro-ot',
template: `
<a load="route:product; params.bind:{id: 42}"></a>
<a load="route:product; params.bind:{id: foo}"></a>
<a load="product/bar"></a>
<au-viewport></au-viewport>
`
})
class Root { }
const { au, host, container } = await start({ appRoot: Root });
const queue = container.get(IPlatform).domWriteQueue;
await queue.yield();
const anchors = Array.from(host.querySelectorAll('a'));
anchors[0].click();
await queue.yield();
assert.html.textContent(host, 'product 42');
anchors[1].click();
await queue.yield();
assert.html.textContent(host, 'nf');
anchors[0].click();
await queue.yield();
assert.html.textContent(host, 'product 42');
anchors[2].click();
await queue.yield();
assert.html.textContent(host, 'nf');
await au.stop(true);
});
});