Skip to content
This repository was archived by the owner on May 7, 2021. It is now read-only.

feat(checkboxes): Added checkbox support for Markdown. #141

Merged
merged 1 commit into from
May 7, 2018
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ <h4>GitHubLink Component Example</h4>
<div class="row">
<div class="col-sm-12">
<div class="form-group">
<github-link-area [content]="content"></github-link-area>
<github-link-area [content]="content" (onInputEvent)="inputEvent($event)"></github-link-area>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,30 @@ import {
})
export class GitHubLinkAreaExampleComponent implements OnInit {

content: string = `There is some text in here
<a href="https://github.com/patternfly/patternfly-ng/issues/127">https://github.com/patternfly/patternfly-ng/issues/127</a>.
content: string = `There is some text in here.
And some checkboxes:<ul>
<li><input type="checkbox" data-checkbox-index="0"></input>An Item.</li>
<li><input type="checkbox" checked="" data-checkbox-index="1"></input>Checked Item.</li>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelkleinhenz what should checked="false" do ?
Checked Item.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover. I'll remove that. :-)

</ul>
For input events, see the browser console.<br>
<a href="https://github.com/patternfly/patternfly-ng/issues/127">
https://github.com/patternfly/patternfly-ng/issues/127</a>.
And some more text. And another issue link:
<a href="https://github.com/patternfly/patternfly-ng/issues/111" rel="nofollow">https://github.com/patternfly/patternfly-ng/issues/111</a>.
<a href="https://github.com/patternfly/patternfly-ng/issues/111" rel="nofollow">
https://github.com/patternfly/patternfly-ng/issues/111</a>.
And for testing purposes, the same link as the first as a dupe:
<a href="https://github.com/patternfly/patternfly-ng/issues/127" rel="nofollow">https://github.com/patternfly/patternfly-ng/issues/127</a>.
Note: the link html formatting is following the currently used OSIO server side link markdown formatting. It may need changes
<a href="https://github.com/patternfly/patternfly-ng/issues/127" rel="nofollow">
https://github.com/patternfly/patternfly-ng/issues/127</a>.
Note: the link html formatting is following the currently used OSIO server
side link markdown formatting. It may need changes
if used with a different markdown compiler`;

constructor() {}

ngOnInit(): void {}

inputEvent(event: any) {
console.log('Input Event detected on input type: ' + event.type +
' with index ' + event.extraData);
}
}
2 changes: 1 addition & 1 deletion src/app/github-link-area/github-link-area.component.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<div [innerHTML]="content"></div>
<div [innerHTML]="content | safe: 'html'"></div>
62 changes: 59 additions & 3 deletions src/app/github-link-area/github-link-area.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
OnChanges,
SimpleChanges,
ViewChild,
ElementRef
ElementRef,
AfterViewChecked,
Output,
EventEmitter
} from '@angular/core';

import { GitHubLinkService } from './github-link.service';
Expand All @@ -26,18 +29,25 @@ import { GitHubLinkService } from './github-link.service';
`],
templateUrl: './github-link-area.component.html'
})
export class GitHubLinkAreaComponent implements OnChanges {
export class GitHubLinkAreaComponent implements OnChanges, AfterViewChecked {

@Input('content') content: string;
@Output('onInputEvent') onInputEvent = new EventEmitter();

constructor(private gitHubLinkService: GitHubLinkService) {}
constructor(
private gitHubLinkService: GitHubLinkService,
private elementRef: ElementRef) {}

ngOnChanges(changes: SimpleChanges): void {
if (changes && changes.content) {
this.updateOnChanges();
}
}

ngAfterViewChecked(): void {
this.instrumentInputs();
}

/*
* Because Angular testing is broken, we need to be able to call
* this from a test. See reason here:
Expand All @@ -48,6 +58,52 @@ export class GitHubLinkAreaComponent implements OnChanges {
this.updateLinks();
}

/*
* Instruments the input elements in the content. This is used to report
* any interaction with inputs back to the parent using Outputs. This feature
* is used in the checkbox feature downstream.
*/
instrumentInputs() {
// this uses a trick. The problem is that ngAfterViewChecked() is called
// multiple times and causes EventListeners added to the inputs multiple
// times. Using a dirty bit does not work as Angular is ansychronous and
// updates can happen between setting the EventListener and reverting the
// dirty bit.
// keeping track of the elements with EventListeners is also not working
// without a lot of extra work. So we do a trick and add a custom attribute
// to the input element when we added the EventListener. The selector
// matches only elements without that attribute, so we have a lightweight
// way of making sure each input element exactly gets one EventListener
// attached.
let el = this.elementRef;
if (el) {
let inputElements = el.nativeElement.querySelectorAll('input:not([data-event-attached])');
if (inputElements && inputElements.length > 0) {
// we need to use a classic loop instead of forEach here
// as forEach on NodeLists is not supported on every browser.
// Example: Chrome works, but Protractor not.
for (let i = 0; i < inputElements.length; ++i) {
inputElements[i].setAttribute('data-event-attached', 'true');
inputElements[i].addEventListener('change', (ref: any) => {
// we only support checkboxes for now, but the mechanism is generic.
// add new interactions here if needed.
if (ref.target && ref.target.getAttribute('type') === 'checkbox') {
let indexStr = ref.target.getAttribute('data-checkbox-index');
this.onInputEvent.emit({
'type': 'checkbox',
// '+' converts the string to an int.
'extraData': {
checkboxIndex: +indexStr,
checked: ref.target.checked
}
});
}
}, false);
}
}
}
}

/*
* Replaces the match (which should be the default icon) with an icon that
* indicates the status contained in linkData.
Expand Down
5 changes: 3 additions & 2 deletions src/app/github-link-area/github-link-area.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { HttpClientModule } from '@angular/common/http';

import { GitHubLinkService } from './github-link.service';
import { GitHubLinkAreaComponent } from './github-link-area.component';
import { SafePipe } from './safe.pipe';

/**
* A module containing objects associated with the GitHubLinkArea component
*/
@NgModule({
imports: [ CommonModule, HttpClientModule ],
declarations: [ GitHubLinkAreaComponent ],
declarations: [ GitHubLinkAreaComponent, SafePipe ],
providers: [ GitHubLinkService ],
exports: [ GitHubLinkAreaComponent ]
exports: [ GitHubLinkAreaComponent, SafePipe ]
})
export class GitHubLinkAreaModule { }
29 changes: 29 additions & 0 deletions src/app/github-link-area/safe.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Pipe, PipeTransform } from '@angular/core';
import {
DomSanitizer,
SafeHtml,
SafeStyle,
SafeScript,
SafeUrl,
SafeResourceUrl
} from '@angular/platform-browser';

@Pipe({
name: 'safe'
})
export class SafePipe implements PipeTransform {

constructor(protected sanitizer: DomSanitizer) {}

public transform(value: any, type: string):
SafeHtml | SafeStyle | SafeScript | SafeUrl | SafeResourceUrl {
switch (type) {
case 'html': return this.sanitizer.bypassSecurityTrustHtml(value);
case 'style': return this.sanitizer.bypassSecurityTrustStyle(value);
case 'script': return this.sanitizer.bypassSecurityTrustScript(value);
case 'url': return this.sanitizer.bypassSecurityTrustUrl(value);
case 'resourceUrl': return this.sanitizer.bypassSecurityTrustResourceUrl(value);
default: throw new Error(`Invalid safe type specified: ${type}`);
}
Copy link
Collaborator

@dlabrecq dlabrecq May 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be wrong, but this object appears to disable/bypass Angular's built-in sanitation? If so, bypassing JavaScript sanitation isn't a good idea.

I'm concerned we're converting user input into a trusted value and introducing a security vulnerability by trusting values that could be malicious?

See: https://angular.io/guide/security#bypass-security-apis
"But be careful. If you trust a value that might be malicious, you are introducing a security vulnerability into your application. If in doubt, find a professional security reviewer."

Copy link
Contributor Author

@michaelkleinhenz michaelkleinhenz May 17, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment below. Note: JS sanitizing is still enabled.

}
}
43 changes: 40 additions & 3 deletions src/app/markdown/examples/markdown-example.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Component, ViewEncapsulation, ElementRef } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';

