Commit ecaa68a7 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'balsalmiq-support' into 'master'

Initial balsamiq support

See merge request !10564
parents 1bf9f012 165bcec3
/* global Flash */
import sqljs from 'sql.js';
import { template as _template } from 'underscore';
const PREVIEW_TEMPLATE = _template(`
<div class="panel panel-default">
<div class="panel-heading"><%- name %></div>
<div class="panel-body">
<img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/>
</div>
</div>
`);
class BalsamiqViewer {
constructor(viewer) {
this.viewer = viewer;
this.endpoint = this.viewer.dataset.endpoint;
}
loadFile() {
const xhr = new XMLHttpRequest();
xhr.open('GET', this.endpoint, true);
xhr.responseType = 'arraybuffer';
xhr.onload = this.renderFile.bind(this);
xhr.onerror = BalsamiqViewer.onError;
xhr.send();
}
renderFile(loadEvent) {
const container = document.createElement('ul');
this.initDatabase(loadEvent.target.response);
const previews = this.getPreviews();
previews.forEach((preview) => {
const renderedPreview = this.renderPreview(preview);
container.appendChild(renderedPreview);
});
container.classList.add('list-inline');
container.classList.add('previews');
this.viewer.appendChild(container);
}
initDatabase(data) {
const previewBinary = new Uint8Array(data);
this.database = new sqljs.Database(previewBinary);
}
getPreviews() {
const thumbnails = this.database.exec('SELECT * FROM thumbnails');
return thumbnails[0].values.map(BalsamiqViewer.parsePreview);
}
getResource(resourceID) {
const resources = this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`);
return resources[0];
}
renderPreview(preview) {
const previewElement = document.createElement('li');
previewElement.classList.add('preview');
previewElement.innerHTML = this.renderTemplate(preview);
return previewElement;
}
renderTemplate(preview) {
const resource = this.getResource(preview.resourceID);
const name = BalsamiqViewer.parseTitle(resource);
const image = preview.image;
const template = PREVIEW_TEMPLATE({
name,
image,
});
return template;
}
static parsePreview(preview) {
return JSON.parse(preview[1]);
}
/*
* resource = {
* columns: ['ID', 'BRANCHID', 'ATTRIBUTES', 'DATA'],
* values: [['id', 'branchId', 'attributes', 'data']],
* }
*
* 'attributes' being a JSON string containing the `name` property.
*/
static parseTitle(resource) {
return JSON.parse(resource.values[0][2]).name;
}
static onError() {
const flash = new Flash('Balsamiq file could not be loaded.');
return flash;
}
}
export default BalsamiqViewer;
import BalsamiqViewer from './balsamiq/balsamiq_viewer';
document.addEventListener('DOMContentLoaded', () => {
const balsamiqViewer = new BalsamiqViewer(document.getElementById('js-balsamiq-viewer'));
balsamiqViewer.loadFile();
});
......@@ -162,6 +162,18 @@
&.code {
padding: 0;
}
.list-inline.previews {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-content: flex-start;
align-items: baseline;
.preview {
padding: $gl-padding;
}
}
}
}
......
......@@ -26,6 +26,7 @@ class Blob < SimpleDelegator
BlobViewer::Image,
BlobViewer::Sketch,
BlobViewer::Balsamiq,
BlobViewer::Video,
......
module BlobViewer
class Balsamiq < Base
include Rich
include ClientSide
self.partial_name = 'balsamiq'
self.extensions = %w(bmpr)
self.binary = true
self.switcher_icon = 'file-image-o'
self.switcher_title = 'preview'
end
end
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('balsamiq_viewer')
.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_url } }
---
title: Added balsamiq file viewer
merge_request: 10564
author:
......@@ -17,6 +17,10 @@ var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false';
var WEBPACK_REPORT = process.env.WEBPACK_REPORT;
var config = {
// because sqljs requires fs.
node: {
fs: "empty"
},
context: path.join(ROOT_PATH, 'app/assets/javascripts'),
entry: {
blob: './blob_edit/blob_bundle.js',
......@@ -46,6 +50,7 @@ var config = {
notebook_viewer: './blob/notebook_viewer.js',
pdf_viewer: './blob/pdf_viewer.js',
pipelines: './pipelines/index.js',
balsamiq_viewer: './blob/balsamiq_viewer.js',
profile: './profile/profile_bundle.js',
protected_branches: './protected_branches/protected_branches_bundle.js',
protected_tags: './protected_tags',
......@@ -140,6 +145,7 @@ var config = {
'notebook_viewer',
'pdf_viewer',
'pipelines',
'balsamiq_viewer',
],
minChunks: function(module, count) {
return module.resource && (/vue_shared/).test(module.resource);
......
import sqljs from 'sql.js';
import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer';
import ClassSpecHelper from '../../helpers/class_spec_helper';
describe('BalsamiqViewer', () => {
let balsamiqViewer;
let endpoint;
let viewer;
describe('class constructor', () => {
beforeEach(() => {
endpoint = 'endpoint';
viewer = {
dataset: {
endpoint,
},
};
balsamiqViewer = new BalsamiqViewer(viewer);
});
it('should set .viewer', () => {
expect(balsamiqViewer.viewer).toBe(viewer);
});
it('should set .endpoint', () => {
expect(balsamiqViewer.endpoint).toBe(endpoint);
});
});
describe('loadFile', () => {
let xhr;
beforeEach(() => {
endpoint = 'endpoint';
xhr = jasmine.createSpyObj('xhr', ['open', 'send']);
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderFile']);
balsamiqViewer.endpoint = endpoint;
spyOn(window, 'XMLHttpRequest').and.returnValue(xhr);
BalsamiqViewer.prototype.loadFile.call(balsamiqViewer);
});
it('should call .open', () => {
expect(xhr.open).toHaveBeenCalledWith('GET', endpoint, true);
});
it('should set .responseType', () => {
expect(xhr.responseType).toBe('arraybuffer');
});
it('should call .send', () => {
expect(xhr.send).toHaveBeenCalled();
});
});
describe('renderFile', () => {
let container;
let loadEvent;
let previews;
beforeEach(() => {
loadEvent = { target: { response: {} } };
viewer = jasmine.createSpyObj('viewer', ['appendChild']);
previews = [document.createElement('ul'), document.createElement('ul')];
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['initDatabase', 'getPreviews', 'renderPreview']);
balsamiqViewer.viewer = viewer;
balsamiqViewer.getPreviews.and.returnValue(previews);
balsamiqViewer.renderPreview.and.callFake(preview => preview);
viewer.appendChild.and.callFake((containerElement) => {
container = containerElement;
});
BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, loadEvent);
});
it('should call .initDatabase', () => {
expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(loadEvent.target.response);
});
it('should call .getPreviews', () => {
expect(balsamiqViewer.getPreviews).toHaveBeenCalled();
});
it('should call .renderPreview for each preview', () => {
const allArgs = balsamiqViewer.renderPreview.calls.allArgs();
expect(allArgs.length).toBe(2);
previews.forEach((preview, i) => {
expect(allArgs[i][0]).toBe(preview);
});
});
it('should set the container HTML', () => {
expect(container.innerHTML).toBe('<ul></ul><ul></ul>');
});
it('should add inline preview classes', () => {
expect(container.classList[0]).toBe('list-inline');
expect(container.classList[1]).toBe('previews');
});
it('should call viewer.appendChild', () => {
expect(viewer.appendChild).toHaveBeenCalledWith(container);
});
});
describe('initDatabase', () => {
let database;
let uint8Array;
let data;
beforeEach(() => {
uint8Array = {};
database = {};
data = 'data';
balsamiqViewer = {};
spyOn(window, 'Uint8Array').and.returnValue(uint8Array);
spyOn(sqljs, 'Database').and.returnValue(database);
BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data);
});
it('should instantiate Uint8Array', () => {
expect(window.Uint8Array).toHaveBeenCalledWith(data);
});
it('should call sqljs.Database', () => {
expect(sqljs.Database).toHaveBeenCalledWith(uint8Array);
});
it('should set .database', () => {
expect(balsamiqViewer.database).toBe(database);
});
});
describe('getPreviews', () => {
let database;
let thumbnails;
let getPreviews;
beforeEach(() => {
database = jasmine.createSpyObj('database', ['exec']);
thumbnails = [{ values: [0, 1, 2] }];
balsamiqViewer = {
database,
};
spyOn(BalsamiqViewer, 'parsePreview').and.callFake(preview => preview.toString());
database.exec.and.returnValue(thumbnails);
getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer);
});
it('should call database.exec', () => {
expect(database.exec).toHaveBeenCalledWith('SELECT * FROM thumbnails');
});
it('should call .parsePreview for each value', () => {
const allArgs = BalsamiqViewer.parsePreview.calls.allArgs();
expect(allArgs.length).toBe(3);
thumbnails[0].values.forEach((value, i) => {
expect(allArgs[i][0]).toBe(value);
});
});
it('should return an array of parsed values', () => {
expect(getPreviews).toEqual(['0', '1', '2']);
});
});
describe('getResource', () => {
let database;
let resourceID;
let resource;
let getResource;
beforeEach(() => {
database = jasmine.createSpyObj('database', ['exec']);
resourceID = 4;
resource = ['resource'];
balsamiqViewer = {
database,
};
database.exec.and.returnValue(resource);
getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID);
});
it('should call database.exec', () => {
expect(database.exec).toHaveBeenCalledWith(`SELECT * FROM resources WHERE id = '${resourceID}'`);
});
it('should return the selected resource', () => {
expect(getResource).toBe(resource[0]);
});
});
describe('renderPreview', () => {
let previewElement;
let innerHTML;
let preview;
let renderPreview;
beforeEach(() => {
innerHTML = '<a>innerHTML</a>';
previewElement = {
outerHTML: '<p>outerHTML</p>',
classList: jasmine.createSpyObj('classList', ['add']),
};
preview = {};
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderTemplate']);
spyOn(document, 'createElement').and.returnValue(previewElement);
balsamiqViewer.renderTemplate.and.returnValue(innerHTML);
renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview);
});
it('should call classList.add', () => {
expect(previewElement.classList.add).toHaveBeenCalledWith('preview');
});
it('should call .renderTemplate', () => {
expect(balsamiqViewer.renderTemplate).toHaveBeenCalledWith(preview);
});
it('should set .innerHTML', () => {
expect(previewElement.innerHTML).toBe(innerHTML);
});
it('should return element', () => {
expect(renderPreview).toBe(previewElement);
});
});
describe('renderTemplate', () => {
let preview;
let name;
let resource;
let template;
let renderTemplate;
beforeEach(() => {
preview = { resourceID: 1, image: 'image' };
name = 'name';
resource = 'resource';
template = `
<div class="panel panel-default">
<div class="panel-heading">name</div>
<div class="panel-body">
<img class="img-thumbnail" src="data:image/png;base64,image"/>
</div>
</div>
`;
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getResource']);
spyOn(BalsamiqViewer, 'parseTitle').and.returnValue(name);
balsamiqViewer.getResource.and.returnValue(resource);
renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview);
});
it('should call .getResource', () => {
expect(balsamiqViewer.getResource).toHaveBeenCalledWith(preview.resourceID);
});
it('should call .parseTitle', () => {
expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource);
});
it('should return the template string', function () {
expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, ''));
});
});
describe('parsePreview', () => {
let preview;
let parsePreview;
beforeEach(() => {
preview = ['{}', '{ "id": 1 }'];
spyOn(JSON, 'parse').and.callThrough();
parsePreview = BalsamiqViewer.parsePreview(preview);
});
ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview');
it('should return the parsed JSON', () => {
expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }'));
});
});
describe('parseTitle', () => {
let title;
let parseTitle;
beforeEach(() => {
title = { values: [['{}', '{}', '{"name":"name"}']] };
spyOn(JSON, 'parse').and.callThrough();
parseTitle = BalsamiqViewer.parseTitle(title);
});
ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview');
it('should return the name value', () => {
expect(parseTitle).toBe('name');
});
});
describe('onError', () => {
beforeEach(() => {
spyOn(window, 'Flash');
BalsamiqViewer.onError();
});
ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'onError');
it('should instantiate Flash', () => {
expect(window.Flash).toHaveBeenCalledWith('Balsamiq file could not be loaded.');
});
});
});
......@@ -27,6 +27,7 @@ module TestEnv
'expand-collapse-files' => '025db92',
'expand-collapse-lines' => '238e82d',
'video' => '8879059',
'add-balsamiq-file' => 'b89b56d',
'crlf-diff' => '5938907',
'conflict-start' => '824be60',
'conflict-resolvable' => '1450cd6',
......
......@@ -5201,6 +5201,10 @@ sprintf-js@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
sql.js@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-0.4.0.tgz#23be9635520eb0ff43a741e7e830397266e88445"
sshpk@^1.7.0:
version "1.10.2"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.10.2.tgz#d5a804ce22695515638e798dbe23273de070a5fa"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment