diff options
26 files changed, 1150 insertions, 18 deletions
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index e4a5fcc67c4..79df225c432 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -1,5 +1,5 @@ <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> diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue new file mode 100644 index 00000000000..fef36eae7b1 --- /dev/null +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -0,0 +1,171 @@ +<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> diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue new file mode 100644 index 00000000000..4bf346946b6 --- /dev/null +++ b/app/assets/javascripts/ide/components/preview/navigator.vue @@ -0,0 +1,147 @@ +<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> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index d3ac57471c9..8caa5b86a9b 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -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'; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 2d74192e6b3..79e38ae911e 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -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'); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 79cdb494e5a..709748fb530 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -1,5 +1,5 @@ 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 () => {}; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index d0bf847dbde..1eda5768709 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -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) { diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index c75add39bcd..a937fb157f8 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -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, diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 2371b201f8c..46b52fa00fc 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -31,4 +31,5 @@ export default () => ({ path: '', entry: {}, }, + clientsidePreviewEnabled: false, }); diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index 92b15cf232d..d895eca7af0 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -1,6 +1,5 @@ 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'}`; +}; diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 31dbf1da73c..37ad6a717d9 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -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%); +} diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index a9499140f8a..2bdf2c2c120 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -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 diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index 1c8801566d4..258d50ad676 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -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 diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml index d29dda43c89..4cae9c51acc 100644 --- a/app/views/ide/index.html.haml +++ b/app/views/ide/index.html.haml @@ -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...') diff --git a/changelogs/unreleased/ide-codesandbox-poc.yml b/changelogs/unreleased/ide-codesandbox-poc.yml new file mode 100644 index 00000000000..7da1f4e6472 --- /dev/null +++ b/changelogs/unreleased/ide-codesandbox-poc.yml @@ -0,0 +1,5 @@ +--- +title: Added live preview for JavaScript projects in the Web IDE +merge_request: 19764 +author: +type: added diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 73115449871..dce1fc1bc45 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -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 diff --git a/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb b/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb new file mode 100644 index 00000000000..1ebb91da00c --- /dev/null +++ b/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb @@ -0,0 +1,20 @@ +# 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 diff --git a/db/schema.rb b/db/schema.rb index 30b8147a474..c132f787530 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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| diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7d1a8b527f4..6c89186beaa 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/package.json b/package.json index 4e5cf05f49b..975dd2619d7 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "chart.js": "1.0.2", "classlist-polyfill": "^1.2.0", "clipboard": "^1.7.1", + "codesandbox-api": "^0.0.18", "compression-webpack-plugin": "^1.1.11", "core-js": "^2.4.1", "cropper": "^2.3.0", @@ -80,6 +81,7 @@ "sanitize-html": "^1.16.1", "select2": "3.5.2-browserify", "sha1": "^1.1.1", + "smooshpack": "^0.0.48", "sortablejs": "^1.7.0", "sql.js": "^0.4.0", "stickyfilljs": "^2.0.5", diff --git a/spec/javascripts/ide/components/panes/right_spec.js b/spec/javascripts/ide/components/panes/right_spec.js index 99879fb0930..c75975d2af6 100644 --- a/spec/javascripts/ide/components/panes/right_spec.js +++ b/spec/javascripts/ide/components/panes/right_spec.js @@ -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(); + }); + }); + }); }); diff --git a/spec/javascripts/ide/components/preview/clientside_spec.js b/spec/javascripts/ide/components/preview/clientside_spec.js new file mode 100644 index 00000000000..3ec65882418 --- /dev/null +++ b/spec/javascripts/ide/components/preview/clientside_spec.js @@ -0,0 +1,362 @@ +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(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/preview/navigator_spec.js b/spec/javascripts/ide/components/preview/navigator_spec.js new file mode 100644 index 00000000000..576d2fae003 --- /dev/null +++ b/spec/javascripts/ide/components/preview/navigator_spec.js @@ -0,0 +1,185 @@ +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`); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js index 70883e16b0d..9c135661997 100644 --- a/spec/javascripts/ide/stores/getters_spec.js +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -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', + }); + }); + }); }); diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index 5f4f4c26d74..4452c470b82 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -95,7 +95,7 @@ beforeEach(() => { let longRunningTestTimeoutHandle; -beforeEach((done) => { +beforeEach(done => { longRunningTestTimeoutHandle = setTimeout(() => { done.fail('Test is running too long!'); }, 2000); diff --git a/yarn.lock b/yarn.lock index f6e3b84c84b..c1e9d0ab73e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" |