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 @@
// MarkdownPreview
//
// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview,
// and showing a warning when more than `x` users are referenced.
// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview
// (including the explanation of slash commands), and showing a warning when
// more than `x` users are referenced.
//
(function () {
var lastTextareaPreviewed;
......@@ -17,32 +18,45 @@
// Minimum number of users referenced before triggering a warning
MarkdownPreview.prototype.referenceThreshold = 10;
MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.';
MarkdownPreview.prototype.ajaxCache = {};
MarkdownPreview.prototype.showPreview = function ($form) {
var mdText;
var preview = $form.find('.js-md-preview');
var url = preview.data('url');
if (preview.hasClass('md-preview-loading')) {
return;
}
mdText = $form.find('textarea.markdown-area').val();
if (mdText.trim().length === 0) {
preview.text('Nothing to preview.');
preview.text(this.emptyMessage);
this.hideReferencedUsers($form);
} else {
preview.addClass('md-preview-loading').text('Loading...');
this.fetchMarkdownPreview(mdText, (function (response) {
preview.removeClass('md-preview-loading').html(response.body);
this.fetchMarkdownPreview(mdText, url, (function (response) {
var body;
if (response.body.length > 0) {
body = response.body;
} else {
body = this.emptyMessage;
}
preview.removeClass('md-preview-loading').html(body);
preview.renderGFM();
this.renderReferencedUsers(response.references.users, $form);
if (response.references.commands) {
this.renderReferencedCommands(response.references.commands, $form);
}
}).bind(this));
}
};
MarkdownPreview.prototype.fetchMarkdownPreview = function (text, success) {
if (!window.preview_markdown_path) {
MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
if (!url) {
return;
}
if (text === this.ajaxCache.text) {
......@@ -51,7 +65,7 @@
}
$.ajax({
type: 'POST',
url: window.preview_markdown_path,
url: url,
data: {
text: text
},
......@@ -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;
}());
......@@ -137,6 +167,8 @@
$form.find('.md-write-holder').show();
$form.find('textarea.markdown-area').focus();
$form.find('.md-preview-holder').hide();
markdownPreview.hideReferencedCommands($form);
});
$(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
include MarkdownPreview
before_action :authorize_read_wiki!
before_action :authorize_create_wiki!, only: [:edit, :create, :history]
before_action :authorize_admin_wiki!, only: :destroy
......@@ -97,9 +95,14 @@ class Projects::WikisController < Projects::ApplicationController
end
def preview_markdown
context = { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
render_markdown_preview(params[:text], context)
result = PreviewMarkdownService.new(@project, current_user, params).execute
render json: {
body: view_context.markdown(result[:text], pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id]),
references: {
users: result[:users]
}
}
end
private
......
class ProjectsController < Projects::ApplicationController
include IssuableCollections
include ExtractsPath
include MarkdownPreview
before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
before_action :project, except: [:index, :new, :create]
......@@ -240,7 +239,15 @@ class ProjectsController < Projects::ApplicationController
end
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
private
......
......@@ -3,7 +3,6 @@ class SnippetsController < ApplicationController
include ToggleAwardEmoji
include SpammableActions
include SnippetsActions
include MarkdownPreview
include RendersBlob
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw]
......@@ -90,7 +89,14 @@ class SnippetsController < ApplicationController
end
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
protected
......
......@@ -122,6 +122,10 @@ module GitlabRoutingHelper
namespace_project_snippet_url(entity.project.namespace, entity.project, entity, *args)
end
def preview_markdown_path(project, *args)
preview_markdown_namespace_project_path(project.namespace, project, *args)
end
def toggle_subscription_path(entity, *args)
if entity.is_a?(Issue)
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
......@@ -2,7 +2,7 @@ module SlashCommands
class InterpretService < BaseService
include Gitlab::SlashCommands::Dsl
attr_reader :issuable, :options
attr_reader :issuable
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and hash of changes to be applied to a record.
......@@ -12,23 +12,21 @@ module SlashCommands
@issuable = issuable
@updates = {}
opts = {
issuable: issuable,
current_user: current_user,
project: project,
params: params
}
content, commands = extractor.extract_commands(content, opts)
content, commands = extractor.extract_commands(content, context)
extract_updates(commands, context)
[content, @updates]
end
commands.each do |name, arg|
definition = self.class.command_definitions_by_name[name.to_sym]
next unless definition
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and array of changes explained.
def explain(content, issuable)
return [content, []] unless current_user.can?(:use_slash_commands)
definition.execute(self, opts, arg)
end
@issuable = issuable
[content, @updates]
content, commands = extractor.extract_commands(content, context)
commands = explain_commands(commands, context)
[content, commands]
end
private
......@@ -40,6 +38,9 @@ module SlashCommands
desc do
"Close this #{issuable.to_ability_name.humanize(capitalize: false)}"
end
explanation do
"Closes this #{issuable.to_ability_name.humanize(capitalize: false)}."
end
condition do
issuable.persisted? &&
issuable.open? &&
......@@ -52,6 +53,9 @@ module SlashCommands
desc do
"Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}"
end
explanation do
"Reopens this #{issuable.to_ability_name.humanize(capitalize: false)}."
end
condition do
issuable.persisted? &&
issuable.closed? &&
......@@ -62,6 +66,7 @@ module SlashCommands
end
desc 'Merge (when the pipeline succeeds)'
explanation 'Merges this merge request when the pipeline succeeds.'
condition do
last_diff_sha = params && params[:merge_request_diff_head_sha]
issuable.is_a?(MergeRequest) &&
......@@ -73,6 +78,9 @@ module SlashCommands
end
desc 'Change title'
explanation do |title_param|
"Changes the title to \"#{title_param}\"."
end
params '<New title>'
condition do
issuable.persisted? &&
......@@ -83,18 +91,25 @@ module SlashCommands
end
desc 'Assign'
explanation do |user|
"Assigns #{user.to_reference}." if user
end
params '@user'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :assign do |assignee_param|
user = extract_references(assignee_param, :user).first
user ||= User.find_by(username: assignee_param)
parse_params do |assignee_param|
extract_references(assignee_param, :user).first ||
User.find_by(username: assignee_param)
end
command :assign do |user|
@updates[:assignee_id] = user.id if user
end
desc 'Remove assignee'
explanation do
"Removes assignee #{issuable.assignee.to_reference}."
end
condition do
issuable.persisted? &&
issuable.assignee_id? &&
......@@ -105,19 +120,26 @@ module SlashCommands
end
desc 'Set milestone'
explanation do |milestone|
"Sets the milestone to #{milestone.to_reference}." if milestone
end
params '%"milestone"'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
project.milestones.active.any?
end
command :milestone do |milestone_param|
milestone = extract_references(milestone_param, :milestone).first
milestone ||= project.milestones.find_by(title: milestone_param.strip)
parse_params do |milestone_param|
extract_references(milestone_param, :milestone).first ||
project.milestones.find_by(title: milestone_param.strip)
end
command :milestone do |milestone|
@updates[:milestone_id] = milestone.id if milestone
end
desc 'Remove milestone'
explanation do
"Removes #{issuable.milestone.to_reference(format: :name)} milestone."
end
condition do
issuable.persisted? &&
issuable.milestone_id? &&
......@@ -128,6 +150,11 @@ module SlashCommands
end
desc 'Add label(s)'
explanation do |labels_param|
labels = find_label_references(labels_param)
"Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
end
params '~label1 ~"label 2"'
condition do
available_labels = LabelsFinder.new(current_user, project_id: project.id).execute
......@@ -147,6 +174,14 @@ module SlashCommands
end
desc 'Remove all or specific label(s)'
explanation do |labels_param = nil|
if labels_param.present?
labels = find_label_references(labels_param)
"Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
else
'Removes all labels.'
end
end
params '~label1 ~"label 2"'
condition do
issuable.persisted? &&
......@@ -169,6 +204,10 @@ module SlashCommands
end
desc 'Replace all label(s)'
explanation do |labels_param|
labels = find_label_references(labels_param)
"Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
end
params '~label1 ~"label 2"'
condition do
issuable.persisted? &&
......@@ -187,6 +226,7 @@ module SlashCommands
end
desc 'Add a todo'
explanation 'Adds a todo.'
condition do
issuable.persisted? &&
!TodoService.new.todo_exist?(issuable, current_user)
......@@ -196,6 +236,7 @@ module SlashCommands
end
desc 'Mark todo as done'
explanation 'Marks todo as done.'
condition do
issuable.persisted? &&
TodoService.new.todo_exist?(issuable, current_user)
......@@ -205,6 +246,9 @@ module SlashCommands
end
desc 'Subscribe'
explanation do
"Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}."
end
condition do
issuable.persisted? &&
!issuable.subscribed?(current_user, project)
......@@ -214,6 +258,9 @@ module SlashCommands
end
desc 'Unsubscribe'
explanation do
"Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}."
end
condition do
issuable.persisted? &&
issuable.subscribed?(current_user, project)
......@@ -223,18 +270,23 @@ module SlashCommands
end
desc 'Set due date'
explanation do |due_date|
"Sets the due date to #{due_date.to_s(:medium)}." if due_date
end
params '<in 2 days | this Friday | December 31st>'
condition do
issuable.respond_to?(:due_date) &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :due do |due_date_param|
due_date = Chronic.parse(due_date_param).try(:to_date)
parse_params do |due_date_param|
Chronic.parse(due_date_param).try(:to_date)
end
command :due do |due_date|
@updates[:due_date] = due_date if due_date
end
desc 'Remove due date'
explanation 'Removes the due date.'
condition do
issuable.persisted? &&
issuable.respond_to?(:due_date) &&
......@@ -245,8 +297,11 @@ module SlashCommands
@updates[:due_date] = nil
end
desc do
"Toggle the Work In Progress status"
desc 'Toggle the Work In Progress status'
explanation do
verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks'
noun = issuable.to_ability_name.humanize(capitalize: false)
"#{verb} this #{noun} as Work In Progress."
end
condition do
issuable.persisted? &&
......@@ -257,45 +312,72 @@ module SlashCommands
@updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
end
desc 'Toggle emoji reward'
desc 'Toggle emoji award'
explanation do |name|
"Toggles :#{name}: emoji award." if name
end
params ':emoji:'
condition do
issuable.persisted?
end
command :award do |emoji|
name = award_emoji_name(emoji)
parse_params do |emoji_param|
match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern)
match[1] if match
end
command :award do |name|
if name && issuable.user_can_award?(current_user, name)
@updates[:emoji_award] = name
end
end
desc 'Set time estimate'
explanation do |time_estimate|
time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate)
"Sets time estimate to #{time_estimate}." if time_estimate
end
params '<1w 3d 2h 14m>'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :estimate do |raw_duration|
time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration)
parse_params do |raw_duration|
Gitlab::TimeTrackingFormatter.parse(raw_duration)
end
command :estimate do |time_estimate|
if time_estimate
@updates[:time_estimate] = time_estimate
end
end
desc 'Add or substract spent time'
explanation do |time_spent|
if time_spent
if time_spent > 0
verb = 'Adds'
value = time_spent
else
verb = 'Substracts'
value = -time_spent
end
"#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time."
end
end
params '<1h 30m | -1h 30m>'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
end
command :spend do |raw_duration|
time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration)
parse_params do |raw_duration|
Gitlab::TimeTrackingFormatter.parse(raw_duration)
end
command :spend do |time_spent|
if time_spent
@updates[:spend_time] = { duration: time_spent, user: current_user }
end
end
desc 'Remove time estimate'
explanation 'Removes time estimate.'
condition do
issuable.persisted? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
......@@ -305,6 +387,7 @@ module SlashCommands
end
desc 'Remove spent time'
explanation 'Removes spent time.'
condition do
issuable.persisted? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
......@@ -318,19 +401,28 @@ module SlashCommands
params '@user'
command :cc
desc 'Defines target branch for MR'
desc 'Define target branch for MR'
explanation do |branch_name|
"Sets target branch to #{branch_name}."
end
params '<Local branch name>'
condition do
issuable.respond_to?(:target_branch) &&
(current_user.can?(:"update_#{issuable.to_ability_name}", issuable) ||
issuable.new_record?)
end
command :target_branch do |target_branch_param|
branch_name = target_branch_param.strip
parse_params do |target_branch_param|
target_branch_param.strip
end
command :target_branch do |branch_name|
@updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name)
end
desc 'Move issue from one column of the board to another'
explanation do |target_list_name|
label = find_label_references(target_list_name).first
"Moves issue to #{label} column in the board." if label
end
params '~"Target column"'
condition do
issuable.is_a?(Issue) &&
......@@ -352,11 +444,35 @@ module SlashCommands
end
end
def find_labels(labels_param)
extract_references(labels_param, :label) |
LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute
end
def find_label_references(labels_param)
find_labels(labels_param).map(&:to_reference)
end
def find_label_ids(labels_param)
label_ids_by_reference = extract_references(labels_param, :label).map(&:id)
labels_ids_by_name = LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute.select(:id)
find_labels(labels_param).map(&:id)
end
label_ids_by_reference | labels_ids_by_name
def explain_commands(commands, opts)
commands.map do |name, arg|
definition = self.class.definition_by_name(name)
next unless definition
definition.explain(self, opts, arg)
end.compact
end
def extract_updates(commands, opts)
commands.each do |name, arg|
definition = self.class.definition_by_name(name)
next unless definition
definition.execute(self, opts, arg)
end
end
def extract_references(arg, type)
......@@ -366,9 +482,13 @@ module SlashCommands
ext.references(type)
end
def award_emoji_name(emoji)
match = emoji.match(Banzai::Filter::EmojiFilter.emoji_pattern)
match[1] if match
def context
{
issuable: issuable,
current_user: current_user,
project: project,
params: params
}
end
end
end
......@@ -26,7 +26,7 @@
.form-group.milestone-description
= f.label :description, "Description", class: "control-label"
.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...'
.clearfix
.error-alert
......
......@@ -5,14 +5,9 @@
- content_for :project_javascripts do
- 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
:javascript
window.uploads_path = "#{namespace_project_uploads_path project.namespace,project}";
window.preview_markdown_path = "#{preview_markdown_path}";
- content_for :header_content do
.js-dropdown-menu-projects
......
- referenced_users = local_assigns.fetch(:referenced_users, nil)
.md-area
.md-header
%ul.nav-links.clearfix
......@@ -28,9 +30,10 @@
.md-write-holder
= 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
%span
= icon("exclamation-triangle")
......
......@@ -9,7 +9,7 @@
.form-group.milestone-description
= f.label :description, "Description", class: "control-label"
.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/notes/hints'
.clearfix
......
......@@ -2,7 +2,7 @@
= 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_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/notes/hints'
......
- 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|
= hidden_field_tag :view, diff_view
......@@ -18,7 +22,7 @@
-# DiffNote
= 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,
attr: :note,
classes: 'note-textarea js-note-text',
......
%ul#notes-list.notes.main-notes-list.timeline
= render "shared/notes/notes"
= render 'projects/notes/edit_form'
= render 'projects/notes/edit_form', project: @project
%ul.notes.notes-form.timeline
%li.timeline-entry
......
......@@ -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|
= 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/notes/hints'
.error-alert
......
......@@ -28,7 +28,7 @@
.form-group
= label_tag :release_description, 'Release notes', class: 'control-label'
.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/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.
......
......@@ -12,7 +12,7 @@
.form-group
= f.label :content, class: 'control-label'
.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/notes/hints'
......
......@@ -17,7 +17,7 @@
= 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/description', issuable: issuable, form: form
= render 'shared/issuable/form/description', issuable: issuable, form: form, project: project
- if issuable.respond_to?(:confidential)
.form-group
......
- project = local_assigns.fetch(:project)
- issuable = local_assigns.fetch(:issuable)
- 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.label :description, 'Description', class: 'control-label'
.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,
classes: 'note-textarea',
placeholder: "Write a comment or drag your files here...",
supports_slash_commands: !issuable.persisted?
= render 'projects/notes/hints', supports_slash_commands: !issuable.persisted?
supports_slash_commands: supports_slash_commands
= render 'projects/notes/hints', supports_slash_commands: supports_slash_commands
.clearfix
.error-alert
---
title: Display slash commands outcome when previewing Markdown
merge_request: 10054
author: Rares Sfirlogea
module Gitlab
module SlashCommands
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 = {})
@name = name
@aliases = attributes[:aliases] || []
@description = attributes[:description] || ''
@params = attributes[:params] || []
@aliases = attributes[:aliases] || []
@description = attributes[:description] || ''
@explanation = attributes[:explanation] || ''
@params = attributes[:params] || []
@condition_block = attributes[:condition_block]
@action_block = attributes[:action_block]
@parse_params_block = attributes[:parse_params_block]
@action_block = attributes[:action_block]
end
def all_names
......@@ -28,14 +31,20 @@ module Gitlab
context.instance_exec(&condition_block)
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)
return if noop? || !available?(opts)
if arg.present?
context.instance_exec(arg, &action_block)
elsif action_block.arity == 0
context.instance_exec(&action_block)
end
execute_block(action_block, context, arg)
end
def to_h(opts)
......@@ -52,6 +61,23 @@ module Gitlab
params: params
}
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
......@@ -44,6 +44,22 @@ module Gitlab
@params = params
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
# to be returned by `.command_names` & `.command_definitions`.
# It accepts a block that will be evaluated with the context given to
......@@ -61,6 +77,24 @@ module Gitlab
@condition_block = block
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`.
#
# Example:
#
# parse_params do |raw|
# raw.strip
# end
# command :command_key do |parsed|
# # Awesome code block
# end
def parse_params(&block)
@parse_params_block = block
end
# Registers a new command which is recognizeable from body of email or
# comment.
# It accepts aliases and takes a block.
......@@ -75,11 +109,13 @@ module Gitlab
definition = CommandDefinition.new(
name,
aliases: aliases,
description: @description,
params: @params,
condition_block: @condition_block,
action_block: block
aliases: aliases,
description: @description,
explanation: @explanation,
params: @params,
condition_block: @condition_block,
parse_params_block: @parse_params_block,
action_block: block
)
self.command_definitions << definition
......@@ -89,8 +125,14 @@ module Gitlab
end
@description = nil
@explanation = nil
@params = nil
@condition_block = nil
@parse_params_block = nil
end
def definition_by_name(name)
command_definitions_by_name[name.to_sym]
end
end
end
......
......@@ -167,6 +167,58 @@ describe Gitlab::SlashCommands::CommandDefinition do
end
end
end
context 'when the command defines parse_params block' do
before do
subject.parse_params_block = ->(raw) { raw.strip }
subject.action_block = ->(parsed) { self.received_arg = parsed }
end
it 'executes the command passing the parsed param' do
subject.execute(context, {}, 'something ')
expect(context.received_arg).to eq('something')
end
end
end
end
end
describe '#explain' do
context 'when the command is not available' do
before do
subject.condition_block = proc { false }
subject.explanation = 'Explanation'
end
it 'returns nil' do
result = subject.explain({}, {}, nil)
expect(result).to be_nil
end
end
context 'when the explanation is a static string' do
before do
subject.explanation = 'Explanation'
end
it 'returns this static string' do
result = subject.explain({}, {}, nil)
expect(result).to eq 'Explanation'
end
end
context 'when the explanation is dynamic' do
before do
subject.explanation = proc { |arg| "Dynamic #{arg}" }
end
it 'invokes the proc' do
result = subject.explain({}, {}, 'explanation')
expect(result).to eq 'Dynamic explanation'
end
end
end
......
......@@ -11,67 +11,99 @@ describe Gitlab::SlashCommands::Dsl do
end
params 'The first argument'
command :one_arg, :once, :first do |arg1|
arg1
explanation 'Static explanation'
command :explanation_with_aliases, :once, :first do |arg|
arg
end
desc do
"A dynamic description for #{noteable.upcase}"
end
params 'The first argument', 'The second argument'
command :two_args do |arg1, arg2|
[arg1, arg2]
command :dynamic_description do |args|
args.split
end
command :cc
explanation do |arg|
"Action does something with #{arg}"
end
condition do
project == 'foo'
end
command :cond_action do |arg|
arg
end
parse_params do |raw_arg|
raw_arg.strip
end
command :with_params_parsing do |parsed|
parsed
end
end
end
describe '.command_definitions' do
it 'returns an array with commands definitions' do
no_args_def, one_arg_def, two_args_def, cc_def, cond_action_def = DummyClass.command_definitions
no_args_def, explanation_with_aliases_def, dynamic_description_def,
cc_def, cond_action_def, with_params_parsing_def =
DummyClass.command_definitions
expect(no_args_def.name).to eq(:no_args)
expect(no_args_def.aliases).to eq([:none])
expect(no_args_def.description).to eq('A command with no args')
expect(no_args_def.explanation).to eq('')
expect(no_args_def.params).to eq([])
expect(no_args_def.condition_block).to be_nil
expect(no_args_def.action_block).to be_a_kind_of(Proc)
expect(no_args_def.parse_params_block).to be_nil
expect(one_arg_def.name).to eq(:one_arg)
expect(one_arg_def.aliases).to eq([:once, :first])
expect(one_arg_def.description).to eq('')
expect(one_arg_def.params).to eq(['The first argument'])
expect(one_arg_def.condition_block).to be_nil
expect(one_arg_def.action_block).to be_a_kind_of(Proc)
expect(explanation_with_aliases_def.name).to eq(:explanation_with_aliases)
expect(explanation_with_aliases_def.aliases).to eq([:once, :first])
expect(explanation_with_aliases_def.description).to eq('')
expect(explanation_with_aliases_def.explanation).to eq('Static explanation')
expect(explanation_with_aliases_def.params).to eq(['The first argument'])
expect(explanation_with_aliases_def.condition_block).to be_nil
expect(explanation_with_aliases_def.action_block).to be_a_kind_of(Proc)
expect(explanation_with_aliases_def.parse_params_block).to be_nil
expect(two_args_def.name).to eq(:two_args)
expect(two_args_def.aliases).to eq([])
expect(two_args_def.to_h(noteable: "issue")[:description]).to eq('A dynamic description for ISSUE')
expect(two_args_def.params).to eq(['The first argument', 'The second argument'])
expect(two_args_def.condition_block).to be_nil
expect(two_args_def.action_block).to be_a_kind_of(Proc)
expect(dynamic_description_def.name).to eq(:dynamic_description)
expect(dynamic_description_def.aliases).to eq([])
expect(dynamic_description_def.to_h(noteable: 'issue')[:description]).to eq('A dynamic description for ISSUE')
expect(dynamic_description_def.explanation).to eq('')
expect(dynamic_description_def.params).to eq(['The first argument', 'The second argument'])
expect(dynamic_description_def.condition_block).to be_nil
expect(dynamic_description_def.action_block).to be_a_kind_of(Proc)
expect(dynamic_description_def.parse_params_block).to be_nil
expect(cc_def.name).to eq(:cc)
expect(cc_def.aliases).to eq([])
expect(cc_def.description).to eq('')
expect(cc_def.explanation).to eq('')
expect(cc_def.params).to eq([])
expect(cc_def.condition_block).to be_nil
expect(cc_def.action_block).to be_nil
expect(cc_def.parse_params_block).to be_nil
expect(cond_action_def.name).to eq(:cond_action)
expect(cond_action_def.aliases).to eq([])
expect(cond_action_def.description).to eq('')
expect(cond_action_def.explanation).to be_a_kind_of(Proc)
expect(cond_action_def.params).to eq([])
expect(cond_action_def.condition_block).to be_a_kind_of(Proc)
expect(cond_action_def.action_block).to be_a_kind_of(Proc)
expect(cond_action_def.parse_params_block).to be_nil
expect(with_params_parsing_def.name).to eq(:with_params_parsing)
expect(with_params_parsing_def.aliases).to eq([])
expect(with_params_parsing_def.description).to eq('')
expect(with_params_parsing_def.explanation).to eq('')
expect(with_params_parsing_def.params).to eq([])
expect(with_params_parsing_def.condition_block).to be_nil
expect(with_params_parsing_def.action_block).to be_a_kind_of(Proc)
expect(with_params_parsing_def.parse_params_block).to be_a_kind_of(Proc)
end
end
end
require 'spec_helper'
describe PreviewMarkdownService do
let(:user) { create(:user) }
let(:project) { create(:empty_project) }
before do
project.add_developer(user)
end
describe 'user references' do
let(:params) { { text: "Take a look #{user.to_reference}" } }
let(:service) { described_class.new(project, user, params) }
it 'returns users referenced in text' do
result = service.execute
expect(result[:users]).to eq [user.username]
end
end
context 'new note with slash commands' do
let(:issue) { create(:issue, project: project) }
let(:params) do
{
text: "Please do it\n/assign #{user.to_reference}",
slash_commands_target_type: 'Issue',
slash_commands_target_id: issue.id
}
end
let(:service) { described_class.new(project, user, params) }
it 'removes slash commands from text' do
result = service.execute
expect(result[:text]).to eq 'Please do it'
end
it 'explains slash commands effect' do
result = service.execute
expect(result[:commands]).to eq "Assigns #{user.to_reference}."
end
end
context 'merge request description' do
let(:params) do
{
text: "My work\n/estimate 2y",
slash_commands_target_type: 'MergeRequest'
}
end
let(:service) { described_class.new(project, user, params) }
it 'removes slash commands from text' do
result = service.execute
expect(result[:text]).to eq 'My work'
end
it 'explains slash commands effect' do
result = service.execute
expect(result[:commands]).to eq 'Sets time estimate to 2y.'
end
end
end
......@@ -798,4 +798,211 @@ describe SlashCommands::InterpretService, services: true do
end
end
end
describe '#explain' do
let(:service) { described_class.new(project, developer) }
let(:merge_request) { create(:merge_request, source_project: project) }
describe 'close command' do
let(:content) { '/close' }
it 'includes issuable name' do
_, explanations = service.explain(content, issue)
expect(explanations).to eq(['Closes this issue.'])
end
end
describe 'reopen command' do
let(:content) { '/reopen' }
let(:merge_request) { create(:merge_request, :closed, source_project: project) }
it 'includes issuable name' do
_, explanations = service.explain(content, merge_request)
expect(explanations).to eq(['Reopens this merge request.'])
end
end
describe 'title command' do
let(:content) { '/title This is new title' }
it 'includes new title' do
_, explanations = service.explain(content, issue)
expect(explanations).to eq(['Changes the title to "This is new title".'])
end
end
describe 'assign command' do
let(:content) { "/assign @#{developer.username} do it!" }
it 'includes only the user reference' do
_, explanations = service.explain(content, merge_request)
expect(explanations).to eq(["Assigns @#{developer.username}."])
end
end
describe 'unassign command' do
let(:content) { '/unassign' }
let(:issue) { create(:issue, project: project, assignee: developer) }
it 'includes current assignee reference' do
_, explanations = service.explain(content, issue)
expect(explanations).to eq(["Removes assignee @#{developer.username}."])
end
end
describe 'milestone command' do
let(:content) { '/milestone %wrong-milestone' }
let!(:milestone) { create(:milestone, project: project, title: '9.10') }
it 'is empty when milestone reference is wrong' do
_, explanations = service.explain(content, issue)
expect(explanations).to eq([])
end
end
describe 'remove milestone command' do
let(:content) { '/remove_milestone' }
let(:merge_request) { create(:merge_request, source_project: project, milestone: milestone) }
it 'includes current milestone name' do
_, explanations = service.explain(content, merge_request)
expect(explanations).to eq(['Removes %"9.10" milestone.'])
end
end
describe 'label command' do
let(:content) { '/label ~missing' }
let!(:label) { create(:label, project: project) }
it 'is empty when there are no correct labels' do
_, explanations = service.explain(content, issue)
expect(explanations).to eq([])
end
end
describe 'unlabel command' do
let(:content) { '/unlabel' }
it 'says all labels if no parameter provided' do
merge_request.update!(label_ids: [bug.id])
_, explanations = service.explain(content, merge_request)
expect(explanations).to eq(['Removes all labels.'])
end
end
describe 'relabel command' do
let(:content) { '/relabel Bug' }
let!(:bug) { create(:label, project: project, title: 'Bug') }
let(:feature) { create(:label, project: project, title: 'Feature') }
it 'includes label name' do
issue.update!(label_ids: [feature.id])
_, explanations = service.explain(content, issue)
expect(explanations).to eq(["Replaces all labels with ~#{bug.id} label."])
end
end
describe 'subscribe command' do
let(:content) { '/subscribe' }
it 'includes issuable name' do
_, explanations = service.explain(content, issue)
expect(explanations).to eq(['Subscribes to this issue.'])
end
end
describe 'unsubscribe command' do
let(:content) { '/unsubscribe' }
it 'includes issuable name' do
merge_request.subscribe(developer, project)
_, explanations = service.explain(content, merge_request)
expect(explanations).to eq(['Unsubscribes from this merge request.'])
end
end
describe 'due command' do
let(:content) { '/due April 1st 2016' }
it 'includes the date' do
_, explanations = service.explain(content, issue)
expect(explanations).to eq(['Sets the due date to Apr 1, 2016.'])
end
end
describe 'wip command' do
let(:content) { '/wip' }
it 'includes the new status' do
_, explanations = service.explain(content, merge_request)
expect(explanations).to eq(['Marks this merge request as Work In Progress.'])
end
end
describe 'award command' do
let(:content) { '/award :confetti_ball: ' }
it 'includes the emoji' do
_, explanations = service.explain(content, issue)
expect(explanations).to eq(['Toggles :confetti_ball: emoji award.'])
end
end
describe 'estimate command' do
let(:content) { '/estimate 79d' }
it 'includes the formatted duration' do
_, explanations = service.explain(content, merge_request)
expect(explanations).to eq(['Sets time estimate to 3mo 3w 4d.'])
end
end
describe 'spend command' do
let(:content) { '/spend -120m' }
it 'includes the formatted duration and proper verb' do
_, explanations = service.explain(content, issue)
expect(explanations).to eq(['Substracts 2h spent time.'])
end
end
describe 'target branch command' do
let(:content) { '/target_branch my-feature ' }
it 'includes the branch name' do
_, explanations = service.explain(content, merge_request)
expect(explanations).to eq(['Sets target branch to my-feature.'])
end
end
describe 'board move command' do
let(:content) { '/board_move ~bug' }
let!(:bug) { create(:label, project: project, title: 'bug') }
let!(:board) { create(:board, project: project) }
it 'includes the label name' do
_, explanations = service.explain(content, issue)
expect(explanations).to eq(["Moves issue to ~#{bug.id} column in the board."])
end
end
end
end
......@@ -257,4 +257,19 @@ shared_examples 'issuable record that supports slash commands in its description
end
end
end
describe "preview of note on #{issuable_type}" do
it 'removes slash commands from note and explains them' do
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
page.within('.js-main-target-form') do
fill_in 'note[note]', with: "Awesome!\n/assign @bob "
click_on 'Preview'
expect(page).to have_content 'Awesome!'
expect(page).not_to have_content '/assign @bob'
expect(page).to have_content 'Assigns @bob.'
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment