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 @@
+
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 %}