From c88cc0c0ec9872b2d4830d88faff7a4588ca4f9f Mon Sep 17 00:00:00 2001 From: Tim Zallmann Date: Thu, 5 Apr 2018 11:12:40 +0000 Subject: Web IDE markdown preview --- app/assets/javascripts/ide/components/ide.vue | 5 -- .../ide/components/ide_file_buttons.vue | 83 ++++++++++++++++++++ .../javascripts/ide/components/repo_editor.vue | 57 +++++++++++++- .../ide/components/repo_file_buttons.vue | 61 --------------- app/assets/javascripts/ide/stores/actions/file.js | 4 + .../javascripts/ide/stores/mutation_types.js | 1 + .../javascripts/ide/stores/mutations/file.js | 6 ++ app/assets/javascripts/ide/stores/utils.js | 8 +- .../ide/stores/workers/files_decorator_worker.js | 23 ++---- .../components/content_viewer/content_viewer.vue | 43 +++++++++++ .../components/content_viewer/lib/viewer_utils.js | 23 ++++++ .../content_viewer/viewers/markdown_viewer.vue | 90 ++++++++++++++++++++++ app/assets/stylesheets/pages/repo.scss | 28 ++++++- .../ide/components/ide_file_buttons_spec.js | 61 +++++++++++++++ .../javascripts/ide/components/repo_editor_spec.js | 60 +++++++++++++-- .../ide/components/repo_file_buttons_spec.js | 47 ----------- spec/javascripts/ide/stores/mutations/file_spec.js | 11 +++ .../content_viewer/content_viewer_spec.js | 41 ++++++++++ 18 files changed, 507 insertions(+), 145 deletions(-) create mode 100644 app/assets/javascripts/ide/components/ide_file_buttons.vue delete mode 100644 app/assets/javascripts/ide/components/repo_file_buttons.vue create mode 100644 app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue create mode 100644 app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js create mode 100644 app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue create mode 100644 spec/javascripts/ide/components/ide_file_buttons_spec.js delete mode 100644 spec/javascripts/ide/components/repo_file_buttons_spec.js create mode 100644 spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index d22869466c9..1c237c0ec97 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -3,7 +3,6 @@ import { mapState, mapGetters } from 'vuex'; import ideSidebar from './ide_side_bar.vue'; import ideContextbar from './ide_context_bar.vue'; import repoTabs from './repo_tabs.vue'; -import repoFileButtons from './repo_file_buttons.vue'; import ideStatusBar from './ide_status_bar.vue'; import repoEditor from './repo_editor.vue'; @@ -12,7 +11,6 @@ export default { ideSidebar, ideContextbar, repoTabs, - repoFileButtons, ideStatusBar, repoEditor, }, @@ -70,9 +68,6 @@ export default { class="multi-file-edit-pane-content" :file="activeFile" /> - diff --git a/app/assets/javascripts/ide/components/ide_file_buttons.vue b/app/assets/javascripts/ide/components/ide_file_buttons.vue new file mode 100644 index 00000000000..6d07329df71 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_file_buttons.vue @@ -0,0 +1,83 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index b1a16350c19..99423362924 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -2,10 +2,16 @@ /* global monaco */ import { mapState, mapGetters, mapActions } from 'vuex'; import flash from '~/flash'; +import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import monacoLoader from '../monaco_loader'; import Editor from '../lib/editor'; +import IdeFileButtons from './ide_file_buttons.vue'; export default { + components: { + ContentViewer, + IdeFileButtons, + }, props: { file: { type: Object, @@ -18,6 +24,16 @@ export default { shouldHideEditor() { return this.file && this.file.binary && !this.file.raw; }, + editTabCSS() { + return { + active: this.file.viewMode === 'edit', + }; + }, + previewTabCSS() { + return { + active: this.file.viewMode === 'preview', + }; + }, }, watch: { file(oldVal, newVal) { @@ -56,6 +72,7 @@ export default { 'changeFileContent', 'setFileLanguage', 'setEditorPosition', + 'setFileViewMode', 'setFileEOL', 'updateViewer', 'updateDelayViewerUpdated', @@ -153,15 +170,47 @@ export default { class="blob-viewer-container blob-editor-container" >
+ class="ide-mode-tabs clearfix" + v-if="!shouldHideEditor"> + +
+ diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue deleted file mode 100644 index 4ea8cf7504b..00000000000 --- a/app/assets/javascripts/ide/components/repo_file_buttons.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 6b034ea1e82..1a17320a1ea 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -149,6 +149,10 @@ export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn } }; +export const setFileViewMode = ({ state, commit }, { file, viewMode }) => { + commit(types.SET_FILE_VIEWMODE, { file, viewMode }); +}; + export const discardFileChanges = ({ state, commit }, path) => { const file = state.entries[path]; diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index ee759bff516..e3f504e5ab0 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -38,6 +38,7 @@ export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA'; export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; export const SET_FILE_POSITION = 'SET_FILE_POSITION'; +export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE'; export const SET_FILE_EOL = 'SET_FILE_EOL'; export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 926b6f66d78..6a143e518f9 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -42,6 +42,7 @@ export default { renderError: data.render_error, raw: null, baseRaw: null, + html: data.html, }); }, [types.SET_FILE_RAW_DATA](state, { file, raw }) { @@ -83,6 +84,11 @@ export default { mrChange, }); }, + [types.SET_FILE_VIEWMODE](state, { file, viewMode }) { + Object.assign(state.entries[file.path], { + viewMode, + }); + }, [types.DISCARD_FILE_CHANGES](state, path) { Object.assign(state.entries[path], { content: state.entries[path].raw, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 63e4de3b17d..4befcc501ef 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -38,6 +38,8 @@ export const dataStructure = () => ({ editorColumn: 1, fileLanguage: '', eol: '', + viewMode: 'edit', + previewMode: null, }); export const decorateData = entity => { @@ -57,8 +59,9 @@ export const decorateData = entity => { changed = false, parentTreeUrl = '', base64 = false, - + previewMode, file_lock, + html, } = entity; return { @@ -79,8 +82,9 @@ export const decorateData = entity => { renderError, content, base64, - + previewMode, file_lock, + html, }; }; diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js index a4cd1ab099f..a1673276900 100644 --- a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js +++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js @@ -1,14 +1,8 @@ +import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import { decorateData, sortTree } from '../utils'; self.addEventListener('message', e => { - const { - data, - projectId, - branchId, - tempFile = false, - content = '', - base64 = false, - } = e.data; + const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data; const treeList = []; let file; @@ -19,9 +13,7 @@ self.addEventListener('message', e => { if (pathSplit.length > 0) { pathSplit.reduce((pathAcc, folderName) => { const parentFolder = acc[pathAcc[pathAcc.length - 1]]; - const folderPath = `${ - parentFolder ? `${parentFolder.path}/` : '' - }${folderName}`; + const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`; const foundEntry = acc[folderPath]; if (!foundEntry) { @@ -33,9 +25,7 @@ self.addEventListener('message', e => { path: folderPath, url: `/${projectId}/tree/${branchId}/${folderPath}/`, type: 'tree', - parentTreeUrl: parentFolder - ? parentFolder.url - : `/${projectId}/tree/${branchId}/`, + parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, tempFile, changed: tempFile, opened: tempFile, @@ -70,13 +60,12 @@ self.addEventListener('message', e => { path, url: `/${projectId}/blob/${branchId}/${path}`, type: 'blob', - parentTreeUrl: fileFolder - ? fileFolder.url - : `/${projectId}/blob/${branchId}`, + parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, tempFile, changed: tempFile, content, base64, + previewMode: viewerInformationForPath(blobName), }); Object.assign(acc, { diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue new file mode 100644 index 00000000000..fb8ccea91c7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue @@ -0,0 +1,43 @@ + + + diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js new file mode 100644 index 00000000000..4f2e1e47dd1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js @@ -0,0 +1,23 @@ +const viewers = { + markdown: { + id: 'markdown', + previewTitle: 'Preview Markdown', + }, +}; + +const fileNameViewers = {}; +const fileExtensionViewers = { + md: 'markdown', + markdown: 'markdown', +}; + +export function viewerInformationForPath(path) { + if (!path) return null; + const name = path.split('/').pop(); + const viewerName = + fileNameViewers[name] || fileExtensionViewers[name ? name.split('.').pop() : ''] || ''; + + return viewers[viewerName]; +} + +export default viewers; diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue new file mode 100644 index 00000000000..09e0094054d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -0,0 +1,90 @@ + + + diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 1f6f7138e1f..8cc5c8fc877 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -308,14 +308,34 @@ height: 100%; } -.multi-file-editor-btn-group { - padding: $gl-bar-padding $gl-padding; - border-top: 1px solid $white-dark; +.preview-container { + height: 100%; + overflow: auto; + + .md-previewer { + padding: $gl-padding; + } +} + +.ide-mode-tabs { border-bottom: 1px solid $white-dark; - background: $white-light; + + .nav-links { + border-bottom: 0; + + li a { + padding: $gl-padding-8 $gl-padding; + line-height: $gl-btn-line-height; + } + } +} + +.ide-btn-group { + padding: $gl-padding-4 $gl-vert-padding; } .ide-status-bar { + border-top: 1px solid $white-dark; padding: $gl-bar-padding $gl-padding; background: $white-light; display: flex; diff --git a/spec/javascripts/ide/components/ide_file_buttons_spec.js b/spec/javascripts/ide/components/ide_file_buttons_spec.js new file mode 100644 index 00000000000..8ac8d1b2acf --- /dev/null +++ b/spec/javascripts/ide/components/ide_file_buttons_spec.js @@ -0,0 +1,61 @@ +import Vue from 'vue'; +import repoFileButtons from '~/ide/components/ide_file_buttons.vue'; +import createVueComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoFileButtons', () => { + const activeFile = file(); + let vm; + + function createComponent() { + const RepoFileButtons = Vue.extend(repoFileButtons); + + activeFile.rawPath = 'test'; + activeFile.blamePath = 'test'; + activeFile.commitsPath = 'test'; + + return createVueComponent(RepoFileButtons, { + file: activeFile, + }); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders Raw, Blame, History and Permalink', done => { + vm = createComponent(); + + vm.$nextTick(() => { + const raw = vm.$el.querySelector('.raw'); + const blame = vm.$el.querySelector('.blame'); + const history = vm.$el.querySelector('.history'); + + expect(raw.href).toMatch(`/${activeFile.rawPath}`); + expect(raw.getAttribute('data-original-title')).toEqual('Raw'); + expect(blame.href).toMatch(`/${activeFile.blamePath}`); + expect(blame.getAttribute('data-original-title')).toEqual('Blame'); + expect(history.href).toMatch(`/${activeFile.commitsPath}`); + expect(history.getAttribute('data-original-title')).toEqual('History'); + expect(vm.$el.querySelector('.permalink').getAttribute('data-original-title')).toEqual( + 'Permalink', + ); + + done(); + }); + }); + + it('renders Download', done => { + activeFile.binary = true; + vm = createComponent(); + + vm.$nextTick(() => { + const raw = vm.$el.querySelector('.raw'); + + expect(raw.href).toMatch(`/${activeFile.rawPath}`); + expect(raw.getAttribute('data-original-title')).toEqual('Download'); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js index 9d3fa1280f4..e79b85050b2 100644 --- a/spec/javascripts/ide/components/repo_editor_spec.js +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -19,7 +19,6 @@ describe('RepoEditor', () => { f.active = true; f.tempFile = true; - f.html = 'testing'; vm.$store.state.openFiles.push(f); vm.$store.state.entries[f.path] = f; vm.monaco = true; @@ -47,6 +46,61 @@ describe('RepoEditor', () => { }); }); + it('renders only an edit tab', done => { + Vue.nextTick(() => { + const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); + expect(tabs.length).toBe(1); + expect(tabs[0].textContent.trim()).toBe('Edit'); + + done(); + }); + }); + + describe('when file is markdown', () => { + beforeEach(done => { + vm.file.previewMode = { + id: 'markdown', + previewTitle: 'Preview Markdown', + }; + + vm.$nextTick(done); + }); + + it('renders an Edit and a Preview Tab', done => { + Vue.nextTick(() => { + const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); + expect(tabs.length).toBe(2); + expect(tabs[0].textContent.trim()).toBe('Edit'); + expect(tabs[1].textContent.trim()).toBe('Preview Markdown'); + + done(); + }); + }); + }); + + describe('when file is markdown and viewer mode is review', () => { + beforeEach(done => { + vm.file.previewMode = { + id: 'markdown', + previewTitle: 'Preview Markdown', + }; + vm.$store.state.viewer = 'diff'; + + vm.$nextTick(done); + }); + + it('renders an Edit and a Preview Tab', done => { + Vue.nextTick(() => { + const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); + expect(tabs.length).toBe(2); + expect(tabs[0].textContent.trim()).toBe('Review'); + expect(tabs[1].textContent.trim()).toBe('Preview Markdown'); + + done(); + }); + }); + }); + describe('when open file is binary and not raw', () => { beforeEach(done => { vm.file.binary = true; @@ -57,10 +111,6 @@ describe('RepoEditor', () => { it('does not render the IDE', () => { expect(vm.shouldHideEditor).toBeTruthy(); }); - - it('shows activeFile html', () => { - expect(vm.$el.textContent).toContain('testing'); - }); }); describe('createEditorInstance', () => { diff --git a/spec/javascripts/ide/components/repo_file_buttons_spec.js b/spec/javascripts/ide/components/repo_file_buttons_spec.js deleted file mode 100644 index c86bdb132b4..00000000000 --- a/spec/javascripts/ide/components/repo_file_buttons_spec.js +++ /dev/null @@ -1,47 +0,0 @@ -import Vue from 'vue'; -import repoFileButtons from '~/ide/components/repo_file_buttons.vue'; -import createVueComponent from '../../helpers/vue_mount_component_helper'; -import { file } from '../helpers'; - -describe('RepoFileButtons', () => { - const activeFile = file(); - let vm; - - function createComponent() { - const RepoFileButtons = Vue.extend(repoFileButtons); - - activeFile.rawPath = 'test'; - activeFile.blamePath = 'test'; - activeFile.commitsPath = 'test'; - - return createVueComponent(RepoFileButtons, { - file: activeFile, - }); - } - - afterEach(() => { - vm.$destroy(); - }); - - it('renders Raw, Blame, History, Permalink and Preview toggle', done => { - vm = createComponent(); - - vm.$nextTick(() => { - const raw = vm.$el.querySelector('.raw'); - const blame = vm.$el.querySelector('.blame'); - const history = vm.$el.querySelector('.history'); - - expect(raw.href).toMatch(`/${activeFile.rawPath}`); - expect(raw.textContent.trim()).toEqual('Raw'); - expect(blame.href).toMatch(`/${activeFile.blamePath}`); - expect(blame.textContent.trim()).toEqual('Blame'); - expect(history.href).toMatch(`/${activeFile.commitsPath}`); - expect(history.textContent.trim()).toEqual('History'); - expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual( - 'Permalink', - ); - - done(); - }); - }); -}); diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js index 88285ee409f..bf9d5166d0a 100644 --- a/spec/javascripts/ide/stores/mutations/file_spec.js +++ b/spec/javascripts/ide/stores/mutations/file_spec.js @@ -194,6 +194,17 @@ describe('IDE store file mutations', () => { }); }); + describe('SET_FILE_VIEWMODE', () => { + it('updates file view mode', () => { + mutations.SET_FILE_VIEWMODE(localState, { + file: localFile, + viewMode: 'preview', + }); + + expect(localFile.viewMode).toBe('preview'); + }); + }); + describe('ADD_PENDING_TAB', () => { beforeEach(() => { const f = { diff --git a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js new file mode 100644 index 00000000000..c7c454a0b45 --- /dev/null +++ b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('ContentViewer', () => { + let vm; + let mock; + + function createComponent(props) { + const ContentViewer = Vue.extend(contentViewer); + vm = mountComponent(ContentViewer, props); + } + + afterEach(() => { + vm.$destroy(); + if (mock) mock.restore(); + }); + + it('markdown preview renders + loads rendered markdown from server', done => { + mock = new MockAdapter(axios); + mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).reply(200, { + body: 'testing', + }); + + createComponent({ + path: 'test.md', + content: '* Test', + projectPath: 'testproject', + }); + + const previewContainer = vm.$el.querySelector('.md-previewer'); + + setTimeout(() => { + expect(previewContainer.textContent).toContain('testing'); + + done(); + }); + }); +}); -- cgit v1.2.1