Skip to content

Commit

Permalink
feat(common): add Image directive skeleton (#45627) (#47082)
Browse files Browse the repository at this point in the history
This commit adds Image directive skeleton as well as a set of basic tests.

PR Close #47082
  • Loading branch information
AndrewKushnir authored and Pawel Kozlowski committed Aug 16, 2022
1 parent 31429ea commit 86e77a5
Show file tree
Hide file tree
Showing 4 changed files with 271 additions and 1 deletion.
6 changes: 5 additions & 1 deletion goldens/public-api/common/errors.md
Expand Up @@ -6,12 +6,16 @@

// @public
export const enum RuntimeErrorCode {
// (undocumented)
INVALID_INPUT = 2951,
// (undocumented)
INVALID_PIPE_ARGUMENT = 2100,
// (undocumented)
NG_FOR_MISSING_DIFFER = -2200,
// (undocumented)
PARENT_NG_SWITCH_NOT_FOUND = 2000
PARENT_NG_SWITCH_NOT_FOUND = 2000,
// (undocumented)
UNEXPECTED_SRC_ATTR = 2950
}

// (No @packageDocumentation comment for this package)
Expand Down
158 changes: 158 additions & 0 deletions packages/common/src/directives/ng_image.ts
@@ -0,0 +1,158 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Directive, Inject, InjectionToken, Input, SimpleChanges, ɵRuntimeError as RuntimeError} from '@angular/core';

import {RuntimeErrorCode} from '../errors';

/**
* Config options recognized by the image loader function.
*/
export interface ImageLoaderConfig {
src: string;
quality?: number;
width?: number;
}

/**
* Represents an image loader function.
*/
export type ImageLoader = (config: ImageLoaderConfig) => string;

/**
* Noop image loader that does no transformation to the original src and just returns it as is.
* This loader is used as a default one if more specific logic is not provided in an app config.
*/
const noopImageLoader = (config: ImageLoaderConfig) => config.src;

/**
* Special token that allows to configure a function that will be used to produce an image URL based
* on the specified input.
*/
export const IMAGE_LOADER = new InjectionToken<ImageLoader>('ImageLoader', {
providedIn: 'root',
factory: () => noopImageLoader,
});

/**
* ** EXPERIMENTAL **
*
* TODO: add Image directive description.
*
* IMPORTANT: this directive should become standalone (i.e. not attached to any NgModule) once
* the `standalone` flag is implemented and available as a public API.
*
* @usageNotes
* TODO: add Image directive usage notes.
*/
@Directive({
selector: 'img[raw-src]',
host: {
'[src]': 'getRewrittenSrc()',
},
})
export class NgImage {
constructor(@Inject(IMAGE_LOADER) private imageLoader: ImageLoader) {}

// Private fields to keep normalized input values.
private _width?: number;
private _height?: number;

/**
* Name of the source image.
* Image name will be processed by the image loader and the final URL will be applied as the `src`
* property of the image.
*/
@Input('raw-src') rawSrc!: string;

/**
* The intrinsic width of the image in px.
* This input is required unless the 'fill-parent' is provided.
*/
@Input()
set width(value: string|number|undefined) {
this._width = inputToInteger(value);
}
get width(): number|undefined {
return this._width;
}

/**
* The intrinsic height of the image in px.
* This input is required unless the 'fill-parent' is provided.
*/
@Input()
set height(value: string|number|undefined) {
this._height = inputToInteger(value);
}
get height(): number|undefined {
return this._height;
}

/**
* Function that takes the name of the image, its width, and a quality %,
* then turns it into a valid image CDN URL.
*/
@Input() loader?: (config: ImageLoaderConfig) => string;

/**
* Get a value of the `src` if it's set on a host <img> element.
* This input is needed to verify that there are no `src` and `raw-src` provided
* at the same time (thus causing an ambiguity on which src to use).
*/
@Input() src?: string;

ngOnInit() {
if (ngDevMode) {
assertExistingSrc(this);
}
}

ngOnChanges(changes: SimpleChanges) {
// TODO: react to input changes.
}

getRewrittenSrc(): string {
// If a loader is provided as an input - use it, otherwise fall back
// to the loader configured globally using the `IMAGE_LOADER` token.
const imgLoader = this.loader ?? this.imageLoader;
const imgConfig = {
src: this.rawSrc,
// TODO: if we're going to support responsive serving, we don't want to request the width
// based solely on the intrinsic width (e.g. if it's on mobile and the viewport is smaller).
// The width would require pre-processing before passing to the image loader function.
width: this.width,
};
return imgLoader(imgConfig);
}
}

/***** Helpers *****/

// Convert input value to integer.
function inputToInteger(value: string|number|undefined): number|undefined {
return typeof value === 'string' ? parseInt(value, 10) : value;
}

