Commit 45e4c665 authored by Rares Sfirlogea's avatar Rares Sfirlogea Committed by Adam Niedzielski

Display slash commands outcome when previewing Markdown

Remove slash commands from Markdown preview and display their outcome next to
the text field.
Introduce new "explanation" block to our slash commands DSL.
Introduce optional "parse_params" block to slash commands DSL that allows to
process a parameter before it is passed to "explanation" or "command" blocks.
Pass path for previewing Markdown as "data" attribute instead of setting
a variable on "window".
parent 2d43f8a2
...@@ -2,8 +2,9 @@ ...@@ -2,8 +2,9 @@
// MarkdownPreview // MarkdownPreview
// //
// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview, // Handles toggling the "Write" and "Preview" tab clicks, rendering the preview
// and showing a warning when more than `x` users are referenced. // (including the explanation of slash commands), and showing a warning when
// more than `x` users are referenced.
// //
(function () { (function () {
var lastTextareaPreviewed; var lastTextareaPreviewed;
...@@ -17,32 +18,45 @@ ...@@ -17,32 +18,45 @@
// Minimum number of users referenced before triggering a warning // Minimum number of users referenced before triggering a warning
MarkdownPreview.prototype.referenceThreshold = 10; MarkdownPreview.prototype.referenceThreshold = 10;
MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.';
MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.ajaxCache = {};
MarkdownPreview.prototype.showPreview = function ($form) { MarkdownPreview.prototype.showPreview = function ($form) {
var mdText; var mdText;
var preview = $form.find('.js-md-preview'); var preview = $form.find('.js-md-preview');
var url = preview.data('url');
if (preview.hasClass('md-preview-loading')) { if (preview.hasClass('md-preview-loading')) {
return; return;
} }
mdText = $form.find('textarea.markdown-area').val(); mdText = $form.find('textarea.markdown-area').val();
if (mdText.trim().length === 0) { if (mdText.trim().length === 0) {
preview.text('Nothing to preview.'); preview.text(this.emptyMessage);
this.hideReferencedUsers($form); this.hideReferencedUsers($form);
} else { } else {
preview.addClass('md-preview-loading').text('Loading...'); preview.addClass('md-preview-loading').text('Loading...');
this.fetchMarkdownPreview(mdText, (function (response) { this.fetchMarkdownPreview(mdText, url, (function (response) {
preview.removeClass('md-preview-loading').html(response.body); var body;
if (response.body.length > 0) {
body = response.body;
} else {
body = this.emptyMessage;
}
preview.removeClass('md-preview-loading').html(body);
preview.renderGFM(); preview.renderGFM();
this.renderReferencedUsers(response.references.users, $form); this.renderReferencedUsers(response.references.users, $form);
if (response.references.commands) {
this.renderReferencedCommands(response.references.commands, $form);
}
}).bind(this)); }).bind(this));
} }
}; };
MarkdownPreview.prototype.fetchMarkdownPreview = function (text, success) { MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
if (!window.preview_markdown_path) { if (!url) {
return; return;
} }
if (text === this.ajaxCache.text) { if (text === this.ajaxCache.text) {
...@@ -51,7 +65,7 @@ ...@@ -51,7 +65,7 @@
} }
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: window.preview_markdown_path, url: url,
data: { data: {
text: text text: text
}, },
...@@ -83,6 +97,22 @@ ...@@ -83,6 +97,22 @@
} }
}; };
MarkdownPreview.prototype.hideReferencedCommands = function ($form) {
$form.find('.referenced-commands').hide();
};
MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form) {
var referencedCommands;
referencedCommands = $form.find('.referenced-commands');
if (commands.length > 0) {
referencedCommands.html(commands);
referencedCommands.show();
} else {
referencedCommands.html('');
referencedCommands.hide();
}
};
return MarkdownPreview; return MarkdownPreview;
}()); }());
...@@ -137,6 +167,8 @@ ...@@ -137,6 +167,8 @@
$form.find('.md-write-holder').show(); $form.find('.md-write-holder').show();
$form.find('textarea.markdown-area').focus(); $form.find('textarea.markdown-area').focus();
$form.find('.md-preview-holder').hide(); $form.find('.md-preview-holder').hide();
markdownPreview.hideReferencedCommands($form);
}); });
$(document).on('markdown-preview:toggle', function (e, keyboardEvent) { $(document).on('markdown-preview:toggle', function (e, keyboardEvent) {
......
module MarkdownPreview
private
def render_markdown_preview(text, markdown_context = {})
render json: {
body: view_context.markdown(text, markdown_context),
references: {
users: preview_referenced_users(text)
}
}
end
def preview_referenced_users(text)
extractor = Gitlab::ReferenceExtractor.new(@project, current_user)
extractor.analyze(text, author: current_user)
extractor.users.map(&:username)
end
end
class Projects::WikisController < Projects::ApplicationController class Projects::WikisController < Projects::ApplicationController
include MarkdownPreview
before_action :authorize_read_wiki! before_action :authorize_read_wiki!
before_action :authorize_create_wiki!, only: [:edit, :create, :history] before_action :authorize_create_wiki!, only: [:edit, :create, :history]
before_action :authorize_admin_wiki!, only: :destroy before_action :authorize_admin_wiki!, only: :destroy
...@@ -97,9 +95,14 @@ class Projects::WikisController < Projects::ApplicationController ...@@ -97,9 +95,14 @@ class Projects::WikisController < Projects::ApplicationController
end end
def preview_markdown def preview_markdown
context = { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } result = PreviewMarkdownService.new(@project, current_user, params).execute
render_markdown_preview(params[:text], context) render json: {
body: view_context.markdown(result[:text], pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id]),
references: {
users: result[:users]
}
}
end end
private private
......
class ProjectsController < Projects::ApplicationController class ProjectsController < Projects::ApplicationController
include IssuableCollections include IssuableCollections
include ExtractsPath include ExtractsPath
include MarkdownPreview
before_action :authenticate_user!, except: [:index, :show, :activity, :refs] before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
before_action :project, except: [:index, :new, :create] before_action :project, except: [:index, :new, :create]
...@@ -240,7 +239,15 @@ class ProjectsController < Projects::ApplicationController ...@@ -240,7 +239,15 @@ class ProjectsController < Projects::ApplicationController
end end
def preview_markdown def preview_markdown
render_markdown_preview(params[:text]) result = PreviewMarkdownService.new(@project, current_user, params).execute
render json: {
body: view_context.markdown(result[:text]),
references: {
users: result[:users],
commands: view_context.markdown(result[:commands])
}
}
end end
private private
......
...@@ -3,7 +3,6 @@ class SnippetsController < ApplicationController ...@@ -3,7 +3,6 @@ class SnippetsController < ApplicationController
include ToggleAwardEmoji include ToggleAwardEmoji
include SpammableActions include SpammableActions
include SnippetsActions include SnippetsActions
include MarkdownPreview
include RendersBlob include RendersBlob
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw] before_action :snippet, only: [:show, :edit, :destroy, :update, :raw]
...@@ -90,7 +89,14 @@ class SnippetsController < ApplicationController ...@@ -90,7 +89,14 @@ class SnippetsController < ApplicationController
end end
def preview_markdown def preview_markdown
render_markdown_preview(params[:text], skip_project_check: true) result = PreviewMarkdownService.new(@project, current_user, params).execute
render json: {
body: view_context.markdown(result[:text], skip_project_check: true),
references: {
users: result[:users]
}
}
end end
protected protected
......
...@@ -122,6 +122,10 @@ module GitlabRoutingHelper ...@@ -122,6 +122,10 @@ module GitlabRoutingHelper
namespace_project_snippet_url(entity.project.namespace, entity.project, entity, *args) namespace_project_snippet_url(entity.project.namespace, entity.project, entity, *args)
end end
def preview_markdown_path(project, *args)
preview_markdown_namespace_project_path(project.namespace, project, *args)
end
def toggle_subscription_path(entity, *args) def toggle_subscription_path(entity, *args)
if entity.is_a?(Issue) if entity.is_a?(Issue)
toggle_subscription_namespace_project_issue_path(entity.project.namespace, entity.project, entity) toggle_subscription_namespace_project_issue_path(entity.project.namespace, entity.project, entity)
......
class PreviewMarkdownService < BaseService
def execute
text, commands = explain_slash_commands(params[:text])
users = find_user_references(text)
success(
text: text,
users: users,
commands: commands.join(' ')
)
end
private
def explain_slash_commands(text)
return text, [] unless %w(Issue MergeRequest).include?(commands_target_type)
slash_commands_service = SlashCommands::InterpretService.new(project, current_user)
slash_commands_service.explain(text, find_commands_target)
end
def find_user_references(text)
extractor = Gitlab::ReferenceExtractor.new(project, current_user)
extractor.analyze(text, author: current_user)
extractor.users.map(&:username)
end
def find_commands_target
if commands_target_id.present?
finder = commands_target_type == 'Issue' ? IssuesFinder : MergeRequestsFinder
finder.new(current_user, project_id: project.id).find(commands_target_id)
else
collection = commands_target_type == 'Issue' ? project.issues : project.merge_requests
collection.build
end
end
def commands_target_type
params[:slash_commands_target_type]
end
def commands_target_id
params[:slash_commands_target_id]
end
end
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
.form-group.milestone-description .form-group.milestone-description
= f.label :description, "Description", class: "control-label" = f.label :description, "Description", class: "control-label"
.col-sm-10 .col-sm-10
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do = render layout: 'projects/md_preview', locals: { url: '' } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...' = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
.clearfix .clearfix
.error-alert .error-alert
......
...@@ -5,14 +5,9 @@ ...@@ -5,14 +5,9 @@
- content_for :project_javascripts do - content_for :project_javascripts do
- project = @target_project || @project - project = @target_project || @project
- if @project_wiki && @page
- preview_markdown_path = namespace_project_wiki_preview_markdown_path(project.namespace, project, @page.slug)
- else
- preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project)
- if current_user - if current_user
:javascript :javascript
window.uploads_path = "#{namespace_project_uploads_path project.namespace,project}"; window.uploads_path = "#{namespace_project_uploads_path project.namespace,project}";
window.preview_markdown_path = "#{preview_markdown_path}";
- content_for :header_content do - content_for :header_content do
.js-dropdown-menu-projects .js-dropdown-menu-projects
......
- referenced_users = local_assigns.fetch(:referenced_users, nil)
.md-area .md-area
.md-header .md-header
%ul.nav-links.clearfix %ul.nav-links.clearfix
...@@ -28,9 +30,10 @@ ...@@ -28,9 +30,10 @@
.md-write-holder .md-write-holder
= yield = yield
.md.md-preview-holder.js-md-preview.hide{ class: (preview_class if defined?(preview_class)) } .md.md-preview-holder.js-md-preview.hide.md-preview{ data: { url: url } }
.referenced-commands.hide
- if defined?(referenced_users) && referenced_users - if referenced_users
.referenced-users.hide .referenced-users.hide
%span %span
= icon("exclamation-triangle") = icon("exclamation-triangle")
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
.form-group.milestone-description .form-group.milestone-description
= f.label :description, "Description", class: "control-label" = f.label :description, "Description", class: "control-label"
.col-sm-10 .col-sm-10
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project) } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...' = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
= render 'projects/notes/hints' = render 'projects/notes/hints'
.clearfix .clearfix
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
= form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do = form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do
= hidden_field_tag :target_id, '', class: 'js-form-target-id' = hidden_field_tag :target_id, '', class: 'js-form-target-id'
= hidden_field_tag :target_type, '', class: 'js-form-target-type' = hidden_field_tag :target_type, '', class: 'js-form-target-type'
= render layout: 'projects/md_preview', locals: { preview_class: 'md-preview', referenced_users: true } do = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do
= render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here..." = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here..."
= render 'projects/notes/hints' = render 'projects/notes/hints'
......
- supports_slash_commands = note_supports_slash_commands?(@note) - supports_slash_commands = note_supports_slash_commands?(@note)
- if supports_slash_commands
- preview_url = preview_markdown_path(@project, slash_commands_target_type: @note.noteable_type, slash_commands_target_id: @note.noteable_id)
- else
- preview_url = preview_markdown_path(@project)
= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| = form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
= hidden_field_tag :view, diff_view = hidden_field_tag :view, diff_view
...@@ -18,7 +22,7 @@ ...@@ -18,7 +22,7 @@
-# DiffNote -# DiffNote
= f.hidden_field :position = f.hidden_field :position
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
= render 'projects/zen', f: f, = render 'projects/zen', f: f,
attr: :note, attr: :note,
classes: 'note-textarea js-note-text', classes: 'note-textarea js-note-text',
......
%ul#notes-list.notes.main-notes-list.timeline %ul#notes-list.notes.main-notes-list.timeline
= render "shared/notes/notes" = render "shared/notes/notes"
= render 'projects/notes/edit_form' = render 'projects/notes/edit_form', project: @project
%ul.notes.notes-form.timeline %ul.notes.notes-form.timeline
%li.timeline-entry %li.timeline-entry
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
= form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f| = form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f|
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..."
= render 'projects/notes/hints' = render 'projects/notes/hints'
.error-alert .error-alert
......
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
.form-group .form-group
= label_tag :release_description, 'Release notes', class: 'control-label' = label_tag :release_description, 'Release notes', class: 'control-label'
.col-sm-10 .col-sm-10
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..."
= render 'projects/notes/hints' = render 'projects/notes/hints'
.help-block Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page. .help-block Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page.
......
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
.form-group .form-group
= f.label :content, class: 'control-label' = f.label :content, class: 'control-label'
.col-sm-10 .col-sm-10
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do = render layout: 'projects/md_preview', locals: { url: namespace_project_wiki_preview_markdown_path(@project.namespace, @project, @page.slug) } do
= render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: 'Write your content or drag files here...' = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: 'Write your content or drag files here...'
= render 'projects/notes/hints' = render 'projects/notes/hints'
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
= render 'shared/issuable/form/template_selector', issuable: issuable = render 'shared/issuable/form/template_selector', issuable: issuable
= render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?)
= render 'shared/issuable/form/description', issuable: issuable, form: form = render 'shared/issuable/form/description', issuable: issuable, form: form, project: project
- if issuable.respond_to?(:confidential) - if issuable.respond_to?(:confidential)
.form-group .form-group
......
- project = local_assigns.fetch(:project)
- issuable = local_assigns.fetch(:issuable) - issuable = local_assigns.fetch(:issuable)
- form = local_assigns.fetch(:form) - form = local_assigns.fetch(:form)
- supports_slash_commands = issuable.new_record?
- if supports_slash_commands
- preview_url = preview_markdown_path(project, slash_commands_target_type: issuable.class.name)
- else
- preview_url = preview_markdown_path(project)
.form-group.detail-page-description .form-group.detail-page-description
= form.label :description, 'Description', class: 'control-label' = form.label :description, 'Description', class: 'control-label'
.col-sm-10 .col-sm-10
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
= render 'projects/zen', f: form, attr: :description, = render 'projects/zen', f: form, attr: :description,
classes: 'note-textarea', classes: 'note-textarea',
placeholder: "Write a comment or drag your files here...", placeholder: "Write a comment or drag your files here...",
supports_slash_commands: !issuable.persisted? supports_slash_commands: supports_slash_commands
= render 'projects/notes/hints', supports_slash_commands: !issuable.persisted? = render 'projects/notes/hints', supports_slash_commands: supports_slash_commands
.clearfix .clearfix
.error-alert .error-alert
---
title: Display slash commands outcome when previewing Markdown
merge_request: 10054
author: Rares Sfirlogea
module Gitlab module Gitlab
module SlashCommands module SlashCommands
class CommandDefinition class CommandDefinition
attr_accessor :name, :aliases, :description, :params, :condition_block, :action_block attr_accessor :name, :aliases, :description, :explanation, :params,
:condition_block, :parse_params_block, :action_block
def initialize(name, attributes = {}) def initialize(name, attributes = {})
@name = name @name = name
@aliases = attributes[:aliases] || [] @aliases = attributes[:aliases] || []
@description = attributes[:description] || '' @description = attributes[:description] || ''
@params = attributes[:params] || [] @explanation = attributes[:explanation] || ''
@params = attributes[:params] || []
@condition_block = attributes[:condition_block] @condition_block = attributes[:condition_block]
@action_block = attributes[:action_block] @parse_params_block = attributes[:parse_params_block]
@action_block = attributes[:action_block]
end end
def all_names def all_names
...@@ -28,14 +31,20 @@ module Gitlab ...@@ -28,14 +31,20 @@ module Gitlab
context.instance_exec(&condition_block) context.instance_exec(&condition_block)
end end
def explain(context, opts, arg)
return unless available?(opts)
if explanation.respond_to?(:call)
execute_block(explanation, context, arg)
else
explanation
end
end
def execute(context, opts, arg) def execute(context, opts, arg)
return if noop? || !available?(opts) return if noop? || !available?(opts)
if arg.present? execute_block(action_block, context, arg)
context.instance_exec(arg, &action_block)
elsif action_block.arity == 0
context.instance_exec(&action_block)
end
end end
def to_h(opts) def to_h(opts)
...@@ -52,6 +61,23 @@ module Gitlab ...@@ -52,6 +61,23 @@ module Gitlab
params: params params: params
} }
end end
private
def execute_block(block, context, arg)
if arg.present?
parsed = parse_params(arg, context)
context.instance_exec(parsed, &block)
elsif block.arity == 0
context.instance_exec(&block)
end
end
def parse_params(arg, context)
return arg unless parse_params_block
context.instance_exec(arg, &parse_params_block)
end
end end
end end
end end
...@@ -44,6 +44,22 @@ module Gitlab ...@@ -44,6 +44,22 @@ module Gitlab
@params = params @params = params
end end
# Allows to give an explanation of what the command will do when
# executed. This explanation is shown when rendering the Markdown
# preview.
#
# Example:
#
# explanation do |arguments|
# "Adds label(s) #{arguments.join(' ')}"
# end
# command :command_key do |arguments|
# # Awesome code block
# end
def explanation(text = '', &block)
@explanation = block_given? ? block : text
end
# Allows to define conditions that must be met in order for the command # Allows to define conditions that must be met in order for the command
# to be returned by `.command_names` & `.command_definitions`. # to be returned by `.command_names` & `.command_definitions`.
# It accepts a block that will be evaluated with the context given to # It accepts a block that will be evaluated with the context given to
...@@ -61,6 +77,24 @@ module Gitlab ...@@ -61,6 +77,24 @@ module Gitlab
@condition_block = block @condition_block = block
end end
# Allows to perform initial parsing of parameters. The result is passed
# both to `command` and `explanation` blocks, instead of the raw
# parameters.
# It accepts a block that will be evaluated with the context given to
# `CommandDefintion#to_h`.