Commit d7938aaf authored by Douwe Maan's avatar Douwe Maan

Merge branch 'ee-49231-import-issues-csv' into 'master'

EE port of 49231-import-issues-csv

See merge request gitlab-org/gitlab-ee!8785
parents 8125a1a4 f6e3fc20
......@@ -101,3 +101,41 @@ body.modal-open {
margin: 0;
}
}
.issues-import-modal,
.issues-export-modal {
.modal-header {
justify-content: flex-start;
.import-export-svg-container {
flex-grow: 1;
height: 56px;
padding: $gl-btn-padding $gl-btn-padding 0;
> svg {
float: right;
height: 100%;
}
}
}
.modal-body {
padding: 0;
.modal-subheader {
justify-content: flex-start;
align-items: center;
border-bottom: 1px solid $modal-border-color;
padding: 14px;
}
.modal-text {
padding: $gl-padding-24 $gl-padding;
min-height: $modal-body-height;
}
}
.checkmark {
color: $green-400;
}
}
......@@ -160,47 +160,11 @@ ul.related-merge-requests > li {
padding-bottom: 37px;
}
.issues-export-modal {
.modal-header {
justify-content: flex-start;
.export-svg-container {
flex-grow: 1;
> svg {
float: right;
}
}
}
.modal-body {
padding: 0;
.export-modal-subheader {
justify-content: flex-start;
align-items: center;
border-bottom: 1px solid $modal-border-color;
padding: 14px;
}
.export-modal-text {
padding: 24px 16px;
line-height: 1.5;
min-height: $modal-body-height;
}
}
.export-svg-container {
height: 56px;
padding: 10px 10px 0;
}
svg {
height: 100%;
}
.issues-nav-controls {
font-size: 0;
.export-checkmark {
color: $green-400;
.btn-group:empty {
display: none;
}
}
......
......@@ -7,12 +7,12 @@ module UploadsActions
UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze
def create
link_to_file = UploadService.new(model, params[:file], uploader_class).execute
uploader = UploadService.new(model, params[:file], uploader_class).execute
respond_to do |format|
if link_to_file
if uploader
format.json do
render json: { link: link_to_file }
render json: { link: uploader.to_h }
end
else
format.json do
......
......@@ -12,7 +12,7 @@ class Projects::IssuesController < Projects::ApplicationController
prepend ::EE::Projects::IssuesController
def self.issue_except_actions
%i[index calendar new create bulk_update]
%i[index calendar new create bulk_update import_csv]
end
def self.set_issuables_index_only_actions
......@@ -39,6 +39,8 @@ class Projects::IssuesController < Projects::ApplicationController
# Allow create a new branch and empty WIP merge request from current issue
before_action :authorize_create_merge_request_from!, only: [:create_merge_request]
before_action :authorize_import_issues!, only: [:import_csv]
before_action :set_suggested_issues_feature_flags, only: [:new]
respond_to :html
......@@ -177,6 +179,20 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
def import_csv
return render_404 unless Feature.enabled?(:issues_import_csv)
if uploader = UploadService.new(project, params[:file]).execute
ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id)
flash[:notice] = _("Your issues are being imported. Once finished, you'll get a confirmation email.")
else
flash[:alert] = _("File upload error.")
end
redirect_to project_issues_path(project)
end
protected
# rubocop: disable CodeReuse/ActiveRecord
......
......@@ -77,6 +77,17 @@ module Emails
mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason))
end
def import_issues_csv_email(user_id, project_id, results)
@user = User.find(user_id)
@project = Project.find(project_id)
@results = results
mail(to: @user.notification_email, subject: subject('Imported issues')) do |format|
format.html { render layout: 'mailer' }
format.text { render layout: 'mailer' }
end
end
private
def setup_issue_mail(issue_id, recipient_id)
......
......@@ -76,6 +76,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.changed_milestone_issue_email(user.id, issue.id, milestone, user.id)
end
def import_issues_csv_email
Notify.import_issues_csv_email(user, project, { success: 3, errors: [5, 6, 7], valid_file: true })
end
def closed_merge_request_email
Notify.closed_merge_request_email(user.id, issue.id, user.id).message
end
......
......@@ -222,6 +222,8 @@ class ProjectPolicy < BasePolicy
rule { owner | admin | guest | group_member }.prevent :request_access
rule { ~request_access_enabled }.prevent :request_access
rule { can?(:developer_access) & can?(:create_issue) }.enable :import_issues
rule { can?(:developer_access) }.policy do
enable :admin_merge_request
enable :admin_milestone
......
# frozen_string_literal: true
module Issues
class ImportCsvService
def initialize(user, project, csv_io)
@user = user
@project = project
@csv_io = csv_io
@results = { success: 0, error_lines: [], parse_error: false }
end
def execute
process_csv
email_results_to_user
@results
end
private
def process_csv
csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8)
CSV.new(csv_data, col_sep: detect_col_sep(csv_data.lines.first), headers: true).each.with_index(2) do |row, line_no|
issue = Issues::CreateService.new(@project, @user, title: row[0], description: row[1]).execute
if issue.persisted?
@results[:success] += 1
else
@results[:error_lines].push(line_no)
end
end
rescue ArgumentError, CSV::MalformedCSVError
@results[:parse_error] = true
end
def email_results_to_user
Notify.import_issues_csv_email(@user.id, @project.id, @results).deliver_now
end
def detect_col_sep(header)
if header.include?(",")
","
elsif header.include?(";")
";"
elsif header.include?("\t")
"\t"
else
raise CSV::MalformedCSVError
end
end
end
end
......@@ -11,7 +11,7 @@ class UploadService
uploader = @uploader_class.new(@model, nil, @uploader_context)
uploader.store!(@file)
uploader.to_h
uploader
end
private
......
- text_style = 'font-size:16px; text-align:center; line-height:30px;'
%p{ style: text_style }
Your CSV import for project
%a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none;" }
= @project.full_name
has been completed.
%p{ style: text_style }
#{pluralize(@results[:success], 'issue')} imported.
- if @results[:error_lines].present?
%p{ style: text_style }
Errors found on line #{'number'.pluralize(@results[:error_lines].size)}: #{@results[:error_lines].join(', ')}. Please check if these lines have an issue title.
- if @results[:parse_error]
%p{ style: text_style }
Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.
Your CSV import for project <%= @project.full_name %> (<%= project_url(@project) %>) has been completed.
<%= pluralize(@results[:success], 'issue') %> imported.
<% if @results[:error_lines].present? %>
Errors found on line <%= 'number'.pluralize(@results[:error_lines].size) %>: <%= @results[:error_lines].join(', ') %>. Please check if these lines have an issue title.
<% end %>
<% if @results[:parse_error] %>
Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.
<% end %>
......@@ -24,6 +24,6 @@
= _("No file selected")
= f.file_field :bfg_object_map, accept: 'text/plain', class: "hidden js-object-map-input", required: true
.form-text.text-muted
= _("The maximum file size allowed is %{max_attachment_size}mb") % { max_attachment_size: Gitlab::CurrentSettings.max_attachment_size }
= _("The maximum file size allowed is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
= f.submit _('Start cleanup'), class: 'btn btn-success'
- show_rss_button = local_assigns.fetch(:show_rss_button, true)
- show_feed_buttons = local_assigns.fetch(:show_feed_buttons, true)
- show_import_button = local_assigns.fetch(:show_import_button, true) && Feature.enabled?(:issues_import_csv) && can?(current_user, :import_issues, @project)
- show_export_button = local_assigns.fetch(:show_export_button, true)
- if show_rss_button
= link_to safe_params.merge(rss_url_options), class: 'btn btn-default append-right-10 has-tooltip', title: 'Subscribe to RSS feed' do
= icon('rss')
= link_to safe_params.merge(calendar_url_options), class: 'btn btn-default append-right-10 has-tooltip', title: 'Subscribe to calendar' do
= custom_icon('icon_calendar')
.nav-controls.issues-nav-controls
- if show_feed_buttons
= render 'shared/issuable/feed_buttons'
- if show_export_button
= render 'projects/issues/export_issues/button'
.btn-group.append-right-10<
- if show_export_button
= render_if_exists 'projects/issues/export_csv/button'
- if show_import_button
= render 'projects/issues/import_csv/button'
- if @can_bulk_update
= button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
- if show_new_issue_link?(@project)
= link_to "New issue", new_project_issue_path(@project,
- if @can_bulk_update
= button_tag _("Edit issues"), class: "btn btn-default append-right-10 js-bulk-update-toggle"
- if show_new_issue_link?(@project)
= link_to _("New issue"), new_project_issue_path(@project,
issue: { assignee_id: finder.assignee.try(:id),
milestone_id: finder.milestones.first.try(:id) }),
class: "btn btn-success",
title: "New issue",
title: _("New issue"),
id: "new_issue_link"
- if show_export_button
= render_if_exists 'projects/issues/export_csv/modal'
- if show_import_button
= render 'projects/issues/import_csv/modal'
- type = local_assigns.fetch(:type, :icon)
%button.csv-import-button.btn{ title: _('Import CSV'), class: ('has-tooltip' if type == :icon),
data: { toggle: 'modal', target: '.issues-import-modal' } }
- if type == :icon
= sprite_icon('upload')
- else
= _('Import CSV')
.issues-import-modal.modal
.modal-dialog
.modal-content
= form_tag import_csv_namespace_project_issues_path, multipart: true do
.modal-header
%h3
= _('Import issues')
.import-export-svg-container
= render 'projects/issues/import_export.svg'
%a.close{ href: '#', 'data-dismiss' => 'modal' } ×
.modal-body
.modal-text
%p
= _("Your issues will be imported in the background. Once finished, you'll get a confirmation email.")
.form-group
= label_tag :file, _('Upload CSV file'), class: 'label-bold'
%div
= file_field_tag :file, accept: '.csv,text/csv', required: true
%p.text-secondary
= _('It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.')
= _('The maximum file size allowed is %{size}.') % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
.modal-footer
%button{ type: 'submit', class: 'btn btn-success', title: _('Import issues') }
= _('Import issues')
......@@ -8,13 +8,9 @@
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues")
- if project_issues(@project).exists?
= render 'projects/issues/export_issues/csv_download'
%div{ class: (container_class) }
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls
= render "projects/issues/nav_btns"
= render 'shared/issuable/search_bar', type: :issues
......@@ -26,4 +22,4 @@
- if new_issue_email
= render 'projects/issuable_by_email', email: new_issue_email, issuable_type: 'issue'
- else
= render 'shared/empty_states/issues', button_path: new_project_issue_path(@project)
= render 'shared/empty_states/issues', button_path: new_project_issue_path(@project), show_import_button: true
- button_path = local_assigns.fetch(:button_path, false)
- project_select_button = local_assigns.fetch(:project_select_button, false)
- show_import_button = local_assigns.fetch(:show_import_button, false) && Feature.enabled?(:issues_import_csv) && can?(current_user, :import_issues, @project)
- has_button = button_path || project_select_button
.row.empty-state
......@@ -21,12 +22,20 @@
- if has_button
.text-center
- if project_select_button
= render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue', type: :issues, with_feature_enabled: 'issues'
= render 'shared/new_project_item_select', path: 'issues/new', label: _('New issue'), type: :issues, with_feature_enabled: 'issues'
- else
= link_to 'New issue', button_path, class: 'btn btn-success', title: 'New issue', id: 'new_issue_link'
= link_to _('New issue'), button_path, class: 'btn btn-success', title: _('New issue'), id: 'new_issue_link'
- if show_import_button
= render 'projects/issues/import_csv/button', type: :text
- else
%h4.text-center= _("There are no issues to show")
%p
= _("The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.")
.text-center
= link_to _('Register / Sign In'), new_user_session_path, class: 'btn btn-success'
- if show_import_button
= render 'projects/issues/import_csv/modal'
= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe to RSS feed' do
= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to RSS feed') do
= icon('rss')
= link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe to calendar' do
= link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do
= custom_icon('icon_calendar')
......@@ -140,3 +140,4 @@
- detect_repository_languages
- repository_cleanup
- delete_stored_files
- import_issues_csv
# frozen_string_literal: true
class ImportIssuesCsvWorker
include ApplicationWorker
sidekiq_retries_exhausted do |job|
Upload.find(job['args'][2]).destroy
end
def perform(current_user_id, project_id, upload_id)
@user = User.find(current_user_id)
@project = Project.find(project_id)
@upload = Upload.find(upload_id)
importer = Issues::ImportCsvService.new(@user, @project, @upload.build_uploader)
importer.execute
@upload.destroy
end
end
---
title: Add importing of issues from CSV file
merge_request: 23532
author:
type: added
......@@ -435,6 +435,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
collection do
post :bulk_update
post :import_csv
## EE-specific START
post :export_csv
......
......@@ -85,6 +85,7 @@
- [repository_cleanup, 1]
- [delete_stored_files, 1]
- [remote_mirror_notification, 2]
- [import_issues_csv, 2]
# EE-specific queues
- [ldap_group_sync, 2]
......
# Importing Issues from CSV
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/23532) in GitLab 11.7.
Issues can be imported by uploading a CSV file. The file will be processed in the background and a notification email
will be sent to you once the import is completed.
> **Note:** A permission level of `Developer` or higher is required to import issues.
## CSV File Format
### Header row
CSV files must contain a header row with at least two columns: `title` and `description`, in that order.
### Column separator
The column separator is automatically detected from the header row.
Supported separator characters are: commas (`,`), semicolons (`;`), and tabs (`\t`).
### Row separator
Lines ending in either `CRLF` or `LF` are supported.
### Quote character
The double-quote (`"`) character is used to quote fields so you can use the column separator within a field. To insert
a double-quote (`"`) within a quoted field, use two double-quote characters in succession, i.e. `""`.
### Data rows
After the header row, succeeding rows must follow the same column order. The issue title is required while the
description is optional.
The user uploading the CSV file will be set as the author of the imported issues.
## Sample Data
```csv
title,description
My Issue Title,My Issue Description
Another Title,"A description, with a comma"
"One More Title","One More Description"
```
......@@ -153,6 +153,15 @@ and projects.
Read more about [Related Issues](related_issues.md).
### Import Issues from CSV
From the project-level issues list, you can find the import button near the "Edit issues" button in the upper-right
side.
![Import CSV button](img/import_csv_button.png)
Learn more about [importing issues from CSV](csv_import.md)
### External Issue Tracker
Alternatively to GitLab's built-in Issue Tracker, you can also use an [external
......
import $ from 'jquery';
import Stats from 'ee/stats';
export default function initExportCSVModal() {
const $modal = $('.issues-export-modal');
const $downloadBtn = $('.csv_download_link');
const $closeBtn = $('.modal-header .close');
Stats.bindTrackableContainer('.issues-export-modal');
$modal.modal({ show: false });
$downloadBtn.on('click', () => $modal.modal('show'));
$closeBtn.on('click', () => $modal.modal('hide'));
}
import '~/pages/projects/issues/index/index';
import initExportCSVModal from './export_csv_modal';
import Stats from 'ee/stats';
document.addEventListener('DOMContentLoaded', initExportCSVModal);
document.addEventListener('DOMContentLoaded', () => {
Stats.bindTrackableContainer('.issues-export-modal');
});
- if (current_user && @project.feature_available?(:export_issues)) || show_promotions?
%button.csv_download_link.btn.append-right-10.has-tooltip{ title: 'Export as CSV' }
= icon('download')
%button.csv_download_link.btn.has-tooltip{ title: _('Export as CSV'),
data: { toggle: 'modal', target: '.issues-export-modal' } }
= sprite_icon('download')
......@@ -4,20 +4,19 @@
.modal-content
.modal-header
%h3
Export issues
.export-svg-container
= render 'projects/issues/export_issues/export_issues_list.svg'
= _('Export issues')
.import-export-svg-container
= render 'projects/issues/import_export.svg'
%a.close{ href: '#', 'data-dismiss' => 'modal' } ×
.modal-body
.export-modal-subheader
= icon('check', { class: 'export-checkmark' })
.modal-subheader
= icon('check', { class: 'checkmark' })
%strong.prepend-left-10
#{pluralize(issuables_count_for_state(:issues, params[:state]), 'issue')} selected
.export-modal-text
The CSV export will be created in the background. Once finished, it will be sent to
%strong= @current_user.notification_email
in an attachment.
- issues_count = issuables_count_for_state(:issues, params[:state])
= n_('%d issue selected', '%d issues selected', issues_count) % issues_count
.modal-text
= _('The CSV export will be created in the background. Once finished, it will be sent to <strong>%{email}</strong> in an attachment.').html_safe % { email: @current_user.notification_email }
.modal-footer
= link_to 'Export issues', export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn btn-success float-left', title: 'Export issues', data: { track_label: "export_issues_csv", track_event: "click_button"}
= link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn btn-success float-left', title: _('Export issues'), data: { track_label: "export_issues_csv", track_event: "click_button" }
- elsif show_promotions?
= render 'shared/promotions/promote_csv_export'
......@@ -12,7 +12,7 @@
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls.d-block.d-sm-none
= render "projects/issues/nav_btns", show_export_button: false, show_rss_button: false
= render "projects/issues/nav_btns", show_feed_buttons: false, show_import_button: false, show_export_button: false
= render 'shared/issuable/search_bar', type: :issues
- if @issues.present?
......
......@@ -478,7 +478,7 @@ module API
requires :file, type: File, desc: 'The file to be uploaded'
end
post ":id/uploads" do
UploadService.new(user_project, params[:file]).execute
UploadService.new(user_project, params[:file]).execute.to_h
end
desc 'Get the users list of a project' do
......
......@@ -23,8 +23,8 @@ module Gitlab
content_type: attachment.content_type
}
link = UploadService.new(project, file).execute
attachments << link if link
uploader = UploadService.new(project, file).execute
attachments << uploader.to_h if uploader
ensure
tmp.close!
end
......
......@@ -40,7 +40,7 @@ module Gitlab
def add_upload(upload)
uploader_context = FileUploader.extract_dynamic_path(upload).named_captures.symbolize_keys
UploadService.new(@project, File.open(upload, 'r'), FileUploader, uploader_context).execute
UploadService.new(@project, File.open(upload, 'r'), FileUploader, uploader_context).execute.to_h
end
def copy_project_uploads
......
......@@ -83,6 +83,11 @@ msgid_plural "%d issues"
msgstr[0] ""
msgstr[1] ""
msgid "%d issue selected"
msgid_plural "%d issues selected"
msgstr[0] ""
msgstr[1] ""
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] ""
......@@ -3195,6 +3200,9 @@ msgstr ""
msgid "Edit identity for %{user_name}"
msgstr ""
msgid "Edit issues"
msgstr ""
msgid "Elasticsearch"
msgstr ""
......@@ -3579,6 +3587,12 @@ msgstr ""
msgid "Explore public groups"
msgstr ""
msgid "Export as CSV"
msgstr ""
msgid "Export issues"
msgstr ""
msgid "External Classification Policy Authorization"
msgstr ""
......@@ -3747,6 +3761,9 @@ msgstr ""
msgid "File templates"
msgstr ""
msgid "File upload error."
msgstr ""
msgid "Files"
msgstr ""
......@@ -4712,6 +4729,9 @@ msgstr ""
msgid "Import"
msgstr ""
msgid "Import CSV"
msgstr ""
msgid "Import Projects from Gitea"
msgstr ""
......@@ -4730,6 +4750,9 @@ msgstr ""
msgid "Import in progress"
msgstr ""
msgid "Import issues"
msgstr ""
msgid "Import multiple repositories by uploading a manifest file."
msgstr ""
......@@ -4927,6 +4950,9 @@ msgstr ""
msgid "IssuesAnalytics|To widen your search, change or remove filters in the filter bar above"
msgstr ""
msgid "It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected."
msgstr ""
msgid "It's you"
msgstr ""
......@@ -8466,6 +8492,12 @@ msgstr ""
msgid "Subscribe at project level"
msgstr ""
msgid "Subscribe to RSS feed"
msgstr ""
msgid "Subscribe to calendar"
msgstr ""
msgid "Subscribed"
msgstr ""
......@@ -8688,6 +8720,9 @@ msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
msgid "The CSV export will be created in the background. Once finished, it will be sent to <strong>%{email}</strong> in an attachment."
msgstr ""
msgid "The Git LFS objects will <strong>not</strong> be synced."
msgstr ""
......@@ -8724,7 +8759,7 @@ msgstr ""
msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
msgstr ""
msgid "The maximum file size allowed is %{max_attachment_size}mb"
msgid "The maximum file size allowed is %{size}."
msgstr ""
msgid "The maximum file size allowed is 200KB."
......@@ -9547,6 +9582,9 @@ msgstr ""
msgid "Upload <code>GoogleCodeProjectHosting.json</code> here:"
msgstr ""
msgid "Upload CSV file"
msgstr ""
msgid "Upload New File"
msgstr ""
......@@ -10195,6 +10233,12 @@ msgstr ""
msgid "Your groups"
msgstr ""
msgid "Your issues are being imported. Once finished, you'll get a confirmation email."
msgstr ""
msgid "Your issues will be imported in the background. Once finished, you'll get a confirmation email."
msgstr ""
msgid "Your name"
msgstr ""
......
......@@ -1026,6 +1026,72 @@ describe Projects::IssuesController do
end
end
describe 'POST #import_csv' do
let(:project) { create(:project, :public) }
let(:file) { fixture_file_upload('spec/fixtures/csv_comma.csv') }
context 'feature disabled' do
it 'returns 404' do
sign_in(user)
project.add_maintainer(user)
stub_feature_flags(issues_import_csv: false)
import_csv
expect(response).to have_gitlab_http_status :not_found
end
end
context 'unauthorized' do
it 'returns 404 for guests' do
sign_out(:user)
import_csv
expect(response).to have_gitlab_http_status :not_found
end
it 'returns 404 for project members with reporter role' do
sign_in(user)
project.add_reporter(user)
import_csv
expect(response).to have_gitlab_http_status :not_found
end
end
context 'authorized' do
before do
sign_in(user)
project.add_developer(user)
end
it "returns 302 for project members with developer role" do
import_csv
expect(flash[:notice]).to include('Your issues are being imported')
expect(response).to redirect_to(project_issues_path(project))
end
it "shows error when upload fails" do
allow_any_instance_of(UploadService).to receive(:execute).and_return(nil)
import_csv
expect(flash[:alert]).to include('File upload error.')
expect(response).to redirect_to(project_issues_path(project))
end
end
def import_csv
post :import_csv, namespace_id: project.namespace.to_param,
project_id: project.to_param,
file: file
end
end
describe 'GET #discussions' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
context 'when authenticated' do
......
title,description
Issue in 中文,Test description
"Hello","World"
"Title with quote""",Description
title;description
Issue in 中文;Test description
Title with, comma;"Description"
"Hello";"World"
title description
Issue in 中文 Test description
"Error Row"
"Hello" "World"
# frozen_string_literal: true
require 'spec_helper'
require 'email_spec'
describe Emails::Issues do
include EmailSpec::Matchers
describe "#import_issues_csv_email" do
let(:user) { create(:user) }
let(:project) { create(:project) }
subject { Notify.import_issues_csv_email(user.id, project.id, @results) }
it "shows number of successful issues imported" do
@results = { success: 165, error_lines: [], parse_error: false }
expect(subject).to have_body_text "165 issues imported"
end
it "shows error when file is invalid" do
@results = { success: 0, error_lines: [], parse_error: true }
expect(subject).to have_body_text "Error parsing CSV"
end
it "shows line numbers with errors" do
@results = { success: 0, error_lines: [23, 34, 58], parse_error: false }
expect(subject).to have_body_text "23, 34, 58"
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Issues::ImportCsvService do
let(:project) { create(:project) }
let(:user) { create(:user) }
subject do
uploader = FileUploader.new(project)
uploader.store!(file)
described_class.new(user, project, uploader).execute
end
describe '#execute' do
context 'invalid file' do
let(:file) { fixture_file_upload('spec/fixtures/banana_sample.gif') }
it 'returns invalid file error' do
expect_any_instance_of(Notify).to receive(:import_issues_csv_email)
expect(subject[:success]).to eq(0)
expect(subject[:parse_error]).to eq(true)
end
end
context 'comma delimited file' do
let(:file) { fixture_file_upload('spec/fixtures/csv_comma.csv') }
it 'imports CSV without errors' do
expect_any_instance_of(Notify).to receive(:import_issues_csv_email)
expect(subject[:success]).to eq(3)
expect(subject[:error_lines]).to eq([])
expect(subject[:parse_error]).to eq(false)
end
end
context 'tab delimited file with error row' do
let(:file) { fixture_file_upload('spec/fixtures/csv_tab.csv') }
it 'imports CSV with some error rows' do
expect_any_instance_of(Notify).to receive(:import_issues_csv_email)
expect(subject[:success]).to eq(2)
expect(subject[:error_lines]).to eq([3])
expect(subject[:parse_error]).to eq(false)
end
end
context 'semicolon delimited file with CRLF' do
let(:file) { fixture_file_upload('spec/fixtures/csv_semicolon.csv') }
it 'imports CSV with a blank row' do
expect_any_instance_of(Notify).to receive(:import_issues_csv_email)
expect(subject[:success]).to eq(3)
expect(subject[:error_lines]).to eq([4])
expect(subject[:parse_error]).to eq(false)
end
end
end
end
......@@ -63,11 +63,11 @@ describe UploadService do
@link_to_file = upload_file(@project, txt)
end
it { expect(@link_to_file).to eq(nil) }
it { expect(@link_to_file).to eq({}) }
end
end
def upload_file(project, file)
described_class.new(project, file, FileUploader).execute
described_class.new(project, file, FileUploader).execute.to_h
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ImportIssuesCsvWorker do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:upload) { create(:upload) }
let(:worker) { described_class.new }
describe '#perform' do
it 'calls #execute on Issues::ImportCsvService and destroys upload' do
expect_any_instance_of(Issues::ImportCsvService).to receive(:execute).and_return({ success: 5, errors: [], valid_file: true })
worker.perform(user.id, project.id, upload.id)
expect { upload.reload }.to raise_error ActiveRecord::RecordNotFound
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