Commit 9a4db854 authored by Douwe Maan's avatar Douwe Maan

Merge branch '1480-export-issues-csv' into 'master'

Export issues to CSV

Closes #1480

See merge request !1126
parents c6534317 5ba2ccff
/* eslint-disable no-new */
$(() => {
class ExportCSVModal {
constructor() {
this.$modal = $('.issues-export-modal');
this.$downloadBtn = $('.csv_download_link');
this.$closeBtn = $('.modal-header .close');
this.init();
}
init() {
this.$modal.modal({ show: false });
this.$downloadBtn.on('click', () => this.$modal.modal('show'));
this.$closeBtn.on('click', () => this.$modal.modal('hide'));
}
}
new ExportCSVModal();
});
...@@ -129,6 +129,21 @@ ul.related-merge-requests > li { ...@@ -129,6 +129,21 @@ ul.related-merge-requests > li {
padding-bottom: 37px; padding-bottom: 37px;
} }
.issues-export-modal {
.export-svg-container {
height: 56px;
padding: 10px 10px 0;
}
svg {
height: 100%;
}
.export-checkmark {
color: $green-light;
}
}
.issue-email-modal-btn { .issue-email-modal-btn {
padding: 0; padding: 0;
color: $gl-link-color; color: $gl-link-color;
......
...@@ -6,6 +6,8 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -6,6 +6,8 @@ class Projects::IssuesController < Projects::ApplicationController
include IssuableCollections include IssuableCollections
include SpammableActions include SpammableActions
prepend_before_action :authenticate_user!, only: [:export_csv]
before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :redirect_to_external_issue_tracker, only: [:index, :new]
before_action :module_enabled before_action :module_enabled
before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests,
...@@ -25,6 +27,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -25,6 +27,7 @@ class Projects::IssuesController < Projects::ApplicationController
def index def index
@collection_type = "Issue" @collection_type = "Issue"
@issues = issues_collection @issues = issues_collection
@issues = @issues.page(params[:page]) @issues = @issues.page(params[:page])
@issuable_meta_data = issuable_meta_data(@issues, @collection_type) @issuable_meta_data = issuable_meta_data(@issues, @collection_type)
...@@ -142,6 +145,14 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -142,6 +145,14 @@ class Projects::IssuesController < Projects::ApplicationController
render_conflict_response render_conflict_response
end end
def export_csv
csv_params = filter_params.permit(IssuableFinder::VALID_PARAMS)
ExportCsvWorker.perform_async(@current_user.id, @project.id, csv_params)
index_path = namespace_project_issues_path(@project.namespace, @project)
redirect_to(index_path, notice: "Your CSV export has started. It will be emailed to #{current_user.notification_email} when complete.")
end
def referenced_merge_requests def referenced_merge_requests
@merge_requests = @issue.referenced_merge_requests(current_user) @merge_requests = @issue.referenced_merge_requests(current_user)
@closed_by_merge_requests = @issue.closed_by_merge_requests(current_user) @closed_by_merge_requests = @issue.closed_by_merge_requests(current_user)
......
...@@ -20,6 +20,7 @@ ...@@ -20,6 +20,7 @@
# #
class IssuableFinder class IssuableFinder
NONE = '0'.freeze NONE = '0'.freeze
VALID_PARAMS = %i(scope state group_id project_id milestone_title assignee_id search label_name sort assignee_username author_id author_username authorized_only due_date iids non_archived weight).freeze
attr_accessor :current_user, :params attr_accessor :current_user, :params
...@@ -77,7 +78,7 @@ class IssuableFinder ...@@ -77,7 +78,7 @@ class IssuableFinder
counts[:all] = counts.values.sum counts[:all] = counts.values.sum
counts[:opened] += counts[:reopened] counts[:opened] += counts[:reopened]
counts counts.with_indifferent_access
end end
def find_by!(*params) def find_by!(*params)
......
...@@ -134,10 +134,7 @@ module IssuablesHelper ...@@ -134,10 +134,7 @@ module IssuablesHelper
state_title = titles[state] || state.to_s.humanize state_title = titles[state] || state.to_s.humanize
count = count = cached_issuables_count_for_state(issuable_type, state)
Rails.cache.fetch(issuables_state_counter_cache_key(issuable_type, state), expires_in: 2.minutes) do
issuables_count_for_state(issuable_type, state)
end
html = content_tag(:span, state_title) html = content_tag(:span, state_title)
html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge') html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
...@@ -145,6 +142,12 @@ module IssuablesHelper ...@@ -145,6 +142,12 @@ module IssuablesHelper
html.html_safe html.html_safe
end end
def cached_issuables_count_for_state(issuable_type, state)
Rails.cache.fetch(issuables_state_counter_cache_key(issuable_type, state), expires_in: 2.minutes) do
issuables_count_for_state(issuable_type, state)
end
end
def cached_assigned_issuables_count(assignee, issuable_type, state) def cached_assigned_issuables_count(assignee, issuable_type, state)
cache_key = hexdigest(['assigned_issuables_count', assignee.id, issuable_type, state].join('-')) cache_key = hexdigest(['assigned_issuables_count', assignee.id, issuable_type, state].join('-'))
Rails.cache.fetch(cache_key, expires_in: 2.minutes) do Rails.cache.fetch(cache_key, expires_in: 2.minutes) do
......
module Emails
module CsvExport
def issues_csv_email(user, project, csv_data, export_status)
@project = project
@issues_count = export_status.fetch(:rows_expected)
@written_count = export_status.fetch(:rows_written)
@truncated = export_status.fetch(:truncated)
filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv"
attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
mail(to: user.notification_email, subject: subject("Exported issues")) do |format|
format.html { render layout: 'mailer' }
format.text { render layout: 'mailer' }
end
end
end
end
...@@ -3,6 +3,7 @@ class Notify < BaseMailer ...@@ -3,6 +3,7 @@ class Notify < BaseMailer
include Emails::AdminNotification include Emails::AdminNotification
include Emails::Issues include Emails::Issues
include Emails::CsvExport
include Emails::MergeRequests include Emails::MergeRequests
include Emails::Notes include Emails::Notes
include Emails::Projects include Emails::Projects
......
...@@ -178,6 +178,14 @@ module Issuable ...@@ -178,6 +178,14 @@ module Issuable
end end
end end
def labels_hash
issue_labels = Hash.new { |h, k| h[k] = [] }
eager_load(:labels).pluck(:id, 'labels.title').each do |issue_id, label|
issue_labels[issue_id] << label
end
issue_labels
end
# Includes table keys in group by clause when sorting # Includes table keys in group by clause when sorting
# preventing errors in postgres # preventing errors in postgres
# #
......
module Issues
class ExportCsvService
include Gitlab::Routing.url_helpers
include GitlabRoutingHelper
# Target attachment size before base64 encoding
TARGET_FILESIZE = 15000000
def initialize(issues_relation)
@issues = issues_relation
@labels = @issues.labels_hash
end
def csv_data
csv_builder.render(TARGET_FILESIZE)
end
def email(user, project)
Notify.issues_csv_email(user, project, csv_data, csv_builder.status).deliver_now
end
def csv_builder
@csv_builder ||=
CsvBuilder.new(@issues.includes(:author, :assignee), header_to_value_hash)
end
private
def header_to_value_hash
{
'Issue ID' => 'iid',
'URL' => -> (issue) { issue_url(issue) },
'Title' => 'title',
'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' },
'Description' => 'description',
'Author' => 'author_name',
'Author Username' => -> (issue) { issue.author&.username },
'Assignee' => 'assignee_name',
'Assignee Username' => -> (issue) { issue.assignee&.username },
'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' },
'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) },
'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) },
'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) },
'Milestone' => -> (issue) { issue.milestone&.title },
'Labels' => -> (issue) { @labels[issue.id].sort.join(',').presence }
}
end
end
end
%p{ style: 'font-size:18px; text-align:center; line-height:30px;' }
Your CSV export of #{ pluralize(@written_count, 'issue') } from project
%a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;" }
= @project.full_name
has been added to this email as an attachment.
- if @truncated
%p
This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 20MB. #{ @written_count } of #{ @issues_count } issues have been included. Consider re-exporting with a narrower selection of issues.
Your CSV export of <%= pluralize(@written_count, 'issue') %> from project <%= @project.full_name %> (<%= project_url(@project) %>) has been added to this email as an attachment.
<% if @truncated %>
This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 20MB. <%= @written_count %> of <%= @issues_count %> issues have been included. Consider re-exporting with a narrower selection of issues.
<% end %>
\ No newline at end of file
.issues-export-modal.modal
.modal-dialog
.modal-content
.modal-header
%a.close{ href: '#', 'data-dismiss' => 'modal' } ×
.export-svg-container.pull-right
= render 'projects/issues/export_issues/export_issues_list.svg'
%h3
Export issues
.modal-header
= icon('check', { class: 'export-checkmark' })
%strong
#{pluralize(cached_issuables_count_for_state(:issues, params[:state]), 'issue')} selected
.modal-body
%div
The CSV export will be created in the background. Once finished, it will be sent to
%strong= @current_user.notification_email
in an attachment.
.modal-footer
= link_to 'Export issues', export_csv_namespace_project_issues_path(@project.namespace, @project, params.permit(IssuableFinder::VALID_PARAMS)), method: :post, class: 'btn btn-success pull-left', title: 'Export issues'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 238 111" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="4" width="82" rx="3" height="28" fill="#fff"/><path id="5" d="m68.926 12.09v-2.41c0-.665-.437-.888-.975-.507l-6.552 4.631c-.542.383-.539.998 0 1.379l6.552 4.631c.542.383.975.154.975-.507v-2.41h4.874c.668 0 1.2-.538 1.2-1.201v-2.406c0-.668-.537-1.201-1.2-1.201h-4.874" fill="#fc8a51"/><path id="6" d="m4 24h74v-20h-74v20m-4-21c0-1.655 1.338-2.996 2.991-2.996h76.02c1.652 0 2.991 1.35 2.991 2.996v22.01c0 1.655-1.338 2.996-2.991 2.996h-76.02c-1.652 0-2.991-1.35-2.991-2.996v-22.01"/><circle id="2" cx="16" cy="14" r="7"/><circle id="0" cx="16" cy="14" r="7"/><mask id="3" width="14" height="14" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="1" width="14" height="14" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd"><rect width="98" height="111" fill="#fff" rx="6"/><path fill="#e5e5e5" fill-rule="nonzero" d="m4 6.01v98.99c0 1.11.897 2.01 2 2.01h85.998c1.105 0 2-.897 2-2.01v-98.99c0-1.11-.897-2.01-2-2.01h-85.998c-1.105 0-2 .897-2 2.01m-4 0c0-3.318 2.685-6.01 6-6.01h85.998c3.314 0 6 2.689 6 6.01v98.99c0 3.318-2.685 6.01-6 6.01h-85.998c-3.314 0-6-2.689-6-6.01v-98.99"/><rect width="76" height="85" x="11" y="12" fill="#f9f9f9" rx="3"/><g transform="translate(37 59)"><use xlink:href="#4"/><path fill="#e5e5e5" fill-rule="nonzero" d="m4 24h74v-20h-74v20m-4-21c0-1.655 1.338-2.996 2.991-2.996h76.02c1.652 0 2.991 1.35 2.991 2.996v22.01c0 1.655-1.338 2.996-2.991 2.996h-76.02c-1.652 0-2.991-1.35-2.991-2.996v-22.01"/><use fill="#fff" stroke="#6b4fbb" stroke-width="8" mask="url(#1)" xlink:href="#0"/><use xlink:href="#5"/></g><g transform="translate(140)"><path fill="#fff" d="m0 4h94v103h-94z"/><path fill="#e5e5e5" fill-rule="nonzero" d="m0 74v30.993c0 3.318 2.687 6.01 6 6.01h85.998c3.316 0 6-2.69 6-6.01v-98.99c0-3.318-2.687-6.01-6-6.01h-85.998c-3.316 0-6 2.69-6 6.01v.993h4v-.993c0-1.11.896-2.01 2-2.01h85.998c1.105 0 2 .897 2 2.01v98.99c0 1.11-.896 2.01-2 2.01h-85.998c-1.105 0-2-.897-2-2.01v-30.993h-4"/><g fill="#f9f9f9"><rect width="82" height="28" x="8" y="12" rx="3"/><rect width="82" height="28" x="8" y="43" rx="3"/></g></g><g fill-rule="nonzero" transform="translate(148 73)"><use fill="#e5e5e5" xlink:href="#6"/><path fill="#6b4fbb" d="m17 17c1.657 0 3-1.343 3-3 0-1.657-1.343-3-3-3-1.657 0-3 1.343-3 3 0 1.657 1.343 3 3 3m0 4c-3.866 0-7-3.134-7-7 0-3.866 3.134-7 7-7 3.866 0 7 3.134 7 7 0 3.866-3.134 7-7 7"/></g><g transform="translate(25 24)"><use xlink:href="#4"/><use fill="#e5e5e5" fill-rule="nonzero" xlink:href="#6"/><use fill="#fff" stroke="#6b4fbb" stroke-width="8" mask="url(#3)" xlink:href="#2"/><use xlink:href="#5"/></g><g transform="translate(107 10)"><use xlink:href="#4"/><use fill="#fc8a51" fill-opacity=".3" fill-rule="nonzero" xlink:href="#6"/><path fill="#6b4fbb" fill-rule="nonzero" d="m16 17c1.657 0 3-1.343 3-3 0-1.657-1.343-3-3-3-1.657 0-3 1.343-3 3 0 1.657 1.343 3 3 3m0 4c-3.866 0-7-3.134-7-7 0-3.866 3.134-7 7-7 3.866 0 7 3.134 7 7 0 3.866-3.134 7-7 7" id="7"/><use xlink:href="#5"/></g><g transform="translate(128 41)"><use xlink:href="#4"/><use fill="#fc8a51" fill-opacity=".3" fill-rule="nonzero" xlink:href="#6"/><use xlink:href="#7"/><path fill="#fc8a51" d="m66.926 12.09v-2.41c0-.665-.437-.888-.975-.507l-6.552 4.631c-.542.383-.539.998 0 1.379l6.552 4.631c.542.383.975.154.975-.507v-2.41h4.874c.668 0 1.2-.538 1.2-1.201v-2.406c0-.668-.537-1.201-1.2-1.201h-4.874"/></g></g></svg>
\ No newline at end of file
...@@ -8,17 +8,23 @@ ...@@ -8,17 +8,23 @@
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('filtered_search') = page_specific_javascript_bundle_tag('filtered_search')
= page_specific_javascript_bundle_tag('issues')
= content_for :meta_tags do = content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues") = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues")
- if project_issues(@project).exists? - if project_issues(@project).exists?
- if current_user
= render "projects/issues/export_issues/csv_download"
%div{ class: (container_class) } %div{ class: (container_class) }
.top-area .top-area
= render 'shared/issuable/nav', type: :issues = render 'shared/issuable/nav', type: :issues
.nav-controls .nav-controls
= link_to params.merge(rss_url_options), class: 'btn append-right-10 has-tooltip', title: 'Subscribe' do = link_to params.merge(rss_url_options), class: 'btn append-right-10 has-tooltip', title: 'Subscribe' do
= icon('rss') = icon('rss')
- if current_user
%button.csv_download_link.btn.append-right-10.has-tooltip{ title: 'Export as CSV' }
= icon('download')
- if can? current_user, :create_issue, @project - if can? current_user, :create_issue, @project
= link_to new_namespace_project_issue_path(@project.namespace, = link_to new_namespace_project_issue_path(@project.namespace,
@project, @project,
......
class ExportCsvWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
def perform(current_user_id, project_id, params)
@current_user = User.find(current_user_id)
@project = Project.find(project_id)
params[:project_id] = project_id
issues = IssuesFinder.new(@current_user, params.symbolize_keys).execute
Issues::ExportCsvService.new(issues).email(@current_user, @project)
end
end
---
title: Issues can be exported as CSV, via email
merge_request: 1126
author:
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
# :medium - Nov 10, 2007 # :medium - Nov 10, 2007
# :long - November 10, 2007 # :long - November 10, 2007
Date::DATE_FORMATS[:medium] = '%b %-d, %Y' Date::DATE_FORMATS[:medium] = '%b %-d, %Y'
Date::DATE_FORMATS[:csv] = '%Y-%m-%d'
# :short - 18 Jan 06:10 # :short - 18 Jan 06:10
# :medium - Jan 18, 2007 6:10am # :medium - Jan 18, 2007 6:10am
# :long - January 18, 2007 06:10 # :long - January 18, 2007 06:10
Time::DATE_FORMATS[:medium] = '%b %-d, %Y %-I:%M%P' Time::DATE_FORMATS[:medium] = '%b %-d, %Y %-I:%M%P'
Time::DATE_FORMATS[:csv] = '%Y-%m-%d %H:%M:%S'
...@@ -284,7 +284,8 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -284,7 +284,8 @@ constraints(ProjectUrlConstrainer.new) do
get :can_create_branch get :can_create_branch
end end
collection do collection do
post :bulk_update post :bulk_update
post :export_csv
end end
end end
......
...@@ -59,3 +59,4 @@ ...@@ -59,3 +59,4 @@
- [admin_emails, 1] - [admin_emails, 1]
- [geo_repository_update, 1] - [geo_repository_update, 1]
- [elastic_batch_project_indexer, 1] - [elastic_batch_project_indexer, 1]
- [export_csv, 1]
...@@ -32,6 +32,7 @@ var config = { ...@@ -32,6 +32,7 @@ var config = {
filtered_search: './filtered_search/filtered_search_bundle.js', filtered_search: './filtered_search/filtered_search_bundle.js',
graphs: './graphs/graphs_bundle.js', graphs: './graphs/graphs_bundle.js',
groups_list: './groups_list.js', groups_list: './groups_list.js',
issues: './issues/issues_bundle.js',
issuable: './issuable/issuable_bundle.js', issuable: './issuable/issuable_bundle.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
merge_request_widget: './merge_request_widget/ci_bundle.js', merge_request_widget: './merge_request_widget/ci_bundle.js',
......
# Generates CSV when given a collection and a mapping.
#
# Example:
#
# columns = {
# 'Title' => 'title',
# 'Comment' => 'comment',
# 'Author' => -> (post) { post.author.full_name }
# 'Created At (UTC)' => -> (post) { post.created_at&.strftime('%Y-%m-%d %H:%M:%S') }
# }
#
# CsvBuilder.new(@posts, columns).render
#
class CsvBuilder
#
# * +collection+ - The data collection to be used
# * +header_to_hash_value+ - A hash of 'Column Heading' => 'value_method'.
#
# The value method will be called once for each object in the collection, to
# determine the value for that row. It can either be the name of a method on
# the object, or a lamda to call passing in the object.
def initialize(collection, header_to_value_hash)
@header_to_value_hash = header_to_value_hash
@collection = collection
@truncated = false
@rows_written = 0
end
# Renders the csv to a string
def render(truncate_after_bytes = nil)
tempfile = Tempfile.new('csv_export')
csv = CSV.new(tempfile)
write_csv csv, until_condition: -> do
truncate_after_bytes && tempfile.size > truncate_after_bytes
end
tempfile.rewind
tempfile.read
ensure
tempfile.close
tempfile.unlink
end
def truncated?
@truncated
end
def rows_written
@rows_written
end
def rows_expected
if truncated? || rows_written == 0
@collection.count
else
rows_written
end
end
def status
{
truncated: truncated?,
rows_written: rows_written,
rows_expected: rows_expected
}
end
private
def headers
@headers ||= @header_to_value_hash.keys
end
def attributes
@attributes ||= @header_to_value_hash.values
end
def row(object)
attributes.map do |attribute|
if attribute.respond_to?(:call)
attribute.call(object)
else
object.public_send(attribute)
end
end
end
def write_csv(csv, until_condition:)
csv << headers
@collection.find_each do |object|
csv << row(object)
@rows_written += 1
if until_condition.call
@truncated = true
break
end
end
end
end
require 'spec_helper'
describe 'Issues csv', feature: true do
let(:user) { create(:user) }
let(:project) { create(:empty_project, :public) }
let(:milestone) { create(:milestone, title: 'v1.0', project: project) }
let(:idea_label) { create(:label, project: project, title: 'Idea') }
let(:feature_label) { create(:label, project: project, title: 'Feature') }
let!(:issue) { create(:issue, project: project, author: user) }
before { login_as(user) }
def request_csv(params = {})
visit namespace_project_issues_path(project.namespace, project, params)
click_on 'Export as CSV'
click_on 'Export issues'
end
def attachment
ActionMailer::Base.deliveries.last.attachments.first
end
def csv
CSV.parse(attachment.decode_body, headers: true)
end
it 'triggers an email export' do
expect(ExportCsvWorker).to receive(:perform_async).with(user.id, project.id, hash_including(project_id: project.id))
request_csv
end
it "doesn't send request params to ExportCsvWorker" do
expect(ExportCsvWorker).to receive(:perform_async).with(anything, anything, hash_excluding(controller: anything, action: anything))
request_csv
end
it 'displays flash message' do
request_csv
expect(page).to have_content 'CSV export has started'
expect(page).to have_content "emailed to #{user.notification_email}"
end
it 'includes a csv attachment' do
request_csv
expect(attachment.content_type).to include('text/csv')
end
it 'ignores pagination' do
create_list(:issue, 30, project: project, author: user)
request_csv
expect(csv.count).to eq 31
end
it 'uses filters from issue index' do
request_csv(state: :closed)
expect(csv.count).to eq 0
end
it 'avoids excessive database calls' do
control_count = ActiveRecord::QueryRecorder.new{ request_csv }.count
create_list(:labeled_issue,
10,
project: project,
assignee: user,
author: user,
milestone: milestone,
labels: [feature_label, idea_label])
expect{ request_csv }.not_to exceed_query_limit(control_count + 5)
end
end
require 'spec_helper'
describe CsvBuilder, lib: true do
let(:object) { double(question: :answer) }
let(:fake_relation) { FakeRelation.new([object]) }
let(:subject) { CsvBuilder.new(fake_relation, 'Q & A' => :question, 'Reversed' => -> (o) { o.question.to_s.reverse }) }
let(:csv_data) { subject.render }
class FakeRelation < Array
def find_each(&block)
each(&block)
end
end
it 'generates a csv' do
expect(csv_data.scan(/(,|\n)/).join).to include ",\n,"
end
it 'uses a temporary file to reduce memory allocation' do
expect(CSV).to receive(:new).with(instance_of(Tempfile)).and_call_original
subject.render
end
it 'counts the number of rows' do
subject.render
expect(subject.rows_written).to eq 1
end
describe 'rows_expected' do
it 'uses rows_written if CSV rendered successfully' do
subject.render
expect(fake_relation).not_to receive(:count)
expect(subject.rows_expected).to eq 1
end
it 'falls back to calling .count before rendering begins' do
expect(subject.rows_expected).to eq 1
end
end
describe 'truncation' do
let(:big_object) { double(question: 'Long' * 1024) }
let(:row_size) { big_object.question.length * 2 }
let(:fake_relation) { FakeRelation.new([big_object, big_object, big_object]) }
it 'occurs after given number of bytes' do
expect(subject.render(row_size * 2).length).to be_between(row_size * 2, row_size * 3)
expect(subject).to be_truncated
expect(subject.rows_written).to eq 2
end
it 'is ignored by default' do
expect(subject.render.length).to be > row_size * 3
expect(subject.rows_written).to eq 3
end
it 'causes rows_expected to fall back to .count' do
subject.render(0)
expect(fake_relation).to receive(:count).and_call_original
expect(subject.rows_expected).to eq 3
end
end
it 'avoids loading all data in a single query' do
expect(fake_relation).to receive(:find_each)
subject.render
end
it 'uses hash keys as headers' do
expect(csv_data).to start_with 'Q & A'
end
it 'gets data by calling method provided as hash value' do
expect(csv_data).to include 'answer'
end
it 'allows lamdas to look up more complicated data' do
expect(csv_data).to include 'rewsna'
end
end
require 'spec_helper'
require 'email_spec'
describe Notify do
include EmailSpec::Matchers
include_context 'gitlab email notification'
describe 'csv export email' do
let(:user) { create(:user) }
let(:empty_project) { create(:empty_project, path: 'myproject') }
let(:export_status) { { truncated: false, rows_expected: 3, rows_written: 3 } }
subject { Notify.issues_csv_email(user, empty_project, "dummy content", export_status) }
let(:attachment) { subject.attachments.first }
it 'attachment has csv mime type' do
expect(attachment.mime_type).to eq 'text/csv'
end
it 'generates a useful filename' do
expect(attachment.filename).to include(Date.today.year.to_s)
expect(attachment.filename).to include('issues')
expect(attachment.filename).to include('myproject')
expect(attachment.filename).to end_with('.csv')
end
it 'mentions number of issues and project name' do
expect(subject).to have_content '3'
expect(subject).to have_content empty_project.name
end
it "doesn't need to mention truncation by default" do
expect(subject).not_to have_content 'truncated'
end
context 'when truncated' do
let(:export_status) { { truncated: true, rows_expected: 12, rows_written: 10 } }
it 'mentions that the csv has been truncated' do
expect(subject).to have_content 'truncated'
end
it 'mentions the number of issues written and expected' do
expect(subject).to have_content '10 of 12 issues'
end
end
end
end
class IssuesCsvMailerPreview < ActionMailer::Preview
def issues_csv_export
user = OpenStruct.new(notification_email: 'a@example.com')
project = Project.unscoped.first
Notify.issues_csv_email(user, project, "Dummy,Csv\n0,1", export_status)
end
private
def export_status
{
truncated: [true, false].sample,
rows_written: 632,
rows_expected: 891
}
end
end
...@@ -331,6 +331,16 @@ describe Issue, "Issuable" do ...@@ -331,6 +331,16 @@ describe Issue, "Issuable" do
end end
end end
describe '.labels_hash' do
let(:feature_label) { create(:label, title: 'Feature') }
let!(:issues) { create_list(:labeled_issue, 3, labels: [feature_label]) }
it 'maps issue ids to labels titles' do
issue_id = issues.first.id
expect(Issue.labels_hash[issue_id]).to eq ['Feature']
end
end
describe '#user_notes_count' do describe '#user_notes_count' do
let(:project) { create(:empty_project) } let(:project) { create(:empty_project) }
let(:issue1) { create(:issue, project: project) } let(:issue1) { create(:issue, project: project) }
......
require 'spec_helper'
describe Issues::ExportCsvService, services: true do
let(:user) { create(:user) }
let(:project) { create(:empty_project, :public) }
let!(:issue) { create(:issue, project: project, author: user) }
let(:subject) { described_class.new(Issue.all) }
it 'renders csv to string' do
expect(subject.csv_data).to be_a String
end
describe '#email' do
it 'emails csv' do
expect{ subject.email(user, project) }.to change(ActionMailer::Base.deliveries, :count)
end
it 'renders with a target filesize' do
expect(subject.csv_builder).to receive(:render).with(described_class::TARGET_FILESIZE)
subject.email(user, project)
end
end
def csv
CSV.parse(subject.csv_data, headers: true)
end
context 'includes' do
let(:milestone) { create(:milestone, title: 'v1.0', project: project) }
let(:idea_label) { create(:label, project: project, title: 'Idea') }
let(:feature_label) { create(:label, project: project, title: 'Feature') }
before do
issue.update!(milestone: milestone,
assignee: user,
description: 'Issue with details',
state: :reopened,
due_date: DateTime.new(2014, 3, 2),
created_at: DateTime.new(2015, 4, 3, 2, 1, 0),
updated_at: DateTime.new(2016, 5, 4, 3, 2, 1),
labels: [feature_label, idea_label])
end
specify 'iid' do
expect(csv[0]['Issue ID']).to eq issue.iid.to_s
end
specify 'url' do
expect(csv[0]['URL']).to match(/http.*#{project.full_path}.*#{issue.iid}/)
end
specify 'title' do
expect(csv[0]['Title']).to eq issue.title
end
specify 'state' do
expect(csv[0]['State']).to eq 'Open'
end
specify 'description' do
expect(csv[0]['Description']).to eq issue.description
end
specify 'author name' do
expect(csv[0]['Author']).to eq issue.author_name
end
specify 'author username' do
expect(csv[0]['Author Username']).to eq issue.author.username
end
specify 'assignee name' do
expect(csv[0]['Assignee']).to eq issue.assignee_name
end
specify 'assignee username' do
expect(csv[0]['Assignee Username']).to eq issue.assignee.username
end
specify 'confidential' do
expect(csv[0]['Confidential']).to eq 'No'
end
specify 'milestone' do
expect(csv[0]['Milestone']).to eq issue.milestone.title
end
specify 'labels' do
expect(csv[0]['Labels']).to eq 'Feature,Idea'
end
specify 'due_date' do
expect(csv[0]['Due Date']).to eq '2014-03-02'
end
specify 'created_at' do
expect(csv[0]['Created At (UTC)']).to eq '2015-04-03 02:01:00'
end
specify 'updated_at' do
expect(csv[0]['Updated At (UTC)']).to eq '2016-05-04 03:02:01'
end
end
context 'with minimal details' do
it 'renders labels as nil' do
expect(csv[0]['Labels']).to eq nil
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