function imgDirectiveDetails(dir: NgImage) {
return `The NgImage directive (activated on an <img> element ` +
`with the \`raw-src="${dir.rawSrc}"\`)`;
}

/***** Assert functions *****/

// Verifies that there is no `src` set on a host element.
function assertExistingSrc(dir: NgImage) {
if (dir.src) {
throw new RuntimeError(
RuntimeErrorCode.UNEXPECTED_SRC_ATTR,
`${imgDirectiveDetails(dir)} detected that the \`src\` is also set (to \`${dir.src}\`). ` +
`Please remove the \`src\` attribute from this image. The NgImage directive will use ` +
`the \`raw-src\` to compute the final image URL and set the \`src\` itself.`);
}
}
6 changes: 6 additions & 0 deletions packages/common/src/errors.ts
Expand Up @@ -13,8 +13,14 @@
export const enum RuntimeErrorCode {
// NgSwitch errors
PARENT_NG_SWITCH_NOT_FOUND = 2000,

// Pipe errors
INVALID_PIPE_ARGUMENT = 2100,

// NgForOf errors
NG_FOR_MISSING_DIFFER = -2200,

// Image directive errors
UNEXPECTED_SRC_ATTR = 2950,
INVALID_INPUT = 2951,
}
102 changes: 102 additions & 0 deletions packages/common/test/directives/ng_image_spec.ts
@@ -0,0 +1,102 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {CommonModule} from '@angular/common';
import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgImage} from '@angular/common/src/directives/ng_image';
import {Component} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {expect} from '@angular/platform-browser/testing/src/matchers';

describe('Image directive', () => {
it('should set `src` to `raw-src` value if image loader is not provided', () => {
setupTestingModule();

const template = '<img raw-src="path/img.png" width="100" height="50">';
const fixture = createTestComponent(template);
fixture.detectChanges();

const nativeElement = fixture.nativeElement as HTMLElement;
const img = nativeElement.querySelector('img')!;
expect(img.src.endsWith('/path/img.png')).toBeTrue();
});

it('should use an image loader provided via `IMAGE_LOADER` token', () => {
const imageLoader = (config: ImageLoaderConfig) => `${config.src}?w=${config.width}`;
setupTestingModule({imageLoader});

const template = '<img raw-src="path/img.png" width="150" height="50">';
const fixture = createTestComponent(template);
fixture.detectChanges();

const nativeElement = fixture.nativeElement as HTMLElement;
const img = nativeElement.querySelector('img')!;
expect(img.src.endsWith('/path/img.png?w=150')).toBeTrue();
});

it('should use an image loader from inputs over the one provided via `IMAGE_LOADER` token',
() => {
const imageLoader = (config: ImageLoaderConfig) =>
`${config.src}?w=${config.width}&source=IMAGE_LOADER`;
setupTestingModule({imageLoader});

const template =
'<img raw-src="path/img.png" [loader]="cmpImageLoader" width="150" height="50">';
const fixture = createTestComponent(template);
fixture.detectChanges();

const nativeElement = fixture.nativeElement as HTMLElement;
const img = nativeElement.querySelector('img')!;
expect(img.src.endsWith('/path/img.png?w=150&source=component')).toBeTrue();
});

describe('setup error handling', () => {
it('should throw if both `src` and `raw-src` are present', () => {
setupTestingModule();

const template = '<img raw-src="path/img.png" src="path/img2.png" width="100" height="50">';
expect(() => {
const fixture = createTestComponent(template);
fixture.detectChanges();
})
.toThrowError(
'NG02950: The NgImage directive (activated on an <img> element with the ' +
'`raw-src="path/img.png"`) detected that the `src` is also set (to `path/img2.png`). ' +
'Please remove the `src` attribute from this image. The NgImage directive will use ' +
'the `raw-src` to compute the final image URL and set the `src` itself.');
});
});
});

// Helpers

@Component({
selector: 'test-cmp',
template: '',
})
class TestComponent {
cmpImageLoader = (config: ImageLoaderConfig) => {
return `${config.src}?w=${config.width}&source=component`;
}
}

function setupTestingModule(config?: {imageLoader: ImageLoader}) {
const providers =
config?.imageLoader ? [{provide: IMAGE_LOADER, useValue: config?.imageLoader}] : [];
TestBed.configureTestingModule({
// Note: the `NgImage` is a part of declarations for now,
// since it's experimental and not yet added to the `CommonModule`.
declarations: [TestComponent, NgImage],
imports: [CommonModule],
providers,
});
}

function createTestComponent(template: string): ComponentFixture<TestComponent> {
return TestBed.overrideComponent(TestComponent, {set: {template: template}})
.createComponent(TestComponent);
}

0 comments on commit 86e77a5

Please sign in to comment.