diff options
author | Phil Hughes <me@iamphill.com> | 2017-05-05 18:47:01 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2017-05-05 18:47:01 +0000 |
commit | 240400152242924e75ea81918b5cfdcf2441549c (patch) | |
tree | 3534cbdece629b2c193a1d09c832ab1d91696b5e | |
parent | ecaa68a7095750ef7575fdefdb200ef01ef88748 (diff) | |
parent | 8985ea1b9c649183e997a76c32aca927a258b51e (diff) | |
download | gitlab-ce-240400152242924e75ea81918b5cfdcf2441549c.tar.gz |
Merge branch 'issue-title-description-realtime' into 'master'
Render Description Realtime :tada:
Closes #25049 and #31355
See merge request !10865
-rw-r--r-- | app/assets/javascripts/issue_show/actions/tasks.js | 27 | ||||
-rw-r--r-- | app/assets/javascripts/issue_show/index.js | 6 | ||||
-rw-r--r-- | app/assets/javascripts/issue_show/issue_title.vue | 80 | ||||
-rw-r--r-- | app/assets/javascripts/issue_show/issue_title_description.vue | 180 | ||||
-rw-r--r-- | app/assets/stylesheets/pages/issues.scss | 9 | ||||
-rw-r--r-- | app/controllers/projects/issues_controller.rb | 11 | ||||
-rw-r--r-- | app/views/projects/issues/show.html.haml | 11 | ||||
-rw-r--r-- | changelogs/unreleased/issue-title-description-realtime.yml | 4 | ||||
-rw-r--r-- | features/project/issues/issues.feature | 1 | ||||
-rw-r--r-- | spec/features/issues/award_spec.rb | 6 | ||||
-rw-r--r-- | spec/features/task_lists_spec.rb | 35 | ||||
-rw-r--r-- | spec/javascripts/issue_show/issue_title_description_spec.js | 60 | ||||
-rw-r--r-- | spec/javascripts/issue_show/issue_title_spec.js | 22 | ||||
-rw-r--r-- | spec/javascripts/issue_show/mock_data.js | 26 | ||||
-rw-r--r-- | spec/javascripts/issue_spec.js | 6 | ||||
-rw-r--r-- | spec/support/features/issuable_slash_commands_shared_examples.rb | 4 |
16 files changed, 353 insertions, 135 deletions
diff --git a/app/assets/javascripts/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js new file mode 100644 index 00000000000..0740a9f559c --- /dev/null +++ b/app/assets/javascripts/issue_show/actions/tasks.js @@ -0,0 +1,27 @@ +export default (newStateData, tasks) => { + const $tasks = $('#task_status'); + const $tasksShort = $('#task_status_short'); + const $issueableHeader = $('.issuable-header'); + const tasksStates = { newState: null, currentState: null }; + + if ($tasks.length === 0) { + if (!(newStateData.task_status.indexOf('0 of 0') === 0)) { + $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); + } else { + $issueableHeader.append('<span id="task_status"></span>'); + } + } else { + tasksStates.newState = newStateData.task_status.indexOf('0 of 0') === 0; + tasksStates.currentState = tasks.indexOf('0 of 0') === 0; + } + + if ($tasks.length !== 0 && !tasksStates.newState) { + $tasks.text(newStateData.task_status); + $tasksShort.text(newStateData.task_status); + } else if (tasksStates.currentState) { + $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); + } else if (tasksStates.newState) { + $tasks.remove(); + $tasksShort.remove(); + } +}; diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index 4d491e70d83..eb20a597bb5 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,16 +1,16 @@ import Vue from 'vue'; -import IssueTitle from './issue_title.vue'; +import IssueTitle from './issue_title_description.vue'; import '../vue_shared/vue_resource_interceptor'; (() => { const issueTitleData = document.querySelector('.issue-title-data').dataset; - const { initialTitle, endpoint } = issueTitleData; + const { canUpdateTasksClass, endpoint } = issueTitleData; const vm = new Vue({ el: '.issue-title-entrypoint', render: createElement => createElement(IssueTitle, { props: { - initialTitle, + canUpdateTasksClass, endpoint, }, }), diff --git a/app/assets/javascripts/issue_show/issue_title.vue b/app/assets/javascripts/issue_show/issue_title.vue deleted file mode 100644 index 00b0e56030a..00000000000 --- a/app/assets/javascripts/issue_show/issue_title.vue +++ /dev/null @@ -1,80 +0,0 @@ -<script> -import Visibility from 'visibilityjs'; -import Poll from './../lib/utils/poll'; -import Service from './services/index'; - -export default { - props: { - initialTitle: { required: true, type: String }, - endpoint: { required: true, type: String }, - }, - data() { - const resource = new Service(this.$http, this.endpoint); - - const poll = new Poll({ - resource, - method: 'getTitle', - successCallback: (res) => { - this.renderResponse(res); - }, - errorCallback: (err) => { - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.error('ISSUE SHOW TITLE REALTIME ERROR', err); - } else { - throw new Error(err); - } - }, - }); - - return { - poll, - timeoutId: null, - title: this.initialTitle, - }; - }, - methods: { - renderResponse(res) { - const body = JSON.parse(res.body); - this.triggerAnimation(body); - }, - triggerAnimation(body) { - const { title } = body; - - /** - * since opacity is changed, even if there is no diff for Vue to update - * we must check the title even on a 304 to ensure no visual change - */ - if (this.title === title) return; - - this.$el.style.opacity = 0; - - this.timeoutId = setTimeout(() => { - this.title = title; - - this.$el.style.transition = 'opacity 0.2s ease'; - this.$el.style.opacity = 1; - - clearTimeout(this.timeoutId); - }, 100); - }, - }, - created() { - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - }, -}; -</script> - -<template> - <h2 class="title" v-html="title"></h2> -</template> diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue new file mode 100644 index 00000000000..dc3ba2550c5 --- /dev/null +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -0,0 +1,180 @@ +<script> +import Visibility from 'visibilityjs'; +import Poll from './../lib/utils/poll'; +import Service from './services/index'; +import tasks from './actions/tasks'; + +export default { + props: { + endpoint: { + required: true, + type: String, + }, + canUpdateTasksClass: { + required: true, + type: String, + }, + }, + data() { + const resource = new Service(this.$http, this.endpoint); + + const poll = new Poll({ + resource, + method: 'getTitle', + successCallback: (res) => { + this.renderResponse(res); + }, + errorCallback: (err) => { + throw new Error(err); + }, + }); + + return { + poll, + apiData: {}, + tasks: '0 of 0', + title: null, + titleText: '', + titleFlag: { + pre: true, + pulse: false, + }, + description: null, + descriptionText: '', + descriptionChange: false, + descriptionFlag: { + pre: true, + pulse: false, + }, + timeAgoEl: $('.issue_edited_ago'), + titleEl: document.querySelector('title'), + }; + }, + methods: { + updateFlag(key, toggle) { + this[key].pre = toggle; + this[key].pulse = !toggle; + }, + renderResponse(res) { + this.apiData = res.json(); + this.triggerAnimation(); + }, + updateTaskHTML() { + tasks(this.apiData, this.tasks); + }, + elementsToVisualize(noTitleChange, noDescriptionChange) { + if (!noTitleChange) { + this.titleText = this.apiData.title_text; + this.updateFlag('titleFlag', true); + } + + if (!noDescriptionChange) { + // only change to true when we need to bind TaskLists the html of description + this.descriptionChange = true; + this.updateTaskHTML(); + this.tasks = this.apiData.task_status; + this.updateFlag('descriptionFlag', true); + } + }, + setTabTitle() { + const currentTabTitleScope = this.titleEl.innerText.split('·'); + currentTabTitleScope[0] = `${this.titleText} (#${this.apiData.issue_number}) `; + this.titleEl.innerText = currentTabTitleScope.join('·'); + }, + animate(title, description) { + this.title = title; + this.description = description; + this.setTabTitle(); + + this.$nextTick(() => { + this.updateFlag('titleFlag', false); + this.updateFlag('descriptionFlag', false); + }); + }, + triggerAnimation() { + // always reset to false before checking the change + this.descriptionChange = false; + + const { title, description } = this.apiData; + this.descriptionText = this.apiData.description_text; + + const noTitleChange = this.title === title; + const noDescriptionChange = this.description === description; + + /** + * since opacity is changed, even if there is no diff for Vue to update + * we must check the title/description even on a 304 to ensure no visual change + */ + if (noTitleChange && noDescriptionChange) return; + + this.elementsToVisualize(noTitleChange, noDescriptionChange); + this.animate(title, description); + }, + updateEditedTimeAgo() { + const toolTipTime = gl.utils.formatDate(this.apiData.updated_at); + this.timeAgoEl.attr('datetime', this.apiData.updated_at); + this.timeAgoEl.attr('title', toolTipTime).tooltip('fixTitle'); + }, + }, + created() { + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + }, + updated() { + // if new html is injected (description changed) - bind TaskList and call renderGFM + if (this.descriptionChange) { + this.updateEditedTimeAgo(); + + $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); + + const tl = new gl.TaskList({ + dataType: 'issue', + fieldName: 'description', + selector: '.detail-page-description', + }); + + return tl && null; + } + + return null; + }, +}; +</script> + +<template> + <div> + <h2 + class="title" + :class="{ 'issue-realtime-pre-pulse': titleFlag.pre, 'issue-realtime-trigger-pulse': titleFlag.pulse }" + ref="issue-title" + v-html="title" + > + </h2> + <div + class="description is-task-list-enabled" + :class="canUpdateTasksClass" + v-if="description" + > + <div + class="wiki" + :class="{ 'issue-realtime-pre-pulse': descriptionFlag.pre, 'issue-realtime-trigger-pulse': descriptionFlag.pulse }" + v-html="description" + ref="issue-content-container-gfm-entry" + > + </div> + <textarea + class="hidden js-task-list-field" + v-if="descriptionText" + >{{descriptionText}}</textarea> + </div> + </div> +</template> diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index b18bbc329c3..ad3b6e0344b 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -18,6 +18,15 @@ } } +.issue-realtime-pre-pulse { + opacity: 0; +} + +.issue-realtime-trigger-pulse { + transition: opacity $fade-in-duration linear; + opacity: 1; +} + .check-all-holder { line-height: 36px; float: left; diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index af9157bfbb5..b7a55a623b7 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -201,7 +201,16 @@ class Projects::IssuesController < Projects::ApplicationController def rendered_title Gitlab::PollingInterval.set_header(response, interval: 3_000) - render json: { title: view_context.markdown_field(@issue, :title) } + + render json: { + title: view_context.markdown_field(@issue, :title), + title_text: @issue.title, + description: view_context.markdown_field(@issue, :description), + description_text: @issue.description, + task_status: @issue.task_status, + issue_number: @issue.iid, + updated_at: @issue.updated_at, + } end def create_merge_request diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 1418ad73553..9084883eb3e 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -51,16 +51,11 @@ .issue-details.issuable-details .detail-page-description.content-block - .issue-title-data.hidden{ "data" => { "initial-title" => markdown_field(@issue, :title), - "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), + .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), + "can-update-tasks-class" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', } } .issue-title-entrypoint - - if @issue.description.present? - .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } - .wiki - = markdown_field(@issue, :description) - %textarea.hidden.js-task-list-field - = @issue.description + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } diff --git a/changelogs/unreleased/issue-title-description-realtime.yml b/changelogs/unreleased/issue-title-description-realtime.yml new file mode 100644 index 00000000000..003e1a4ab33 --- /dev/null +++ b/changelogs/unreleased/issue-title-description-realtime.yml @@ -0,0 +1,4 @@ +--- +title: Add realtime descriptions to issue show pages +merge_request: +author: diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature index 4dee0cd23dc..1b00d8a32a0 100644 --- a/features/project/issues/issues.feature +++ b/features/project/issues/issues.feature @@ -82,6 +82,7 @@ Feature: Project Issues # Markdown + @javascript Scenario: Headers inside the description should have ids generated for them. Given I visit issue page "Release 0.4" Then Header "Description header" should have correct id and link diff --git a/spec/features/issues/award_spec.rb b/spec/features/issues/award_spec.rb index 401e1ea2b89..08e3f99e29f 100644 --- a/spec/features/issues/award_spec.rb +++ b/spec/features/issues/award_spec.rb @@ -6,9 +6,12 @@ feature 'Issue awards', js: true, feature: true do let(:issue) { create(:issue, project: project) } describe 'logged in' do + include WaitForVueResource + before do login_as(user) visit namespace_project_issue_path(project.namespace, project, issue) + wait_for_vue_resource end it 'adds award to issue' do @@ -38,8 +41,11 @@ feature 'Issue awards', js: true, feature: true do end describe 'logged out' do + include WaitForVueResource + before do visit namespace_project_issue_path(project.namespace, project, issue) + wait_for_vue_resource end it 'does not see award menu button' do diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index c33692fc4a9..8bd13caf2b0 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -62,12 +62,15 @@ feature 'Task Lists', feature: true do visit namespace_project_issue_path(project.namespace, project, issue) end - describe 'for Issues' do - describe 'multiple tasks' do + describe 'for Issues', feature: true do + describe 'multiple tasks', js: true do + include WaitForVueResource + let!(:issue) { create(:issue, description: markdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) + wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 6) @@ -76,25 +79,24 @@ feature 'Task Lists', feature: true do it 'contains the required selectors' do visit_issue(project, issue) + wait_for_vue_resource - container = '.detail-page-description .description.js-task-list-container' - - expect(page).to have_selector(container) - expect(page).to have_selector("#{container} .wiki .task-list .task-list-item .task-list-item-checkbox") - expect(page).to have_selector("#{container} .js-task-list-field") - expect(page).to have_selector('form.js-issuable-update') + expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") expect(page).to have_selector('a.btn-close') end it 'is only editable by author' do visit_issue(project, issue) - expect(page).to have_selector('.js-task-list-container') + wait_for_vue_resource - logout(:user) + expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") + logout(:user) login_as(user2) visit current_path - expect(page).not_to have_selector('.js-task-list-container') + wait_for_vue_resource + + expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") end it 'provides a summary on Issues#index' do @@ -103,11 +105,14 @@ feature 'Task Lists', feature: true do end end - describe 'single incomplete task' do + describe 'single incomplete task', js: true do + include WaitForVueResource + let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) + wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) @@ -116,15 +121,18 @@ feature 'Task Lists', feature: true do it 'provides a summary on Issues#index' do visit namespace_project_issues_path(project.namespace, project) + expect(page).to have_content("0 of 1 task completed") end end - describe 'single complete task' do + describe 'single complete task', js: true do + include WaitForVueResource let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) + wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) @@ -133,6 +141,7 @@ feature 'Task Lists', feature: true do it 'provides a summary on Issues#index' do visit namespace_project_issues_path(project.namespace, project) + expect(page).to have_content("1 of 1 task completed") end end diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js new file mode 100644 index 00000000000..1ec4fe58b08 --- /dev/null +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -0,0 +1,60 @@ +import Vue from 'vue'; +import $ from 'jquery'; +import '~/render_math'; +import '~/render_gfm'; +import issueTitleDescription from '~/issue_show/issue_title_description.vue'; +import issueShowData from './mock_data'; + +window.$ = $; + +const issueShowInterceptor = data => (request, next) => { + next(request.respondWith(JSON.stringify(data), { + status: 200, + headers: { + 'POLL-INTERVAL': 1, + }, + })); +}; + +describe('Issue Title', () => { + document.body.innerHTML = '<span id="task_status"></span>'; + + let IssueTitleDescriptionComponent; + + beforeEach(() => { + IssueTitleDescriptionComponent = Vue.extend(issueTitleDescription); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); + }); + + it('should render a title/description and update title/description on update', (done) => { + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + + const issueShowComponent = new IssueTitleDescriptionComponent({ + propsData: { + canUpdateIssue: '.css-stuff', + endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', + }, + }).$mount(); + + setTimeout(() => { + expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); + expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); + expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>'); + expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('this is a description'); + + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); + + setTimeout(() => { + expect(document.querySelector('title').innerText).toContain('2 (#1)'); + expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); + expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); + expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('42'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/issue_show/issue_title_spec.js b/spec/javascripts/issue_show/issue_title_spec.js deleted file mode 100644 index 03edbf9f947..00000000000 --- a/spec/javascripts/issue_show/issue_title_spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import Vue from 'vue'; -import issueTitle from '~/issue_show/issue_title.vue'; - -describe('Issue Title', () => { - let IssueTitleComponent; - - beforeEach(() => { - IssueTitleComponent = Vue.extend(issueTitle); - }); - - it('should render a title', () => { - const component = new IssueTitleComponent({ - propsData: { - initialTitle: 'wow', - endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', - }, - }).$mount(); - - expect(component.$el.classList).toContain('title'); - expect(component.$el.innerHTML).toContain('wow'); - }); -}); diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js new file mode 100644 index 00000000000..ad5a7b63470 --- /dev/null +++ b/spec/javascripts/issue_show/mock_data.js @@ -0,0 +1,26 @@ +export default { + initialRequest: { + title: '<p>this is a title</p>', + title_text: 'this is a title', + description: '<p>this is a description!</p>', + description_text: 'this is a description', + issue_number: 1, + task_status: '2 of 4 completed', + }, + secondRequest: { + title: '<p>2</p>', + title_text: '2', + description: '<p>42</p>', + description_text: '42', + issue_number: 1, + task_status: '0 of 0 completed', + }, + issueSpecRequest: { + title: '<p>this is a title</p>', + title_text: 'this is a title', + description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>', + description_text: '- [ ] Task List Item', + issue_number: 1, + task_status: '0 of 1 completed', + }, +}; diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index 0fd573eae3f..763f5ee9e50 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -81,12 +81,6 @@ describe('Issue', function() { this.issue = new Issue(); }); - it('modifies the Markdown field', function() { - spyOn(jQuery, 'ajax').and.stub(); - $('input[type=checkbox]').attr('checked', true).trigger('change'); - expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); - }); - it('submits an ajax request on tasklist:changed', function() { spyOn(jQuery, 'ajax').and.callFake(function(req) { expect(req.type).toBe('PATCH'); diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 610decdcddb..595bb92f5c0 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -25,7 +25,7 @@ shared_examples 'issuable record that supports slash commands in its description wait_for_ajax end - describe "new #{issuable_type}" do + describe "new #{issuable_type}", js: true do context 'with commands in the description' do it "creates the #{issuable_type} and interpret commands accordingly" do visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts) @@ -44,7 +44,7 @@ shared_examples 'issuable record that supports slash commands in its description end end - describe "note on #{issuable_type}" do + describe "note on #{issuable_type}", js: true do before do visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) end |