diff options
-rw-r--r-- | app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js | 114 | ||||
-rw-r--r-- | app/assets/javascripts/blob/balsamiq_viewer.js | 6 | ||||
-rw-r--r-- | app/assets/stylesheets/framework/files.scss | 12 | ||||
-rw-r--r-- | app/models/blob.rb | 1 | ||||
-rw-r--r-- | app/models/blob_viewer/balsamiq.rb | 12 | ||||
-rw-r--r-- | app/views/projects/blob/viewers/_balsamiq.html.haml | 4 | ||||
-rw-r--r-- | changelogs/unreleased/balsalmiq-support.yml | 4 | ||||
-rw-r--r-- | config/webpack.config.js | 6 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js | 342 | ||||
-rw-r--r-- | spec/support/test_env.rb | 1 | ||||
-rw-r--r-- | yarn.lock | 4 |
12 files changed, 507 insertions, 0 deletions
diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js new file mode 100644 index 00000000000..cdbfe36ca1c --- /dev/null +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -0,0 +1,114 @@ +/* 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; diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js new file mode 100644 index 00000000000..1dacf84470f --- /dev/null +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -0,0 +1,6 @@ +import BalsamiqViewer from './balsamiq/balsamiq_viewer'; + +document.addEventListener('DOMContentLoaded', () => { + const balsamiqViewer = new BalsamiqViewer(document.getElementById('js-balsamiq-viewer')); + balsamiqViewer.loadFile(); +}); diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index c197bf6b9f5..1dd0e5ab581 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -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; + } + } } } diff --git a/app/models/blob.rb b/app/models/blob.rb index a4fae22a0c4..eaf0b713122 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -26,6 +26,7 @@ class Blob < SimpleDelegator BlobViewer::Image, BlobViewer::Sketch, + BlobViewer::Balsamiq, BlobViewer::Video, diff --git a/app/models/blob_viewer/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb new file mode 100644 index 00000000000..f982521db99 --- /dev/null +++ b/app/models/blob_viewer/balsamiq.rb @@ -0,0 +1,12 @@ +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 diff --git a/app/views/projects/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml new file mode 100644 index 00000000000..28670e7de97 --- /dev/null +++ b/app/views/projects/blob/viewers/_balsamiq.html.haml @@ -0,0 +1,4 @@ +- 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 } } diff --git a/changelogs/unreleased/balsalmiq-support.yml b/changelogs/unreleased/balsalmiq-support.yml new file mode 100644 index 00000000000..56a0b4c83fa --- /dev/null +++ b/changelogs/unreleased/balsalmiq-support.yml @@ -0,0 +1,4 @@ +--- +title: Added balsamiq file viewer +merge_request: 10564 +author: diff --git a/config/webpack.config.js b/config/webpack.config.js index a8ea57896c2..160dc9057f2 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -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); diff --git a/package.json b/package.json index dd73636121a..800327d8a08 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "raw-loader": "^0.5.1", "react-dev-utils": "^0.5.2", "select2": "3.5.2-browserify", + "sql.js": "^0.4.0", "stats-webpack-plugin": "^0.4.3", "three": "^0.84.0", "three-orbit-controls": "^82.1.0", diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js new file mode 100644 index 00000000000..85816ee1f11 --- /dev/null +++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js @@ -0,0 +1,342 @@ +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.'); + }); + }); +}); diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 0b3c6169c9b..8e31c26591b 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -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', diff --git a/yarn.lock b/yarn.lock index 7bfa1426ce0..8aac2b1b1cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" |