const markdownIt = require('markdown-it');
const markdown = new markdownIt();
Expand All @@ -13,16 +14,52 @@ const markdown = new markdownIt();

export class MarkdownExampleComponent {

private renderedText: string = '<p>hello, markdown!\</p>';
private renderedText: string = '<h1>hello, markdown!\</h1><ul>' +
// tslint:disable-next-line:max-line-length
'<li><input class="markdown-checkbox" type="checkbox" data-checkbox-index="0"></input> Item 0</li>' +
// tslint:disable-next-line:max-line-length
'<li><input class="markdown-checkbox" type="checkbox" checked="" data-checkbox-index="1"></input> Item 1</li>' +
// tslint:disable-next-line:max-line-length
'<li><input class="markdown-checkbox" type="checkbox" data-checkbox-index="2"></input> Item 2</li></ul>';
private renderedTextNoEdit: string = '<p>Edit is not allowed here</p>';
private rawText: string = '#hello, markdown!';
private rawText: string = '# hello, markdown!\n* [ ] Item 1\n* [x] Item 2\n* [ ] Item 3';
private allowEdit = false;

onSaveOrPreview(value: any) {
const rawText = value.rawText;
const callBack = value.callBack;
console.log('MarkdownExampleComponent: Received markdown markup update in client: ' + rawText);
setTimeout(() => {
callBack(rawText, markdown.render(rawText));
let text: string = markdown.render(rawText);
const regex = /\[[ xX]\]|\[\]/gm;
let m;
let matchIndex = 0;
// tslint:disable-next-line:no-conditional-assignment
while ((m = regex.exec(text)) !== null) {
// This is necessary to avoid infinite loops with zero-width matches.
if (m.index === regex.lastIndex) {
regex.lastIndex++;
}
if (m.length > 0) {
// JavaScript does not have a replace by index method.
let matchLen = m[0].length;
let matchEndIndex = regex.lastIndex;
let matchStartIndex = matchEndIndex - matchLen;
let replaceStr;
if (m[0] === '[]' || m[0] === '[ ]')
// tslint:disable-next-line:max-line-length
replaceStr = '<input class="markdown-checkbox" type="checkbox" data-checkbox-index="' + matchIndex + '"></input>';
else
// tslint:disable-next-line:max-line-length
replaceStr = '<input class="markdown-checkbox" type="checkbox" checked="" data-checkbox-index="' + matchIndex + '"></input>';
// tslint:disable-next-line:max-line-length
text = text.substring(0, matchStartIndex) + replaceStr + text.substring(matchEndIndex, text.length);
}
matchIndex++;
}
// tslint:disable-next-line:max-line-length
console.log('MarkdownExampleComponent: Rendering on service side completed, sending to component: ' + text);
callBack(rawText, text);
}, 2000);
}

Expand Down
8 changes: 4 additions & 4 deletions src/app/markdown/markdown.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</li>
</ul>
<div class="editor-container">
<div class="editor"
<div class="editor" #previewArea
(click)="enableEditor()"
[class.active]="editorActive"
[class.show-less]="!showMore && !editorActive">
Expand All @@ -23,10 +23,10 @@
class="editor-box editor-markdown"
[innerText]="rawText">
</p>
<div #editorBox
<div #editorBox #previewArea
*ngIf="viewType==='preview' && renderedText.length"
class="editor-box editor-preview">
<github-link-area [content]="renderedText"></github-link-area>
class="editor-box editor-preview markdown-rendered">
<github-link-area [content]="renderedText" (onInputEvent)="onInputEvent($event)"></github-link-area>
</div>
<div #editorBox
*ngIf="viewType==='preview' && !renderedText.length"
Expand Down
84 changes: 84 additions & 0 deletions src/app/markdown/markdown.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
async,
ComponentFixture,
fakeAsync,
inject,
TestBed,
tick
} from '@angular/core/testing';

