Commit 174aeb62 authored by Lukas 'Eipi' Eipert's avatar Lukas 'Eipi' Eipert Committed by Bob Van Landuyt

Remove Balsamiq File Preview

The Balsamiq File Preview is not utilized a lot. In fact it has been
broken since over a year on gitlab.com and on self managed instances[0].
The Balsamiq preview itself doesn't have a lot of value as it just
renders the preview of the first Balsamiq page and not all the pages.
We have received no user complaints whatsoever, meaning that likely
_noone_ relied on this feature.

This MR removes the previewer, as it is also riddled with tech debt, for
example shipping a asm.js compiled version of sql.js

[0]: https://gitlab.com/gitlab-org/gitlab/-/issues/285525

Changelog: removed
parent c33c5d65
import { template as _template } from 'lodash';
import sqljs from 'sql.js';
import axios from '~/lib/utils/axios_utils';
import { successCodes } from '~/lib/utils/http_status';
const PREVIEW_TEMPLATE = _template(`
<div class="card">
<div class="card-header"><%- name %></div>
<div class="card-body">
<img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/>
</div>
</div>
`);
class BalsamiqViewer {
constructor(viewer) {
this.viewer = viewer;
}
loadFile(endpoint) {
return axios
.get(endpoint, {
responseType: 'arraybuffer',
validateStatus(status) {
return status !== successCodes.OK;
},
})
.then(({ data }) => {
this.renderFile(data);
})
.catch((e) => {
throw new Error(e);
});
}
renderFile(fileBuffer) {
const container = document.createElement('ul');
this.initDatabase(fileBuffer);
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;
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;
}
}
export default BalsamiqViewer;
import createFlash from '~/flash';
import { __ } from '~/locale';
import BalsamiqViewer from './balsamiq/balsamiq_viewer';
function onError() {
const flash = createFlash({
message: __('Balsamiq file could not be loaded.'),
});
return flash;
}
export default function loadBalsamiqFile() {
const viewer = document.getElementById('js-balsamiq-viewer');
if (!(viewer instanceof Element)) return;
const { endpoint } = viewer.dataset;
const balsamiqViewer = new BalsamiqViewer(viewer);
balsamiqViewer.loadFile(endpoint).catch(onError);
}
...@@ -17,8 +17,6 @@ import eventHub from '../../notes/event_hub'; ...@@ -17,8 +17,6 @@ import eventHub from '../../notes/event_hub';
const loadRichBlobViewer = (type) => { const loadRichBlobViewer = (type) => {
switch (type) { switch (type) {
case 'balsamiq':
return import(/* webpackChunkName: 'balsamiq_viewer' */ '../balsamiq_viewer');
case 'notebook': case 'notebook':
return import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer'); return import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer');
case 'openapi': case 'openapi':
......
...@@ -35,7 +35,6 @@ class Blob < SimpleDelegator ...@@ -35,7 +35,6 @@ class Blob < SimpleDelegator
BlobViewer::Image, BlobViewer::Image,
BlobViewer::Sketch, BlobViewer::Sketch,
BlobViewer::Balsamiq,
BlobViewer::Video, BlobViewer::Video,
BlobViewer::Audio, BlobViewer::Audio,
......
# frozen_string_literal: true
module BlobViewer
class Balsamiq < Base
include Rich
include ClientSide
self.partial_name = 'balsamiq'
self.extensions = %w(bmpr)
self.binary = true
self.switcher_icon = 'doc-image'
self.switcher_title = 'preview'
end
end
.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_path } }
...@@ -326,7 +326,7 @@ module.exports = { ...@@ -326,7 +326,7 @@ module.exports = {
], ],
}, },
{ {
test: /\.(worker(\.min)?\.js|pdf|bmpr)$/, test: /\.(worker(\.min)?\.js|pdf)$/,
exclude: /node_modules/, exclude: /node_modules/,
loader: 'file-loader', loader: 'file-loader',
options: { options: {
...@@ -735,7 +735,7 @@ module.exports = { ...@@ -735,7 +735,7 @@ module.exports = {
devtool: NO_SOURCEMAPS ? false : devtool, devtool: NO_SOURCEMAPS ? false : devtool,
node: { node: {
fs: 'empty', // sqljs requires fs fs: 'empty', // editorconfig requires 'fs'
setImmediate: false, setImmediate: false,
}, },
}; };
...@@ -28,7 +28,6 @@ module.exports = { ...@@ -28,7 +28,6 @@ module.exports = {
'jquery/dist/jquery.slim.js', 'jquery/dist/jquery.slim.js',
'pdfjs-dist/build/pdf', 'pdfjs-dist/build/pdf',
'pdfjs-dist/build/pdf.worker.min', 'pdfjs-dist/build/pdf.worker.min',
'sql.js',
'core-js', 'core-js',
'echarts', 'echarts',
'lodash', 'lodash',
......
...@@ -5447,9 +5447,6 @@ msgstr "" ...@@ -5447,9 +5447,6 @@ msgstr ""
msgid "Badges|Your badges" msgid "Badges|Your badges"
msgstr "" msgstr ""
msgid "Balsamiq file could not be loaded."
msgstr ""
msgid "BambooService|Atlassian Bamboo" msgid "BambooService|Atlassian Bamboo"
msgstr "" msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Balsamiq file blob', :js do
let(:project) { create(:project, :public, :repository) }
before do
visit project_blob_path(project, 'add-balsamiq-file/files/images/balsamiq.bmpr')
wait_for_requests
end
it 'displays Balsamiq file content' do
expect(page).to have_content("Mobile examples")
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Balsamiq file blob', :js do
let(:project) { create(:project, :public, :repository) }
before do
stub_feature_flags(refactor_blob_viewer: false)
visit project_blob_path(project, 'add-balsamiq-file/files/images/balsamiq.bmpr')
wait_for_requests
end
it 'displays Balsamiq file content' do
expect(page).to have_content("Mobile examples")
end
end
import sqljs from 'sql.js';
import ClassSpecHelper from 'helpers/class_spec_helper';
import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer';
import axios from '~/lib/utils/axios_utils';
jest.mock('sql.js');
describe('BalsamiqViewer', () => {
const mockArrayBuffer = new ArrayBuffer(10);
let balsamiqViewer;
let viewer;
describe('class constructor', () => {
beforeEach(() => {
viewer = {};
balsamiqViewer = new BalsamiqViewer(viewer);
});
it('should set .viewer', () => {
expect(balsamiqViewer.viewer).toBe(viewer);
});
});
describe('loadFile', () => {
let bv;
const endpoint = 'endpoint';
const requestSuccess = Promise.resolve({
data: mockArrayBuffer,
status: 200,
});
beforeEach(() => {
viewer = {};
bv = new BalsamiqViewer(viewer);
});
it('should call `axios.get` on `endpoint` param with responseType set to `arraybuffer', () => {
jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
jest.spyOn(bv, 'renderFile').mockReturnValue();
bv.loadFile(endpoint);
expect(axios.get).toHaveBeenCalledWith(
endpoint,
expect.objectContaining({
responseType: 'arraybuffer',
}),
);
});
it('should call `renderFile` on request success', (done) => {
jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
bv.loadFile(endpoint)
.then(() => {
expect(bv.renderFile).toHaveBeenCalledWith(mockArrayBuffer);
})
.then(done)
.catch(done.fail);
});
it('should not call `renderFile` on request failure', (done) => {
jest.spyOn(axios, 'get').mockReturnValue(Promise.reject());
jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
bv.loadFile(endpoint)
.then(() => {
done.fail('Expected loadFile to throw error!');
})
.catch(() => {
expect(bv.renderFile).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
});
describe('renderFile', () => {
let container;
let previews;
beforeEach(() => {
viewer = {
appendChild: jest.fn(),
};
previews = [document.createElement('ul'), document.createElement('ul')];
balsamiqViewer = {
initDatabase: jest.fn(),
getPreviews: jest.fn(),
renderPreview: jest.fn(),
};
balsamiqViewer.viewer = viewer;
balsamiqViewer.getPreviews.mockReturnValue(previews);
balsamiqViewer.renderPreview.mockImplementation((preview) => preview);
viewer.appendChild.mockImplementation((containerElement) => {
container = containerElement;
});
BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, mockArrayBuffer);
});
it('should call .initDatabase', () => {
expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(mockArrayBuffer);
});
it('should call .getPreviews', () => {
expect(balsamiqViewer.getPreviews).toHaveBeenCalled();
});
it('should call .renderPreview for each preview', () => {
const allArgs = balsamiqViewer.renderPreview.mock.calls;
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 uint8Array;
let data;
beforeEach(() => {
uint8Array = {};
data = 'data';
balsamiqViewer = {};
window.Uint8Array = jest.fn();
window.Uint8Array.mockReturnValue(uint8Array);
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).not.toBe(null);
});
});
describe('getPreviews', () => {
let database;
let thumbnails;
let getPreviews;
beforeEach(() => {
database = {
exec: jest.fn(),
};
thumbnails = [{ values: [0, 1, 2] }];
balsamiqViewer = {
database,
};
jest
.spyOn(BalsamiqViewer, 'parsePreview')
.mockImplementation((preview) => preview.toString());
database.exec.mockReturnValue(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.mock.calls;
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 = {
exec: jest.fn(),
};
resourceID = 4;
resource = ['resource'];
balsamiqViewer = {
database,
};
database.exec.mockReturnValue(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: {
add: jest.fn(),
},
};
preview = {};
balsamiqViewer = {
renderTemplate: jest.fn(),
};
jest.spyOn(document, 'createElement').mockReturnValue(previewElement);
balsamiqViewer.renderTemplate.mockReturnValue(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="card">
<div class="card-header">name</div>
<div class="card-body">
<img class="img-thumbnail" src="data:image/png;base64,image"/>
</div>
</div>
`;
balsamiqViewer = {
getResource: jest.fn(),
};
jest.spyOn(BalsamiqViewer, 'parseTitle').mockReturnValue(name);
balsamiqViewer.getResource.mockReturnValue(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', () => {
expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, ''));
});
});
describe('parsePreview', () => {
let preview;
let parsePreview;
beforeEach(() => {
preview = ['{}', '{ "id": 1 }'];
jest.spyOn(JSON, 'parse');
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"}']] };
jest.spyOn(JSON, 'parse');
parseTitle = BalsamiqViewer.parseTitle(title);
});
ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview');
it('should return the name value', () => {
expect(parseTitle).toBe('name');
});
});
});
...@@ -41,7 +41,6 @@ module TestEnv ...@@ -41,7 +41,6 @@ module TestEnv
'pages-deploy-target' => '7975be0', 'pages-deploy-target' => '7975be0',
'audio' => 'c3c21fd', 'audio' => 'c3c21fd',
'video' => '8879059', 'video' => '8879059',
'add-balsamiq-file' => 'b89b56d',
'crlf-diff' => '5938907', 'crlf-diff' => '5938907',
'conflict-start' => '824be60', 'conflict-start' => '824be60',
'conflict-resolvable' => '1450cd6', 'conflict-resolvable' => '1450cd6',
......
...@@ -10820,11 +10820,6 @@ sprintf-js@~1.0.2: ...@@ -10820,11 +10820,6 @@ sprintf-js@~1.0.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
sql.js@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-0.4.0.tgz#23be9635520eb0ff43a741e7e830397266e88445"
integrity sha1-I76WNVIOsP9Dp0Hn6DA5cmbohEU=
sshpk@^1.7.0: sshpk@^1.7.0:
version "1.16.1" version "1.16.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
......
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