Commit 7b4b9e1c authored by Phil Hughes's avatar Phil Hughes

Web IDE & CodeSandbox

This enables JavaScripts projects to have live previews straight in the
browser without requiring any local configuration. This uses the
CodeSandbox package `sandpack` to compile it all inside of an iframe.

This feature is off by default and can be toggled on in the admin
settings. Only projects with a `package.json` and a `main` key are
supported.

Updates happen in real-time with hot-reloading. We just watch for
changes to files and then send them to `sandpack` to allow it to reload
the iframe. The iframe includes a very simple navigation bar, the text
bar is `readonly` to stop users navigating away from the preview and
the back and forward buttons just pop/splice the navigation stack
which is tracked by a listener on `sandpack`

There is a button inside the iframe which allows the user to open the
projects inside of CodeSandbox. This button is only visible on
**public** projects. On private or internal projects this button
get hidden to protect private code being leaked into an external
public URL.

Closes #47268
parent f3b36ac1
<script>
import { mapActions, mapState } from 'vuex';
import { mapActions, mapState, mapGetters } from 'vuex';
import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue';
import { rightSidebarViews } from '../../constants';
......@@ -7,6 +7,7 @@ import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue';
import MergeRequestInfo from '../merge_requests/info.vue';
import ResizablePanel from '../resizable_panel.vue';
import Clientside from '../preview/clientside.vue';
export default {
directives: {
......@@ -18,15 +19,20 @@ export default {
JobsDetail,
ResizablePanel,
MergeRequestInfo,
Clientside,
},
computed: {
...mapState(['rightPane', 'currentMergeRequestId']),
...mapState(['rightPane', 'currentMergeRequestId', 'clientsidePreviewEnabled']),
...mapGetters(['packageJson']),
pipelinesActive() {
return (
this.rightPane === rightSidebarViews.pipelines ||
this.rightPane === rightSidebarViews.jobsDetail
);
},
showLivePreview() {
return this.packageJson && this.clientsidePreviewEnabled;
},
},
methods: {
...mapActions(['setRightPane']),
......@@ -49,8 +55,9 @@ export default {
:collapsible="false"
:initial-width="350"
:min-size="350"
class="multi-file-commit-panel-inner"
:class="`ide-right-sidebar-${rightPane}`"
side="right"
class="multi-file-commit-panel-inner"
>
<component :is="rightPane" />
</resizable-panel>
......@@ -98,6 +105,26 @@ export default {
/>
</button>
</li>
<li v-if="showLivePreview">
<button
v-tooltip
:title="__('Live preview')"
:aria-label="__('Live preview')"
:class="{
active: rightPane === $options.rightSidebarViews.clientSidePreview
}"
data-container="body"
data-placement="left"
class="ide-sidebar-link is-right"
type="button"
@click="clickTab($event, $options.rightSidebarViews.clientSidePreview)"
>
<icon
:size="16"
name="live-preview"
/>
</button>
</li>
</ul>
</nav>
</div>
......
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import _ from 'underscore';
import { Manager } from 'smooshpack';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import Navigator from './navigator.vue';
import { packageJsonPath } from '../../constants';
import { createPathWithExt } from '../../utils';
export default {
components: {
LoadingIcon,
Navigator,
},
data() {
return {
manager: {},
loading: false,
};
},
computed: {
...mapState(['entries', 'promotionSvgPath', 'links']),
...mapGetters(['packageJson', 'currentProject']),
normalizedEntries() {
return Object.keys(this.entries).reduce((acc, path) => {
const file = this.entries[path];
if (file.type === 'tree' || !(file.raw || file.content)) return acc;
return {
...acc,
[`/${path}`]: {
code: file.content || file.raw,
},
};
}, {});
},
mainEntry() {
if (!this.packageJson.raw) return false;
const parsedPackage = JSON.parse(this.packageJson.raw);
return parsedPackage.main;
},
showPreview() {
return this.mainEntry && !this.loading;
},
showEmptyState() {
return !this.mainEntry && !this.loading;
},
showOpenInCodeSandbox() {
return this.currentProject && this.currentProject.visibility === 'public';
},
sandboxOpts() {
return {
files: { ...this.normalizedEntries },
entry: `/${this.mainEntry}`,
showOpenInCodeSandbox: this.showOpenInCodeSandbox,
};
},
},
watch: {
entries: {
deep: true,
handler: 'update',
},
},
mounted() {
this.loading = true;
return this.loadFileContent(packageJsonPath)
.then(() => {
this.loading = false;
})
.then(() => this.$nextTick())
.then(() => this.initPreview());
},
beforeDestroy() {
if (!_.isEmpty(this.manager)) {
this.manager.listener();
}
this.manager = {};
clearTimeout(this.timeout);
this.timeout = null;
},
methods: {
...mapActions(['getFileData', 'getRawFileData']),
loadFileContent(path) {
return this.getFileData({ path, makeFileActive: false }).then(() =>
this.getRawFileData({ path }),
);
},
initPreview() {
if (!this.mainEntry) return null;
return this.loadFileContent(this.mainEntry)
.then(() => this.$nextTick())
.then(() =>
this.initManager('#ide-preview', this.sandboxOpts, {
fileResolver: {
isFile: p => Promise.resolve(!!this.entries[createPathWithExt(p)]),
readFile: p => this.loadFileContent(createPathWithExt(p)).then(content => content),
},
}),
);
},
update() {
if (this.timeout) return;
this.timeout = setTimeout(() => {
if (_.isEmpty(this.manager)) {
this.initPreview();
return;
}
this.manager.updatePreview(this.sandboxOpts);
clearTimeout(this.timeout);
this.timeout = null;
}, 500);
},
initManager(el, opts, resolver) {
this.manager = new Manager(el, opts, resolver);
},
},
};
</script>
<template>
<div class="preview h-100 w-100 d-flex flex-column">
<template v-if="showPreview">
<navigator
:manager="manager"
/>
<div id="ide-preview"></div>
</template>
<div
v-else-if="showEmptyState"
v-once
class="d-flex h-100 flex-column align-items-center justify-content-center svg-content"
>
<img
:src="promotionSvgPath"
:alt="s__('IDE|Live Preview')"
width="130"
height="100"
/>
<h3>
{{ s__('IDE|Live Preview') }}
</h3>
<p class="text-center">
{{ s__('IDE|Preview your web application using Web IDE client-side evaluation.') }}
</p>
<a
:href="links.webIDEHelpPagePath"
class="btn btn-primary"
target="_blank"
rel="noopener noreferrer"
>
{{ s__('IDE|Get started with Live Preview') }}
</a>
</div>
<loading-icon
v-else
size="2"
class="align-self-center mt-auto mb-auto"
/>
</div>
</template>
<script>
import { listen } from 'codesandbox-api';
import Icon from '~/vue_shared/components/icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
export default {
components: {
Icon,
LoadingIcon,
},
props: {
manager: {
type: Object,
required: true,
},
},
data() {
return {
currentBrowsingIndex: null,
navigationStack: [],
forwardNavigationStack: [],
path: '',
loading: true,
};
},
computed: {
backButtonDisabled() {
return this.navigationStack.length <= 1;
},
forwardButtonDisabled() {
return !this.forwardNavigationStack.length;
},
},
mounted() {
this.listener = listen(e => {
switch (e.type) {
case 'urlchange':
this.onUrlChange(e);
break;
case 'done':
this.loading = false;
break;
default:
break;
}
});
},
beforeDestroy() {
this.listener();
},
methods: {
onUrlChange(e) {
const lastPath = this.path;
this.path = e.url.replace(this.manager.bundlerURL, '') || '/';
if (lastPath !== this.path) {
this.currentBrowsingIndex =
this.currentBrowsingIndex === null ? 0 : this.currentBrowsingIndex + 1;
this.navigationStack.push(this.path);
}
},
back() {
const lastPath = this.path;
this.visitPath(this.navigationStack[this.currentBrowsingIndex - 1]);
this.forwardNavigationStack.push(lastPath);
if (this.currentBrowsingIndex === 1) {
this.currentBrowsingIndex = null;
this.navigationStack = [];
}
},
forward() {
this.visitPath(this.forwardNavigationStack.splice(0, 1)[0]);
},
refresh() {
this.visitPath(this.path);
},
visitPath(path) {
this.manager.iframe.src = `${this.manager.bundlerURL}${path}`;
},
},
};
</script>
<template>
<header class="ide-preview-header d-flex align-items-center">
<button
:aria-label="s__('IDE|Back')"
:disabled="backButtonDisabled"
:class="{
'disabled-content': backButtonDisabled
}"
type="button"
class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
@click="back"
>
<icon
:size="24"
name="chevron-left"
class="m-auto"
/>
</button>
<button
:aria-label="s__('IDE|Back')"
:disabled="forwardButtonDisabled"
:class="{
'disabled-content': forwardButtonDisabled
}"
type="button"
class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
@click="forward"
>
<icon
:size="24"
name="chevron-right"
class="m-auto"
/>
</button>
<button
:aria-label="s__('IDE|Refresh preview')"
type="button"
class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
@click="refresh"
>
<icon
:size="18"
name="retry"
class="m-auto"
/>
</button>
<div class="position-relative w-100 prepend-left-4">
<input
:value="path || '/'"
type="text"
class="ide-navigator-location form-control bg-white"
readonly
/>
<loading-icon
v-if="loading"
class="position-absolute ide-preview-loading-icon"
/>
</div>
</header>
</template>
......@@ -32,6 +32,7 @@ export const rightSidebarViews = {
pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail',
mergeRequestInfo: 'merge-request-info',
clientSidePreview: 'clientside',
};
export const stageKeys = {
......@@ -58,3 +59,5 @@ export const modalTypes = {
rename: 'rename',
tree: 'tree',
};
export const packageJsonPath = 'package.json';
......@@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate';
import ide from './components/ide.vue';
import store from './stores';
import router from './ide_router';
import { convertPermissionToBoolean } from '../lib/utils/common_utils';
Vue.use(Translate);
......@@ -23,13 +24,18 @@ export function initIde(el) {
noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
committedStateSvgPath: el.dataset.committedStateSvgPath,
pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath,
promotionSvgPath: el.dataset.promotionSvgPath,
});
this.setLinks({
ciHelpPagePath: el.dataset.ciHelpPagePath,
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
});
this.setInitialData({
clientsidePreviewEnabled: convertPermissionToBoolean(el.dataset.clientsidePreviewEnabled),
});
},
methods: {
...mapActions(['setEmptyStateSvgs', 'setLinks']),
...mapActions(['setEmptyStateSvgs', 'setLinks', 'setInitialData']),
},
render(createElement) {
return createElement('ide');
......
import { getChangesCountForFiles, filePathMatches } from './utils';
import { activityBarViews } from '../constants';
import { activityBarViews, packageJsonPath } from '../constants';
export const activeFile = state => state.openFiles.find(file => file.active) || null;
......@@ -90,5 +90,7 @@ export const lastCommit = (state, getters) => {
export const currentBranch = (state, getters) =>
getters.currentProject && getters.currentProject.branches[state.currentBranchId];
export const packageJson = state => state.entries[packageJsonPath];
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -115,13 +115,20 @@ export default {
},
[types.SET_EMPTY_STATE_SVGS](
state,
{ emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath, pipelinesEmptyStateSvgPath },
{
emptyStateSvgPath,
noChangesStateSvgPath,
committedStateSvgPath,
pipelinesEmptyStateSvgPath,
promotionSvgPath,
},
) {
Object.assign(state, {
emptyStateSvgPath,
noChangesStateSvgPath,
committedStateSvgPath,
pipelinesEmptyStateSvgPath,
promotionSvgPath,
});
},
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
......
......@@ -44,7 +44,7 @@ export default {
rawPath: data.raw_path,
binary: data.binary,
renderError: data.render_error,
raw: null,
raw: (state.entries[file.path] && state.entries[file.path].raw) || null,
baseRaw: null,
html: data.html,
size: data.size,
......
......@@ -31,4 +31,5 @@ export default () => ({
path: '',
entry: {},
},
clientsidePreviewEnabled: false,
});
import { commitItemIconMap } from './constants';
// eslint-disable-next-line import/prefer-default-export
export const getCommitIconMap = file => {
if (file.deleted) {
return commitItemIconMap.deleted;
......@@ -10,3 +9,9 @@ export const getCommitIconMap = file => {
return commitItemIconMap.modified;
};
export const createPathWithExt = p => {
const ext = p.lastIndexOf('.') >= 0 ? p.substring(p.lastIndexOf('.') + 1) : '';
return `${p.substring(1, p.lastIndexOf('.') + 1 || p.length)}${ext || '.js'}`;
};
......@@ -1229,6 +1229,10 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
background-color: $white-light;
border-left: 1px solid $white-dark;
}
.ide-right-sidebar-clientside {
padding: 0;
}
}
.ide-pipeline {
......@@ -1412,3 +1416,40 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
color: $white-normal;
background-color: $blue-500;
}
.ide-preview-header {
padding: 0 $grid-size;
border-bottom: 1px solid $white-dark;
background-color: $gray-light;
min-height: 44px;
}
.ide-navigator-btn {
height: 24px;
min-width: 24px;
max-width: 24px;
padding: 0;
margin: 0 ($grid-size / 2);
color: $gl-gray-light;
&:first-child {
margin-left: 0;
}
}
.ide-navigator-location {
padding-top: ($grid-size / 2);
padding-bottom: ($grid-size / 2);
&:focus {
outline: 0;
box-shadow: none;
border-color: $theme-gray-200;
}
}
.ide-preview-loading-icon {
right: $grid-size;
top: 50%;
transform: translateY(-50%);
}
......@@ -255,7 +255,8 @@ module ApplicationSettingsHelper
:instance_statistics_visibility_private,
:user_default_external,
:user_oauth_applications,
:version_check_enabled
:version_check_enabled,
:web_ide_clientside_preview_enabled
]
end
end
......@@ -338,4 +338,27 @@
= render_if_exists 'admin/application_settings/custom_templates_form', expanded: expanded
%section.settings.no-animate#js-web-ide-settings{ class: ('expanded' if expanded) }
.settings-header
%h4
= _('Web IDE')
%button.btn.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= _('Manage Web IDE features')
.settings-content
= form_for @application_setting, url: admin_application_settings_path(anchor: "#js-web-ide-settings"), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
.form-check
= f.check_box :web_ide_clientside_preview_enabled, class: 'form-check-input'
= f.label :web_ide_clientside_preview_enabled, class: 'form-check-label' do
= s_('IDE|Client side evaluation')
%span.form-text.text-muted
= s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox client side evaluation.')
= f.submit _('Save changes'), class: "btn btn-success"
= render_if_exists 'admin/application_settings/pseudonymizer_settings', expanded: expanded
......@@ -8,7 +8,10 @@
"no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
"committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
"pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'),
"ci-help-page-path" => help_page_path('ci/quick_start/README'), } }
"promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'),
"ci-help-page-path" => help_page_path('ci/quick_start/README'),
"web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'),
"clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s } }
.text-center
= icon('spinner spin 2x')
%h2.clgray= _('Loading the GitLab IDE...')
---
title: Added live preview for JavaScript projects in the Web IDE
merge_request: 19764
author:
type: added
......@@ -546,3 +546,27 @@
:why: Our own library
:versions: []
:when: 2018-07-17 21:02:54.529227000 Z
- - :approve
- lz-string
- :who: Phil Hughes
:why: https://github.com/pieroxy/lz-string/blob/master/LICENSE.txt
:versions: []
:when: 2018-08-03 08:22:44.973457000 Z
- - :approve
- smooshpack
- :who: Phil Hughes
:why: https://github.com/CompuIves/codesandbox-client/blob/master/packages/sandpack/LICENSE.md
:versions: []
:when: 2018-08-03 08:24:29.578991000 Z
- - :approve
- codesandbox-import-util-types
- :who: Phil Hughes
:why: https://github.com/codesandbox-app/codesandbox-importers/blob/master/packages/types/LICENSE
:versions: []
:when: 2018-08-03 12:22:47.574421000 Z
- - :approve
- codesandbox-import-utils
- :who: Phil Hughes
:why: https://github.com/codesandbox-app/codesandbox-importers/blob/master/packages/import-utils/LICENSE
:versions: []
:when: 2018-08-03 12:23:24.083046000 Z
# frozen_string_literal: true
class AddWebIdeClientSidePreviewEnabledToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_column_with_default(:application_settings, :web_ide_clientside_preview_enabled,
:boolean,
default: false,
allow_null: false)
end
def down
remove_column(:application_settings, :web_ide_clientside_preview_enabled)
end
end
......@@ -169,6 +169,7 @@ ActiveRecord::Schema.define(version: 20180726172057) do
t.boolean "mirror_available", default: true, null: false
t.boolean "hide_third_party_offers", default: false, null: false
t.boolean "instance_statistics_visibility_private", default: false, null: false
t.boolean "web_ide_clientside_preview_enabled", default: false, null: false
end
create_table "audit_events", force: :cascade do |t|
......
......@@ -2905,18 +2905,39 @@ msgstr ""
msgid "ID"
msgstr ""
msgid "IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox client side evaluation."
msgstr ""
msgid "IDE|Back"
msgstr ""
msgid "IDE|Client side evaluation"
msgstr ""
msgid "IDE|Commit"
msgstr ""
msgid "IDE|Edit"
msgstr ""
msgid "IDE|Get started with Live Preview"
msgstr ""
msgid "IDE|Go to project"
msgstr ""
msgid "IDE|Live Preview"
msgstr ""
msgid "IDE|Open in file view"
msgstr ""
msgid "IDE|Preview your web application using Web IDE client-side evaluation."
msgstr ""
msgid "IDE|Refresh preview"
msgstr ""
msgid "IDE|Review"
msgstr ""
......@@ -3234,6 +3255,9 @@ msgstr ""
msgid "List your GitHub repositories"
msgstr ""
msgid "Live preview"
msgstr ""
msgid "Loading the GitLab IDE..."
msgstr ""
......@@ -3267,6 +3291,9 @@ msgstr ""
msgid "Manage Git repositories with fine-grained access controls that keep your code secure. Perform code reviews and enhance collaboration with merge requests. Each project can also have an issue tracker and a wiki."
msgstr ""
msgid "Manage Web IDE features"
msgstr ""
msgid "Manage access"
msgstr ""
......
......@@ -69,4 +69,17 @@ describe('IDE right pane', () => {
});
});
});
describe('live preview', () => {
it('renders live preview button', done => {
Vue.set(vm.$store.state.entries, 'package.json', { name: 'package.json' });
vm.$store.state.clientsidePreviewEnabled = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('button[aria-label="Live preview"]')).not.toBeNull();
done();
});
});
});
});
import Vue from 'vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createStore } from '~/ide/stores';
import Clientside from '~/ide/components/preview/clientside.vue';
import timeoutPromise from 'spec/helpers/set_timeout_promise_helper';
import { resetStore, file } from '../../helpers';
describe('IDE clientside preview', () => {
let vm;
let Component;
beforeAll(() => {
Component = Vue.extend(Clientside);
});
beforeEach(done => {
const store = createStore();
Vue.set(store.state.entries, 'package.json', {
...file('package.json'),
});
Vue.set(store.state, 'currentProjectId', 'gitlab-ce');
Vue.set(store.state.projects, 'gitlab-ce', {
visibility: 'public',
});
vm = createComponentWithStore(Component, store);
spyOn(vm, 'getFileData').and.returnValue(Promise.resolve());
spyOn(vm, 'getRawFileData').and.returnValue(Promise.resolve(''));
spyOn(vm, 'initManager');
vm.$mount();
timeoutPromise()
.then(() => vm.$nextTick())
.then(done)
.catch(done.fail);
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
describe('without main entry', () => {
it('creates sandpack manager', () => {
expect(vm.initManager).not.toHaveBeenCalled();
});
});
describe('with main entry', () => {
beforeEach(done => {
Vue.set(
vm.$store.state.entries['package.json'],
'raw',
JSON.stringify({
main: 'index.js',
}),
);
vm
.$nextTick()
.then(() => vm.initPreview())
.then(vm.$nextTick)
.then(done)
.catch(done.fail);
});
it('creates sandpack manager', () => {
expect(vm.initManager).toHaveBeenCalledWith(
'#ide-preview',
{
files: jasmine.any(Object),
entry: '/index.js',
showOpenInCodeSandbox: true,
},
{
fileResolver: {
isFile: jasmine.any(Function),
readFile: jasmine.any(Function),
},
},
);
});
});
describe('computed', () => {
describe('normalizedEntries', () => {
beforeEach(done => {
vm.$store.state.entries['index.js'] = {
...file('index.js'),
type: 'blob',
raw: 'test',
};
vm.$store.state.entries['index2.js'] = {
...file('index2.js'),
type: 'blob',
content: 'content',
};
vm.$store.state.entries.tree = {
...file('tree'),
type: 'tree',
};
vm.$store.state.entries.empty = {
...file('empty'),
type: 'blob',
};
vm.$nextTick(done);
});
it('returns flattened list of blobs with content', () => {
expect(vm.normalizedEntries).toEqual({
'/index.js': {
code: 'test',
},
'/index2.js': {
code: 'content',
},
});
});
});
describe('mainEntry', () => {
it('returns false when package.json is empty', () => {
expect(vm.mainEntry).toBe(false);
});
it('returns main key from package.json', done => {
Vue.set(
vm.$store.state.entries['package.json'],
'raw',
JSON.stringify({
main: 'index.js',
}),
);
vm.$nextTick(() => {
expect(vm.mainEntry).toBe('index.js');
done();
});
});
});
describe('showPreview', () => {
it('returns false if no mainEntry', () => {
expect(vm.showPreview).toBe(false);
});
it('returns false if loading', done => {
Vue.set(
vm.$store.state.entries['package.json'],
'raw',
JSON.stringify({
main: 'index.js',
}),
);
vm.loading = true;
vm.$nextTick(() => {
expect(vm.showPreview).toBe(false);
done();
});
});
it('returns true if not loading and mainEntry exists', done => {
Vue.set(
vm.$store.state.entries['package.json'],
'raw',
JSON.stringify({
main: 'index.js',
}),
);
vm.loading = false;
vm.$nextTick(() => {
expect(vm.showPreview).toBe(true);
done();
});
});
});
describe('showEmptyState', () => {
it('returns true if no mainEnry exists', () => {
expect(vm.showEmptyState).toBe(true);
});
it('returns false if loading', done => {
Vue.set(
vm.$store.state.entries['package.json'],
'raw',
JSON.stringify({
main: 'index.js',
}),
);
vm.loading = true;
vm.$nextTick(() => {
expect(vm.showEmptyState).toBe(false);
done();
});
});
it('returns false if not loading and mainEntry exists', done => {
Vue.set(
vm.$store.state.entries['package.json'],
'raw',
JSON.stringify({
main: 'index.js',
}),
);
vm.loading = false;
vm.$nextTick(() => {
expect(vm.showEmptyState).toBe(false);
done();
});
});
});
describe('showOpenInCodeSandbox', () => {
it('returns true when visiblity is public', () => {
expect(vm.showOpenInCodeSandbox).toBe(true);
});
it('returns false when visiblity is private', done => {
vm.$store.state.projects['gitlab-ce'].visibility = 'private';
vm.$nextTick(() => {
expect(vm.showOpenInCodeSandbox).toBe(false);
done();
});
});
});
describe('sandboxOpts', () => {
beforeEach(done => {
vm.$store.state.entries['index.js'] = {
...file('index.js'),
type: 'blob',
raw: 'test',
};
Vue.set(
vm.$store.state.entries['package.json'],
'raw',
JSON.stringify({
main: 'index.js',
}),
);
vm.$nextTick(done);
});
it('returns sandbox options', () => {
expect(vm.sandboxOpts).toEqual({
files: {
'/index.js': {
code: 'test',
},
'/package.json': {
code: '{"main":"index.js"}',
},
},
entry: '/index.js',
showOpenInCodeSandbox: true,
});
});
});
});
describe('methods', () => {
describe('loadFileContent', () => {
it('calls getFileData', () => {
expect(vm.getFileData).toHaveBeenCalledWith({
path: 'package.json',
makeFileActive: false,
});
});
it('calls getRawFileData', () => {
expect(vm.getRawFileData).toHaveBeenCalledWith({ path: 'package.json' });
});
});
describe('update', () => {
beforeEach(() => {
jasmine.clock().install();
vm.manager.updatePreview = jasmine.createSpy('updatePreview');
vm.manager.listener = jasmine.createSpy('updatePreview');
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('calls initPreview if manager is empty', () => {
spyOn(vm, 'initPreview');
vm.manager = {};
vm.update();
jasmine.clock().tick(500);
expect(vm.initPreview).toHaveBeenCalled();
});
it('calls updatePreview', () => {
vm.update();
jasmine.clock().tick(500);
expect(vm.manager.updatePreview).toHaveBeenCalledWith(vm.sandboxOpts);
});
});
});
describe('template', () => {
it('renders ide-preview element when showPreview is true', done => {
Vue.set(
vm.$store.state.entries['package.json'],
'raw',
JSON.stringify({
main: 'index.js',
}),
);
vm.loading = false;
vm.$nextTick(() => {
expect(vm.$el.querySelector('#ide-preview')).not.toBe(null);
done();
});
});
it('renders empty state', done => {
vm.loading = false;
vm.$nextTick(() => {
expect(vm.$el.textContent).toContain(
'Preview your web application using Web IDE client-side evaluation.',
);
done();
});
});
it('renders loading icon', done => {
vm.loading = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
done();
});
});
});
});
import Vue from 'vue';
import ClientsideNavigator from '~/ide/components/preview/navigator.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('IDE clientside preview navigator', () => {
let vm;
let Component;
let manager;
beforeAll(() => {
Component = Vue.extend(ClientsideNavigator);
});
beforeEach(() => {
manager = {
bundlerURL: gl.TEST_HOST,
iframe: { src: '' },
};
vm = mountComponent(Component, {
manager,
});
});
afterEach(() => {
vm.$destroy();
});
it('renders readonly URL bar', () => {
expect(vm.$el.querySelector('input[readonly]').value).toBe('/');
});
it('disables back button when navigationStack is empty', () => {
expect(vm.$el.querySelector('.ide-navigator-btn')).toHaveAttr('disabled');
expect(vm.$el.querySelector('.ide-navigator-btn').classList).toContain('disabled-content');
});
it('disables forward button when forwardNavigationStack is empty', () => {
vm.forwardNavigationStack = [];
expect(vm.$el.querySelectorAll('.ide-navigator-btn')[1]).toHaveAttr('disabled');
expect(vm.$el.querySelectorAll('.ide-navigator-btn')[1].classList).toContain(
'disabled-content',
);
});
it('calls back method when clicking back button', done => {
vm.navigationStack.push('/test');
vm.navigationStack.push('/test2');
spyOn(vm, 'back');
vm.$nextTick(() => {
vm.$el.querySelector('.ide-navigator-btn').click();
expect(vm.back).toHaveBeenCalled();
done();
});
});
it('calls forward method when clicking forward button', done => {
vm.forwardNavigationStack.push('/test');
spyOn(vm, 'forward');
vm.$nextTick(() => {
vm.$el.querySelectorAll('.ide-navigator-btn')[1].click();
expect(vm.forward).toHaveBeenCalled();
done();
});
});
describe('onUrlChange', () => {
it('updates the path', () => {
vm.onUrlChange({
url: `${gl.TEST_HOST}/url`,
});
expect(vm.path).toBe('/url');
});
it('sets currentBrowsingIndex 0 if not already set', () => {
vm.onUrlChange({
url: `${gl.TEST_HOST}/url`,
});
expect(vm.currentBrowsingIndex).toBe(0);
});
it('increases currentBrowsingIndex if path doesnt match', () => {
vm.onUrlChange({
url: `${gl.TEST_HOST}/url`,
});
vm.onUrlChange({
url: `${gl.TEST_HOST}/url2`,
});
expect(vm.currentBrowsingIndex).toBe(1);
});
it('does not increase currentBrowsingIndex if path matches', () => {
vm.onUrlChange({
url: `${gl.TEST_HOST}/url`,
});
vm.onUrlChange({
url: `${gl.TEST_HOST}/url`,
});
expect(vm.currentBrowsingIndex).toBe(0);
});
it('pushes path into navigation stack', () => {
vm.onUrlChange({
url: `${gl.TEST_HOST}/url`,
});
expect(vm.navigationStack).toEqual(['/url']);
});
});
describe('back', () => {
beforeEach(() => {
vm.path = '/test2';
vm.currentBrowsingIndex = 1;
vm.navigationStack.push('/test');
vm.navigationStack.push('/test2');
spyOn(vm, 'visitPath');
vm.back();
});
it('visits the last entry in navigationStack', () => {
expect(vm.visitPath).toHaveBeenCalledWith('/test');
});
it('adds last entry to forwardNavigationStack', () => {
expect(vm.forwardNavigationStack).toEqual(['/test2']);
});
it('clears navigation stack if currentBrowsingIndex is 1', () => {
expect(vm.navigationStack).toEqual([]);
});
it('sets currentBrowsingIndex to null is currentBrowsingIndex is 1', () => {
expect(vm.currentBrowsingIndex).toBe(null);
});
});
describe('forward', () => {
it('calls visitPath with first entry in forwardNavigationStack', () => {
spyOn(vm, 'visitPath');
vm.forwardNavigationStack.push('/test');
vm.forwardNavigationStack.push('/test2');
vm.forward();
expect(vm.visitPath).toHaveBeenCalledWith('/test');
});
});
describe('refresh', () => {
it('calls refresh with current path', () => {
spyOn(vm, 'visitPath');
vm.path = '/test';
vm.refresh();
expect(vm.visitPath).toHaveBeenCalledWith('/test');
});
});
describe('visitPath', () => {
it('updates iframe src with passed in path', () => {
vm.visitPath('/testpath');
expect(manager.iframe.src).toBe(`${gl.TEST_HOST}/testpath`);
});
});
});
......@@ -179,4 +179,14 @@ describe('IDE store getters', () => {
});
});
});
describe('packageJson', () => {
it('returns package.json entry', () => {
localState.entries['package.json'] = { name: 'package.json' };
expect(getters.packageJson(localState)).toEqual({
name: 'package.json',
});
});
});
});
......@@ -95,7 +95,7 @@ beforeEach(() => {
let longRunningTestTimeoutHandle;
beforeEach((done) => {
beforeEach(done => {
longRunningTestTimeoutHandle = setTimeout(() => {
done.fail('Test is running too long!');
}, 2000);
......
......@@ -1258,6 +1258,10 @@ binary-extensions@^1.0.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205"
binaryextensions@2:
version "2.1.1"
resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.1.1.tgz#3209a51ca4a4ad541a3b8d3d6a6d5b83a2485935"
bitsyntax@~0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/bitsyntax/-/bitsyntax-0.0.4.tgz#eb10cc6f82b8c490e3e85698f07e83d46e0cba82"
......@@ -1776,6 +1780,22 @@ code-point-at@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
codesandbox-api@^0.0.18:
version "0.0.18"
resolved "https://registry.yarnpkg.com/codesandbox-api/-/codesandbox-api-0.0.18.tgz#56b96b37533f80d20c21861e5e477d3557e613ca"
codesandbox-import-util-types@^1.2.11:
version "1.2.11"
resolved "https://registry.yarnpkg.com/codesandbox-import-util-types/-/codesandbox-import-util-types-1.2.11.tgz#68e812f21d6b309e9a52eec5cf027c3e63b4c703"
codesandbox-import-utils@^1.2.3:
version "1.2.11"
resolved "https://registry.yarnpkg.com/codesandbox-import-utils/-/codesandbox-import-utils-1.2.11.tgz#b88423a4a7c785175c784c84e87f5950820280e1"
dependencies:
codesandbox-import-util-types "^1.2.11"
istextorbinary "^2.2.1"
lz-string "^1.4.4"
collection-visit@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
......@@ -2645,6 +2665,10 @@ ecc-jsbn@~0.1.1:
dependencies:
jsbn "~0.1.0"
editions@^1.3.3:
version "1.3.4"
resolved "https://registry.yarnpkg.com/editions/-/editions-1.3.4.tgz#3662cb592347c3168eb8e498a0ff73271d67f50b"
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
......@@ -3920,7 +3944,7 @@ https-proxy-agent@^2.2.1:
agent-base "^4.1.0"
debug "^3.1.0"
iconv-lite@0.4:
iconv-lite@0.4, iconv-lite@0.4.23, iconv-lite@^0.4.22, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
version "0.4.23"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
dependencies:
......@@ -3934,12 +3958,6 @@ iconv-lite@0.4.19, iconv-lite@^0.4.17:
version "0.4.19"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
iconv-lite@0.4.23, iconv-lite@^0.4.22, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
version "0.4.23"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
dependencies:
safer-buffer ">= 2.1.2 < 3"
icss-replace-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
......@@ -4490,6 +4508,14 @@ istanbul@^0.4.5:
which "^1.1.1"
wordwrap "^1.0.0"
istextorbinary@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.2.1.tgz#a5231a08ef6dd22b268d0895084cf8d58b5bec53"
dependencies:
binaryextensions "2"
editions "^1.3.3"
textextensions "2"
isurl@^1.0.0-alpha5:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67"
......@@ -4839,6 +4865,10 @@ lodash.get@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
lodash.kebabcase@4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
......@@ -4948,6 +4978,10 @@ lru-cache@^4.0.1, lru-cache@^4.1.1, lru-cache@^4.1.2:
pseudomap "^1.0.2"
yallist "^2.1.2"
lz-string@^1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
mailcomposer@4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/mailcomposer/-/mailcomposer-4.0.1.tgz#0e1c44b2a07cf740ee17dc149ba009f19cadfeb4"
......@@ -6735,6 +6769,14 @@ smart-buffer@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.0.1.tgz#07ea1ca8d4db24eb4cac86537d7d18995221ace3"
smooshpack@^0.0.48:
version "0.0.48"
resolved "https://registry.yarnpkg.com/smooshpack/-/smooshpack-0.0.48.tgz#6fbeaaf59226a1fe500f56aa17185eed377d2823"
dependencies:
codesandbox-api "^0.0.18"
codesandbox-import-utils "^1.2.3"
lodash.isequal "^4.5.0"
smtp-connection@2.12.0:
version "2.12.0"
resolved "https://registry.yarnpkg.com/smtp-connection/-/smtp-connection-2.12.0.tgz#d76ef9127cb23c2259edb1e8349c2e8d5e2d74c1"
......@@ -7240,6 +7282,10 @@ text-table@~0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
textextensions@2:
version "2.2.0"
resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.2.0.tgz#38ac676151285b658654581987a0ce1a4490d286"
three-orbit-controls@^82.1.0:
version "82.1.0"
resolved "https://registry.yarnpkg.com/three-orbit-controls/-/three-orbit-controls-82.1.0.tgz#11a7f33d0a20ecec98f098b37780f6537374fab4"
......
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