diff --git a/app/assets/javascripts/work_items/components/work_item_list_actions.vue b/app/assets/javascripts/work_items/components/work_item_list_actions.vue index 8ae810a0ee1ba865facbf1db6c58c47f4f52bcdb..0bb7fe817fa551d4037497a9a85f5da381837296 100644 --- a/app/assets/javascripts/work_items/components/work_item_list_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_list_actions.vue @@ -8,18 +8,22 @@ import { } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import WorkItemCsvExportModal from './work_items_csv_export_modal.vue'; +import WorkItemsCsvImportModal from './work_items_csv_import_modal.vue'; export default { exportModalId: 'work-item-export-modal', + importModalId: 'work-item-import-modal', components: { GlDisclosureDropdownItem, GlDisclosureDropdown, GlDisclosureDropdownGroup, WorkItemCsvExportModal, + WorkItemsCsvImportModal, }, i18n: { exportAsCSV: s__('WorkItem|Export as CSV'), importFromJira: s__('WorkItem|Import from Jira'), + importCsv: s__('WorkItem|Import CSV'), }, directives: { GlModal: GlModalDirective, @@ -38,6 +42,9 @@ export default { calendarPath: { default: null, }, + canImportWorkItems: { + default: false, + }, }, props: { workItemCount: { @@ -55,6 +62,11 @@ export default { required: false, default: false, }, + fullPath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -68,6 +80,11 @@ export default { href: this.projectImportJiraPath, }; }, + importCsv() { + return { + text: this.$options.i18n.importCsv, + }; + }, exportAsCSV() { return { text: this.$options.i18n.exportAsCSV, @@ -96,7 +113,10 @@ export default { return this.rssPath || this.calendarPath; }, hasImportExportOptions() { - return this.showImportExportButtons && (this.projectImportJiraPath || this.showExportButton); + return ( + this.showImportExportButtons && + (Boolean(this.projectImportJiraPath) || this.showExportButton || this.canImportWorkItems) + ); }, shouldShowDropdown() { return this.hasImportExportOptions || this.hasSubscriptionOptions; @@ -143,12 +163,25 @@ export default { :item="exportAsCSV" /> + + + + +import { GlModal, GlFormGroup } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __, s__, sprintf } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import workItemsCsvImportMutation from '../graphql/work_items_csv_import.mutation.graphql'; + +export default { + i18n: { + maximumFileSizeText: __('The maximum file size allowed is %{size}.'), + importWorkItemsText: s__('WorkItem|Import work items'), + importIssuesText: __('Import issues'), + uploadCsvFileText: __('Upload CSV file'), + workItemMainText: s__( + "WorkItem|Your work items will be imported in the background. Once finished, you'll get a confirmation email.", + ), + workItemHelpText: s__( + 'WorkItem|It must have a header row and at least two columns: the first column is the work item title and the second column is the work item description. The separator is automatically detected.', + ), + issuesMainText: __( + "Your issues will be imported in the background. Once finished, you'll get a confirmation email.", + ), + issuesHelpText: __( + '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.', + ), + }, + actionCancel: { + text: __('Cancel'), + }, + components: { + GlModal, + GlFormGroup, + }, + mixins: [glFeatureFlagMixin()], + inject: { + maxAttachmentSize: { + default: 0, + }, + }, + props: { + modalId: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + }, + data() { + return { + isImporting: false, + selectedFile: null, + }; + }, + computed: { + isPlanningViewsEnabled() { + return this.glFeatures.workItemPlanningView; + }, + maxFileSizeText() { + return sprintf(this.$options.i18n.maximumFileSizeText, { size: this.maxAttachmentSize }); + }, + actionPrimary() { + return { + text: this.isPlanningViewsEnabled + ? this.$options.i18n.importWorkItemsText + : this.$options.i18n.importIssuesText, + attributes: { + variant: 'confirm', + loading: this.isImporting, + 'data-testid': 'import-work-items-button', + 'data-track-action': 'click_button', + 'data-track-label': 'import_work_items_csv', + }, + }; + }, + modalTitle() { + return this.isPlanningViewsEnabled + ? this.$options.i18n.importWorkItemsText + : this.$options.i18n.importIssuesText; + }, + descriptionText() { + return this.isPlanningViewsEnabled + ? this.$options.i18n.workItemMainText + : this.$options.i18n.issuesMainText; + }, + helpText() { + return this.isPlanningViewsEnabled + ? this.$options.i18n.workItemHelpText + : this.$options.i18n.issuesHelpText; + }, + }, + methods: { + onFileChange(event) { + const files = event.target?.files; + this.selectedFile = files.length > 0 ? files[0] : null; + }, + async importWorkItems() { + if (!this.selectedFile) { + createAlert({ + message: s__('WorkItem|Please select a file to import.'), + }); + return; + } + + this.isImporting = true; + + try { + const { data } = await this.$apollo.mutate({ + mutation: workItemsCsvImportMutation, + variables: { + input: { + projectPath: this.fullPath, + file: this.selectedFile, + }, + }, + context: { + hasUpload: true, + }, + }); + + const { message } = data.workItemsCsvImport; + + if (message) { + createAlert({ + message, + variant: 'success', + }); + this.$refs.modal?.hide(); + this.selectedFile = null; + if (this.$refs.fileInput) { + this.$refs.fileInput.value = ''; + } + } + } catch (error) { + createAlert({ + message: this.isPlanningViewsEnabled + ? s__('WorkItem|An error occurred while importing work items.') + : s__('Issues|An error occurred while importing issues.'), + }); + } finally { + this.isImporting = false; + } + }, + }, +}; + + + diff --git a/app/assets/javascripts/work_items/graphql/work_items_csv_import.mutation.graphql b/app/assets/javascripts/work_items/graphql/work_items_csv_import.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..825be38bed5d81e404cd6ba4cb301a6cb17f8295 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_items_csv_import.mutation.graphql @@ -0,0 +1,6 @@ +mutation WorkItemsCsvImport($input: WorkItemsCsvImportInput!) { + workItemsCsvImport(input: $input) { + message + errors + } +} diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 89cf188c4506954bf1392f48a61fcf5a40145302..f8256b3b583b19ae5bdca13a4cbcf0a227001d28 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -73,6 +73,8 @@ export const initWorkItemsRoot = ({ workItemType, workspaceType, withTabs } = {} projectImportJiraPath, rssPath, calendarPath, + maxAttachmentSize, + canImportWorkItems, } = el.dataset; const isGroup = workspaceType === WORKSPACE_GROUP; @@ -156,6 +158,8 @@ export const initWorkItemsRoot = ({ workItemType, workspaceType, withTabs } = {} projectImportJiraPath, rssPath, calendarPath, + maxAttachmentSize, + canImportWorkItems: parseBoolean(canImportWorkItems), }, mounted() { performanceMarkAndMeasure({ diff --git a/app/assets/javascripts/work_items/pages/work_items_list_app.vue b/app/assets/javascripts/work_items/pages/work_items_list_app.vue index 5e65d0aa3a1363a37f51ba612b7dbd92dff93353..b3dda9bb6c07499037e2168131e687c4ed331691 100644 --- a/app/assets/javascripts/work_items/pages/work_items_list_app.vue +++ b/app/assets/javascripts/work_items/pages/work_items_list_app.vue @@ -1318,6 +1318,7 @@ export default { :show-import-export-buttons="showImportExportButtons" :work-item-count="currentTabCount" :query-variables="csvExportQueryVariables" + :full-path="rootPageFullPath" /> @@ -1354,6 +1355,7 @@ export default { :show-import-export-buttons="showImportExportButtons" :work-item-count="currentTabCount" :query-variables="csvExportQueryVariables" + :full-path="rootPageFullPath" /> diff --git a/app/helpers/work_items_helper.rb b/app/helpers/work_items_helper.rb index 50fb7716e427dc6661b7b1a930dce6e983874194..4b17874e1110fab6e700117b3fb90eeeee72ec9b 100644 --- a/app/helpers/work_items_helper.rb +++ b/app/helpers/work_items_helper.rb @@ -13,6 +13,7 @@ def work_items_data(resource_parent, current_user) data[:project_import_jira_path] = project_import_jira_path(resource_parent) data[:rss_path] = project_work_items_path(resource_parent, format: :atom) data[:calendar_path] = project_work_items_path(resource_parent, format: :ics) + data[:can_import_work_items] = can?(current_user, :import_work_items, resource_parent).to_s end end end @@ -60,6 +61,7 @@ def base_data(resource_parent, current_user, group) group_id: group&.id, time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s, can_read_crm_contact: can?(current_user, :read_crm_contact, resource_parent.crm_group).to_s, + max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), can_read_crm_organization: can?(current_user, :read_crm_organization, resource_parent.crm_group).to_s } end diff --git a/ee/spec/helpers/boards_helper_spec.rb b/ee/spec/helpers/boards_helper_spec.rb index cc2c4d447034295013433292f100ec00d0e7cf06..5fa4778cd01356bb764671f809a31638284d1944 100644 --- a/ee/spec/helpers/boards_helper_spec.rb +++ b/ee/spec/helpers/boards_helper_spec.rb @@ -59,6 +59,7 @@ allow(helper).to receive(:can?).with(user, :create_projects, project.group).and_return(false) allow(helper).to receive(:can?).with(user, :read_crm_organization, project.crm_group).and_return(false) allow(helper).to receive(:can?).with(user, :read_crm_contact, project.crm_group).and_return(false) + allow(helper).to receive(:can?).with(user, :import_work_items, project).and_return(false) end shared_examples 'serializes the availability of a licensed feature' do |feature_name, feature_key| @@ -155,6 +156,7 @@ allow(helper).to receive(:can?).with(user, :create_projects, group).and_return(false) allow(helper).to receive(:can?).with(user, :read_crm_organization, group).and_return(false) allow(helper).to receive(:can?).with(user, :read_crm_contact, group).and_return(false) + allow(helper).to receive(:can?).with(user, :import_work_items, project).and_return(false) end it 'returns the correct permission for creating an epic from board' do diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 03b6c729394e1e9213b8b49cccb6d787329189b8..5cce0cd722096a900f2b045cc180732db7e2b5c3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -35606,6 +35606,9 @@ msgstr "" msgid "IssuesAnalytics|Total:" msgstr "" +msgid "Issues|An error occurred while importing issues." +msgstr "" + msgid "Issues|Move selected" msgstr "" @@ -72589,6 +72592,9 @@ msgstr "" msgid "WorkItem|An error occurred while getting work item counts." msgstr "" +msgid "WorkItem|An error occurred while importing work items." +msgstr "" + msgid "WorkItem|An error occurred while making status default." msgstr "" @@ -72931,9 +72937,15 @@ msgstr "" msgid "WorkItem|How do I use custom fields?" msgstr "" +msgid "WorkItem|Import CSV" +msgstr "" + msgid "WorkItem|Import from Jira" msgstr "" +msgid "WorkItem|Import work items" +msgstr "" + msgid "WorkItem|In progress" msgstr "" @@ -72954,6 +72966,9 @@ msgstr[1] "" msgid "WorkItem|Issues settings" msgstr "" +msgid "WorkItem|It must have a header row and at least two columns: the first column is the work item title and the second column is the work item description. The separator is automatically detected." +msgstr "" + msgid "WorkItem|Iteration" msgstr "" @@ -73193,6 +73208,9 @@ msgstr "" msgid "WorkItem|Please review the %{linkStart}contribution guidelines%{linkEnd} for this project." msgstr "" +msgid "WorkItem|Please select a file to import." +msgstr "" + msgid "WorkItem|Progress" msgstr "" @@ -73598,6 +73616,9 @@ msgstr "" msgid "WorkItem|Your data might be out of date. Refresh to see the latest information." msgstr "" +msgid "WorkItem|Your work items will be imported in the background. Once finished, you'll get a confirmation email." +msgstr "" + msgid "WorkItem|Zoom link" msgstr "" diff --git a/spec/frontend/work_items/components/work_item_list_actions_spec.js b/spec/frontend/work_items/components/work_item_list_actions_spec.js index 103e7aed4aaeff57cdda8fe2c4d22f3720101377..5d800c03af3439cb9f5b3437cfcf2122c71f3341 100644 --- a/spec/frontend/work_items/components/work_item_list_actions_spec.js +++ b/spec/frontend/work_items/components/work_item_list_actions_spec.js @@ -3,6 +3,7 @@ import { createMockDirective } from 'helpers/vue_mock_directive'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import WorkItemListActions from '~/work_items/components/work_item_list_actions.vue'; import WorkItemCsvExportModal from '~/work_items/components/work_items_csv_export_modal.vue'; +import WorkItemsCsvImportModal from '~/work_items/components/work_items_csv_import_modal.vue'; describe('WorkItemsListActions component', () => { let wrapper; @@ -11,6 +12,7 @@ describe('WorkItemsListActions component', () => { const projectImportJiraPath = 'gitlab-org/gitlab-test/-/import/jira'; const rssPath = '/rss/path'; const calendarPath = '/calendar/path'; + const fullPath = 'gitlab-org/gitlab-test'; const workItemCount = 10; const showImportExportButtons = true; @@ -30,11 +32,13 @@ describe('WorkItemsListActions component', () => { projectImportJiraPath: null, rssPath: null, calendarPath: null, + canImportWorkItems: false, ...injectedProperties, }, propsData: { workItemCount, showImportExportButtons, + fullPath, ...props, }, }); @@ -43,6 +47,8 @@ describe('WorkItemsListActions component', () => { const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findExportButton = () => wrapper.findByTestId('export-as-csv-button'); const findExportModal = () => wrapper.findComponent(WorkItemCsvExportModal); + const findImportButton = () => wrapper.findByTestId('import-csv-button'); + const findImportModal = () => wrapper.findComponent(WorkItemsCsvImportModal); const findImportFromJiraLink = () => wrapper.findByTestId('import-from-jira-link'); const findRssLink = () => wrapper.findByTestId('subscribe-rss'); const findCalendarLink = () => wrapper.findByTestId('subscribe-calendar'); @@ -97,6 +103,41 @@ describe('WorkItemsListActions component', () => { expect(findExportModal().exists()).toBe(false); }); }); + + describe('when canImportWorkItems=true', () => { + beforeEach(() => { + wrapper = createComponent({ canImportWorkItems: true }); + }); + + it('displays the import button and the dropdown', () => { + expect(findImportButton().exists()).toBe(true); + expect(findDropdown().exists()).toBe(true); + }); + + it('renders the import modal', () => { + expect(findImportModal().props()).toMatchObject({ + modalId: 'work-item-import-modal', + fullPath, + }); + }); + + it('opens the import modal', () => { + findImportButton().vm.$emit('click'); + + expect(glModalDirective).toHaveBeenCalledWith('work-item-import-modal'); + }); + }); + + describe('when canImportWorkItems=false', () => { + beforeEach(() => { + wrapper = createComponent({ canImportWorkItems: false }); + }); + + it('does not display the import button and modal', () => { + expect(findImportButton().exists()).toBe(false); + expect(findImportModal().exists()).toBe(false); + }); + }); }); describe('subscribe dropdown options', () => { diff --git a/spec/frontend/work_items/components/work_items_csv_import_modal_spec.js b/spec/frontend/work_items/components/work_items_csv_import_modal_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c09254a521497856107a7477a8040f4a62641716 --- /dev/null +++ b/spec/frontend/work_items/components/work_items_csv_import_modal_spec.js @@ -0,0 +1,182 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlModal } from '@gitlab/ui'; +import { stubComponent } from 'helpers/stub_component'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createAlert } from '~/alert'; +import WorkItemsCsvImportModal from '~/work_items/components/work_items_csv_import_modal.vue'; +import workItemsCsvImportMutation from '~/work_items/graphql/work_items_csv_import.mutation.graphql'; + +jest.mock('~/alert'); +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +Vue.use(VueApollo); + +describe('WorkItemsCsvImportModal', () => { + let wrapper; + + const mockSuccessResponse = { + data: { + workItemsCsvImport: { + message: 'Import started successfully', + errors: [], + }, + }, + }; + const workItemsCsvImportSuccessHandler = jest.fn().mockResolvedValue(mockSuccessResponse); + const workItemsCsvImportNetworkErrorHandler = jest + .fn() + .mockRejectedValue(new Error('Network error')); + + function createComponent(options = {}) { + const { + injectedProperties = {}, + props = {}, + workItemsCsvImportHandler = jest.fn().mockResolvedValue(mockSuccessResponse), + } = options; + + return mountExtended(WorkItemsCsvImportModal, { + apolloProvider: createMockApollo([[workItemsCsvImportMutation, workItemsCsvImportHandler]]), + propsData: { + modalId: 'csv-import-modal', + fullPath: 'group/project', + ...props, + }, + provide: { + maxAttachmentSize: '10MB', + glFeatures: { + workItemPlanningView: true, + }, + ...injectedProperties, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: '
', + }), + }, + }); + } + + const findModal = () => wrapper.findComponent(GlModal); + const findFileInput = () => wrapper.findByLabelText('Upload CSV file'); + + describe('template', () => { + it('passes correct title props to modal', () => { + wrapper = createComponent(); + expect(findModal().props('title')).toContain('Import work items'); + }); + + it('displays a note about the maximum allowed file size', () => { + const maxAttachmentSize = '500MB'; + wrapper = createComponent({ injectedProperties: { maxAttachmentSize } }); + expect(findModal().text()).toContain(`The maximum file size allowed is ${maxAttachmentSize}`); + }); + + it('displays the correct primary button action text', () => { + wrapper = createComponent(); + expect(findModal().props('actionPrimary')).toMatchObject({ + text: 'Import work items', + attributes: { + 'data-testid': 'import-work-items-button', + }, + }); + }); + + it('displays the cancel button', () => { + wrapper = createComponent(); + expect(findModal().props('actionCancel')).toEqual({ text: 'Cancel' }); + }); + + it('displays the file input', () => { + wrapper = createComponent(); + expect(findFileInput().exists()).toBe(true); + expect(findFileInput().attributes('accept')).toBe('.csv,text/csv'); + }); + + describe('when workItemPlanningView is disabled', () => { + beforeEach(() => { + wrapper = createComponent({ + injectedProperties: { + glFeatures: { + workItemPlanningView: false, + }, + }, + }); + }); + + it('displays issues text in modal title', () => { + expect(findModal().props('title')).toBe('Import issues'); + }); + + it('displays issues text in primary button', () => { + expect(findModal().props('actionPrimary').text).toBe('Import issues'); + }); + }); + }); + + describe('importWorkItems', () => { + it('shows error when no file is selected', async () => { + wrapper = createComponent(); + + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Please select a file to import.', + }); + }); + + it('imports successfully with selected file', async () => { + wrapper = createComponent({ workItemsCsvImportHandler: workItemsCsvImportSuccessHandler }); + + const file = new File(['content'], 'test.csv', { type: 'text/csv' }); + const fileInput = findFileInput(); + Object.defineProperty(fileInput.element, 'files', { + value: [file], + configurable: true, + }); + await fileInput.trigger('change'); + + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(workItemsCsvImportSuccessHandler).toHaveBeenCalledWith({ + input: { + projectPath: 'group/project', + file, + }, + }); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Import started successfully', + variant: 'success', + }); + }); + + it('shows generic error message when import fails', async () => { + wrapper = createComponent({ + workItemsCsvImportHandler: workItemsCsvImportNetworkErrorHandler, + }); + + const file = new File(['content'], 'test.csv', { type: 'text/csv' }); + const fileInput = findFileInput(); + Object.defineProperty(fileInput.element, 'files', { + value: [file], + configurable: true, + }); + await fileInput.trigger('change'); + + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while importing work items.', + }); + }); + }); +}); diff --git a/spec/helpers/boards_helper_spec.rb b/spec/helpers/boards_helper_spec.rb index 7ebb68a21217ef683f2493e91209e7fb183c0b2e..7b7f963a677848dc0f4a47dc0451eb6f09230426 100644 --- a/spec/helpers/boards_helper_spec.rb +++ b/spec/helpers/boards_helper_spec.rb @@ -112,6 +112,7 @@ allow(helper).to receive(:can?).with(user, :create_projects, project.group).and_return(false) allow(helper).to receive(:can?).with(user, :read_crm_organization, project.crm_group).and_return(false) allow(helper).to receive(:can?).with(user, :read_crm_contact, project.crm_group).and_return(false) + allow(helper).to receive(:can?).with(user, :import_work_items, project).and_return(false) end it 'returns board type as parent' do diff --git a/spec/helpers/work_items_helper_spec.rb b/spec/helpers/work_items_helper_spec.rb index d71451a90b0fd1f0c74ddbf72fde1c77ea59fce1..e49fc8c45e3faba5d2a89ab159b33a0da33bb808 100644 --- a/spec/helpers/work_items_helper_spec.rb +++ b/spec/helpers/work_items_helper_spec.rb @@ -48,7 +48,8 @@ project_import_jira_path: project_import_jira_path(project), can_read_crm_contact: 'true', rss_path: project_work_items_path(project, format: :atom), - calendar_path: project_work_items_path(project, format: :ics) + calendar_path: project_work_items_path(project, format: :ics), + can_import_work_items: "true" } ) end