Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const baseConfig = [
'no-shadow': 'error',
'no-template-curly-in-string': 'warn',
'no-throw-literal': 'error',
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
yoda: 'warn',
},
},
Expand All @@ -86,12 +87,14 @@ const baseConfig = [
// Disable eslint rules that interfere with typescript rules
'no-empty-function': 'off',
'no-shadow': 'off',
'no-unused-vars': 'off',

'@typescript-eslint/array-type': 'error',
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'no-public' }],
'@typescript-eslint/no-empty-function': ['error', { allow: ['arrowFunctions', 'constructors'] }],
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/no-shadow': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
];
Expand Down
1 change: 1 addition & 0 deletions libs/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export {
type RouterlessLinkHandlerContext,
} from './lib/any-link/link-handler';
export { RouterLinkHandler, type RouterLinkHandlerContext } from './lib/any-link/router-link-handler';
export { ID_GENERATOR_OPTIONS, IdGenerator, type IdGeneratorOptions } from './lib/id-generator';
1 change: 0 additions & 1 deletion libs/common/src/lib/any-link/any-link.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { fireEvent, render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import { AnyLink } from './any-link';
import { LinkHandler, type LinkAttributes, type LinkCommand, type PreparedLink } from './link-handler';
import { TestBed } from '@angular/core/testing';

class MockLinkHandler extends LinkHandler {
readonly prepareLink = vi.fn(
Expand Down
28 changes: 27 additions & 1 deletion libs/common/src/lib/any-link/link-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
} from './link-handler';

describe('RouterlessLinkHandler', () => {
const BASE_URL = 'https://example.com/privacy';

let handler: RouterlessLinkHandler;
let browserLocation: { assign: ReturnType<typeof vi.fn>; replace: ReturnType<typeof vi.fn> };
let angularLocation: {
Expand Down Expand Up @@ -82,7 +84,7 @@ describe('RouterlessLinkHandler', () => {
});

describe('prepareLink', () => {
it('should serialize absolute commands into external URLs', () => {
it('should serialize route commands into external URLs', () => {
configureTestingModule();

const attributes: LinkAttributes = { rel: 'noopener', target: '_blank' };
Expand All @@ -93,6 +95,30 @@ describe('RouterlessLinkHandler', () => {
expect(link.handlerContext.isAnchorLikeElement).toBe(false);
});

it('should preserve absolute URL commands', () => {
configureTestingModule();

const link = handler.prepareLink({ command: `${BASE_URL}` });

expect(link.href).toBe(BASE_URL);
});

it('should apply query params and fragment options to absolute URL commands', () => {
configureTestingModule();

const link = handler.prepareLink({
command: `${BASE_URL}?existing=1#old`,
queryParamsHandling: 'merge',
queryParams: {
existing: '2',
add: ['a', 'b'],
},
fragment: 'new',
});

expect(link.href).toBe(`${BASE_URL}?existing=2&add=a&add=b#new`);
});

it('should resolve relative commands and preserve query params and fragment when configured', () => {
configureTestingModule();

Expand Down
19 changes: 18 additions & 1 deletion libs/common/src/lib/any-link/link-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import {
UrlTree,
} from '@angular/router';
import { CUSTOM_ELEMENT_REGISTRY, LOCATION } from '@atlasng/core';
import { castArray, isAnchorLikeElement, isUrlTree } from './utils';
import {
applyQueryParamsAndFragmentToUrl,
castArray,
isAnchorLikeElement,
isUrlTree,
tryParseAbsoluteUrl,
} from './utils';

/**
* Command input for link preparation.
Expand Down Expand Up @@ -160,6 +166,7 @@ export class RouterlessLinkHandler extends LinkHandler<RouterlessLinkHandlerCont
): PreparedLink<RouterlessLinkHandlerContext> {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
if (command.relativeTo) {
// eslint-disable-next-line no-console
console.warn('The "relativeTo" option is not supported by RouterlessLinkHandler.');
}
}
Expand Down Expand Up @@ -188,14 +195,17 @@ export class RouterlessLinkHandler extends LinkHandler<RouterlessLinkHandlerCont
): boolean {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
if (options.skipLocationChange) {
// eslint-disable-next-line no-console
console.warn('The "skipLocationChange" option is not supported by RouterlessLinkHandler.');
}

if (options.state) {
// eslint-disable-next-line no-console
console.warn('The "state" option is not supported by RouterlessLinkHandler.');
}

if (options.browserUrl) {
// eslint-disable-next-line no-console
console.warn('The "browserUrl" option is not supported by RouterlessLinkHandler.');
}
}
Expand Down Expand Up @@ -232,6 +242,12 @@ export class RouterlessLinkHandler extends LinkHandler<RouterlessLinkHandlerCont
* @returns External URL ready for navigation.
*/
protected serializeCommand(command: LinkCommand): string {
const absoluteUrl = tryParseAbsoluteUrl(command.command);
if (absoluteUrl) {
applyQueryParamsAndFragmentToUrl(absoluteUrl, command);
return absoluteUrl.toString();
}

const urlTree = this.commandToUrlTree(command);
const url = this.serializer.serialize(urlTree);
return this.location.prepareExternalUrl(url);
Expand Down Expand Up @@ -293,6 +309,7 @@ export class RouterlessLinkHandler extends LinkHandler<RouterlessLinkHandlerCont
path = String(part.segmentPath);
} else {
if ('outlets' in part && (typeof ngDevMode === 'undefined' || ngDevMode)) {
// eslint-disable-next-line no-console
console.warn('Outlets in command arrays are not supported by RouterlessLinkHandler and will be skipped.');
}

Expand Down
18 changes: 10 additions & 8 deletions libs/common/src/lib/any-link/router-link-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { CUSTOM_ELEMENT_REGISTRY, LOCATION } from '@atlasng/core';
import { RouterLinkHandler } from './router-link-handler';

describe('RouterLinkHandler', () => {
const BASE_URL = 'https://example.com/page';

let handler: RouterLinkHandler;
let router: {
createUrlTree: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -99,7 +101,7 @@ describe('RouterLinkHandler', () => {
configureTestingModule();

const link = handler.prepareLink({
command: 'https://example.com/page?existing=1#old',
command: `${BASE_URL}?existing=1#old`,
queryParamsHandling: 'merge',
queryParams: {
existing: '2',
Expand All @@ -108,31 +110,31 @@ describe('RouterLinkHandler', () => {
fragment: 'next',
});

expect(link.href).toBe('https://example.com/page?existing=2&extra=a&extra=b#next');
expect(link.href).toBe(`${BASE_URL}?existing=2&extra=a&extra=b#next`);
expect(link.handlerContext.urlTree).toBeUndefined();
});

it('should replace existing search params when query params are provided without queryParamsHandling', () => {
configureTestingModule();

const link = handler.prepareLink({
command: 'https://example.com/page?existing=1',
command: `${BASE_URL}?existing=1`,
queryParams: { param: 'value' },
});

expect(link.href).toBe('https://example.com/page?param=value');
expect(link.href).toBe(`${BASE_URL}?param=value`);
});

it('should keep the original fragment when preserveFragment is true', () => {
configureTestingModule();

const link = handler.prepareLink({
command: 'https://example.com/page#old',
command: `${BASE_URL}#old`,
preserveFragment: true,
fragment: 'new',
});

expect(link.href).toBe('https://example.com/page#old');
expect(link.href).toBe(`${BASE_URL}#old`);
});

it('should use UrlTree commands directly', () => {
Expand Down Expand Up @@ -181,7 +183,7 @@ describe('RouterLinkHandler', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);

handler.prepareLink({
command: 'https://example.com/page',
command: `${BASE_URL}`,
relativeTo: {} as ActivatedRoute,
});

Expand All @@ -197,7 +199,7 @@ describe('RouterLinkHandler', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);

handler.prepareLink({
command: 'https://example.com/page',
command: `${BASE_URL}`,
relativeTo: {} as ActivatedRoute,
});

Expand Down
64 changes: 17 additions & 47 deletions libs/common/src/lib/any-link/router-link-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
RouterlessLinkHandler,
RouterlessLinkHandlerContext,
} from './link-handler';
import { canParseUrl, castArray, isUrlTree } from './utils';
import { castArray, isUrlTree, tryParseAbsoluteUrl } from './utils';

/**
* Context for router link handling.
Expand All @@ -32,7 +32,7 @@ export class RouterLinkHandler extends RouterlessLinkHandler implements LinkHand
/** Angular Router instance used to build and execute route-based navigation. */
protected readonly router = inject(Router);

/** Centralized Angular error handler for async navigation failures. */
/** Angular error handler for reporting navigation failures. */
protected readonly errorHandler = inject(ErrorHandler);

/**
Expand All @@ -50,31 +50,26 @@ export class RouterLinkHandler extends RouterlessLinkHandler implements LinkHand
attributes?: LinkAttributes,
injector?: Injector,
): PreparedLink<RouterLinkHandlerContext> {
let href: string;
let urlTree: UrlTree | undefined;

if (typeof command.command === 'string' && canParseUrl(command.command)) {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
if (command.relativeTo) {
console.warn('The "relativeTo" option is not supported for absolute URLs in RouterLinkHandler.');
}
if (tryParseAbsoluteUrl(command.command)) {
if (command.relativeTo && (typeof ngDevMode === 'undefined' || ngDevMode)) {
// eslint-disable-next-line no-console
console.warn('The "relativeTo" option is not supported for absolute URLs in RouterLinkHandler.');
}

const url = new URL(command.command);
this.applyQueryParamsAndFragment(url, command);
href = url.toString();
} else {
if (isUrlTree(command.command)) {
urlTree = command.command;
} else {
const relativeTo = command.relativeTo ?? injector?.get(ActivatedRoute, null);
urlTree = this.router.createUrlTree(castArray(command.command), { ...command, relativeTo });
}
return super.prepareLink({ ...command, relativeTo: undefined }, element, attributes, injector);
}

const url = this.router.serializeUrl(urlTree);
href = this.location.prepareExternalUrl(url);
let urlTree: UrlTree | undefined;
if (isUrlTree(command.command)) {
urlTree = command.command;
} else {
const relativeTo = command.relativeTo ?? injector?.get(ActivatedRoute, null);
urlTree = this.router.createUrlTree(castArray(command.command), { ...command, relativeTo });
}

const url = this.router.serializeUrl(urlTree);
const href = this.location.prepareExternalUrl(url);

return {
href,
attributes,
Expand Down Expand Up @@ -124,29 +119,4 @@ export class RouterLinkHandler extends RouterlessLinkHandler implements LinkHand

return !isAnchorLikeElement;
}

/**
* Applies query-param and fragment options from a link command onto an absolute URL.
*
* @param url Absolute URL instance to mutate.
* @param command Link command containing query/fragment options.
*/
private applyQueryParamsAndFragment(url: URL, command: LinkCommand): void {
if (!command.queryParamsHandling && command.queryParams) {
url.search = '';
}
if (command.queryParamsHandling !== 'preserve' && command.queryParams) {
for (const [key, value] of Object.entries(command.queryParams)) {
const values = castArray(value);
url.searchParams.delete(key);
for (const v of values) {
url.searchParams.append(key, v);
}
}
}

if (!command.preserveFragment && command.fragment !== undefined) {
url.hash = command.fragment;
}
}
}
Loading