From 0bc22d810acdc2f0f4c60f8a4b156eae9ac5a420 Mon Sep 17 00:00:00 2001 From: Sam Chow Date: Mon, 13 Aug 2018 13:58:59 -0400 Subject: [PATCH] Add components for generating sec keys --- config_app/config_endpoints/api/__init__.py | 9 + config_app/config_endpoints/api/superuser.py | 9 +- .../datetime-picker/datetime-picker.html | 3 + .../datetime-picker/datetime-picker.js | 60 ++++++ .../highlighted-languages.constant.json | 8 + .../markdown/markdown-editor.component.css | 24 +++ .../markdown/markdown-editor.component.html | 43 ++++ .../markdown-editor.component.spec.ts | 188 ++++++++++++++++++ .../markdown/markdown-editor.component.ts | 147 ++++++++++++++ .../markdown/markdown-input.component.css | 14 ++ .../markdown/markdown-input.component.html | 29 +++ .../markdown/markdown-input.component.spec.ts | 34 ++++ .../markdown/markdown-input.component.ts | 34 ++++ .../markdown/markdown-toolbar.component.css | 8 + .../markdown/markdown-toolbar.component.html | 61 ++++++ .../markdown-toolbar.component.spec.ts | 11 + .../markdown/markdown-toolbar.component.ts | 17 ++ .../markdown/markdown-view.component.css | 12 ++ .../markdown/markdown-view.component.html | 2 + .../markdown/markdown-view.component.spec.ts | 79 ++++++++ .../markdown/markdown-view.component.ts | 48 +++++ .../js/components/markdown/markdown.module.ts | 97 +++++++++ .../request-service-key-dialog.js | 5 +- config_app/js/config-app.module.ts | 18 +- config_app/templates/index.html | 3 + 25 files changed, 955 insertions(+), 8 deletions(-) create mode 100644 config_app/js/components/datetime-picker/datetime-picker.html create mode 100644 config_app/js/components/datetime-picker/datetime-picker.js create mode 100644 config_app/js/components/markdown/highlighted-languages.constant.json create mode 100644 config_app/js/components/markdown/markdown-editor.component.css create mode 100644 config_app/js/components/markdown/markdown-editor.component.html create mode 100644 config_app/js/components/markdown/markdown-editor.component.spec.ts create mode 100644 config_app/js/components/markdown/markdown-editor.component.ts create mode 100644 config_app/js/components/markdown/markdown-input.component.css create mode 100644 config_app/js/components/markdown/markdown-input.component.html create mode 100644 config_app/js/components/markdown/markdown-input.component.spec.ts create mode 100644 config_app/js/components/markdown/markdown-input.component.ts create mode 100644 config_app/js/components/markdown/markdown-toolbar.component.css create mode 100644 config_app/js/components/markdown/markdown-toolbar.component.html create mode 100644 config_app/js/components/markdown/markdown-toolbar.component.spec.ts create mode 100644 config_app/js/components/markdown/markdown-toolbar.component.ts create mode 100644 config_app/js/components/markdown/markdown-view.component.css create mode 100644 config_app/js/components/markdown/markdown-view.component.html create mode 100644 config_app/js/components/markdown/markdown-view.component.spec.ts create mode 100644 config_app/js/components/markdown/markdown-view.component.ts create mode 100644 config_app/js/components/markdown/markdown.module.ts diff --git a/config_app/config_endpoints/api/__init__.py b/config_app/config_endpoints/api/__init__.py index 118b68802..25389a1c0 100644 --- a/config_app/config_endpoints/api/__init__.py +++ b/config_app/config_endpoints/api/__init__.py @@ -3,6 +3,7 @@ import logging from flask import Blueprint, request, abort from flask_restful import Resource, Api from flask_restful.utils.cors import crossdomain +from data import model from email.utils import formatdate from calendar import timegm from functools import partial, wraps @@ -29,6 +30,14 @@ api = ApiExceptionHandlingApi() api.init_app(api_bp) +def log_action(kind, user_or_orgname, metadata=None, repo=None, repo_name=None): + if not metadata: + metadata = {} + + if repo: + repo_name = repo.name + + model.log.log_action(kind, user_or_orgname, repo_name, user_or_orgname, request.remote_addr, metadata) def format_date(date): """ Output an RFC822 date format. """ diff --git a/config_app/config_endpoints/api/superuser.py b/config_app/config_endpoints/api/superuser.py index be20d484b..fd82ec6ee 100644 --- a/config_app/config_endpoints/api/superuser.py +++ b/config_app/config_endpoints/api/superuser.py @@ -5,12 +5,13 @@ import subprocess from flask import request, jsonify, make_response +from endpoints.exception import NotFound from data.database import ServiceKeyApprovalType from data.model import ServiceKeyDoesNotExist from util.config.validator import EXTRA_CA_DIRECTORY from config_app.config_endpoints.exception import InvalidRequest -from config_app.config_endpoints.api import resource, ApiResource, nickname +from config_app.config_endpoints.api import resource, ApiResource, nickname, log_action, validate_json_request from config_app.config_endpoints.api.superuser_models_pre_oci import pre_oci_model from config_app.config_util.ssl import load_certificate, CertInvalidException from config_app.c_app import app, config_provider, INIT_SCRIPTS_LOCATION @@ -170,7 +171,6 @@ class SuperUserServiceKeyApproval(ApiResource): @validate_json_request('ApproveServiceKey') def post(self, kid): notes = request.get_json().get('notes', '') - approver = app.config.get('SUPER_USERS', [])[0] # get the first superuser created in the config tool try: key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes) @@ -182,7 +182,10 @@ class SuperUserServiceKeyApproval(ApiResource): 'expiration_date': key.expiration_date, } - log_action('service_key_approve', None, key_log_metadata) + # Note: this may not actually be the current person modifying the config, but if they're in the config tool, + # they have full access to the DB and could pretend to be any user, so pulling any superuser is likely fine + super_user = app.config.get('SUPER_USERS', [None])[0] + log_action('service_key_approve', super_user, key_log_metadata) except ServiceKeyDoesNotExist: raise NotFound() except ServiceKeyAlreadyApproved: diff --git a/config_app/js/components/datetime-picker/datetime-picker.html b/config_app/js/components/datetime-picker/datetime-picker.html new file mode 100644 index 000000000..653c3869a --- /dev/null +++ b/config_app/js/components/datetime-picker/datetime-picker.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/config_app/js/components/datetime-picker/datetime-picker.js b/config_app/js/components/datetime-picker/datetime-picker.js new file mode 100644 index 000000000..cb1463e57 --- /dev/null +++ b/config_app/js/components/datetime-picker/datetime-picker.js @@ -0,0 +1,60 @@ +const templateUrl = require('./datetime-picker.html'); +/** + * An element which displays a datetime picker. + */ +angular.module('quay-config').directive('datetimePicker', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl, + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'datetime': '=datetime', + }, + controller: function($scope, $element) { + var datetimeSet = false; + + $(function() { + $element.find('input').datetimepicker({ + 'format': 'LLL', + 'sideBySide': true, + 'showClear': true, + 'minDate': new Date(), + 'debug': false + }); + + $element.find('input').on("dp.change", function (e) { + $scope.$apply(function() { + $scope.datetime = e.date ? e.date.unix() : null; + }); + }); + }); + + $scope.$watch('selected_datetime', function(value) { + if (!datetimeSet) { return; } + + if (!value) { + if ($scope.datetime) { + $scope.datetime = null; + } + return; + } + + $scope.datetime = (new Date(value)).getTime()/1000; + }); + + $scope.$watch('datetime', function(value) { + if (!value) { + $scope.selected_datetime = null; + datetimeSet = true; + return; + } + + $scope.selected_datetime = moment.unix(value).format('LLL'); + datetimeSet = true; + }); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/config_app/js/components/markdown/highlighted-languages.constant.json b/config_app/js/components/markdown/highlighted-languages.constant.json new file mode 100644 index 000000000..56ee3c2cf --- /dev/null +++ b/config_app/js/components/markdown/highlighted-languages.constant.json @@ -0,0 +1,8 @@ +[ + "javascript", + "python", + "bash", + "nginx", + "xml", + "shell" +] diff --git a/config_app/js/components/markdown/markdown-editor.component.css b/config_app/js/components/markdown/markdown-editor.component.css new file mode 100644 index 000000000..dcf129171 --- /dev/null +++ b/config_app/js/components/markdown/markdown-editor.component.css @@ -0,0 +1,24 @@ +.markdown-editor-element textarea { + height: 300px; + resize: vertical; + width: 100%; +} + +.markdown-editor-actions { + display: flex; + justify-content: flex-end; + margin-top: 20px; +} + +.markdown-editor-buttons { + display: flex; + justify-content: space-between; +} + +.markdown-editor-buttons button { + margin: 0 10px; +} + +.markdown-editor-buttons button:last-child { + margin: 0; +} diff --git a/config_app/js/components/markdown/markdown-editor.component.html b/config_app/js/components/markdown/markdown-editor.component.html new file mode 100644 index 000000000..abb632938 --- /dev/null +++ b/config_app/js/components/markdown/markdown-editor.component.html @@ -0,0 +1,43 @@ +
+ + + +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
diff --git a/config_app/js/components/markdown/markdown-editor.component.spec.ts b/config_app/js/components/markdown/markdown-editor.component.spec.ts new file mode 100644 index 000000000..a1a9f32d3 --- /dev/null +++ b/config_app/js/components/markdown/markdown-editor.component.spec.ts @@ -0,0 +1,188 @@ +import { MarkdownEditorComponent, EditMode } from './markdown-editor.component'; +import { MarkdownSymbol } from '../../../types/common.types'; +import { Mock } from 'ts-mocks'; +import Spy = jasmine.Spy; + + +describe("MarkdownEditorComponent", () => { + var component: MarkdownEditorComponent; + var textarea: Mock; + var documentMock: Mock; + var $windowMock: Mock; + + beforeEach(() => { + textarea = new Mock(); + documentMock = new Mock(); + $windowMock = new Mock(); + const $documentMock: any = [documentMock.Object]; + component = new MarkdownEditorComponent($documentMock, $windowMock.Object, 'chrome'); + component.textarea = textarea.Object; + }); + + describe("onBeforeUnload", () => { + + it("returns false to alert user about losing current changes", () => { + component.changeEditMode("write"); + const allow: boolean = component.onBeforeUnload(); + + expect(allow).toBe(false); + }); + }); + + describe("ngOnDestroy", () => { + + it("removes 'beforeunload' event listener", () => { + $windowMock.setup(mock => mock.onbeforeunload).is(() => 1); + component.ngOnDestroy(); + + expect($windowMock.Object.onbeforeunload.call(this)).toEqual(null); + }); + }); + + describe("changeEditMode", () => { + + it("sets component's edit mode to given mode", () => { + const editMode: EditMode = "preview"; + component.changeEditMode(editMode); + + expect(component.currentEditMode).toEqual(editMode); + }); + }); + + describe("insertSymbol", () => { + var event: {symbol: MarkdownSymbol}; + var markdownSymbols: {type: MarkdownSymbol, characters: string, shiftBy: number}[]; + var innerText: string; + + beforeEach(() => { + event = {symbol: 'heading1'}; + innerText = "Here is some text"; + markdownSymbols = [ + {type: 'heading1', characters: '# ', shiftBy: 2}, + {type: 'heading2', characters: '## ', shiftBy: 3}, + {type: 'heading3', characters: '### ', shiftBy: 4}, + {type: 'bold', characters: '****', shiftBy: 2}, + {type: 'italics', characters: '__', shiftBy: 1}, + {type: 'bulleted-list', characters: '- ', shiftBy: 2}, + {type: 'numbered-list', characters: '1. ', shiftBy: 3}, + {type: 'quote', characters: '> ', shiftBy: 2}, + {type: 'link', characters: '[](url)', shiftBy: 1}, + {type: 'code', characters: '``', shiftBy: 1}, + ]; + + textarea.setup(mock => mock.focus); + textarea.setup(mock => mock.substr).is((start, end) => ''); + textarea.setup(mock => mock.val).is((value?) => innerText); + textarea.setup(mock => mock.prop).is((prop) => { + switch (prop) { + case "selectionStart": + return 0; + case "selectionEnd": + return 0; + } + }); + documentMock.setup(mock => mock.execCommand).is((commandID, showUI, value) => false); + }); + + it("focuses on markdown textarea", () => { + component.insertSymbol(event); + + expect(textarea.Object.focus).toHaveBeenCalled(); + }); + + it("inserts correct characters for given symbol at cursor position", () => { + markdownSymbols.forEach((symbol) => { + event.symbol = symbol.type; + component.insertSymbol(event); + + expect((documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText'); + expect((documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false); + expect((documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(symbol.characters); + + (documentMock.Object.execCommand).calls.reset(); + }); + }); + + it("splices highlighted selection between inserted characters instead of deleting them", () => { + markdownSymbols.slice(0, 1).forEach((symbol) => { + textarea.setup(mock => mock.prop).is((prop) => { + switch (prop) { + case "selectionStart": + return 0; + case "selectionEnd": + return innerText.length; + } + }); + event.symbol = symbol.type; + component.insertSymbol(event); + + expect((documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText'); + expect((documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false); + expect((documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(`${symbol.characters.slice(0, symbol.shiftBy)}${innerText}${symbol.characters.slice(symbol.shiftBy, symbol.characters.length)}`); + + (documentMock.Object.execCommand).calls.reset(); + }); + }); + + it("moves cursor to correct position for given symbol", () => { + markdownSymbols.forEach((symbol) => { + event.symbol = symbol.type; + component.insertSymbol(event); + + expect((textarea.Object.prop).calls.argsFor(2)[0]).toEqual('selectionStart'); + expect((textarea.Object.prop).calls.argsFor(2)[1]).toEqual(symbol.shiftBy); + expect((textarea.Object.prop).calls.argsFor(3)[0]).toEqual('selectionEnd'); + expect((textarea.Object.prop).calls.argsFor(3)[1]).toEqual(symbol.shiftBy); + + (textarea.Object.prop).calls.reset(); + }); + }); + }); + + describe("saveChanges", () => { + + beforeEach(() => { + component.content = "# Some markdown content"; + }); + + it("emits output event with changed content", (done) => { + component.save.subscribe((event: {editedContent: string}) => { + expect(event.editedContent).toEqual(component.content); + done(); + }); + + component.saveChanges(); + }); + }); + + describe("discardChanges", () => { + + it("prompts user to confirm discarding changes", () => { + const confirmSpy: Spy = $windowMock.setup(mock => mock.confirm).is((message) => false).Spy; + component.discardChanges(); + + expect(confirmSpy.calls.argsFor(0)[0]).toEqual(`Are you sure you want to discard your changes?`); + }); + + it("emits output event with no content if user confirms discarding changes", (done) => { + $windowMock.setup(mock => mock.confirm).is((message) => true); + component.discard.subscribe((event: {}) => { + expect(event).toEqual({}); + done(); + }); + + component.discardChanges(); + }); + + it("does not emit output event if user declines confirmation of discarding changes", (done) => { + $windowMock.setup(mock => mock.confirm).is((message) => false); + component.discard.subscribe((event: {}) => { + fail(`Should not emit output event`); + done(); + }); + + component.discardChanges(); + done(); + }); + }); +}); diff --git a/config_app/js/components/markdown/markdown-editor.component.ts b/config_app/js/components/markdown/markdown-editor.component.ts new file mode 100644 index 000000000..4eb161bd4 --- /dev/null +++ b/config_app/js/components/markdown/markdown-editor.component.ts @@ -0,0 +1,147 @@ +import { Component, Inject, Input, Output, EventEmitter, ViewChild, HostListener, OnDestroy } from 'ng-metadata/core'; +import { MarkdownSymbol, BrowserPlatform } from './markdown.module'; +import './markdown-editor.component.css'; + + +/** + * An editing interface for Markdown content. + */ +@Component({ + selector: 'markdown-editor', + templateUrl: require('./markdown-editor.component.html'), +}) +export class MarkdownEditorComponent implements OnDestroy { + + @Input('<') public content: string; + + @Output() public save: EventEmitter<{editedContent: string}> = new EventEmitter(); + @Output() public discard: EventEmitter = new EventEmitter(); + + // Textarea is public for testability, should not be directly accessed + @ViewChild('#markdown-textarea') public textarea: ng.IAugmentedJQuery; + + private editMode: EditMode = "write"; + + constructor(@Inject('$document') private $document: ng.IDocumentService, + @Inject('$window') private $window: ng.IWindowService, + @Inject('BrowserPlatform') private browserPlatform: BrowserPlatform) { + this.$window.onbeforeunload = this.onBeforeUnload.bind(this); + } + + @HostListener('window:beforeunload', []) + public onBeforeUnload(): boolean { + return false; + } + + public ngOnDestroy(): void { + this.$window.onbeforeunload = () => null; + } + + public changeEditMode(newMode: EditMode): void { + this.editMode = newMode; + } + + public insertSymbol(event: {symbol: MarkdownSymbol}): void { + this.textarea.focus(); + + const startPos: number = this.textarea.prop('selectionStart'); + const endPos: number = this.textarea.prop('selectionEnd'); + const innerText: string = this.textarea.val().slice(startPos, endPos); + var shiftBy: number = 0; + var characters: string = ''; + + switch (event.symbol) { + case 'heading1': + characters = '# '; + shiftBy = 2; + break; + case 'heading2': + characters = '## '; + shiftBy = 3; + break; + case 'heading3': + characters = '### '; + shiftBy = 4; + break; + case 'bold': + characters = '****'; + shiftBy = 2; + break; + case 'italics': + characters = '__'; + shiftBy = 1; + break; + case 'bulleted-list': + characters = '- '; + shiftBy = 2; + break; + case 'numbered-list': + characters = '1. '; + shiftBy = 3; + break; + case 'quote': + characters = '> '; + shiftBy = 2; + break; + case 'link': + characters = '[](url)'; + shiftBy = 1; + break; + case 'code': + characters = '``'; + shiftBy = 1; + break; + } + + const cursorPos: number = startPos + shiftBy; + + if (startPos != endPos) { + this.insertText(`${characters.slice(0, shiftBy)}${innerText}${characters.slice(shiftBy, characters.length)}`, + startPos, + endPos); + } + else { + this.insertText(characters, startPos, endPos); + } + + this.textarea.prop('selectionStart', cursorPos); + this.textarea.prop('selectionEnd', cursorPos); + } + + public saveChanges(): void { + this.save.emit({editedContent: this.content}); + } + + public discardChanges(): void { + if (this.$window.confirm(`Are you sure you want to discard your changes?`)) { + this.discard.emit({}); + } + } + + public get currentEditMode(): EditMode { + return this.editMode; + } + + /** + * Insert text in such a way that the browser adds it to the 'undo' stack. This has different feature support + * depending on the platform. + */ + private insertText(text: string, startPos: number, endPos: number): void { + if (this.browserPlatform === 'firefox') { + // FIXME: Ctrl-Z highlights previous text + this.textarea.val(this.textarea.val().substr(0, startPos) + + text + + this.textarea.val().substr(endPos, this.textarea.val().length)); + } + else { + // TODO: Test other platforms (IE...) + this.$document[0].execCommand('insertText', false, text); + } + } +} + + +/** + * Type representing the current editing mode. + */ +export type EditMode = "write" | "preview"; diff --git a/config_app/js/components/markdown/markdown-input.component.css b/config_app/js/components/markdown/markdown-input.component.css new file mode 100644 index 000000000..d8a8e3a78 --- /dev/null +++ b/config_app/js/components/markdown/markdown-input.component.css @@ -0,0 +1,14 @@ +.markdown-input-container .glyphicon-edit { + float: right; + color: #ddd; + transition: color 0.5s ease-in-out; +} + +.markdown-input-container .glyphicon-edit:hover { + cursor: pointer; + color: #444; +} + +.markdown-input-placeholder-editable:hover { + cursor: pointer; +} \ No newline at end of file diff --git a/config_app/js/components/markdown/markdown-input.component.html b/config_app/js/components/markdown/markdown-input.component.html new file mode 100644 index 000000000..e50b7f005 --- /dev/null +++ b/config_app/js/components/markdown/markdown-input.component.html @@ -0,0 +1,29 @@ +
+
+ +
+ +
+ + + Click to set {{ ::$ctrl.fieldTitle }} + + + + No {{ ::$ctrl.fieldTitle }} has been set + +
+ + +
+ +
+
diff --git a/config_app/js/components/markdown/markdown-input.component.spec.ts b/config_app/js/components/markdown/markdown-input.component.spec.ts new file mode 100644 index 000000000..56b3ba697 --- /dev/null +++ b/config_app/js/components/markdown/markdown-input.component.spec.ts @@ -0,0 +1,34 @@ +import { MarkdownInputComponent } from './markdown-input.component'; +import { Mock } from 'ts-mocks'; +import Spy = jasmine.Spy; + + +describe("MarkdownInputComponent", () => { + var component: MarkdownInputComponent; + + beforeEach(() => { + component = new MarkdownInputComponent(); + }); + + describe("editContent", () => { + + }); + + describe("saveContent", () => { + var editedContent: string; + + it("emits output event with changed content", (done) => { + editedContent = "# Some markdown here"; + component.contentChanged.subscribe((event: {content: string}) => { + expect(event.content).toEqual(editedContent); + done(); + }); + + component.saveContent({editedContent: editedContent}); + }); + }); + + describe("discardContent", () => { + + }); +}); diff --git a/config_app/js/components/markdown/markdown-input.component.ts b/config_app/js/components/markdown/markdown-input.component.ts new file mode 100644 index 000000000..45b869ce4 --- /dev/null +++ b/config_app/js/components/markdown/markdown-input.component.ts @@ -0,0 +1,34 @@ +import { Component, Input, Output, EventEmitter } from 'ng-metadata/core'; +import './markdown-input.component.css'; + + +/** + * Displays editable Markdown content. + */ +@Component({ + selector: 'markdown-input', + templateUrl: require('./markdown-input.component.html'), +}) +export class MarkdownInputComponent { + + @Input('<') public content: string; + @Input('<') public canWrite: boolean; + @Input('@') public fieldTitle: string; + + @Output() public contentChanged: EventEmitter<{content: string}> = new EventEmitter(); + + private isEditing: boolean = false; + + public editContent(): void { + this.isEditing = true; + } + + public saveContent(event: {editedContent: string}): void { + this.contentChanged.emit({content: event.editedContent}); + this.isEditing = false; + } + + public discardContent(event: any): void { + this.isEditing = false; + } +} diff --git a/config_app/js/components/markdown/markdown-toolbar.component.css b/config_app/js/components/markdown/markdown-toolbar.component.css new file mode 100644 index 000000000..f2521caa7 --- /dev/null +++ b/config_app/js/components/markdown/markdown-toolbar.component.css @@ -0,0 +1,8 @@ +.markdown-toolbar-element .dropdown-menu li > * { + margin: 0 4px; +} + +.markdown-toolbar-element .dropdown-menu li:hover { + cursor: pointer; + background-color: #e6e6e6; +} diff --git a/config_app/js/components/markdown/markdown-toolbar.component.html b/config_app/js/components/markdown/markdown-toolbar.component.html new file mode 100644 index 000000000..6ad04dc71 --- /dev/null +++ b/config_app/js/components/markdown/markdown-toolbar.component.html @@ -0,0 +1,61 @@ +
+ +
diff --git a/config_app/js/components/markdown/markdown-toolbar.component.spec.ts b/config_app/js/components/markdown/markdown-toolbar.component.spec.ts new file mode 100644 index 000000000..90d366d1f --- /dev/null +++ b/config_app/js/components/markdown/markdown-toolbar.component.spec.ts @@ -0,0 +1,11 @@ +import { MarkdownToolbarComponent } from './markdown-toolbar.component'; +import { MarkdownSymbol } from '../../../types/common.types'; + + +describe("MarkdownToolbarComponent", () => { + var component: MarkdownToolbarComponent; + + beforeEach(() => { + component = new MarkdownToolbarComponent(); + }); +}); diff --git a/config_app/js/components/markdown/markdown-toolbar.component.ts b/config_app/js/components/markdown/markdown-toolbar.component.ts new file mode 100644 index 000000000..64da08fc8 --- /dev/null +++ b/config_app/js/components/markdown/markdown-toolbar.component.ts @@ -0,0 +1,17 @@ +import { Component, Input, Output, EventEmitter } from 'ng-metadata/core'; +import { MarkdownSymbol } from './markdown.module'; +import './markdown-toolbar.component.css'; + + +/** + * Toolbar containing Markdown symbol shortcuts. + */ +@Component({ + selector: 'markdown-toolbar', + templateUrl: require('./markdown-toolbar.component.html'), +}) +export class MarkdownToolbarComponent { + + @Input('<') public allowUndo: boolean = true; + @Output() public insertSymbol: EventEmitter<{symbol: MarkdownSymbol}> = new EventEmitter(); +} diff --git a/config_app/js/components/markdown/markdown-view.component.css b/config_app/js/components/markdown/markdown-view.component.css new file mode 100644 index 000000000..bfbc4957d --- /dev/null +++ b/config_app/js/components/markdown/markdown-view.component.css @@ -0,0 +1,12 @@ +.markdown-view-content { + word-wrap: break-word; + overflow: hidden; +} + +.markdown-view-content p { + margin: 0; +} + +code * { + font-family: "Lucida Console", Monaco, monospace; +} diff --git a/config_app/js/components/markdown/markdown-view.component.html b/config_app/js/components/markdown/markdown-view.component.html new file mode 100644 index 000000000..c3c164d4a --- /dev/null +++ b/config_app/js/components/markdown/markdown-view.component.html @@ -0,0 +1,2 @@ +
diff --git a/config_app/js/components/markdown/markdown-view.component.spec.ts b/config_app/js/components/markdown/markdown-view.component.spec.ts new file mode 100644 index 000000000..2f2379541 --- /dev/null +++ b/config_app/js/components/markdown/markdown-view.component.spec.ts @@ -0,0 +1,79 @@ +import { MarkdownViewComponent } from './markdown-view.component'; +import { SimpleChanges } from 'ng-metadata/core'; +import { Converter, ConverterOptions } from 'showdown'; +import { Mock } from 'ts-mocks'; +import Spy = jasmine.Spy; + + +describe("MarkdownViewComponent", () => { + var component: MarkdownViewComponent; + var markdownConverterMock: Mock; + var $sceMock: Mock; + var $sanitizeMock: ng.sanitize.ISanitizeService; + + beforeEach(() => { + markdownConverterMock = new Mock(); + $sceMock = new Mock(); + $sanitizeMock = jasmine.createSpy('$sanitizeSpy').and.callFake((html: string) => html); + component = new MarkdownViewComponent(markdownConverterMock.Object, $sceMock.Object, $sanitizeMock); + }); + + describe("ngOnChanges", () => { + var changes: SimpleChanges; + var markdown: string; + var expectedPlaceholder: string; + var markdownChars: string[]; + + beforeEach(() => { + changes = {}; + markdown = `## Heading\n Code line\n\n- Item\n> Quote\`code snippet\`\n\nThis is my project!`; + expectedPlaceholder = `

placeholder

`; + markdownChars = ['#', '-', '>', '`']; + markdownConverterMock.setup(mock => mock.makeHtml).is((text) => text); + $sceMock.setup(mock => mock.trustAsHtml).is((html) => html); + }); + + it("calls markdown converter to convert content to HTML when content is changed", () => { + changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false}; + component.ngOnChanges(changes); + + expect((markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue); + }); + + it("only converts first line of content to HTML if flag is set when content is changed", () => { + component.firstLineOnly = true; + changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false}; + component.ngOnChanges(changes); + + const expectedHtml: string = markdown.split('\n') + .filter(line => line.indexOf(' ') != 0) + .filter(line => line.trim().length != 0) + .filter(line => markdownChars.indexOf(line.trim()[0]) == -1)[0]; + + expect((markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(expectedHtml); + }); + + it("sets converted HTML to be a placeholder if flag is set and content is empty", () => { + component.placeholderNeeded = true; + changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false}; + component.ngOnChanges(changes); + + expect((markdownConverterMock.Object.makeHtml)).not.toHaveBeenCalled(); + expect(($sceMock.Object.trustAsHtml).calls.argsFor(0)[0]).toEqual(expectedPlaceholder); + }); + + it("sets converted HTML to empty string if placeholder flag is false and content is empty", () => { + changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false}; + component.ngOnChanges(changes); + + expect((markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue); + }); + + it("calls $sanitize service to sanitize changed HTML content", () => { + changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false}; + component.ngOnChanges(changes); + + expect(($sanitizeMock).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue); + }); + }); +}); diff --git a/config_app/js/components/markdown/markdown-view.component.ts b/config_app/js/components/markdown/markdown-view.component.ts new file mode 100644 index 000000000..9732da3bc --- /dev/null +++ b/config_app/js/components/markdown/markdown-view.component.ts @@ -0,0 +1,48 @@ +import { Component, Input, Inject, OnChanges, SimpleChanges } from 'ng-metadata/core'; +import { Converter, ConverterOptions } from 'showdown'; +import './markdown-view.component.css'; + + +/** + * Renders Markdown content to HTML. + */ +@Component({ + selector: 'markdown-view', + templateUrl: require('./markdown-view.component.html'), +}) +export class MarkdownViewComponent implements OnChanges { + + @Input('<') public content: string; + @Input('<') public firstLineOnly: boolean = false; + @Input('<') public placeholderNeeded: boolean = false; + + private convertedHTML: string = ''; + private readonly placeholder: string = `

placeholder

`; + private readonly markdownChars: string[] = ['#', '-', '>', '`']; + + constructor(@Inject('markdownConverter') private markdownConverter: Converter, + @Inject('$sce') private $sce: ng.ISCEService, + @Inject('$sanitize') private $sanitize: ng.sanitize.ISanitizeService) { + + } + + public ngOnChanges(changes: SimpleChanges): void { + if (changes['content']) { + if (!changes['content'].currentValue && this.placeholderNeeded) { + this.convertedHTML = this.$sce.trustAsHtml(this.placeholder); + } else if (this.firstLineOnly && changes['content'].currentValue) { + const firstLine: string = changes['content'].currentValue.split('\n') + // Skip code lines + .filter(line => line.indexOf(' ') != 0) + // Skip empty lines + .filter(line => line.trim().length != 0) + // Skip control lines + .filter(line => this.markdownChars.indexOf(line.trim()[0]) == -1)[0]; + + this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(firstLine)); + } else { + this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(changes['content'].currentValue || '')); + } + } + } +} diff --git a/config_app/js/components/markdown/markdown.module.ts b/config_app/js/components/markdown/markdown.module.ts new file mode 100644 index 000000000..18dfd0e75 --- /dev/null +++ b/config_app/js/components/markdown/markdown.module.ts @@ -0,0 +1,97 @@ +import { NgModule } from 'ng-metadata/core'; +import { Converter } from 'showdown'; +import * as showdown from 'showdown'; +import { registerLanguage, highlightAuto } from 'highlight.js/lib/highlight'; +import 'highlight.js/styles/vs.css'; +const highlightedLanguages: string[] = require('./highlighted-languages.constant.json'); + +/** + * A type representing a Markdown symbol. + */ +export type MarkdownSymbol = 'heading1' + | 'heading2' + | 'heading3' + | 'bold' + | 'italics' + | 'bulleted-list' + | 'numbered-list' + | 'quote' + | 'code' + | 'link' + | 'code'; + +/** + * Type representing current browser platform. + * TODO: Add more browser platforms. + */ +export type BrowserPlatform = "firefox" + | "chrome"; + +/** + * Dynamically fetch and register a new language with Highlight.js + */ +export const addHighlightedLanguage = (language: string): Promise<{}> => { + return new Promise(async(resolve, reject) => { + try { + // TODO(alecmerdler): Use `import()` here instead of `require` after upgrading to TypeScript 2.4 + const langModule = require(`highlight.js/lib/languages/${language}`); + registerLanguage(language, langModule); + console.debug(`Language ${language} registered for syntax highlighting`); + resolve(); + } catch (error) { + console.debug(`Language ${language} not supported for syntax highlighting`); + reject(error); + } + }); +}; + + +/** + * Showdown JS extension for syntax highlighting using Highlight.js. Will attempt to register detected languages. + */ +export const showdownHighlight = (): showdown.FilterExtension => { + const htmlunencode = (text: string) => { + return (text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>')); + }; + + const left = '
]*>';
+  const right = '
'; + const flags = 'g'; + const replacement = (wholeMatch: string, match: string, leftSide: string, rightSide: string) => { + const language: string = leftSide.slice(leftSide.indexOf('language-') + ('language-').length, + leftSide.indexOf('"', leftSide.indexOf('language-'))); + addHighlightedLanguage(language).catch(error => null); + + match = htmlunencode(match); + return leftSide + highlightAuto(match).value + rightSide; + }; + + return { + type: 'output', + filter: (text, converter, options) => { + return (showdown).helper.replaceRecursiveRegExp(text, replacement, left, right, flags); + } + }; +}; + + +// Import default syntax-highlighting supported languages +highlightedLanguages.forEach((langName) => addHighlightedLanguage(langName)); + + +/** + * Markdown editor and view module. + */ +@NgModule({ + imports: [], + declarations: [], + providers: [ + {provide: 'markdownConverter', useValue: new Converter({extensions: [showdownHighlight]})}, + ], +}) +export class MarkdownModule { + +} diff --git a/config_app/js/components/request-service-key-dialog/request-service-key-dialog.js b/config_app/js/components/request-service-key-dialog/request-service-key-dialog.js index bd3351567..26d80494b 100644 --- a/config_app/js/components/request-service-key-dialog/request-service-key-dialog.js +++ b/config_app/js/components/request-service-key-dialog/request-service-key-dialog.js @@ -1,10 +1,11 @@ +const templateUrl = require('./request-service-key-dialog.html'); /** * An element which displays a dialog for requesting or creating a service key. */ -angular.module('quay').directive('requestServiceKeyDialog', function () { +angular.module('quay-config').directive('requestServiceKeyDialog', function () { var directiveDefinitionObject = { priority: 0, - templateUrl: '/static/directives/request-service-key-dialog.html', + templateUrl, replace: false, transclude: true, restrict: 'C', diff --git a/config_app/js/config-app.module.ts b/config_app/js/config-app.module.ts index 5c8a2c0bb..5f7fa6b7e 100644 --- a/config_app/js/config-app.module.ts +++ b/config_app/js/config-app.module.ts @@ -5,12 +5,17 @@ import { ConfigSetupAppComponent } from './components/config-setup-app/config-se import { DownloadTarballModalComponent } from './components/download-tarball-modal/download-tarball-modal.component'; import { LoadConfigComponent } from './components/load-config/load-config.component'; import { KubeDeployModalComponent } from './components/kube-deploy-modal/kube-deploy-modal.component'; +import { MarkdownModule } from './components/markdown/markdown.module'; +import { MarkdownInputComponent } from './components/markdown/markdown-input.component'; +import { MarkdownViewComponent } from './components/markdown/markdown-view.component'; +import { MarkdownToolbarComponent } from './components/markdown/markdown-toolbar.component'; +import { MarkdownEditorComponent } from './components/markdown/markdown-editor.component'; -const quayDependencies: string[] = [ +const quayDependencies: any[] = [ 'restangular', 'ngCookies', 'angularFileUpload', - 'ngSanitize' + 'ngSanitize', ]; @NgModule(({ @@ -41,12 +46,19 @@ function provideConfig($provide: ng.auto.IProvideService, @NgModule({ - imports: [ DependencyConfig ], + imports: [ + DependencyConfig, + MarkdownModule, + ], declarations: [ ConfigSetupAppComponent, DownloadTarballModalComponent, LoadConfigComponent, KubeDeployModalComponent, + MarkdownInputComponent, + MarkdownViewComponent, + MarkdownToolbarComponent, + MarkdownEditorComponent, ], providers: [] }) diff --git a/config_app/templates/index.html b/config_app/templates/index.html index 25b11b2e1..25804720e 100644 --- a/config_app/templates/index.html +++ b/config_app/templates/index.html @@ -24,6 +24,9 @@ + + + {% for script_path in main_scripts %}