Commit 4464c22d authored by Jarka Kadlecova's avatar Jarka Kadlecova

Support descriptions for snippets

parent 8039b9c3
......@@ -218,6 +218,16 @@ import ShortcutsBlob from './shortcuts_blob';
new gl.GLForm($('.tag-form'));
new RefSelectDropdown($('.js-branch-select'), window.gl.availableRefs);
break;
case 'projects:snippets:new':
case 'projects:snippets:edit':
case 'projects:snippets:create':
case 'projects:snippets:update':
case 'snippets:new':
case 'snippets:edit':
case 'snippets:create':
case 'snippets:update':
new gl.GLForm($('.snippet-form'));
break;
case 'projects:releases:edit':
new ZenMode();
new gl.GLForm($('.release-form'));
......
......@@ -5,7 +5,7 @@ import './preview_markdown';
window.DropzoneInput = (function() {
function DropzoneInput(form) {
var updateAttachingMessage, $attachingFileMessage, $mdArea, $attachButton, $cancelButton, $retryLink, $uploadingErrorContainer, $uploadingErrorMessage, $uploadProgress, $uploadingProgressContainer, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divHover, divSpinner, dropzone, $formDropzone, formTextarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, maxFileSize, pasteText, uploadsPath, showError, showSpinner, uploadFile;
var updateAttachingMessage, $attachingFileMessage, $mdArea, $attachButton, $cancelButton, $retryLink, $uploadingErrorContainer, $uploadingErrorMessage, $uploadProgress, $uploadingProgressContainer, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divHover, divSpinner, dropzone, $formDropzone, formTextarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, maxFileSize, pasteText, uploadsPath, showError, showSpinner, uploadFile, addFileToForm;
Dropzone.autoDiscover = false;
divHover = '<div class="div-dropzone-hover"></div>';
iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>';
......@@ -71,6 +71,7 @@ window.DropzoneInput = (function() {
pasteText(response.link.markdown, shouldPad);
// Show 'Attach a file' link only when all files have been uploaded.
if (!processingFileCount) $attachButton.removeClass('hide');
addFileToForm(response.link.url);
},
error: function(file, errorMessage = 'Attaching the file failed.', xhr) {
// If 'error' event is fired by dropzone, the second parameter is error message.
......@@ -197,6 +198,10 @@ window.DropzoneInput = (function() {
return formTextarea.trigger('input');
};
addFileToForm = function(path) {
$(form).append('<input type="hidden" name="files[]" value="' + path + '">');
};
getFilename = function(e) {
var value;
if (window.clipboardData && window.clipboardData.getData) {
......
......@@ -107,6 +107,6 @@ class Projects::SnippetsController < Projects::ApplicationController
end
def snippet_params
params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level)
params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
end
end
......@@ -45,6 +45,8 @@ class SnippetsController < ApplicationController
@snippet = CreateSnippetService.new(nil, current_user, create_params).execute
move_temporary_files if params[:files]
recaptcha_check_with_fallback { render :new }
end
......@@ -124,6 +126,12 @@ class SnippetsController < ApplicationController
end
def snippet_params
params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level)
params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
end
def move_temporary_files
params[:files].each do |file|
FileMover.new(file, @snippet).execute
end
end
end
......@@ -9,6 +9,8 @@ class UploadsController < ApplicationController
private
def find_model
return nil unless params[:id]
return render_404 unless upload_model && upload_mount
@model = upload_model.find(params[:id])
......@@ -33,6 +35,8 @@ class UploadsController < ApplicationController
end
def authorize_create_access!
return unless model
# for now we support only personal snippets comments
authorized = can?(current_user, :comment_personal_snippet, model)
......
......@@ -128,7 +128,7 @@ module GitlabRoutingHelper
def preview_markdown_path(project, *args)
if @snippet.is_a?(PersonalSnippet)
preview_markdown_snippet_path(@snippet)
preview_markdown_snippets_path
else
preview_markdown_namespace_project_path(project.namespace, project, *args)
end
......
......@@ -10,6 +10,7 @@ class Snippet < ActiveRecord::Base
include Spammable
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
cache_markdown_field :content
# Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets.
......
class FileMover
attr_reader :secret, :file_name, :model
def initialize(file_path, model, update_field = :description)
@secret = File.split(File.dirname(file_path)).last
@file_name = File.basename(file_path)
@model = model
end
def execute
move
update_markdown
end
private
def move
FileUtils.mkdir_p(file_path)
FileUtils.move(temp_file_path, file_path)
end
def update_markdown(field = :description)
updated_text = model.send(field).sub(temp_file_uploader.to_markdown, uploader.to_markdown)
model.update_attribute(field, updated_text)
end
def temp_file_path
temp_file_uploader.retrieve_from_store!(file_name)
temp_file_uploader.file.path
end
def file_path
return @file_path if @file_path
uploader.retrieve_from_store!(file_name)
@file_path = uploader.file.path
end
def uploader
@uploader ||= PersonalFileUploader.new(model, secret)
end
def temp_file_uploader
@temp_file_uploader ||= PersonalFileUploader.new(nil, secret)
end
end
......@@ -10,6 +10,10 @@ class PersonalFileUploader < FileUploader
end
def self.model_path(model)
File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s)
if model
File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s)
else
File.join("/#{base_dir}", 'temp')
end
end
end
- header_title "Snippets", snippets_path
- content_for :page_specific_javascripts do
- if @snippet&.persisted? && current_user
- if @snippet && current_user
:javascript
window.uploads_path = "#{upload_path('personal_snippet', @snippet)}";
window.preview_markdown_path = "#{preview_markdown_snippet_path(@snippet)}";
window.uploads_path = "#{upload_path('personal_snippet', id: @snippet.id)}";
= render template: "layouts/application"
- project = local_assigns.fetch(:project)
- issuable = local_assigns.fetch(:issuable)
- model = local_assigns.fetch(:model)
- form = local_assigns.fetch(:form)
- supports_slash_commands = issuable.new_record?
- supports_slash_commands = !model.persisted?
- if supports_slash_commands
- preview_url = preview_markdown_path(project, slash_commands_target_type: issuable.class.name)
- preview_url = preview_markdown_path(project, slash_commands_target_type: model.class.name)
- else
- preview_url = preview_markdown_path(project)
......
......@@ -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, project: project
= render 'shared/form_elements/description', model: issuable, form: form, project: project
- if issuable.respond_to?(:confidential)
.form-group
......
......@@ -3,7 +3,7 @@
= page_specific_javascript_bundle_tag('snippet')
.snippet-form-holder
= form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit" } do |f|
= form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit common-note-form" } do |f|
= form_errors(@snippet)
.form-group
......@@ -11,6 +11,8 @@
.col-sm-10
= f.text_field :title, class: 'form-control', required: true, autofocus: true
= render 'shared/form_elements/description', model: @snippet, project: @project, form: f
= render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet
.file-editor
......@@ -23,6 +25,9 @@
.file-content.code
%pre#editor= @snippet.content
= f.hidden_field :content, class: 'snippet-file-content'
- if params[:files]
- params[:files].each_with_index do |file, index|
= hidden_field_tag "files[]", file, id: "files_#{index}"
.form-actions
- if @snippet.new_record?
......
......@@ -22,3 +22,10 @@
- if @snippet.updated_at != @snippet.created_at
= edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true)
%div
- if @snippet.description.present?
.description
.wiki
= markdown_field(@snippet, :description)
%textarea.hidden.js-task-list-field
= @snippet.description
---
title: Support descriptions for snippets
merge_request:
author:
......@@ -2,6 +2,9 @@ resources :snippets, concerns: :awardable do
member do
get :raw
post :mark_as_spam
end
collection do
post :preview_markdown
end
......
......@@ -20,7 +20,7 @@ scope path: :uploads do
constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /[^\/]+/ }
# create uploads for models, snippets (notes) available for now
post ':model/:id/',
post ':model',
to: 'uploads#create',
constraints: { model: /personal_snippet/, id: /\d+/ },
as: 'upload'
......
class AddDescriptionToSnippets < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def change
add_column :snippets, :description, :text
add_column :snippets, :description_html, :text
end
end
......@@ -1153,6 +1153,8 @@ ActiveRecord::Schema.define(version: 20170523091700) do
t.text "title_html"
t.text "content_html"
t.integer "cached_markdown_version"
t.text "description"
t.text "description_html"
end
add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree
......
......@@ -43,6 +43,7 @@ Parameters:
"id": 1,
"title": "test",
"file_name": "add.rb",
"description": "Ruby test snippet",
"author": {
"id": 1,
"username": "john_smith",
......@@ -70,8 +71,9 @@ Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `title` (required) - The title of a snippet
- `file_name` (required) - The name of a snippet file
- `description` (optional) - The description of a snippet
- `code` (required) - The content of a snippet
- `visibility` (required) - The snippet's visibility
- `visibility` (optional) - The snippet's visibility
## Update snippet
......@@ -87,6 +89,7 @@ Parameters:
- `snippet_id` (required) - The ID of a project's snippet
- `title` (optional) - The title of a snippet
- `file_name` (optional) - The name of a snippet file
- `description` (optional) - The description of a snippet
- `code` (optional) - The content of a snippet
- `visibility` (optional) - The snippet's visibility
......
......@@ -48,6 +48,7 @@ Example response:
"id": 1,
"title": "test",
"file_name": "add.rb",
"description": "Ruby test snippet",
"author": {
"id": 1,
"username": "john_smith",
......@@ -73,16 +74,17 @@ POST /snippets
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `title` | String | yes | The title of a snippet |
| `file_name` | String | yes | The name of a snippet file |
| `content` | String | yes | The content of a snippet |
| `visibility` | String | yes | The snippet's visibility |
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `title` | String | yes | The title of a snippet |
| `file_name` | String | yes | The name of a snippet file |
| `content` | String | yes | The content of a snippet |
| `description` | String | no | The description of a snippet |
| `visibility` | String | no | The snippet's visibility |
``` bash
curl --request POST --data '{"title": "This is a snippet", "content": "Hello world", "file_name": "test.txt", "visibility": "internal" }' --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets
curl --request POST --data '{"title": "This is a snippet", "content": "Hello world", "description": "Hello World snippet", "file_name": "test.txt", "visibility": "internal" }' --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets
```
Example response:
......@@ -92,6 +94,7 @@ Example response:
"id": 1,
"title": "This is a snippet",
"file_name": "test.txt",
"description": "Hello World snippet",
"author": {
"id": 1,
"username": "john_smith",
......@@ -117,13 +120,14 @@ PUT /snippets/:id
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | Integer | yes | The ID of a snippet |
| `title` | String | no | The title of a snippet |
| `file_name` | String | no | The name of a snippet file |
| `content` | String | no | The content of a snippet |
| `visibility` | String | no | The snippet's visibility |
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | Integer | yes | The ID of a snippet |
| `title` | String | no | The title of a snippet |
| `file_name` | String | no | The name of a snippet file |
| `description` | String | no | The description of a snippet |
| `content` | String | no | The content of a snippet |
| `visibility` | String | no | The snippet's visibility |
``` bash
......@@ -137,6 +141,7 @@ Example response:
"id": 1,
"title": "test",
"file_name": "add.rb",
"description": "description of snippet",
"author": {
"id": 1,
"username": "john_smith",
......
......@@ -224,7 +224,7 @@ module API
end
class ProjectSnippet < Grape::Entity
expose :id, :title, :file_name
expose :id, :title, :file_name, :description
expose :author, using: Entities::UserBasic
expose :updated_at, :created_at
......@@ -234,7 +234,7 @@ module API
end
class PersonalSnippet < Grape::Entity
expose :id, :title, :file_name
expose :id, :title, :file_name, :description
expose :author, using: Entities::UserBasic
expose :updated_at, :created_at
......
......@@ -49,6 +49,7 @@ module API
requires :title, type: String, desc: 'The title of the snippet'
requires :file_name, type: String, desc: 'The file name of the snippet'
requires :code, type: String, desc: 'The content of the snippet'
optional :description, type: String, desc: 'The description of a snippet'
requires :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet'
......@@ -77,6 +78,7 @@ module API
optional :title, type: String, desc: 'The title of the snippet'
optional :file_name, type: String, desc: 'The file name of the snippet'
optional :code, type: String, desc: 'The content of the snippet'
optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet'
......
......@@ -58,6 +58,7 @@ module API
requires :title, type: String, desc: 'The title of a snippet'
requires :file_name, type: String, desc: 'The name of a snippet file'
requires :content, type: String, desc: 'The content of a snippet'
optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
default: 'internal',
......@@ -85,6 +86,7 @@ module API
optional :title, type: String, desc: 'The title of a snippet'
optional :file_name, type: String, desc: 'The name of a snippet file'
optional :content, type: String, desc: 'The content of a snippet'
optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet'
......
......@@ -78,8 +78,18 @@ describe Projects::SnippetsController do
post :create, {
namespace_id: project.namespace.to_param,
project_id: project,
project_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params)
project_snippet: { title: 'Title', content: 'Content', description: 'Description' }.merge(snippet_params)
}.merge(additional_params)
Snippet.last
end
it 'creates the snippet correctly' do
snippet = create_snippet(project, visibility_level: Snippet::PRIVATE)
expect(snippet.title).to eq('Title')
expect(snippet.content).to eq('Content')
expect(snippet.description).to eq('Description')
end
context 'when the snippet is spam' do
......
......@@ -171,12 +171,50 @@ describe SnippetsController do
sign_in(user)
post :create, {
personal_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params)
personal_snippet: { title: 'Title', content: 'Content', description: 'Description' }.merge(snippet_params)
}.merge(additional_params)
Snippet.last
end
it 'creates the snippet correctly' do
snippet = create_snippet(visibility_level: Snippet::PRIVATE)
expect(snippet.title).to eq('Title')
expect(snippet.content).to eq('Content')
expect(snippet.description).to eq('Description')
end
context 'when the snippet description contains a file' do
let(:picture_file) { '/temp/secret56/picture.jpg' }
let(:text_file) { '/temp/secret78/text.txt' }
let(:description) do
"Description with picture: ![picture](/uploads#{picture_file}) and "\
"text: [text.txt](/uploads#{text_file})"
end
before do
allow(FileUtils).to receive(:mkdir_p)
allow(FileUtils).to receive(:move)
end
subject { create_snippet({ description: description }, { files: [picture_file, text_file] }) }
it 'creates the snippet' do
expect { subject }.to change { Snippet.count }.by(1)
end
it 'stores the snippet description correctly' do
snippet = subject
expected_description = "Description with picture: "\
"![picture](/uploads/personal_snippet/#{snippet.id}/secret56/picture.jpg) and "\
"text: [text.txt](/uploads/personal_snippet/#{snippet.id}/secret78/text.txt)"
expect(snippet.description).to eq(expected_description)
end
end
context 'when the snippet is spam' do
before do
allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true)
......
......@@ -3,6 +3,7 @@ FactoryGirl.define do
author
title { generate(:title) }
content { generate(:title) }
description { generate(:title) }
file_name { generate(:filename) }
trait :public do
......
require 'rails_helper'
feature 'Create Snippet', :js, feature: true do
include DropzoneHelper
let(:user) { create(:user) }
let(:project) { create(:project, :repository, :public) }
def fill_form
fill_in 'project_snippet_title', with: 'My Snippet Title'
fill_in 'project_snippet_description', with: 'My Snippet **Description**'
page.within('.file-editor') do
find('.ace_editor').native.send_keys('Hello World!')
end
end
context 'when a user is authenticated' do
before do
project.team << [user, :master]
login_as(user)
visit namespace_project_snippets_path(project.namespace, project)
click_on('New snippet')
end
it 'creates a new snippet' do
fill_form
click_button('Create snippet')
wait_for_ajax
expect(page).to have_content('My Snippet Title')
expect(page).to have_content('Hello World!')
page.within('.snippet-header .description') do
expect(page).to have_content('My Snippet Description')
expect(page).to have_selector('strong')
end
end
it 'uploads a file when dragging into textarea' do
fill_form
dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
expect(page.find_field("project_snippet_description").value).to have_content('banana_sample')
click_button('Create snippet')
wait_for_ajax
link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
expect(link).to match(%r{/#{Regexp.escape(project.full_path) }/uploads/\h{32}/banana_sample\.gif\z})
end
it 'creates a snippet when all reuiqred fields are filled in after validation failing' do
fill_in 'project_snippet_title', with: 'My Snippet Title'
click_button('Create snippet')
expect(page).to have_selector('#error_explanation')
fill_form
dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
click_button('Create snippet')
wait_for_ajax
expect(page).to have_content('My Snippet Title')
expect(page).to have_content('Hello World!')
page.within('.snippet-header .description') do
expect(page).to have_content('My Snippet Description')
expect(page).to have_selector('strong')
end
link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
expect(link).to match(%r{/#{Regexp.escape(project.full_path) }/uploads/\h{32}/banana_sample\.gif\z})
end
end
context 'when a user is not authenticated' do
it 'shows a public snippet on the index page but not the New snippet button' do
snippet = create(:project_snippet, :public, project: project)
visit namespace_project_snippets_path(project.namespace, project)
expect(page).to have_content(snippet.title)
expect(page).not_to have_content('New snippet')
end
end
end
require 'rails_helper'
feature 'Create Snippet', :js, feature: true do
include DropzoneHelper
before do
login_as :user
visit new_snippet_path
end
scenario 'Authenticated user creates a snippet' do
def fill_form
fill_in 'personal_snippet_title', with: 'My Snippet Title'
fill_in 'personal_snippet_description', with: 'My Snippet **Description**'
page.within('.file-editor') do
find('.ace_editor').native.send_keys 'Hello World!'
end
end
click_button 'Create snippet'
scenario 'Authenticated user creates a snippet' do
fill_form
click_button('Create snippet')
wait_for_requests
expect(page).to have_content('My Snippet Title')
page.within('.snippet-header .description') do
expect(page).to have_content('My Snippet Description')
expect(page).to have_selector('strong')
end
expect(page).to have_content('Hello World!')
end
scenario 'uploads a file when dragging into textarea' do
fill_form
dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
expect(page.find_field("personal_snippet_description").value).to have_content('banana_sample')
click_button('Create snippet')
wait_for_requests
link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
expect(link).to match(%r{/uploads/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z})
end
scenario 'validation fails for the first time' do
fill_in 'personal_snippet_title', with: 'My Snippet Title'
click_button('Create snippet')
expect(page).to have_selector('#error_explanation')
fill_form
dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
click_button('Create snippet')
wait_for_requests
expect(page).to have_content('My Snippet Title')
page.within('.snippet-header .description') do
expect(page).to have_content('My Snippet Description')
expect(page).to have_selector('strong')
end
expect(page).to have_content('Hello World!')
link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
expect(link).to match(%r{/uploads/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z})
end
scenario 'Authenticated user creates a snippet with + in filename' do
......
require 'rails_helper'
feature 'Edit Snippet', :js, feature: true do
include DropzoneHelper