mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
committed by
Miško Hevery
parent
22b96b9690
commit
7c9b411777
@@ -0,0 +1,100 @@
|
||||
import { Component, ViewChild } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CodeExampleComponent } from './code-example.component';
|
||||
import { CodeExampleModule } from './code-example.module';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { MockLogger } from 'testing/logger.service';
|
||||
|
||||
describe('CodeExampleComponent', () => {
|
||||
let hostComponent: HostComponent;
|
||||
let codeExampleComponent: CodeExampleComponent;
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ CodeExampleModule ],
|
||||
declarations: [
|
||||
HostComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: Logger, useClass: MockLogger },
|
||||
]
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
hostComponent = fixture.componentInstance;
|
||||
codeExampleComponent = hostComponent.codeExampleComponent;
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should be able to capture the code snippet provided in content', () => {
|
||||
expect(codeExampleComponent.code.trim()).toBe(`const foo = "bar";`);
|
||||
});
|
||||
|
||||
it('should change aio-code classes based on title presence', () => {
|
||||
expect(codeExampleComponent.title).toBe('Great Example');
|
||||
expect(fixture.nativeElement.querySelector('header')).toBeTruthy();
|
||||
expect(codeExampleComponent.classes).toEqual({
|
||||
'headed-code': true,
|
||||
'simple-code': false
|
||||
});
|
||||
|
||||
codeExampleComponent.title = '';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(codeExampleComponent.title).toBe('');
|
||||
expect(fixture.nativeElement.querySelector('header')).toBeFalsy();
|
||||
expect(codeExampleComponent.classes).toEqual({
|
||||
'headed-code': false,
|
||||
'simple-code': true
|
||||
});
|
||||
});
|
||||
|
||||
it('should set avoidFile class if path has .avoid.', () => {
|
||||
const codeExampleComponentElement: HTMLElement =
|
||||
fixture.nativeElement.querySelector('code-example');
|
||||
|
||||
expect(codeExampleComponent.path).toBe('code-path');
|
||||
expect(codeExampleComponentElement.className.indexOf('avoidFile') === -1).toBe(true);
|
||||
|
||||
codeExampleComponent.path = 'code-path.avoid.';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(codeExampleComponentElement.className.indexOf('avoidFile') === -1).toBe(false);
|
||||
});
|
||||
|
||||
it('should coerce hidecopy', () => {
|
||||
expect(codeExampleComponent.hidecopy).toBe(false);
|
||||
|
||||
hostComponent.hidecopy = true;
|
||||
fixture.detectChanges();
|
||||
expect(codeExampleComponent.hidecopy).toBe(true);
|
||||
|
||||
hostComponent.hidecopy = 'false';
|
||||
fixture.detectChanges();
|
||||
expect(codeExampleComponent.hidecopy).toBe(false);
|
||||
|
||||
hostComponent.hidecopy = 'true';
|
||||
fixture.detectChanges();
|
||||
expect(codeExampleComponent.hidecopy).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: 'aio-host-comp',
|
||||
template: `
|
||||
<code-example [title]="title" [path]="path" [hidecopy]="hidecopy">
|
||||
{{code}}
|
||||
</code-example>
|
||||
`
|
||||
})
|
||||
class HostComponent {
|
||||
code = `const foo = "bar";`;
|
||||
title = 'Great Example';
|
||||
path = 'code-path';
|
||||
hidecopy: boolean | string = false;
|
||||
|
||||
@ViewChild(CodeExampleComponent) codeExampleComponent: CodeExampleComponent;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* tslint:disable component-selector */
|
||||
import { Component, HostBinding, ElementRef, ViewChild, Input, AfterViewInit } from '@angular/core';
|
||||
import { CodeComponent } from './code.component';
|
||||
|
||||
/**
|
||||
* An embeddable code block that displays nicely formatted code.
|
||||
* Example usage:
|
||||
*
|
||||
* ```
|
||||
* <code-example language="ts" linenums="2" class="special" title="Do Stuff">
|
||||
* // a code block
|
||||
* console.log('do stuff');
|
||||
* </code-example>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'code-example',
|
||||
template: `
|
||||
<!-- Content projection is used to get the content HTML provided to this component -->
|
||||
<div #content style="display: none"><ng-content></ng-content></div>
|
||||
|
||||
<header *ngIf="title">{{title}}</header>
|
||||
|
||||
<aio-code [ngClass]="classes"
|
||||
[language]="language"
|
||||
[linenums]="linenums"
|
||||
[path]="path"
|
||||
[region]="region"
|
||||
[hideCopy]="hidecopy"
|
||||
[title]="title">
|
||||
</aio-code>
|
||||
`,
|
||||
})
|
||||
export class CodeExampleComponent implements AfterViewInit {
|
||||
classes: {};
|
||||
|
||||
code: string;
|
||||
|
||||
@Input() language: string;
|
||||
|
||||
@Input() linenums: string;
|
||||
|
||||
@Input() region: string;
|
||||
|
||||
@Input()
|
||||
set title(title: string) {
|
||||
this._title = title;
|
||||
this.classes = {
|
||||
'headed-code': !!this.title,
|
||||
'simple-code': !this.title,
|
||||
};
|
||||
}
|
||||
get title(): string { return this._title; }
|
||||
private _title: string;
|
||||
|
||||
@Input()
|
||||
set path(path: string) {
|
||||
this._path = path;
|
||||
this.isAvoid = this.path.indexOf('.avoid.') !== -1;
|
||||
}
|
||||
get path(): string { return this._path; }
|
||||
private _path = '';
|
||||
|
||||
@Input()
|
||||
set hidecopy(hidecopy: boolean) {
|
||||
// Coerce the boolean value.
|
||||
this._hidecopy = hidecopy != null && `${hidecopy}` !== 'false';
|
||||
}
|
||||
get hidecopy(): boolean { return this._hidecopy; }
|
||||
private _hidecopy: boolean;
|
||||
|
||||
@Input('hide-copy')
|
||||
set hyphenatedHideCopy(hidecopy: boolean) {
|
||||
this.hidecopy = hidecopy;
|
||||
}
|
||||
|
||||
@Input('hideCopy')
|
||||
set capitalizedHideCopy(hidecopy: boolean) {
|
||||
this.hidecopy = hidecopy;
|
||||
}
|
||||
|
||||
@HostBinding('class.avoidFile') isAvoid = false;
|
||||
|
||||
@ViewChild('content') content: ElementRef;
|
||||
|
||||
@ViewChild(CodeComponent) aioCode: CodeComponent;
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.aioCode.code = this.content.nativeElement.innerHTML;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NgModule, Type } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CodeExampleComponent } from './code-example.component';
|
||||
import { CodeModule } from './code.module';
|
||||
import { WithCustomElementComponent } from '../element-registry';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule, CodeModule ],
|
||||
declarations: [ CodeExampleComponent ],
|
||||
exports: [ CodeExampleComponent ],
|
||||
entryComponents: [ CodeExampleComponent ]
|
||||
})
|
||||
export class CodeExampleModule implements WithCustomElementComponent {
|
||||
customElementComponent: Type<any> = CodeExampleComponent;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Component, ViewChild, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { MockLogger } from 'testing/logger.service';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
|
||||
import { CodeTabsComponent } from './code-tabs.component';
|
||||
import { CodeTabsModule } from './code-tabs.module';
|
||||
|
||||
describe('CodeTabsComponent', () => {
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
let hostComponent: HostComponent;
|
||||
let codeTabsComponent: CodeTabsComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ HostComponent ],
|
||||
imports: [ CodeTabsModule, NoopAnimationsModule ],
|
||||
schemas: [ NO_ERRORS_SCHEMA ],
|
||||
providers: [
|
||||
{ provide: Logger, useClass: MockLogger },
|
||||
]
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
hostComponent = fixture.componentInstance;
|
||||
codeTabsComponent = hostComponent.codeTabsComponent;
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should get correct tab info', () => {
|
||||
const tabs = codeTabsComponent.tabs;
|
||||
expect(tabs.length).toBe(2);
|
||||
|
||||
// First code pane expectations
|
||||
expect(tabs[0].class).toBe('class-A');
|
||||
expect(tabs[0].language).toBe('language-A');
|
||||
expect(tabs[0].linenums).toBe('linenums-A');
|
||||
expect(tabs[0].path).toBe('path-A');
|
||||
expect(tabs[0].region).toBe('region-A');
|
||||
expect(tabs[0].title).toBe('title-A');
|
||||
expect(tabs[0].code.trim()).toBe('Code example 1');
|
||||
|
||||
// Second code pane expectations
|
||||
expect(tabs[1].class).toBe('class-B');
|
||||
expect(tabs[1].language).toBe('language-B');
|
||||
expect(tabs[1].linenums).toBe('default-linenums', 'Default linenums should have been used');
|
||||
expect(tabs[1].path).toBe('path-B');
|
||||
expect(tabs[1].region).toBe('region-B');
|
||||
expect(tabs[1].title).toBe('title-B');
|
||||
expect(tabs[1].code.trim()).toBe('Code example 2');
|
||||
});
|
||||
|
||||
it('should create the right number of tabs with the right labels and classes', () => {
|
||||
const matTabs = fixture.nativeElement.querySelectorAll('.mat-tab-label');
|
||||
expect(matTabs.length).toBe(2);
|
||||
|
||||
expect(matTabs[0].textContent.trim()).toBe('title-A');
|
||||
expect(matTabs[0].querySelector('.class-A')).toBeTruthy();
|
||||
|
||||
expect(matTabs[1].textContent.trim()).toBe('title-B');
|
||||
expect(matTabs[1].querySelector('.class-B')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show the first tab with the right code', () => {
|
||||
const codeContent = fixture.nativeElement.querySelector('aio-code').textContent;
|
||||
expect(codeContent.indexOf('Code example 1') !== -1).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: 'aio-host-comp',
|
||||
template: `
|
||||
<code-tabs linenums="default-linenums">
|
||||
<code-pane class="class-A"
|
||||
language="language-A"
|
||||
linenums="linenums-A"
|
||||
path="path-A"
|
||||
region="region-A"
|
||||
title="title-A">
|
||||
Code example 1
|
||||
</code-pane>
|
||||
<code-pane class="class-B"
|
||||
language="language-B"
|
||||
path="path-B"
|
||||
region="region-B"
|
||||
title="title-B">
|
||||
Code example 2
|
||||
</code-pane>
|
||||
</code-tabs>
|
||||
`
|
||||
})
|
||||
class HostComponent {
|
||||
@ViewChild(CodeTabsComponent) codeTabsComponent: CodeTabsComponent;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/* tslint:disable component-selector */
|
||||
import { Component, AfterViewInit, ViewChild, Input, ViewChildren, QueryList, OnInit } from '@angular/core';
|
||||
import { CodeComponent } from './code.component';
|
||||
|
||||
export interface TabInfo {
|
||||
class: string|null;
|
||||
code: string;
|
||||
language: string|null;
|
||||
linenums: any;
|
||||
path: string;
|
||||
region: string;
|
||||
title: string|null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a set of tab group of code snippets.
|
||||
*
|
||||
* The innerHTML of the `<code-tabs>` component should contain `<code-pane>` elements.
|
||||
* Each `<code-pane>` has the same interface as the embedded `<code-example>` component.
|
||||
* The optional `linenums` attribute is the default `linenums` for each code pane.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'code-tabs',
|
||||
template: `
|
||||
<!-- Use content projection so that the provided HTML's code-panes can be split into tabs -->
|
||||
<div #content style="display: none"><ng-content></ng-content></div>
|
||||
|
||||
<mat-tab-group class="code-tab-group" disableRipple>
|
||||
<mat-tab style="overflow-y: hidden;" *ngFor="let tab of tabs">
|
||||
<ng-template mat-tab-label>
|
||||
<span class="{{ tab.class }}">{{ tab.title }}</span>
|
||||
</ng-template>
|
||||
<aio-code class="{{ tab.class }}"
|
||||
[language]="tab.language"
|
||||
[linenums]="tab.linenums"
|
||||
[path]="tab.path"
|
||||
[region]="tab.region"
|
||||
[title]="tab.title">
|
||||
</aio-code>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
`,
|
||||
})
|
||||
export class CodeTabsComponent implements OnInit, AfterViewInit {
|
||||
tabs: TabInfo[];
|
||||
|
||||
@Input('linenums') linenums: string;
|
||||
|
||||
@ViewChild('content') content;
|
||||
|
||||
@ViewChildren(CodeComponent) codeComponents: QueryList<CodeComponent>;
|
||||
|
||||
ngOnInit() {
|
||||
this.tabs = [];
|
||||
const codeExamples = this.content.nativeElement.querySelectorAll('code-pane');
|
||||
|
||||
for (let i = 0; i < codeExamples.length; i++) {
|
||||
const tabContent = codeExamples[i];
|
||||
this.tabs.push(this.getTabInfo(tabContent));
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.codeComponents.toArray().forEach((codeComponent, i) => {
|
||||
codeComponent.code = this.tabs[i].code;
|
||||
});
|
||||
}
|
||||
|
||||
/** Gets the extracted TabInfo data from the provided code-pane element. */
|
||||
private getTabInfo(tabContent: HTMLElement): TabInfo {
|
||||
return {
|
||||
class: tabContent.getAttribute('class'),
|
||||
code: tabContent.innerHTML,
|
||||
language: tabContent.getAttribute('language'),
|
||||
linenums: tabContent.getAttribute('linenums') || this.linenums,
|
||||
path: tabContent.getAttribute('path') || '',
|
||||
region: tabContent.getAttribute('region') || '',
|
||||
title: tabContent.getAttribute('title')
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule, Type } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CodeTabsComponent } from './code-tabs.component';
|
||||
import { MatTabsModule } from '@angular/material';
|
||||
import { CodeModule } from './code.module';
|
||||
import { WithCustomElementComponent } from '../element-registry';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule, MatTabsModule, CodeModule ],
|
||||
declarations: [ CodeTabsComponent ],
|
||||
exports: [ CodeTabsComponent ],
|
||||
entryComponents: [ CodeTabsComponent ]
|
||||
})
|
||||
export class CodeTabsModule implements WithCustomElementComponent {
|
||||
customElementComponent: Type<any> = CodeTabsComponent;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { Component, ViewChild, AfterViewInit } from '@angular/core';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
|
||||
import { CodeComponent } from './code.component';
|
||||
import { CodeModule } from './code.module';
|
||||
import { CopierService } from 'app/shared//copier.service';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { PrettyPrinter } from './pretty-printer.service';
|
||||
|
||||
const oneLineCode = 'const foo = "bar";';
|
||||
|
||||
const smallMultiLineCode = `
|
||||
<hero-details>
|
||||
<h2>Bah Dah Bing</h2>
|
||||
<hero-team>
|
||||
<h3>NYC Team</h3>
|
||||
</hero-team>
|
||||
</hero-details>`;
|
||||
|
||||
const bigMultiLineCode = smallMultiLineCode + smallMultiLineCode + smallMultiLineCode;
|
||||
|
||||
describe('CodeComponent', () => {
|
||||
let hostComponent: HostComponent;
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
|
||||
// WARNING: Chance of cross-test pollution
|
||||
// CodeComponent injects PrettyPrintService
|
||||
// Once PrettyPrintService runs once _anywhere_, its ctor loads `prettify.js`
|
||||
// which sets `window['prettyPrintOne']`
|
||||
// That global survives these tests unless
|
||||
// we take strict measures to wipe it out in the `afterAll`
|
||||
// and make sure THAT runs after the tests by making component creation async
|
||||
afterAll(() => {
|
||||
delete (window as any)['prettyPrint'];
|
||||
delete (window as any)['prettyPrintOne'];
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ NoopAnimationsModule, CodeModule ],
|
||||
declarations: [ HostComponent ],
|
||||
providers: [
|
||||
PrettyPrinter,
|
||||
CopierService,
|
||||
{provide: Logger, useClass: TestLogger }
|
||||
]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
// Must be async because
|
||||
// CodeComponent creates PrettyPrintService which async loads `prettify.js`.
|
||||
// If not async, `afterAll` finishes before tests do!
|
||||
beforeEach(async(() => {
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
hostComponent = fixture.componentInstance;
|
||||
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
describe('pretty printing', () => {
|
||||
it('should format a one-line code sample', () => {
|
||||
// 'pln' spans are a tell-tale for syntax highlighing
|
||||
const spans = fixture.nativeElement.querySelectorAll('span.pln');
|
||||
expect(spans.length).toBeGreaterThan(0, 'formatted spans');
|
||||
});
|
||||
|
||||
function hasLineNumbers() {
|
||||
// presence of `<li>`s are a tell-tale for line numbers
|
||||
return 0 < fixture.nativeElement.querySelectorAll('li').length;
|
||||
}
|
||||
|
||||
it('should format a one-line code sample without linenums by default', () => {
|
||||
expect(hasLineNumbers()).toBe(false);
|
||||
});
|
||||
|
||||
it('should add line numbers to one-line code sample when linenums set true', () => {
|
||||
hostComponent.linenums = 'true';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(hasLineNumbers()).toBe(true);
|
||||
});
|
||||
|
||||
it('should format a small multi-line code without linenums by default', () => {
|
||||
hostComponent.setCode(smallMultiLineCode);
|
||||
expect(hasLineNumbers()).toBe(false);
|
||||
});
|
||||
|
||||
it('should add line numbers to a big multi-line code by default', () => {
|
||||
hostComponent.setCode(bigMultiLineCode);
|
||||
expect(hasLineNumbers()).toBe(true);
|
||||
});
|
||||
|
||||
it('should format big multi-line code without linenums when linenums set false', () => {
|
||||
hostComponent.linenums = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
hostComponent.setCode(bigMultiLineCode);
|
||||
expect(hasLineNumbers()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace handling', () => {
|
||||
it('should remove common indentation from the code before rendering', () => {
|
||||
hostComponent.linenums = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
hostComponent.setCode(' abc\n let x = text.split(\'\\n\');\n ghi\n\n jkl\n');
|
||||
const codeContent = fixture.nativeElement.querySelector('code').textContent;
|
||||
expect(codeContent).toEqual('abc\n let x = text.split(\'\\n\');\nghi\n\njkl');
|
||||
});
|
||||
|
||||
it('should trim whitespace from the code before rendering', () => {
|
||||
hostComponent.linenums = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
hostComponent.setCode('\n\n\n' + smallMultiLineCode + '\n\n\n');
|
||||
const codeContent = fixture.nativeElement.querySelector('code').textContent;
|
||||
expect(codeContent).toEqual(codeContent.trim());
|
||||
});
|
||||
|
||||
it('should trim whitespace from code before computing whether to format linenums', () => {
|
||||
hostComponent.setCode('\n\n\n' + oneLineCode + '\n\n\n');
|
||||
|
||||
// `<li>`s are a tell-tale for line numbers
|
||||
const lis = fixture.nativeElement.querySelectorAll('li');
|
||||
expect(lis.length).toBe(0, 'should be no linenums');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error message', () => {
|
||||
|
||||
function getErrorMessage() {
|
||||
const missing: HTMLElement = fixture.nativeElement.querySelector('.code-missing');
|
||||
return missing ? missing.textContent : null;
|
||||
}
|
||||
|
||||
it('should not display "code-missing" class when there is some code', () => {
|
||||
expect(getErrorMessage()).toBeNull('should not have element with "code-missing" class');
|
||||
});
|
||||
|
||||
it('should display error message when there is no code (after trimming)', () => {
|
||||
hostComponent.setCode(' \n ');
|
||||
expect(getErrorMessage()).toContain('missing');
|
||||
});
|
||||
|
||||
it('should show path and region in missing-code error message', () => {
|
||||
hostComponent.path = 'fizz/buzz/foo.html';
|
||||
hostComponent.region = 'something';
|
||||
fixture.detectChanges();
|
||||
|
||||
hostComponent.setCode(' \n ');
|
||||
expect(getErrorMessage()).toMatch(/for[\s\S]fizz\/buzz\/foo\.html#something$/);
|
||||
});
|
||||
|
||||
it('should show path only in missing-code error message when no region', () => {
|
||||
hostComponent.path = 'fizz/buzz/foo.html';
|
||||
fixture.detectChanges();
|
||||
|
||||
hostComponent.setCode(' \n ');
|
||||
expect(getErrorMessage()).toMatch(/for[\s\S]fizz\/buzz\/foo\.html$/);
|
||||
});
|
||||
|
||||
it('should show simple missing-code error message when no path/region', () => {
|
||||
hostComponent.setCode(' \n ');
|
||||
expect(getErrorMessage()).toMatch(/missing.$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy button', () => {
|
||||
|
||||
function getButton() {
|
||||
const btnDe = fixture.debugElement.query(By.css('button'));
|
||||
return btnDe ? btnDe.nativeElement : null;
|
||||
}
|
||||
|
||||
it('should be hidden if the `hideCopy` input is true', () => {
|
||||
hostComponent.hideCopy = true;
|
||||
fixture.detectChanges();
|
||||
expect(getButton()).toBe(null);
|
||||
});
|
||||
|
||||
it('should have title', () => {
|
||||
expect(getButton().title).toBe('Copy code snippet');
|
||||
});
|
||||
|
||||
it('should have no aria-label by default', () => {
|
||||
expect(getButton().getAttribute('aria-label')).toBe('');
|
||||
});
|
||||
|
||||
it('should have aria-label explaining what is being copied when title passed in', () => {
|
||||
hostComponent.title = 'a/b/c/foo.ts';
|
||||
fixture.detectChanges();
|
||||
expect(getButton().getAttribute('aria-label')).toContain(hostComponent.title);
|
||||
});
|
||||
|
||||
it('should call copier service when clicked', () => {
|
||||
const copierService: CopierService = TestBed.get(CopierService);
|
||||
const spy = spyOn(copierService, 'copyText');
|
||||
expect(spy.calls.count()).toBe(0, 'before click');
|
||||
getButton().click();
|
||||
expect(spy.calls.count()).toBe(1, 'after click');
|
||||
});
|
||||
|
||||
it('should copy code text when clicked', () => {
|
||||
const copierService: CopierService = TestBed.get(CopierService);
|
||||
const spy = spyOn(copierService, 'copyText');
|
||||
getButton().click();
|
||||
expect(spy.calls.argsFor(0)[0]).toBe(oneLineCode, 'after click');
|
||||
});
|
||||
|
||||
it('should preserve newlines in the copied code', () => {
|
||||
const copierService: CopierService = TestBed.get(CopierService);
|
||||
const spy = spyOn(copierService, 'copyText');
|
||||
const expectedCode = smallMultiLineCode.trim().replace(/</g, '<').replace(/>/g, '>');
|
||||
let actualCode;
|
||||
|
||||
hostComponent.setCode(smallMultiLineCode);
|
||||
|
||||
[false, true, 42].forEach(linenums => {
|
||||
hostComponent.linenums = linenums;
|
||||
fixture.detectChanges();
|
||||
getButton().click();
|
||||
actualCode = spy.calls.mostRecent().args[0];
|
||||
|
||||
expect(actualCode).toBe(expectedCode, `when linenums=${linenums}`);
|
||||
expect(actualCode.match(/\r?\n/g).length).toBe(5);
|
||||
|
||||
spy.calls.reset();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display a message when copy succeeds', () => {
|
||||
const snackBar: MatSnackBar = TestBed.get(MatSnackBar);
|
||||
const copierService: CopierService = TestBed.get(CopierService);
|
||||
spyOn(snackBar, 'open');
|
||||
spyOn(copierService, 'copyText').and.returnValue(true);
|
||||
getButton().click();
|
||||
expect(snackBar.open).toHaveBeenCalledWith('Code Copied', '', { duration: 800 });
|
||||
});
|
||||
|
||||
it('should display an error when copy fails', () => {
|
||||
const snackBar: MatSnackBar = TestBed.get(MatSnackBar);
|
||||
const copierService: CopierService = TestBed.get(CopierService);
|
||||
const logger: TestLogger = TestBed.get(Logger);
|
||||
spyOn(snackBar, 'open');
|
||||
spyOn(copierService, 'copyText').and.returnValue(false);
|
||||
getButton().click();
|
||||
expect(snackBar.open).toHaveBeenCalledWith('Copy failed. Please try again!', '', { duration: 800 });
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(jasmine.any(Error));
|
||||
expect(logger.error.calls.mostRecent().args[0].message).toMatch(/^ERROR copying code to clipboard:/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
//// Test helpers ////
|
||||
// tslint:disable:member-ordering
|
||||
@Component({
|
||||
selector: 'aio-host-comp',
|
||||
template: `
|
||||
<aio-code [language]="language"
|
||||
[linenums]="linenums" [path]="path" [region]="region"
|
||||
[hideCopy]="hideCopy" [title]="title"></aio-code>
|
||||
`
|
||||
})
|
||||
class HostComponent implements AfterViewInit {
|
||||
hideCopy: boolean;
|
||||
language: string;
|
||||
linenums: boolean | number | string;
|
||||
path: string;
|
||||
region: string;
|
||||
title: string;
|
||||
|
||||
@ViewChild(CodeComponent) codeComponent: CodeComponent;
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.setCode(oneLineCode);
|
||||
}
|
||||
|
||||
/** Changes the displayed code on the code component. */
|
||||
setCode(code: string) {
|
||||
this.codeComponent.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
class TestLogger {
|
||||
log = jasmine.createSpy('log');
|
||||
error = jasmine.createSpy('error');
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/core';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { PrettyPrinter } from './pretty-printer.service';
|
||||
import { CopierService } from 'app/shared/copier.service';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
/**
|
||||
* If linenums is not set, this is the default maximum number of lines that
|
||||
* an example can display without line numbers.
|
||||
*/
|
||||
const DEFAULT_LINE_NUMS_COUNT = 10;
|
||||
|
||||
/**
|
||||
* Formatted Code Block
|
||||
*
|
||||
* Pretty renders a code block, used in the docs and API reference by the code-example and
|
||||
* code-tabs embedded components.
|
||||
* It includes a "copy" button that will send the content to the clipboard when clicked
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* ```
|
||||
* <aio-code
|
||||
* [language]="ts"
|
||||
* [linenums]="true"
|
||||
* [path]="router/src/app/app.module.ts"
|
||||
* [region]="animations-module">
|
||||
* </aio-code>
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* Renders code provided through the `updateCode` method.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'aio-code',
|
||||
template: `
|
||||
<pre class="prettyprint lang-{{language}}">
|
||||
<button *ngIf="!hideCopy" class="material-icons copy-button no-print"
|
||||
title="Copy code snippet"
|
||||
[attr.aria-label]="ariaLabel"
|
||||
(click)="doCopy()">
|
||||
<span aria-hidden="true">content_copy</span>
|
||||
</button>
|
||||
<code class="animated fadeIn" #codeContainer></code>
|
||||
</pre>
|
||||
`
|
||||
})
|
||||
export class CodeComponent implements OnChanges {
|
||||
ariaLabel = '';
|
||||
|
||||
/** The code to be copied when clicking the copy button, this should not be HTML encoded */
|
||||
private codeText: string;
|
||||
|
||||
/** Code that should be formatted with current inputs and displayed in the view. */
|
||||
set code(code: string) {
|
||||
this._code = code;
|
||||
|
||||
if (!this._code || !this._code.trim()) {
|
||||
this.showMissingCodeMessage();
|
||||
} else {
|
||||
this.formatDisplayedCode();
|
||||
}
|
||||
}
|
||||
get code(): string { return this._code; }
|
||||
_code: string;
|
||||
|
||||
/** Whether the copy button should be shown. */
|
||||
@Input() hideCopy: boolean;
|
||||
|
||||
/** Language to render the code (e.g. javascript, dart, typescript). */
|
||||
@Input() language: string;
|
||||
|
||||
/**
|
||||
* Whether to display line numbers:
|
||||
* - If false: hide
|
||||
* - If true: show
|
||||
* - If number: show but start at that number
|
||||
*/
|
||||
@Input() linenums: boolean | number | string;
|
||||
|
||||
/** Path to the source of the code. */
|
||||
@Input() path: string;
|
||||
|
||||
/** Region of the source of the code being displayed. */
|
||||
@Input() region: string;
|
||||
|
||||
/** Optional title to be displayed above the code. */
|
||||
@Input()
|
||||
set title(title: string) {
|
||||
this._title = title;
|
||||
this.ariaLabel = this.title ? `Copy code snippet from ${this.title}` : '';
|
||||
}
|
||||
get title(): string { return this._title; }
|
||||
private _title: string;
|
||||
|
||||
/** The element in the template that will display the formatted code. */
|
||||
@ViewChild('codeContainer') codeContainer: ElementRef;
|
||||
|
||||
constructor(
|
||||
private snackbar: MatSnackBar,
|
||||
private pretty: PrettyPrinter,
|
||||
private copier: CopierService,
|
||||
private logger: Logger) {}
|
||||
|
||||
ngOnChanges() {
|
||||
// If some inputs have changed and there is code displayed, update the view with the latest
|
||||
// formatted code.
|
||||
if (this.code) {
|
||||
this.formatDisplayedCode();
|
||||
}
|
||||
}
|
||||
|
||||
private formatDisplayedCode() {
|
||||
const leftAlignedCode = leftAlign(this.code);
|
||||
this.setCodeHtml(leftAlignedCode); // start with unformatted code
|
||||
this.codeText = this.getCodeText(); // store the unformatted code as text (for copying)
|
||||
|
||||
this.pretty.formatCode(leftAlignedCode, this.language, this.getLinenums(leftAlignedCode))
|
||||
.subscribe(c => this.setCodeHtml(c), err => { /* ignore failure to format */ }
|
||||
);
|
||||
}
|
||||
|
||||
/** Sets the message showing that the code could not be found. */
|
||||
private showMissingCodeMessage() {
|
||||
const src = this.path ? this.path + (this.region ? '#' + this.region : '') : '';
|
||||
const srcMsg = src ? ` for\n${src}` : '.';
|
||||
this.setCodeHtml(`<p class="code-missing">The code sample is missing${srcMsg}</p>`);
|
||||
}
|
||||
|
||||
/** Sets the innerHTML of the code container to the provided code string. */
|
||||
private setCodeHtml(formattedCode: string) {
|
||||
// **Security:** Code example content is provided by docs authors and as such its considered to
|
||||
// be safe for innerHTML purposes.
|
||||
this.codeContainer.nativeElement.innerHTML = formattedCode;
|
||||
}
|
||||
|
||||
/** Gets the textContent of the displayed code element. */
|
||||
private getCodeText() {
|
||||
// `prettify` may remove newlines, e.g. when `linenums` are on. Retrieve the content of the
|
||||
// container as text, before prettifying it.
|
||||
// We take the textContent because we don't want it to be HTML encoded.
|
||||
return this.codeContainer.nativeElement.textContent;
|
||||
}
|
||||
|
||||
/** Copies the code snippet to the user's clipboard. */
|
||||
doCopy() {
|
||||
const code = this.codeText;
|
||||
const successfullyCopied = this.copier.copyText(code);
|
||||
|
||||
if (successfullyCopied) {
|
||||
this.logger.log('Copied code to clipboard:', code);
|
||||
this.snackbar.open('Code Copied', '', { duration: 800 });
|
||||
} else {
|
||||
this.logger.error(new Error(`ERROR copying code to clipboard: "${code}"`));
|
||||
this.snackbar.open('Copy failed. Please try again!', '', { duration: 800 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Gets the calculated value of linenums (boolean/number). */
|
||||
getLinenums(code: string) {
|
||||
const linenums =
|
||||
typeof this.linenums === 'boolean' ? this.linenums :
|
||||
this.linenums === 'true' ? true :
|
||||
this.linenums === 'false' ? false :
|
||||
typeof this.linenums === 'string' ? parseInt(this.linenums, 10) :
|
||||
this.linenums;
|
||||
|
||||
// if no linenums, enable line numbers if more than one line
|
||||
return linenums == null || isNaN(linenums as number) ?
|
||||
(code.match(/\n/g) || []).length > DEFAULT_LINE_NUMS_COUNT : linenums;
|
||||
}
|
||||
}
|
||||
|
||||
function leftAlign(text: string): string {
|
||||
let indent = Number.MAX_VALUE;
|
||||
|
||||
const lines = text.split('\n');
|
||||
lines.forEach(line => {
|
||||
const lineIndent = line.search(/\S/);
|
||||
if (lineIndent !== -1) {
|
||||
indent = Math.min(lineIndent, indent);
|
||||
}
|
||||
});
|
||||
|
||||
return lines.map(line => line.substr(indent)).join('\n').trim();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CodeComponent } from './code.component';
|
||||
import { MatSnackBarModule } from '@angular/material';
|
||||
import { PrettyPrinter } from './pretty-printer.service';
|
||||
import { CopierService } from 'app/shared/copier.service';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule, MatSnackBarModule ],
|
||||
declarations: [ CodeComponent ],
|
||||
entryComponents: [ CodeComponent ],
|
||||
exports: [ CodeComponent ],
|
||||
providers: [ PrettyPrinter, CopierService ]
|
||||
})
|
||||
export class CodeModule { }
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { fromPromise } from 'rxjs/observable/fromPromise';
|
||||
import 'rxjs/add/operator/map';
|
||||
import 'rxjs/add/operator/first';
|
||||
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
|
||||
declare const System: {
|
||||
import(name: string): Promise<any>;
|
||||
};
|
||||
|
||||
type PrettyPrintOne = (code: string, language?: string, linenums?: number | boolean) => string;
|
||||
|
||||
/**
|
||||
* Wrapper around the prettify.js library
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrettyPrinter {
|
||||
|
||||
private prettyPrintOne: Observable<PrettyPrintOne>;
|
||||
|
||||
constructor(private logger: Logger) {
|
||||
this.prettyPrintOne = fromPromise(this.getPrettyPrintOne()).share();
|
||||
}
|
||||
|
||||
private getPrettyPrintOne(): Promise<PrettyPrintOne> {
|
||||
const ppo = (window as any)['prettyPrintOne'];
|
||||
return ppo ? Promise.resolve(ppo) :
|
||||
// prettify.js is not in window global; load it with webpack loader
|
||||
System.import('assets/js/prettify.js')
|
||||
.then(
|
||||
() => (window as any)['prettyPrintOne'],
|
||||
err => {
|
||||
const msg = 'Cannot get prettify.js from server';
|
||||
this.logger.error(msg, err);
|
||||
// return a pretty print fn that always fails.
|
||||
return () => { throw new Error(msg); };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format code snippet as HTML
|
||||
* @param {string} code - the code snippet to format; should already be HTML encoded
|
||||
* @param {string} [language] - The language of the code to render (could be javascript, html, typescript, etc)
|
||||
* @param {string|number} [linenums] - Whether to display line numbers:
|
||||
* - false: don't display
|
||||
* - true: do display
|
||||
* - number: do display but start at the given number
|
||||
* @returns Observable<string> - Observable of formatted code
|
||||
*/
|
||||
formatCode(code: string, language?: string, linenums?: number | boolean) {
|
||||
return this.prettyPrintOne.map(ppo => {
|
||||
try {
|
||||
return ppo(code, language, linenums);
|
||||
} catch (err) {
|
||||
const msg = `Could not format code that begins '${code.substr(0, 50)}...'.`;
|
||||
console.error(msg, err);
|
||||
throw new Error(msg);
|
||||
}
|
||||
})
|
||||
.first(); // complete immediately
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user