import { DebugElement, SimpleChanges, SimpleChange } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';

import { MarkdownModule } from './markdown.module';
import { MarkdownComponent } from './markdown.component';

describe('Markdown component - ', () => {
let comp: MarkdownComponent;
let fixture: ComponentFixture<MarkdownComponent>;

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [ FormsModule, MarkdownModule ],
declarations: [ ]
})
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(MarkdownComponent);
comp = fixture.componentInstance;
});
}));

it('Should render handle Markdown checkboxes correctly.', () => {
// tslint:disable-next-line:max-line-length
comp.inpRawText = '# hello, markdown!\n* [ ] Item 1\n* [x] Item 2\n* [ ] Item 3';
comp.inpRenderedText = '<h1>hello, markdown!\</h1><ul>' +
// tslint:disable-next-line:max-line-length
'<li><input class="markdown-checkbox" type="checkbox" data-checkbox-index="0"></input> Item 0</li>' +
// tslint:disable-next-line:max-line-length
'<li><input class="markdown-checkbox" type="checkbox" checked="" data-checkbox-index="1"></input> Item 1</li>' +
// tslint:disable-next-line:max-line-length
'<li><input class="markdown-checkbox" type="checkbox" data-checkbox-index="2"></input> Item 2</li></ul>';
// this first detectChanges() updates the component that one of the @Inputs has changed.
fixture.detectChanges();
// because of https://github.com/angular/angular/issues/9866, detectChanges() does not
// call ngOnChanges() on the component (yeah, it it as broken as it sounds). So
// we need to call the component manually to update.
comp.ngOnChanges({
inpRawText: {} as SimpleChange,
inpRenderedText: {} as SimpleChange
} as SimpleChanges);
// and because the test framework is not even able to detect inner changes to a component,
// we need to call detectChanges() again.
fixture.detectChanges();
// also, using query() is also not working. Maybe due to the dynamic update of innerHTML.
// So we need to use the nativeElement to get a selector working.
// tslint:disable-next-line:max-line-length
let markdownPreview: Element = fixture.debugElement.nativeElement.querySelector('.markdown-rendered');
expect(markdownPreview).not.toBeNull();
// preview render of the template default
let markdownCheckboxElementList = markdownPreview.querySelectorAll('.markdown-checkbox');
expect(markdownCheckboxElementList).not.toBeNull();
expect(markdownCheckboxElementList.length).toBe(3);
expect(markdownCheckboxElementList[0].hasAttribute('checked')).toBeFalsy();
expect(markdownCheckboxElementList[1].hasAttribute('checked')).toBeTruthy();
expect(markdownCheckboxElementList[2].hasAttribute('checked')).toBeFalsy();
// tick a checkbox
let checkboxElem = markdownCheckboxElementList[0] as HTMLElement;
checkboxElem.click();
// see if it ends up in the Markdown
expect(comp.rawText.indexOf('[x] Item 1')).toBeGreaterThan(-1);
// tick another checkbox
checkboxElem = markdownCheckboxElementList[2] as HTMLElement;
checkboxElem.click();
// see if it ends up in the Markdown
expect(comp.rawText.indexOf('[x] Item 3')).toBeGreaterThan(-1);
// untick a checkbox
checkboxElem = markdownCheckboxElementList[1] as HTMLElement;
checkboxElem.click();
// see if it ends up in the Markdown
expect(comp.rawText.indexOf('[ ] Item 2')).toBeGreaterThan(-1);
});

});
Loading