Add components for generating sec keys
This commit is contained in:
parent
cc9bedbeb9
commit
0bc22d810a
25 changed files with 955 additions and 8 deletions
|
@ -3,6 +3,7 @@ import logging
|
||||||
from flask import Blueprint, request, abort
|
from flask import Blueprint, request, abort
|
||||||
from flask_restful import Resource, Api
|
from flask_restful import Resource, Api
|
||||||
from flask_restful.utils.cors import crossdomain
|
from flask_restful.utils.cors import crossdomain
|
||||||
|
from data import model
|
||||||
from email.utils import formatdate
|
from email.utils import formatdate
|
||||||
from calendar import timegm
|
from calendar import timegm
|
||||||
from functools import partial, wraps
|
from functools import partial, wraps
|
||||||
|
@ -29,6 +30,14 @@ api = ApiExceptionHandlingApi()
|
||||||
|
|
||||||
api.init_app(api_bp)
|
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):
|
def format_date(date):
|
||||||
""" Output an RFC822 date format. """
|
""" Output an RFC822 date format. """
|
||||||
|
|
|
@ -5,12 +5,13 @@ import subprocess
|
||||||
|
|
||||||
from flask import request, jsonify, make_response
|
from flask import request, jsonify, make_response
|
||||||
|
|
||||||
|
from endpoints.exception import NotFound
|
||||||
from data.database import ServiceKeyApprovalType
|
from data.database import ServiceKeyApprovalType
|
||||||
from data.model import ServiceKeyDoesNotExist
|
from data.model import ServiceKeyDoesNotExist
|
||||||
from util.config.validator import EXTRA_CA_DIRECTORY
|
from util.config.validator import EXTRA_CA_DIRECTORY
|
||||||
|
|
||||||
from config_app.config_endpoints.exception import InvalidRequest
|
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_endpoints.api.superuser_models_pre_oci import pre_oci_model
|
||||||
from config_app.config_util.ssl import load_certificate, CertInvalidException
|
from config_app.config_util.ssl import load_certificate, CertInvalidException
|
||||||
from config_app.c_app import app, config_provider, INIT_SCRIPTS_LOCATION
|
from config_app.c_app import app, config_provider, INIT_SCRIPTS_LOCATION
|
||||||
|
@ -170,7 +171,6 @@ class SuperUserServiceKeyApproval(ApiResource):
|
||||||
@validate_json_request('ApproveServiceKey')
|
@validate_json_request('ApproveServiceKey')
|
||||||
def post(self, kid):
|
def post(self, kid):
|
||||||
notes = request.get_json().get('notes', '')
|
notes = request.get_json().get('notes', '')
|
||||||
approver = app.config.get('SUPER_USERS', [])[0] # get the first superuser created in the config tool
|
|
||||||
try:
|
try:
|
||||||
key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes)
|
key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes)
|
||||||
|
|
||||||
|
@ -182,7 +182,10 @@ class SuperUserServiceKeyApproval(ApiResource):
|
||||||
'expiration_date': key.expiration_date,
|
'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:
|
except ServiceKeyDoesNotExist:
|
||||||
raise NotFound()
|
raise NotFound()
|
||||||
except ServiceKeyAlreadyApproved:
|
except ServiceKeyAlreadyApproved:
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
<span class="datetime-picker-element">
|
||||||
|
<input class="form-control" type="text" ng-model="selected_datetime"/>
|
||||||
|
</span>
|
60
config_app/js/components/datetime-picker/datetime-picker.js
Normal file
60
config_app/js/components/datetime-picker/datetime-picker.js
Normal file
|
@ -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;
|
||||||
|
});
|
|
@ -0,0 +1,8 @@
|
||||||
|
[
|
||||||
|
"javascript",
|
||||||
|
"python",
|
||||||
|
"bash",
|
||||||
|
"nginx",
|
||||||
|
"xml",
|
||||||
|
"shell"
|
||||||
|
]
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
<div class="markdown-editor-element">
|
||||||
|
<!-- Write/preview tabs -->
|
||||||
|
<ul class="nav nav-tabs" style="width: 100%;">
|
||||||
|
<li role="presentation" ng-class="$ctrl.editMode == 'write' ? 'active': ''"
|
||||||
|
ng-click="$ctrl.changeEditMode('write')">
|
||||||
|
<a href="#">Write</a>
|
||||||
|
</li>
|
||||||
|
<li role="presentation" ng-class="$ctrl.editMode == 'preview' ? 'active': ''"
|
||||||
|
ng-click="$ctrl.changeEditMode('preview')">
|
||||||
|
<a href="#">Preview</a>
|
||||||
|
</li>
|
||||||
|
<!-- Editing toolbar -->
|
||||||
|
<li style="float: right;">
|
||||||
|
<markdown-toolbar ng-if="$ctrl.editMode == 'write'"
|
||||||
|
(insert-symbol)="$ctrl.insertSymbol($event)"></markdown-toolbar>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content" style="padding: 10px 0 0 0;">
|
||||||
|
<div ng-show="$ctrl.editMode == 'write'">
|
||||||
|
<textarea id="markdown-textarea"
|
||||||
|
placeholder="Enter {{ ::$ctrl.fieldTitle }}"
|
||||||
|
ng-model="$ctrl.content"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="markdown-editor-preview"
|
||||||
|
ng-if="$ctrl.editMode == 'preview'">
|
||||||
|
<markdown-view content="$ctrl.content"></markdown-view>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="markdown-editor-actions">
|
||||||
|
<div class="markdown-editor-buttons">
|
||||||
|
<button type="button" class="btn btn-default"
|
||||||
|
ng-click="$ctrl.discardChanges()">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary"
|
||||||
|
ng-click="$ctrl.saveChanges()">
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -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<ng.IAugmentedJQuery | any>;
|
||||||
|
var documentMock: Mock<HTMLElement & Document>;
|
||||||
|
var $windowMock: Mock<ng.IWindowService>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
textarea = new Mock<ng.IAugmentedJQuery | any>();
|
||||||
|
documentMock = new Mock<HTMLElement & Document>();
|
||||||
|
$windowMock = new Mock<ng.IWindowService>();
|
||||||
|
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(<Spy>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((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
|
||||||
|
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
|
||||||
|
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(symbol.characters);
|
||||||
|
|
||||||
|
(<Spy>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((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
|
||||||
|
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
|
||||||
|
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(`${symbol.characters.slice(0, symbol.shiftBy)}${innerText}${symbol.characters.slice(symbol.shiftBy, symbol.characters.length)}`);
|
||||||
|
|
||||||
|
(<Spy>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((<Spy>textarea.Object.prop).calls.argsFor(2)[0]).toEqual('selectionStart');
|
||||||
|
expect((<Spy>textarea.Object.prop).calls.argsFor(2)[1]).toEqual(symbol.shiftBy);
|
||||||
|
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[0]).toEqual('selectionEnd');
|
||||||
|
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[1]).toEqual(symbol.shiftBy);
|
||||||
|
|
||||||
|
(<Spy>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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
147
config_app/js/components/markdown/markdown-editor.component.ts
Normal file
147
config_app/js/components/markdown/markdown-editor.component.ts
Normal file
|
@ -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<any> = 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(<string>this.textarea.val().substr(0, startPos) +
|
||||||
|
text +
|
||||||
|
<string>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";
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
<div class="markdown-input-container">
|
||||||
|
<div>
|
||||||
|
<span class="glyphicon glyphicon-edit"
|
||||||
|
ng-if="$ctrl.canWrite && !$ctrl.isEditing"
|
||||||
|
ng-click="$ctrl.editContent()"
|
||||||
|
data-title="Edit {{ ::$ctrl.fieldTitle }}" data-placement="left" bs-tooltip></span>
|
||||||
|
<div ng-if="$ctrl.content && !$ctrl.isEditing">
|
||||||
|
<markdown-view content="$ctrl.content"></markdown-view>
|
||||||
|
</div>
|
||||||
|
<!-- Not set and can write -->
|
||||||
|
<span class="markdown-input-placeholder-editable"
|
||||||
|
ng-if="!$ctrl.content && $ctrl.canWrite"
|
||||||
|
ng-click="$ctrl.editContent()">
|
||||||
|
<i>Click to set {{ ::$ctrl.fieldTitle }}</i>
|
||||||
|
</span>
|
||||||
|
<!-- Not set and cannot write -->
|
||||||
|
<span class="markdown-input-placeholder"
|
||||||
|
ng-if="!$ctrl.content && !$ctrl.canWrite">
|
||||||
|
<i>No {{ ::$ctrl.fieldTitle }} has been set</i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inline editor -->
|
||||||
|
<div ng-if="$ctrl.isEditing" style="margin-top: 20px;">
|
||||||
|
<markdown-editor content="$ctrl.content"
|
||||||
|
(save)="$ctrl.saveContent($event)"
|
||||||
|
(discard)="$ctrl.discardContent($event)"></markdown-editor>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -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", () => {
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
<div class="markdown-toolbar-element">
|
||||||
|
<div class="btn-toolbar" role="toolbar">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-default btn-sm dropdown-toggle"
|
||||||
|
data-toggle="dropdown"
|
||||||
|
data-title="Add header" data-container="body" bs-tooltip>
|
||||||
|
<span class="glyphicon glyphicon-text-size"></span>
|
||||||
|
<span class="caret"></span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading1'})"><h2>Heading</h2></li>
|
||||||
|
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading2'})"><h3>Heading</h3></li>
|
||||||
|
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading3'})"><h4>Heading</h4></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-default btn-sm"
|
||||||
|
data-title="Bold" data-container="body" bs-tooltip
|
||||||
|
ng-click="$ctrl.insertSymbol.emit({symbol: 'bold'})">
|
||||||
|
<span class="glyphicon glyphicon-bold"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-default btn-sm"
|
||||||
|
data-title="Italics" data-container="body" bs-tooltip
|
||||||
|
ng-click="$ctrl.insertSymbol.emit({symbol: 'italics'})">
|
||||||
|
<span class="glyphicon glyphicon-italic"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-default btn-sm"
|
||||||
|
data-title="Block quote" data-container="body" bs-tooltip
|
||||||
|
ng-click="$ctrl.insertSymbol.emit({symbol: 'quote'})">
|
||||||
|
<i class="fa fa-quote-left" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-default btn-sm"
|
||||||
|
data-title="Code snippet" data-container="body" bs-tooltip
|
||||||
|
ng-click="$ctrl.insertSymbol.emit({symbol: 'code'})">
|
||||||
|
<span class="glyphicon glyphicon-menu-left" style="margin-right: -6px;"></span>
|
||||||
|
<span class="glyphicon glyphicon-menu-right"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-default btn-sm"
|
||||||
|
data-title="URL" data-container="body" bs-tooltip
|
||||||
|
ng-click="$ctrl.insertSymbol.emit({symbol: 'link'})">
|
||||||
|
<span class="glyphicon glyphicon-link"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-default btn-sm"
|
||||||
|
data-title="Bulleted list" data-container="body" bs-tooltip
|
||||||
|
ng-click="$ctrl.insertSymbol.emit({symbol: 'bulleted-list'})">
|
||||||
|
<span class="glyphicon glyphicon-list"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-default btn-sm"
|
||||||
|
data-title="Numbered list" data-container="body" data-container="body" bs-tooltip
|
||||||
|
ng-click="$ctrl.insertSymbol.emit({symbol: 'numbered-list'})">
|
||||||
|
<i class="fa fa-list-ol" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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();
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
<div class="markdown-view-content"
|
||||||
|
ng-bind-html="$ctrl.convertedHTML"></div>
|
|
@ -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<Converter>;
|
||||||
|
var $sceMock: Mock<ng.ISCEService>;
|
||||||
|
var $sanitizeMock: ng.sanitize.ISanitizeService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
markdownConverterMock = new Mock<Converter>();
|
||||||
|
$sceMock = new Mock<ng.ISCEService>();
|
||||||
|
$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 = `<p style="visibility:hidden">placeholder</p>`;
|
||||||
|
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((<Spy>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((<Spy>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((<Spy>markdownConverterMock.Object.makeHtml)).not.toHaveBeenCalled();
|
||||||
|
expect((<Spy>$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((<Spy>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((<Spy>$sanitizeMock).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
48
config_app/js/components/markdown/markdown-view.component.ts
Normal file
48
config_app/js/components/markdown/markdown-view.component.ts
Normal file
|
@ -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 = `<p style="visibility:hidden">placeholder</p>`;
|
||||||
|
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 || ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
97
config_app/js/components/markdown/markdown.module.ts
Normal file
97
config_app/js/components/markdown/markdown.module.ts
Normal file
|
@ -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 = '<pre><code\\b[^>]*>';
|
||||||
|
const right = '</code></pre>';
|
||||||
|
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 (<any>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: [<any>showdownHighlight]})},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MarkdownModule {
|
||||||
|
|
||||||
|
}
|
|
@ -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.
|
* 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 = {
|
var directiveDefinitionObject = {
|
||||||
priority: 0,
|
priority: 0,
|
||||||
templateUrl: '/static/directives/request-service-key-dialog.html',
|
templateUrl,
|
||||||
replace: false,
|
replace: false,
|
||||||
transclude: true,
|
transclude: true,
|
||||||
restrict: 'C',
|
restrict: 'C',
|
||||||
|
|
|
@ -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 { DownloadTarballModalComponent } from './components/download-tarball-modal/download-tarball-modal.component';
|
||||||
import { LoadConfigComponent } from './components/load-config/load-config.component';
|
import { LoadConfigComponent } from './components/load-config/load-config.component';
|
||||||
import { KubeDeployModalComponent } from './components/kube-deploy-modal/kube-deploy-modal.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',
|
'restangular',
|
||||||
'ngCookies',
|
'ngCookies',
|
||||||
'angularFileUpload',
|
'angularFileUpload',
|
||||||
'ngSanitize'
|
'ngSanitize',
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule(({
|
@NgModule(({
|
||||||
|
@ -41,12 +46,19 @@ function provideConfig($provide: ng.auto.IProvideService,
|
||||||
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [ DependencyConfig ],
|
imports: [
|
||||||
|
DependencyConfig,
|
||||||
|
MarkdownModule,
|
||||||
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
ConfigSetupAppComponent,
|
ConfigSetupAppComponent,
|
||||||
DownloadTarballModalComponent,
|
DownloadTarballModalComponent,
|
||||||
LoadConfigComponent,
|
LoadConfigComponent,
|
||||||
KubeDeployModalComponent,
|
KubeDeployModalComponent,
|
||||||
|
MarkdownInputComponent,
|
||||||
|
MarkdownViewComponent,
|
||||||
|
MarkdownToolbarComponent,
|
||||||
|
MarkdownEditorComponent,
|
||||||
],
|
],
|
||||||
providers: []
|
providers: []
|
||||||
})
|
})
|
||||||
|
|
|
@ -24,6 +24,9 @@
|
||||||
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-sanitize.min.js"></script>
|
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-sanitize.min.js"></script>
|
||||||
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-cookies.min.js"></script>
|
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-cookies.min.js"></script>
|
||||||
<script src="//cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0,d3js@3.3.3"></script>
|
<script src="//cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0,d3js@3.3.3"></script>
|
||||||
|
<script src="//cdn.jsdelivr.net/g/momentjs"></script>
|
||||||
|
<script src="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.37/js/bootstrap-datetimepicker.min.js"></script>
|
||||||
|
|
||||||
|
|
||||||
{% for script_path in main_scripts %}
|
{% for script_path in main_scripts %}
|
||||||
<script src="/static/{{ script_path }}"></script>
|
<script src="/static/{{ script_path }}"></script>
|
||||||
|
|
Reference in a new issue