Commit 563ea346 authored by Phil Hughes's avatar Phil Hughes

Merge branch '12910-snippets-description' into 'master'

Support descriptions for snippets

Closes #31894

See merge request !11071
parents 7adddf49 2e311d9d
...@@ -218,6 +218,16 @@ import initSettingsPanels from './settings_panels'; ...@@ -218,6 +218,16 @@ import initSettingsPanels from './settings_panels';
new gl.GLForm($('.tag-form')); new gl.GLForm($('.tag-form'));
new RefSelectDropdown($('.js-branch-select'), window.gl.availableRefs); new RefSelectDropdown($('.js-branch-select'), window.gl.availableRefs);
break; 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': case 'projects:releases:edit':
new ZenMode(); new ZenMode();
new gl.GLForm($('.release-form')); new gl.GLForm($('.release-form'));
......
...@@ -5,7 +5,7 @@ import './preview_markdown'; ...@@ -5,7 +5,7 @@ import './preview_markdown';
window.DropzoneInput = (function() { window.DropzoneInput = (function() {
function DropzoneInput(form) { 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; Dropzone.autoDiscover = false;
divHover = '<div class="div-dropzone-hover"></div>'; divHover = '<div class="div-dropzone-hover"></div>';
iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>'; iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>';
...@@ -71,6 +71,7 @@ window.DropzoneInput = (function() { ...@@ -71,6 +71,7 @@ window.DropzoneInput = (function() {
pasteText(response.link.markdown, shouldPad); pasteText(response.link.markdown, shouldPad);
// Show 'Attach a file' link only when all files have been uploaded. // Show 'Attach a file' link only when all files have been uploaded.
if (!processingFileCount) $attachButton.removeClass('hide'); if (!processingFileCount) $attachButton.removeClass('hide');
addFileToForm(response.link.url);
}, },
error: function(file, errorMessage = 'Attaching the file failed.', xhr) { error: function(file, errorMessage = 'Attaching the file failed.', xhr) {
// If 'error' event is fired by dropzone, the second parameter is error message. // If 'error' event is fired by dropzone, the second parameter is error message.
...@@ -198,6 +199,10 @@ window.DropzoneInput = (function() { ...@@ -198,6 +199,10 @@ window.DropzoneInput = (function() {
return formTextarea.trigger('input'); return formTextarea.trigger('input');
}; };
addFileToForm = function(path) {
$(form).append('<input type="hidden" name="files[]" value="' + _.escape(path) + '">');
};
getFilename = function(e) { getFilename = function(e) {
var value; var value;
if (window.clipboardData && window.clipboardData.getData) { if (window.clipboardData && window.clipboardData.getData) {
......
...@@ -107,6 +107,6 @@ class Projects::SnippetsController < Projects::ApplicationController ...@@ -107,6 +107,6 @@ class Projects::SnippetsController < Projects::ApplicationController
end end
def snippet_params 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
end end
...@@ -45,6 +45,8 @@ class SnippetsController < ApplicationController ...@@ -45,6 +45,8 @@ class SnippetsController < ApplicationController
@snippet = CreateSnippetService.new(nil, current_user, create_params).execute @snippet = CreateSnippetService.new(nil, current_user, create_params).execute
move_temporary_files if @snippet.valid? && params[:files]
recaptcha_check_with_fallback { render :new } recaptcha_check_with_fallback { render :new }
end end
...@@ -124,6 +126,12 @@ class SnippetsController < ApplicationController ...@@ -124,6 +126,12 @@ class SnippetsController < ApplicationController
end end
def snippet_params 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
end end
...@@ -9,12 +9,16 @@ class UploadsController < ApplicationController ...@@ -9,12 +9,16 @@ class UploadsController < ApplicationController
private private
def find_model def find_model
return nil unless params[:id]
return render_404 unless upload_model && upload_mount return render_404 unless upload_model && upload_mount
@model = upload_model.find(params[:id]) @model = upload_model.find(params[:id])
end end
def authorize_access! def authorize_access!
return nil unless model
authorized = authorized =
case model case model
when Note when Note
...@@ -33,6 +37,8 @@ class UploadsController < ApplicationController ...@@ -33,6 +37,8 @@ class UploadsController < ApplicationController
end end
def authorize_create_access! def authorize_create_access!
return nil unless model
# for now we support only personal snippets comments # for now we support only personal snippets comments
authorized = can?(current_user, :comment_personal_snippet, model) authorized = can?(current_user, :comment_personal_snippet, model)
...@@ -73,7 +79,12 @@ class UploadsController < ApplicationController ...@@ -73,7 +79,12 @@ class UploadsController < ApplicationController
def uploader def uploader
return @uploader if defined?(@uploader) return @uploader if defined?(@uploader)
if model.is_a?(PersonalSnippet) case model
when nil
@uploader = PersonalFileUploader.new(nil, params[:secret])
@uploader.retrieve_from_store!(params[:filename])
when PersonalSnippet
@uploader = PersonalFileUploader.new(model, params[:secret]) @uploader = PersonalFileUploader.new(model, params[:secret])
@uploader.retrieve_from_store!(params[:filename]) @uploader.retrieve_from_store!(params[:filename])
......
...@@ -128,7 +128,7 @@ module GitlabRoutingHelper ...@@ -128,7 +128,7 @@ module GitlabRoutingHelper
def preview_markdown_path(project, *args) def preview_markdown_path(project, *args)
if @snippet.is_a?(PersonalSnippet) if @snippet.is_a?(PersonalSnippet)
preview_markdown_snippet_path(@snippet) preview_markdown_snippets_path
else else
preview_markdown_namespace_project_path(project.namespace, project, *args) preview_markdown_namespace_project_path(project.namespace, project, *args)
end end
......
...@@ -11,6 +11,7 @@ class Snippet < ActiveRecord::Base ...@@ -11,6 +11,7 @@ class Snippet < ActiveRecord::Base
include Editable include Editable
cache_markdown_field :title, pipeline: :single_line cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
cache_markdown_field :content cache_markdown_field :content
# Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets. # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets.
......
class FileMover
attr_reader :secret, :file_name, :model, :update_field
def initialize(file_path, model, update_field = :description)
@secret = File.split(File.dirname(file_path)).last
@file_name = File.basename(file_path)
@model = model
@update_field = update_field
end
def execute
move
uploader.record_upload if update_markdown
end
private
def move
FileUtils.mkdir_p(File.dirname(file_path))
FileUtils.move(temp_file_path, file_path)
end
def update_markdown
updated_text = model.read_attribute(update_field).gsub(temp_file_uploader.to_markdown, uploader.to_markdown)
model.update_attribute(update_field, updated_text)
true
rescue
revert
false
end
def temp_file_path
return @temp_file_path if @temp_file_path
temp_file_uploader.retrieve_from_store!(file_name)
@temp_file_path = 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
def revert
Rails.logger.warn("Markdown not updated, file move reverted for #{model}")
FileUtils.move(file_path, temp_file_path)
end
end
...@@ -10,6 +10,10 @@ class PersonalFileUploader < FileUploader ...@@ -10,6 +10,10 @@ class PersonalFileUploader < FileUploader
end end
def self.model_path(model) def self.model_path(model)
if model
File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s) File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s)
else
File.join("/#{base_dir}", 'temp')
end
end end
end end
...@@ -6,8 +6,6 @@ module RecordsUploads ...@@ -6,8 +6,6 @@ module RecordsUploads
before :remove, :destroy_upload before :remove, :destroy_upload
end end
private
# After storing an attachment, create a corresponding Upload record # After storing an attachment, create a corresponding Upload record
# #
# NOTE: We're ignoring the argument passed to this callback because we want # NOTE: We're ignoring the argument passed to this callback because we want
...@@ -15,13 +13,16 @@ module RecordsUploads ...@@ -15,13 +13,16 @@ module RecordsUploads
# `Tempfile` object the callback gets. # `Tempfile` object the callback gets.
# #
# Called `after :store` # Called `after :store`
def record_upload(_tempfile) def record_upload(_tempfile = nil)
return unless model
return unless file_storage? return unless file_storage?
return unless file.exists? return unless file.exists?
Upload.record(self) Upload.record(self)
end end
private
# Before removing an attachment, destroy any Upload records at the same path # Before removing an attachment, destroy any Upload records at the same path
# #
# Called `before :remove` # Called `before :remove`
......
- header_title "Snippets", snippets_path - header_title "Snippets", snippets_path
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
- if @snippet&.persisted? && current_user - if @snippet && current_user
:javascript :javascript
window.uploads_path = "#{upload_path('personal_snippet', @snippet)}"; window.uploads_path = "#{upload_path('personal_snippet', id: @snippet.id)}";
window.preview_markdown_path = "#{preview_markdown_snippet_path(@snippet)}";
= render template: "layouts/application" = render template: "layouts/application"
- project = local_assigns.fetch(:project) - project = local_assigns.fetch(:project)
- issuable = local_assigns.fetch(:issuable) - model = local_assigns.fetch(:model)
- form = local_assigns.fetch(:form) - form = local_assigns.fetch(:form)
- supports_slash_commands = issuable.new_record? - supports_slash_commands = model.new_record?
- if supports_slash_commands - 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 - else
- preview_url = preview_markdown_path(project) - preview_url = preview_markdown_path(project)
......
...@@ -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, project: project = render 'shared/form_elements/description', model: issuable, form: form, project: project
- if issuable.respond_to?(:confidential) - if issuable.respond_to?(:confidential)
.form-group .form-group
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
= page_specific_javascript_bundle_tag('snippet') = page_specific_javascript_bundle_tag('snippet')
.snippet-form-holder .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_errors(@snippet)
.form-group .form-group
...@@ -11,6 +11,8 @@ ...@@ -11,6 +11,8 @@
.col-sm-10 .col-sm-10
= f.text_field :title, class: 'form-control', required: true, autofocus: true = 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 = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet
.file-editor .file-editor
...@@ -23,6 +25,9 @@ ...@@ -23,6 +25,9 @@
.file-content.code .file-content.code
%pre#editor= @snippet.content %pre#editor= @snippet.content
= f.hidden_field :content, class: 'snippet-file-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 .form-actions
- if @snippet.new_record? - if @snippet.new_record?
......
...@@ -22,3 +22,9 @@ ...@@ -22,3 +22,9 @@
- if @snippet.updated_at != @snippet.created_at - if @snippet.updated_at != @snippet.created_at
= edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true) = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true)
- 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 ...@@ -2,6 +2,9 @@ resources :snippets, concerns: :awardable do
member do member do
get :raw get :raw
post :mark_as_spam post :mark_as_spam
end
collection do
post :preview_markdown post :preview_markdown
end end
......
...@@ -9,6 +9,11 @@ scope path: :uploads do ...@@ -9,6 +9,11 @@ scope path: :uploads do
to: 'uploads#show', to: 'uploads#show',
constraints: { model: /personal_snippet/, id: /\d+/, filename: /[^\/]+/ } constraints: { model: /personal_snippet/, id: /\d+/, filename: /[^\/]+/ }
# show temporary uploads
get 'temp/:secret/:filename',
to: 'uploads#show',
constraints: { filename: /[^\/]+/ }
# Appearance # Appearance
get ":model/:mounted_as/:id/:filename", get ":model/:mounted_as/:id/:filename",
to: "uploads#show", to: "uploads#show",
...@@ -20,7 +25,7 @@ scope path: :uploads do ...@@ -20,7 +25,7 @@ scope path: :uploads do
constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /[^\/]+/ } 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 # create uploads for models, snippets (notes) available for now
post ':model/:id/', post ':model',
to: 'uploads#create', to: 'uploads#create',
constraints: { model: /personal_snippet/, id: /\d+/ }, constraints: { model: /personal_snippet/, id: /\d+/ },
as: 'upload' 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
...@@ -1198,6 +1198,8 @@ ActiveRecord::Schema.define(version: 20170525174156) do ...@@ -1198,6 +1198,8 @@ ActiveRecord::Schema.define(version: 20170525174156) do
t.text "title_html" t.text "title_html"
t.text "content_html" t.text "content_html"
t.integer "cached_markdown_version" t.integer "cached_markdown_version"
t.text "description"
t.text "description_html"
end end
add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree
......
...@@ -43,6 +43,7 @@ Parameters: ...@@ -43,6 +43,7 @@ Parameters:
"id": 1, "id": 1,
"title": "test", "title": "test",
"file_name": "add.rb", "file_name": "add.rb",
"description": "Ruby test snippet",
"author": { "author": {
"id": 1, "id": 1,
"username": "john_smith", "username": "john_smith",
...@@ -70,6 +71,7 @@ Parameters: ...@@ -70,6 +71,7 @@ Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user - `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 - `title` (required) - The title of a snippet
- `file_name` (required) - The name of a snippet file - `file_name` (required) - The name of a snippet file
- `description` (optional) - The description of a snippet
- `code` (required) - The content of a snippet - `code` (required) - The content of a snippet
- `visibility` (required) - The snippet's visibility - `visibility` (required) - The snippet's visibility
...@@ -87,6 +89,7 @@ Parameters: ...@@ -87,6 +89,7 @@ Parameters:
- `snippet_id` (required) - The ID of a project's snippet - `snippet_id` (required) - The ID of a project's snippet
- `title` (optional) - The title of a snippet - `title` (optional) - The title of a snippet
- `file_name` (optional) - The name of a snippet file - `file_name` (optional) - The name of a snippet file
- `description` (optional) - The description of a snippet
- `code` (optional) - The content of a snippet - `code` (optional) - The content of a snippet
- `visibility` (optional) - The snippet's visibility - `visibility` (optional) - The snippet's visibility
......
...@@ -48,6 +48,7 @@ Example response: ...@@ -48,6 +48,7 @@ Example response:
"id": 1, "id": 1,
"title": "test", "title": "test",
"file_name": "add.rb", "file_name": "add.rb",
"description": "Ruby test snippet",
"author": { "author": {
"id": 1, "id": 1,
"username": "john_smith", "username": "john_smith",
...@@ -78,11 +79,12 @@ Parameters: ...@@ -78,11 +79,12 @@ Parameters:
| `title` | String | yes | The title of a snippet | | `title` | String | yes | The title of a snippet |
| `file_name` | String | yes | The name of a snippet file | | `file_name` | String | yes | The name of a snippet file |
| `content` | String | yes | The content of a snippet | | `content` | String | yes | The content of a snippet |
| `visibility` | String | yes | The snippet's visibility | | `description` | String | no | The description of a snippet |
| `visibility` | String | no | The snippet's visibility |
``` bash ``` 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: Example response:
...@@ -92,6 +94,7 @@ Example response: ...@@ -92,6 +94,7 @@ Example response:
"id": 1, "id": 1,
"title": "This is a snippet", "title": "This is a snippet",
"file_name": "test.txt", "file_name": "test.txt",
"description": "Hello World snippet",
"author": { "author": {
"id": 1, "id": 1,
"username": "john_smith", "username": "john_smith",
...@@ -122,6 +125,7 @@ Parameters: ...@@ -122,6 +125,7 @@ Parameters:
| `id` | Integer | yes | The ID of a snippet | | `id` | Integer | yes | The ID of a snippet |
| `title` | String | no | The title of a snippet | | `title` | String | no | The title of a snippet |
| `file_name` | String | no | The name of a snippet file | | `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 | | `content` | String | no | The content of a snippet |
| `visibility` | String | no | The snippet's visibility | | `visibility` | String | no | The snippet's visibility |
...@@ -137,6 +141,7 @@ Example response: ...@@ -137,6 +141,7 @@ Example response:
"id": 1, "id": 1,
"title": "test", "title": "test",
"file_name": "add.rb", "file_name": "add.rb",
"description": "description of snippet",
"author": { "author": {
"id": 1, "id": 1,
"username": "john_smith", "username": "john_smith",
......
...@@ -226,7 +226,7 @@ module API ...@@ -226,7 +226,7 @@ module API
end end
class ProjectSnippet < Grape::Entity class ProjectSnippet < Grape::Entity
expose :id, :title, :file_name expose :id, :title, :file_name, :description
expose :author, using: Entities::UserBasic expose :author, using: Entities::UserBasic
expose :updated_at, :created_at expose :updated_at, :created_at
...@@ -236,7 +236,7 @@ module API ...@@ -236,7 +236,7 @@ module API
end end
class PersonalSnippet < Grape::Entity class PersonalSnippet < Grape::Entity
expose :id, :title, :file_name expose :id, :title, :file_name, :description
expose :author, using: Entities::UserBasic expose :author, using: Entities::UserBasic
expose :updated_at, :created_at expose :updated_at, :created_at
......
...@@ -49,6 +49,7 @@ module API ...@@ -49,6 +49,7 @@ module API
requires :title, type: String, desc: 'The title of the snippet' requires :title, type: String, desc: 'The title of the snippet'
requires :file_name, type: String, desc: 'The file name 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' requires :code, type: String, desc: 'The content of the snippet'
optional :description, type: String, desc: 'The description of a snippet'
requires :visibility, type: String, requires :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values, values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet' desc: 'The visibility of the snippet'
...@@ -77,6 +78,7 @@ module API ...@@ -77,6 +78,7 @@ module API
optional :title, type: String, desc: 'The title of the snippet' optional :title, type: String, desc: 'The title of the snippet'
optional :file_name, type: String, desc: 'The file name 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 :code, type: String, desc: 'The content of the snippet'
optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String, optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values, values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet' desc: 'The visibility of the snippet'
......
...@@ -58,6 +58,7 @@ module API ...@@ -58,6 +58,7 @@ module API
requires :title, type: String, desc: 'The title of a snippet' requires :title, type: String, desc: 'The title of a snippet'
requires :file_name, type: String, desc: 'The name of a snippet file' requires :file_name, type: String, desc: 'The name of a snippet file'
requires :content, type: String, desc: 'The content of a snippet' requires :content, type: String, desc: 'The content of a snippet'
optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String, optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values, values: Gitlab::VisibilityLevel.string_values,
default: 'internal', default: 'internal',
...@@ -85,6 +86,7 @@ module API ...@@ -85,6 +86,7 @@ module API
optional :title, type: String, desc: 'The title of a snippet' optional :title, type: String, desc: 'The title of a snippet'
optional :file_name, type: String, desc: 'The name of a snippet file' optional :file_name, type: String, desc: 'The name of a snippet file'
optional :content, type: String, desc: 'The content of a snippet' optional :content, type: String, desc: 'The content of a snippet'
optional :description, type: String, desc: 'The description of a snippet'
optional :visibility, type: String, optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values, values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet' desc: 'The visibility of the snippet'
......
...@@ -78,8 +78,18 @@ describe Projects::SnippetsController do ...@@ -78,8 +78,18 @@ describe Projects::SnippetsController do
post :create, { post :create, {
namespace_id: project.namespace.to_param, namespace_id: project.namespace.to_param,
project_id: project, 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) }.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 end
context 'when the snippet is spam' do context 'when the snippet is spam' do
......
...@@ -171,12 +171,50 @@ describe SnippetsController do ...@@ -171,12 +171,50 @@ describe SnippetsController do
sign_in(user) sign_in(user)
post :create, { 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) }.merge(additional_params)
Snippet.last Snippet.last
end 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 context 'when the snippet is spam' do
before do before do
allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true) allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true)
......
...@@ -92,6 +92,40 @@ describe UploadsController do ...@@ -92,6 +92,40 @@ describe UploadsController do
end end
end end
end end
context 'temporal with valid image' do
subject do
post :create, model: 'personal_snippet', file: jpg, format: :json
end
it 'returns a content with original filename, new link, and correct type.' do
subject
expect(response.body).to match '\"alt\":\"rails_sample\"'
expect(response.body).to match "\"url\":\"/uploads/temp"
end
it 'does not create an Upload record' do
expect { subject }.not_to change { Upload.count }
end
end
context 'temporal with valid non-image file' do
subject do
post :create, model: 'personal_snippet', file: txt, format: :json
end
it 'returns a content with original filename, new link, and correct type.' do
subject
expect(response.body).to match '\"alt\":\"doc_sample.txt\"'
expect(response.body).to match "\"url\":\"/uploads/temp"
end
it 'does not create an Upload record' do
expect { subject }.not_to change { Upload.count }
end
end
end end
end end
......
...@@ -3,6 +3,7 @@ FactoryGirl.define do ...@@ -3,6 +3,7 @@ FactoryGirl.define do
author author
title { generate(:title) } title { generate(:title) }
content { generate(:title) } content { generate(:title) }
description { generate(:title) }
file_name { generate(:filename) } file_name { generate(:filename) }
trait :public do 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_requests
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_requests
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_requests
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' require 'rails_helper'
feature 'Create Snippet', :js, feature: true do feature 'Create Snippet', :js, feature: true do
include DropzoneHelper
before do before do
login_as :user login_as :user
visit new_snippet_path visit new_snippet_path
end end
scenario 'Authenticated user creates a snippet' do def fill_form
fill_in 'personal_snippet_title', with: 'My Snippet Title' fill_in 'personal_snippet_title', with: 'My Snippet Title'
fill_in 'personal_snippet_description', with: 'My Snippet **Description**'
page.within('.file-editor') do page.within('.file-editor') do
find('.ace_editor').native.send_keys 'Hello World!' find('.ace_editor').native.send_keys 'Hello World!'
end end
end
click_button 'Create snippet' scenario 'Authenticated user creates a snippet' do
fill_form
click_button('Create snippet')
wait_for_requests wait_for_requests
expect(page).to have_content('My Snippet Title') 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!') expect(page).to have_content('Hello World!')
end end
scenario 'previews a snippet with file' do
fill_in 'personal_snippet_description', with: 'My Snippet'
dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
find('.js-md-preview-button').click
page.within('#new_personal_snippet .md-preview') do
expect(page).to have_content('My Snippet')
link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
expect(link).to match(%r{/uploads/temp/\h{32}/banana_sample\.gif\z})
visit(link)
expect(page.status_code).to eq(200)
end
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})
visit(link)
expect(page.status_code).to eq(200)
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})
visit(link)
expect(page.status_code).to eq(200)
end
scenario 'Authenticated user creates a snippet with + in filename' do scenario 'Authenticated user creates a snippet with + in filename' do
fill_in 'personal_snippet_title', with: 'My Snippet Title' fill_in 'personal_snippet_title', with: 'My Snippet Title'
page.within('.file-editor') do page.within('.file-editor') do
......
require 'rails_helper'
feature 'Edit Snippet', :js, feature: true do
include DropzoneHelper
let(:file_name) { 'test.rb' }
let(:content) { 'puts "test"' }
let(:user) { create(:user) }
let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content, author: user) }
before do
login_as(user)
visit edit_snippet_path(snippet)
wait_for_requests
end
it 'updates the snippet' do
fill_in 'personal_snippet_title', with: 'New Snippet Title'
click_button('Save changes')
wait_for_requests
expect(page).to have_content('New Snippet Title')
end
it 'updates the snippet with files attached' do
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('Save changes')
wait_for_requests
link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
expect(link).to match(%r{/uploads/personal_snippet/#{snippet.id}/\h{32}/banana_sample\.gif\z})
end
end
...@@ -92,6 +92,7 @@ Milestone: ...@@ -92,6 +92,7 @@ Milestone:
ProjectSnippet: ProjectSnippet:
- id - id
- title - title
- description
- content - content
- author_id - author_id
- project_id - project_id
......
...@@ -36,11 +36,34 @@ describe API::ProjectSnippets do ...@@ -36,11 +36,34 @@ describe API::ProjectSnippets do
end end
end end
describe 'GET /projects/:project_id/snippets/:id' do
let(:user) { create(:user) }
let(:snippet) { create(:project_snippet, :public, project: project) }
it 'returns snippet json' do
get api("/projects/#{project.id}/snippets/#{snippet.id}", user)
expect(response).to have_http_status(200)
expect(json_response['title']).to eq(snippet.title)
expect(json_response['description']).to eq(snippet.description)
expect(json_response['file_name']).to eq(snippet.file_name)
end
it 'returns 404 for invalid snippet id' do
get api("/projects/#{project.id}/snippets/1234", user)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 Not found')
end
end
describe 'POST /projects/:project_id/snippets/' do describe 'POST /projects/:project_id/snippets/' do
let(:params) do let(:params) do
{ {
title: 'Test Title', title: 'Test Title',
file_name: 'test.rb', file_name: 'test.rb',
description: 'test description',
code: 'puts "hello world"', code: 'puts "hello world"',
visibility: 'public' visibility: 'public'
} }
...@@ -52,6 +75,7 @@ describe API::ProjectSnippets do ...@@ -52,6 +75,7 @@ describe API::ProjectSnippets do
expect(response).to have_http_status(201) expect(response).to have_http_status(201)
snippet = ProjectSnippet.find(json_response['id']) snippet = ProjectSnippet.find(json_response['id'])
expect(snippet.content).to eq(params[:code]) expect(snippet.content).to eq(params[:code])
expect(snippet.description).to eq(params[:description])
expect(snippet.title).to eq(params[:title]) expect(snippet.title).to eq(params[:title])
expect(snippet.file_name).to eq(params[:file_name]) expect(snippet.file_name).to eq(params[:file_name])
expect(snippet.visibility_level).to eq(Snippet::PUBLIC) expect(snippet.visibility_level).to eq(Snippet::PUBLIC)
...@@ -106,12 +130,14 @@ describe API::ProjectSnippets do ...@@ -106,12 +130,14 @@ describe API::ProjectSnippets do
it 'updates snippet' do it 'updates snippet' do
new_content = 'New content' new_content = 'New content'
new_description = 'New description'
put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), code: new_content put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), code: new_content, description: new_description
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
snippet.reload snippet.reload
expect(snippet.content).to eq(new_content) expect(snippet.content).to eq(new_content)
expect(snippet.description).to eq(new_description)
end end
it 'returns 404 for invalid snippet id' do it 'returns 404 for invalid snippet id' do
......
...@@ -80,11 +80,33 @@ describe API::Snippets do ...@@ -80,11 +80,33 @@ describe API::Snippets do
end end
end end
describe 'GET /snippets/:id' do
let(:snippet) { create(:personal_snippet, author: user) }
it 'returns snippet json' do
get api("/snippets/#{snippet.id}", user)
expect(response).to have_http_status(200)
expect(json_response['title']).to eq(snippet.title)
expect(json_response['description']).to eq(snippet.description)
expect(json_response['file_name']).to eq(snippet.file_name)
end
it 'returns 404 for invalid snippet id' do
get api("/snippets/1234", user)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 Not found')
end
end
describe 'POST /snippets/' do describe 'POST /snippets/' do
let(:params) do let(:params) do
{ {
title: 'Test Title', title: 'Test Title',
file_name: 'test.rb', file_name: 'test.rb',
description: 'test description',
content: 'puts "hello world"', content: 'puts "hello world"',
visibility: 'public' visibility: 'public'
} }
...@@ -97,6 +119,7 @@ describe API::Snippets do ...@@ -97,6 +119,7 @@ describe API::Snippets do
expect(response).to have_http_status(201) expect(response).to have_http_status(201)
expect(json_response['title']).to eq(params[:title]) expect(json_response['title']).to eq(params[:title])
expect(json_response['description']).to eq(params[:description])
expect(json_response['file_name']).to eq(params[:file_name]) expect(json_response['file_name']).to eq(params[:file_name])
end end
...@@ -150,12 +173,14 @@ describe API::Snippets do ...@@ -150,12 +173,14 @@ describe API::Snippets do
it 'updates snippet' do it 'updates snippet' do
new_content = 'New content' new_content = 'New content'
new_description = 'New description'
put api("/snippets/#{snippet.id}", user), content: new_content put api("/snippets/#{snippet.id}", user), content: new_content, description: new_description
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
snippet.reload snippet.reload
expect(snippet.content).to eq(new_content) expect(snippet.content).to eq(new_content)
expect(snippet.description).to eq(new_description)
end end
it 'returns 404 for invalid snippet id' do it 'returns 404 for invalid snippet id' do
......
require 'spec_helper'
describe FileMover do
let(:filename) { 'banana_sample.gif' }
let(:file) { fixture_file_upload(Rails.root.join('spec', 'fixtures', filename)) }
let(:temp_description) do
'test ![banana_sample](/uploads/temp/secret55/banana_sample.gif) same ![banana_sample]'\
'(/uploads/temp/secret55/banana_sample.gif)'
end
let(:temp_file_path) { File.join('secret55', filename).to_s }
let(:file_path) { File.join('uploads', 'personal_snippet', snippet.id.to_s, 'secret55', filename).to_s }
let(:snippet) { create(:personal_snippet, description: temp_description) }
subject { described_class.new(file_path, snippet).execute }
describe '#execute' do
before do
expect(FileUtils).to receive(:mkdir_p).with(a_string_including(File.dirname(file_path)))
expect(FileUtils).to receive(:move).with(a_string_including(temp_file_path), a_string_including(file_path))
allow_any_instance_of(CarrierWave::SanitizedFile).to receive(:exists?).and_return(true)
allow_any_instance_of(CarrierWave::SanitizedFile).to receive(:size).and_return(10)
end
context 'when move and field update successful' do
it 'updates the description correctly' do
subject
expect(snippet.reload.description)
.to eq(
"test ![banana_sample](/uploads/personal_snippet/#{snippet.id}/secret55/banana_sample.gif)"\
" same ![banana_sample](/uploads/personal_snippet/#{snippet.id}/secret55/banana_sample.gif)"
)
end
it 'creates a new update record' do
expect { subject }.to change { Upload.count }.by(1)
end
end
context 'when update_markdown fails' do
before do
expect(FileUtils).to receive(:move).with(a_string_including(file_path), a_string_including(temp_file_path))
end
subject { described_class.new(file_path, snippet, :non_existing_field).execute }
it 'does not update the description' do
subject
expect(snippet.reload.description)
.to eq(
"test ![banana_sample](/uploads/temp/secret55/banana_sample.gif)"\
" same ![banana_sample](/uploads/temp/secret55/banana_sample.gif)"
)
end
it 'does not create a new update record' do
expect { subject }.not_to change { Upload.count }
end
end
end
end
require 'rails_helper' require 'rails_helper'
describe RecordsUploads do describe RecordsUploads do
let(:uploader) do let!(:uploader) do
class RecordsUploadsExampleUploader < GitlabUploader class RecordsUploadsExampleUploader < GitlabUploader
include RecordsUploads include RecordsUploads
...@@ -57,6 +57,13 @@ describe RecordsUploads do ...@@ -57,6 +57,13 @@ describe RecordsUploads do
uploader.store!(upload_fixture('rails_sample.jpg')) uploader.store!(upload_fixture('rails_sample.jpg'))
end end
it 'does not create an Upload record if model is missing' do
expect_any_instance_of(RecordsUploadsExampleUploader).to receive(:model).and_return(nil)
expect(Upload).not_to receive(:record).with(uploader)
uploader.store!(upload_fixture('rails_sample.jpg'))
end
it 'it destroys Upload records at the same path before recording' do it 'it destroys Upload records at the same path before recording' do
existing = Upload.create!( existing = Upload.create!(
path: File.join('uploads', 'rails_sample.jpg'), path: File.join('uploads', 'rails_sample.jpg'),
......
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