Commit 4ae20b52 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '290823-allow-selecting-fields-for-requirements-export-be' into 'master'

GraphQL: Add fields selection to export requirements mutation

See merge request gitlab-org/gitlab!52706
parents fe4bb060 28c750a5
...@@ -10,29 +10,21 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker ...@@ -10,29 +10,21 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
def perform(type, current_user_id, project_id, params) def perform(type, current_user_id, project_id, params)
user = User.find(current_user_id) user = User.find(current_user_id)
project = Project.find(project_id) project = Project.find(project_id)
finder_params = map_params(params, project_id)
export_service(type.to_sym, user, project, finder_params).email(user) export_service(type, user, project, params).email(user)
rescue ActiveRecord::RecordNotFound => error rescue ActiveRecord::RecordNotFound => error
logger.error("Failed to export CSV (current_user_id:#{current_user_id}, project_id:#{project_id}): #{error.message}") logger.error("Failed to export CSV (current_user_id:#{current_user_id}, project_id:#{project_id}): #{error.message}")
end end
private private
def map_params(params, project_id)
params
.symbolize_keys
.except(:sort)
.merge(project_id: project_id)
end
def export_service(type, user, project, params) def export_service(type, user, project, params)
issuable_class = service_classes_for(type) issuable_classes = issuable_classes_for(type.to_sym)
issuables = issuable_class[:finder].new(user, params).execute issuables = issuable_classes[:finder].new(user, parse_params(params, project.id)).execute
issuable_class[:service].new(issuables, project) issuable_classes[:service].new(issuables, project)
end end
def service_classes_for(type) def issuable_classes_for(type)
case type case type
when :issue when :issue
{ finder: IssuesFinder, service: Issues::ExportCsvService } { finder: IssuesFinder, service: Issues::ExportCsvService }
...@@ -43,6 +35,13 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker ...@@ -43,6 +35,13 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
end end
end end
def parse_params(params, project_id)
params
.symbolize_keys
.except(:sort)
.merge(project_id: project_id)
end
def type_error_message(type) def type_error_message(type)
"Type parameter must be :issue or :merge_request, it was #{type}" "Type parameter must be :issue or :merge_request, it was #{type}"
end end
......
...@@ -9983,6 +9983,11 @@ input ExportRequirementsInput { ...@@ -9983,6 +9983,11 @@ input ExportRequirementsInput {
""" """
search: String search: String
"""
List of selected requirements fields to be exported.
"""
selectedFields: [String!]
""" """
List requirements by sort order. List requirements by sort order.
""" """
......
...@@ -27180,6 +27180,24 @@ ...@@ -27180,6 +27180,24 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "selectedFields",
"description": "List of selected requirements fields to be exported.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
{ {
"name": "clientMutationId", "name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.", "description": "A unique identifier for the client performing the mutation.",
...@@ -14,9 +14,30 @@ module Mutations ...@@ -14,9 +14,30 @@ module Mutations
required: true, required: true,
description: 'Full project path the requirements are associated with.' description: 'Full project path the requirements are associated with.'
argument :selected_fields, [GraphQL::STRING_TYPE],
required: false,
description: 'List of selected requirements fields to be exported.'
def ready?(**args)
if args[:selected_fields].present?
invalid_fields =
::RequirementsManagement::MapExportFieldsService.new(args[:selected_fields]).invalid_fields
if invalid_fields.any?
message = "The following fields are incorrect: #{invalid_fields.join(', ')}."\
" See https://docs.gitlab.com/ee/user/project/requirements/#exported-csv-file-format"\
" for permitted fields."
raise Gitlab::Graphql::Errors::ArgumentError, message
end
end
super
end
def resolve(args) def resolve(args)
project_path = args.delete(:project_path) project_path = args.delete(:project_path)
project = authorized_find!(project_path) project = authorized_find!(project_path)
IssuableExportCsvWorker.perform_async(:requirement, current_user.id, project.id, args) IssuableExportCsvWorker.perform_async(:requirement, current_user.id, project.id, args)
{ {
......
...@@ -2,6 +2,12 @@ ...@@ -2,6 +2,12 @@
module RequirementsManagement module RequirementsManagement
class ExportCsvService < ::Issuable::ExportCsv::BaseService class ExportCsvService < ::Issuable::ExportCsv::BaseService
def initialize(issuables_relation, project, fields = [])
super(issuables_relation, project)
@fields = fields
end
def email(user) def email(user)
Notify.requirements_csv_email(user, project, csv_data, csv_builder.status).deliver_now Notify.requirements_csv_email(user, project, csv_data, csv_builder.status).deliver_now
end end
...@@ -13,16 +19,7 @@ module RequirementsManagement ...@@ -13,16 +19,7 @@ module RequirementsManagement
end end
def header_to_value_hash def header_to_value_hash
{ RequirementsManagement::MapExportFieldsService.new(@fields).execute
'Requirement ID' => 'iid',
'Title' => 'title',
'Description' => 'description',
'Author' => -> (requirement) { requirement.author&.name },
'Author Username' => -> (requirement) { requirement.author&.username },
'Created At (UTC)' => -> (requirement) { requirement.created_at.utc },
'State' => -> (requirement) { requirement.last_test_report_state == 'passed' ? 'Satisfied' : '' },
'State Updated At (UTC)' => -> (requirement) { requirement.latest_report&.created_at&.utc }
}
end end
end end
end end
# frozen_string_literal: true
module RequirementsManagement
class MapExportFieldsService < BaseService
attr_reader :fields
def initialize(fields)
@fields = fields
end
def execute
return header_to_value_hash if fields.empty?
selected_fields_to_hash
end
def invalid_fields
fields.reject { |field| permitted_field?(field) }
end
private
def header_to_value_hash
@header_to_value_hash ||= {
'Requirement ID' => 'iid',
'Title' => 'title',
'Description' => 'description',
'Author' => -> (requirement) { requirement.author&.name },
'Author Username' => -> (requirement) { requirement.author&.username },
'Created At (UTC)' => -> (requirement) { requirement.created_at.utc },
'State' => -> (requirement) { requirement.last_test_report_state == 'passed' ? 'Satisfied' : '' },
'State Updated At (UTC)' => -> (requirement) { requirement.latest_report&.created_at&.utc }
}
end
def selected_fields_to_hash
header_to_value_hash.select { |key| requested_field?(key) }
end
def requested_field?(field)
field.downcase.in?(fields.map(&:downcase))
end
def permitted_field?(field)
field.downcase.in?(keys.map(&:downcase))
end
def keys
header_to_value_hash.keys
end
end
end
...@@ -9,8 +9,18 @@ module EE ...@@ -9,8 +9,18 @@ module EE
private private
override :service_classes_for override :export_service
def service_classes_for(type) def export_service(type, user, project, params)
return super unless type == :requirement
fields = params.with_indifferent_access.delete(:selected_fields) || []
issuable_classes = issuable_classes_for(type.to_sym)
issuables = issuable_classes[:finder].new(user, parse_params(params, project.id)).execute
issuable_classes[:service].new(issuables, project, fields)
end
override :issuable_classes_for
def issuable_classes_for(type)
return super unless type == :requirement return super unless type == :requirement
{ finder: ::RequirementsManagement::RequirementsFinder, service: ::RequirementsManagement::ExportCsvService } { finder: ::RequirementsManagement::RequirementsFinder, service: ::RequirementsManagement::ExportCsvService }
......
---
title: 'Allows fields selection when exporting Requirements with GraphQL'
merge_request: 52706
author:
type: added
...@@ -5,10 +5,33 @@ require 'spec_helper' ...@@ -5,10 +5,33 @@ require 'spec_helper'
RSpec.describe Mutations::RequirementsManagement::ExportRequirements do RSpec.describe Mutations::RequirementsManagement::ExportRequirements do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:requirement) { create(:requirement, project: project) } let(:fields) { [] }
let(:args) do
{
project_path: project.full_path,
author_username: user.username,
state: 'OPENED',
search: 'foo',
selected_fields: fields
}
end
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
describe '#ready' do
context 'with selected fields argument' do
let(:fields) { ['title', 'description', 'created at', 'username'] }
it 'raises exception when invalid fields are given' do
expect { mutation.ready?(**args) }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError,
"The following fields are incorrect: created at, username."\
" See https://docs.gitlab.com/ee/user/project/requirements/#exported-csv-file-format"\
" for permitted fields.")
end
end
end
describe '#resolve' do describe '#resolve' do
shared_examples 'requirements not available' do shared_examples 'requirements not available' do
it 'raises a not accessible error' do it 'raises a not accessible error' do
...@@ -16,18 +39,11 @@ RSpec.describe Mutations::RequirementsManagement::ExportRequirements do ...@@ -16,18 +39,11 @@ RSpec.describe Mutations::RequirementsManagement::ExportRequirements do
end end
end end
subject do subject { mutation.resolve(**args) }
mutation.resolve(
project_path: project.full_path,
author_username: user.username,
state: 'OPENED',
search: 'foo'
)
end
it_behaves_like 'requirements not available' it_behaves_like 'requirements not available'
context 'when the user can update the requirement' do context 'when the user can export requirements' do
before do before do
project.add_developer(user) project.add_developer(user)
end end
...@@ -37,11 +53,9 @@ RSpec.describe Mutations::RequirementsManagement::ExportRequirements do ...@@ -37,11 +53,9 @@ RSpec.describe Mutations::RequirementsManagement::ExportRequirements do
stub_licensed_features(requirements: true) stub_licensed_features(requirements: true)
end end
it 'export requirements' do it 'exports requirements' do
args = { author_username: user.username, state: 'OPENED', search: 'foo' }
expect(IssuableExportCsvWorker).to receive(:perform_async) expect(IssuableExportCsvWorker).to receive(:perform_async)
.with(:requirement, user.id, project.id, args) .with(:requirement, user.id, project.id, args.except(:project_path))
subject subject
end end
......
...@@ -7,8 +7,9 @@ RSpec.describe RequirementsManagement::ExportCsvService do ...@@ -7,8 +7,9 @@ RSpec.describe RequirementsManagement::ExportCsvService do
let_it_be(:group) { create_default(:group) } let_it_be(:group) { create_default(:group) }
let_it_be(:project) { create_default(:project, :public) } let_it_be(:project) { create_default(:project, :public) }
let_it_be_with_reload(:requirement) { create(:requirement, state: :opened, author: user) } let_it_be_with_reload(:requirement) { create(:requirement, state: :opened, author: user) }
let(:fields) { [] }
subject { described_class.new(RequirementsManagement::Requirement.all, project) } subject { described_class.new(RequirementsManagement::Requirement.all, project, fields) }
before do before do
stub_licensed_features(requirements: true) stub_licensed_features(requirements: true)
...@@ -37,7 +38,7 @@ RSpec.describe RequirementsManagement::ExportCsvService do ...@@ -37,7 +38,7 @@ RSpec.describe RequirementsManagement::ExportCsvService do
end end
context 'includes' do context 'includes' do
let_it_be(:report) { create(:test_report, requirement: requirement, state: :failed, build: nil, author: user) } let_it_be(:report) { create(:test_report, requirement: requirement, state: :passed, build: nil, author: user) }
let(:time_format) { '%Y-%m-%d %H:%M:%S %Z' } let(:time_format) { '%Y-%m-%d %H:%M:%S %Z' }
it 'includes the columns required for import' do it 'includes the columns required for import' do
...@@ -95,5 +96,18 @@ RSpec.describe RequirementsManagement::ExportCsvService do ...@@ -95,5 +96,18 @@ RSpec.describe RequirementsManagement::ExportCsvService do
expect(csv[0]['State Updated At (UTC)']).to eq report.created_at.utc.strftime(time_format) expect(csv[0]['State Updated At (UTC)']).to eq report.created_at.utc.strftime(time_format)
end end
end end
context 'when selected fields are present' do
let(:fields) { ['Title', 'Author username', 'created at', 'state', 'State updated At (UTC)'] }
it 'returns data for requested fields' do
expect(csv[0].to_hash).to eq(
'Title' => requirement.title,
'Author Username' => requirement.author.username,
'State' => 'Satisfied',
'State Updated At (UTC)' => report.created_at.utc.strftime(time_format)
)
end
end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe RequirementsManagement::MapExportFieldsService do
let(:selected_fields) { ['Title', 'Author username', 'state'] }
let(:invalid_fields) { ['Title', 'Author Username', 'State', 'Invalid Field', 'Other Field'] }
let(:available_fields) do
[
'Requirement ID',
'Title',
'Description',
'Author',
'Author Username',
'Created At (UTC)',
'State',
'State Updated At (UTC)'
]
end
describe '#execute' do
it 'returns a hash with selected fields only' do
result = described_class.new(selected_fields).execute
expect(result).to be_a(Hash)
expect(result.keys).to match_array(selected_fields.map(&:titleize))
end
context 'when the fields collection is empty' do
it 'returns a hash with all fields' do
result = described_class.new([]).execute
expect(result).to be_a(Hash)
expect(result.keys).to match_array(available_fields)
end
end
context 'when fields collection includes invalid fields' do
it 'returns a hash with valid selected fields only' do
result = described_class.new(invalid_fields).execute
expect(result).to be_a(Hash)
expect(result.keys).to eq(selected_fields.map(&:titleize))
end
end
end
describe '#invalid_fields' do
it 'returns an array containing invalid fields' do
result = described_class.new(invalid_fields).invalid_fields
expect(result).to match_array(['Invalid Field', 'Other Field'])
end
end
end
...@@ -17,7 +17,8 @@ RSpec.describe IssuableExportCsvWorker do ...@@ -17,7 +17,8 @@ RSpec.describe IssuableExportCsvWorker do
end end
it 'calls the Requirements export service' do it 'calls the Requirements export service' do
expect(RequirementsManagement::ExportCsvService).to receive(:new).with(anything, project).once.and_call_original expect(RequirementsManagement::ExportCsvService)
.to receive(:new).with(anything, project, []).once.and_call_original
subject subject
end end
...@@ -28,6 +29,19 @@ RSpec.describe IssuableExportCsvWorker do ...@@ -28,6 +29,19 @@ RSpec.describe IssuableExportCsvWorker do
subject subject
end end
context 'with selected fields are present' do
let(:selected_fields) { %w(Title Description State') }
it 'calls the Requirements export service with selected fields' do
params[:selected_fields] = selected_fields
expect(RequirementsManagement::ExportCsvService)
.to receive(:new).with(anything, project, selected_fields).once.and_call_original
subject
end
end
context 'with record not found' do context 'with record not found' do
let(:logger) { described_class.new.send(:logger) } let(:logger) { described_class.new.send(:logger) }
......
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