Commit 37db21d6 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'ide-codesandbox-poc' into 'master'

Web IDE & Codesandbox POC

Closes #47268

See merge request gitlab-org/gitlab-ce!19764
parents 4e1b6d1e 7b4b9e1c
<script> <script>
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import tooltip from '../../../vue_shared/directives/tooltip'; import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue'; import Icon from '../../../vue_shared/components/icon.vue';
import { rightSidebarViews } from '../../constants'; import { rightSidebarViews } from '../../constants';
...@@ -7,6 +7,7 @@ import PipelinesList from '../pipelines/list.vue'; ...@@ -7,6 +7,7 @@ import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue'; import JobsDetail from '../jobs/detail.vue';
import MergeRequestInfo from '../merge_requests/info.vue'; import MergeRequestInfo from '../merge_requests/info.vue';
import ResizablePanel from '../resizable_panel.vue'; import ResizablePanel from '../resizable_panel.vue';
import Clientside from '../preview/clientside.vue';
export default { export default {
directives: { directives: {
...@@ -18,15 +19,20 @@ export default { ...@@ -18,15 +19,20 @@ export default {
JobsDetail, JobsDetail,
ResizablePanel, ResizablePanel,
MergeRequestInfo, MergeRequestInfo,
Clientside,
}, },
computed: { computed: {
...mapState(['rightPane', 'currentMergeRequestId']), ...mapState(['rightPane', 'currentMergeRequestId', 'clientsidePreviewEnabled']),
...mapGetters(['packageJson']),
pipelinesActive() { pipelinesActive() {
return ( return (
this.rightPane === rightSidebarViews.pipelines || this.rightPane === rightSidebarViews.pipelines ||
this.rightPane === rightSidebarViews.jobsDetail this.rightPane === rightSidebarViews.jobsDetail
); );
}, },
showLivePreview() {
return this.packageJson && this.clientsidePreviewEnabled;
},
}, },
methods: { methods: {
...mapActions(['setRightPane']), ...mapActions(['setRightPane']),
...@@ -49,8 +55,9 @@ export default { ...@@ -49,8 +55,9 @@ export default {
:collapsible="false" :collapsible="false"
:initial-width="350" :initial-width="350"
:min-size="350" :min-size="350"
class="multi-file-commit-panel-inner" :class="`ide-right-sidebar-${rightPane}`"
side="right" side="right"
class="multi-file-commit-panel-inner"
> >
<component :is="rightPane" /> <component :is="rightPane" />
</resizable-panel> </resizable-panel>
...@@ -98,6 +105,26 @@ export default { ...@@ -98,6 +105,26 @@ export default {
/> />
</button> </button>
</li> </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> </ul>
</nav> </nav>
</div> </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 = { ...@@ -32,6 +32,7 @@ export const rightSidebarViews = {
pipelines: 'pipelines-list', pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail', jobsDetail: 'jobs-detail',
mergeRequestInfo: 'merge-request-info', mergeRequestInfo: 'merge-request-info',
clientSidePreview: 'clientside',
}; };
export const stageKeys = { export const stageKeys = {
...@@ -58,3 +59,5 @@ export const modalTypes = { ...@@ -58,3 +59,5 @@ export const modalTypes = {
rename: 'rename', rename: 'rename',
tree: 'tree', tree: 'tree',
}; };
export const packageJsonPath = 'package.json';
...@@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate'; ...@@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate';
import ide from './components/ide.vue'; import ide from './components/ide.vue';
import store from './stores'; import store from './stores';
import router from './ide_router'; import router from './ide_router';
import { convertPermissionToBoolean } from '../lib/utils/common_utils';
Vue.use(Translate); Vue.use(Translate);
...@@ -23,13 +24,18 @@ export function initIde(el) { ...@@ -23,13 +24,18 @@ export function initIde(el) {
noChangesStateSvgPath: el.dataset.noChangesStateSvgPath, noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
committedStateSvgPath: el.dataset.committedStateSvgPath, committedStateSvgPath: el.dataset.committedStateSvgPath,
pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath, pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath,
promotionSvgPath: el.dataset.promotionSvgPath,
}); });
this.setLinks({ this.setLinks({
ciHelpPagePath: el.dataset.ciHelpPagePath, ciHelpPagePath: el.dataset.ciHelpPagePath,
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
});
this.setInitialData({
clientsidePreviewEnabled: convertPermissionToBoolean(el.dataset.clientsidePreviewEnabled),
}); });
}, },
methods: { methods: {
...mapActions(['setEmptyStateSvgs', 'setLinks']), ...mapActions(['setEmptyStateSvgs', 'setLinks', 'setInitialData']),
}, },
render(createElement) { render(createElement) {
return createElement('ide'); return createElement('ide');
......
import { getChangesCountForFiles, filePathMatches } from './utils'; 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; export const activeFile = state => state.openFiles.find(file => file.active) || null;
...@@ -90,5 +90,7 @@ export const lastCommit = (state, getters) => { ...@@ -90,5 +90,7 @@ export const lastCommit = (state, getters) => {
export const currentBranch = (state, getters) => export const currentBranch = (state, getters) =>
getters.currentProject && getters.currentProject.branches[state.currentBranchId]; 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 // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -115,13 +115,20 @@ export default { ...@@ -115,13 +115,20 @@ export default {
}, },
[types.SET_EMPTY_STATE_SVGS]( [types.SET_EMPTY_STATE_SVGS](
state, state,
{ emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath, pipelinesEmptyStateSvgPath }, {
emptyStateSvgPath,
noChangesStateSvgPath,
committedStateSvgPath,
pipelinesEmptyStateSvgPath,
promotionSvgPath,
},
) { ) {
Object.assign(state, { Object.assign(state, {
emptyStateSvgPath, emptyStateSvgPath,
noChangesStateSvgPath, noChangesStateSvgPath,
committedStateSvgPath, committedStateSvgPath,
pipelinesEmptyStateSvgPath, pipelinesEmptyStateSvgPath,
promotionSvgPath,
}); });
}, },
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) { [types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
......
...@@ -44,7 +44,7 @@ export default { ...@@ -44,7 +44,7 @@ export default {
rawPath: data.raw_path, rawPath: data.raw_path,
binary: data.binary, binary: data.binary,
renderError: data.render_error, renderError: data.render_error,
raw: null, raw: (state.entries[file.path] && state.entries[file.path].raw) || null,
baseRaw: null, baseRaw: null,
html: data.html, html: data.html,
size: data.size, size: data.size,
......
...@@ -31,4 +31,5 @@ export default () => ({ ...@@ -31,4 +31,5 @@ export default () => ({
path: '', path: '',
entry: {}, entry: {},
}, },
clientsidePreviewEnabled: false,
}); });
import { commitItemIconMap } from './constants'; import { commitItemIconMap } from './constants';
// eslint-disable-next-line import/prefer-default-export
export const getCommitIconMap = file => { export const getCommitIconMap = file => {
if (file.deleted) { if (file.deleted) {
return commitItemIconMap.deleted; return commitItemIconMap.deleted;
...@@ -10,3 +9,9 @@ export const getCommitIconMap = file => { ...@@ -10,3 +9,9 @@ export const getCommitIconMap = file => {
return commitItemIconMap.modified; 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; ...@@ -1229,6 +1229,10 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
background-color: $white-light; background-color: $white-light;
border-left: 1px solid $white-dark; border-left: 1px solid $white-dark;
} }
.ide-right-sidebar-clientside {
padding: 0;
}
} }
.ide-pipeline { .ide-pipeline {
...@@ -1412,3 +1416,40 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; ...@@ -1412,3 +1416,40 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
color: $white-normal; color: $white-normal;
background-color: $blue-500; 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 ...@@ -255,7 +255,8 @@ module ApplicationSettingsHelper
:instance_statistics_visibility_private, :instance_statistics_visibility_private,
:user_default_external, :user_default_external,
:user_oauth_applications, :user_oauth_applications,
:version_check_enabled :version_check_enabled,
:web_ide_clientside_preview_enabled
] ]
end end
end end
...@@ -338,4 +338,27 @@ ...@@ -338,4 +338,27 @@
= render_if_exists 'admin/application_settings/custom_templates_form', expanded: expanded = 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 = render_if_exists 'admin/application_settings/pseudonymizer_settings', expanded: expanded
...@@ -8,7 +8,10 @@ ...@@ -8,7 +8,10 @@
"no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), "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'), "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'), "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 .text-center
= icon('spinner spin 2x') = icon('spinner spin 2x')
%h2.clgray= _('Loading the GitLab IDE...') %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 @@ ...@@ -546,3 +546,27 @@
:why: Our own library :why: Our own library
:versions: [] :versions: []
:when: 2018-07-17 21:02:54.529227000 Z :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 ...@@ -169,6 +169,7 @@ ActiveRecord::Schema.define(version: 20180726172057) do
t.boolean "mirror_available", default: true, null: false t.boolean "mirror_available", default: true, null: false
t.boolean "hide_third_party_offers", default: false, null: false t.boolean "hide_third_party_offers", default: false, null: false
t.boolean "instance_statistics_visibility_private", 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 end
create_table "audit_events", force: :cascade do |t| create_table "audit_events", force: :cascade do |t|
......
...@@ -2905,18 +2905,39 @@ msgstr "" ...@@ -2905,18 +2905,39 @@ msgstr ""
msgid "ID" msgid "ID"
msgstr "" 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" msgid "IDE|Commit"
msgstr "" msgstr ""
msgid "IDE|Edit" msgid "IDE|Edit"
msgstr "" msgstr ""
msgid "IDE|Get started with Live Preview"
msgstr ""
msgid "IDE|Go to project" msgid "IDE|Go to project"
msgstr "" msgstr ""
msgid "IDE|Live Preview"
msgstr ""
msgid "IDE|Open in file view" msgid "IDE|Open in file view"
msgstr "" msgstr ""
msgid "IDE|Preview your web application using Web IDE client-side evaluation."
msgstr ""
msgid "IDE|Refresh preview"
msgstr ""
msgid "IDE|Review" msgid "IDE|Review"
msgstr "" msgstr ""
...@@ -3234,6 +3255,9 @@ msgstr "" ...@@ -3234,6 +3255,9 @@ msgstr ""
msgid "List your GitHub repositories" msgid "List your GitHub repositories"
msgstr "" msgstr ""
msgid "Live preview"
msgstr ""
msgid "Loading the GitLab IDE..." msgid "Loading the GitLab IDE..."
msgstr "" msgstr ""
...@@ -3267,6 +3291,9 @@ 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." 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 "" msgstr ""
msgid "Manage Web IDE features"
msgstr ""
msgid "Manage access" msgid "Manage access"
msgstr "" msgstr ""
......
...@@ -69,4 +69,17 @@ describe('IDE right pane', () => { ...@@ -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', () => { ...@@ -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(() => { ...@@ -95,7 +95,7 @@ beforeEach(() => {
let longRunningTestTimeoutHandle; let longRunningTestTimeoutHandle;
beforeEach((done) => { beforeEach(done => {
longRunningTestTimeoutHandle = setTimeout(() => { longRunningTestTimeoutHandle = setTimeout(() => {
done.fail('Test is running too long!'); done.fail('Test is running too long!');
}, 2000); }, 2000);
......
...@@ -1258,6 +1258,10 @@ binary-extensions@^1.0.0: ...@@ -1258,6 +1258,10 @@ binary-extensions@^1.0.0:
version "1.11.0" version "1.11.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205" 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: bitsyntax@~0.0.4:
version "0.0.4" version "0.0.4"
resolved "https://registry.yarnpkg.com/bitsyntax/-/bitsyntax-0.0.4.tgz#eb10cc6f82b8c490e3e85698f07e83d46e0cba82" resolved "https://registry.yarnpkg.com/bitsyntax/-/bitsyntax-0.0.4.tgz#eb10cc6f82b8c490e3e85698f07e83d46e0cba82"
...@@ -1776,6 +1780,22 @@ code-point-at@^1.0.0: ...@@ -1776,6 +1780,22 @@ code-point-at@^1.0.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" 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: collection-visit@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
...@@ -2645,6 +2665,10 @@ ecc-jsbn@~0.1.1: ...@@ -2645,6 +2665,10 @@ ecc-jsbn@~0.1.1:
dependencies: dependencies:
jsbn "~0.1.0" 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: ee-first@1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
...@@ -3920,7 +3944,7 @@ https-proxy-agent@^2.2.1: ...@@ -3920,7 +3944,7 @@ https-proxy-agent@^2.2.1:
agent-base "^4.1.0" agent-base "^4.1.0"
debug "^3.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" version "0.4.23"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
dependencies: dependencies:
...@@ -3934,12 +3958,6 @@ iconv-lite@0.4.19, iconv-lite@^0.4.17: ...@@ -3934,12 +3958,6 @@ iconv-lite@0.4.19, iconv-lite@^0.4.17:
version "0.4.19" version "0.4.19"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" 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: icss-replace-symbols@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
...@@ -4490,6 +4508,14 @@ istanbul@^0.4.5: ...@@ -4490,6 +4508,14 @@ istanbul@^0.4.5:
which "^1.1.1" which "^1.1.1"
wordwrap "^1.0.0" 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: isurl@^1.0.0-alpha5:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67" resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67"
...@@ -4839,6 +4865,10 @@ lodash.get@^4.4.2: ...@@ -4839,6 +4865,10 @@ lodash.get@^4.4.2:
version "4.4.2" version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" 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: lodash.kebabcase@4.1.1:
version "4.1.1" version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" 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: ...@@ -4948,6 +4978,10 @@ lru-cache@^4.0.1, lru-cache@^4.1.1, lru-cache@^4.1.2:
pseudomap "^1.0.2" pseudomap "^1.0.2"
yallist "^2.1.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: mailcomposer@4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/mailcomposer/-/mailcomposer-4.0.1.tgz#0e1c44b2a07cf740ee17dc149ba009f19cadfeb4" resolved "https://registry.yarnpkg.com/mailcomposer/-/mailcomposer-4.0.1.tgz#0e1c44b2a07cf740ee17dc149ba009f19cadfeb4"
...@@ -6735,6 +6769,14 @@ smart-buffer@^4.0.1: ...@@ -6735,6 +6769,14 @@ smart-buffer@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.0.1.tgz#07ea1ca8d4db24eb4cac86537d7d18995221ace3" 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: smtp-connection@2.12.0:
version "2.12.0" version "2.12.0"
resolved "https://registry.yarnpkg.com/smtp-connection/-/smtp-connection-2.12.0.tgz#d76ef9127cb23c2259edb1e8349c2e8d5e2d74c1" resolved "https://registry.yarnpkg.com/smtp-connection/-/smtp-connection-2.12.0.tgz#d76ef9127cb23c2259edb1e8349c2e8d5e2d74c1"
...@@ -7240,6 +7282,10 @@ text-table@~0.2.0: ...@@ -7240,6 +7282,10 @@ text-table@~0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" 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: three-orbit-controls@^82.1.0:
version "82.1.0" version "82.1.0"
resolved "https://registry.yarnpkg.com/three-orbit-controls/-/three-orbit-controls-82.1.0.tgz#11a7f33d0a20ecec98f098b37780f6537374fab4" 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