From 6c008132a174d1dc4daf1f0b57932090b599b0ea Mon Sep 17 00:00:00 2001 From: Long Nguyen Date: Fri, 1 Aug 2025 02:56:57 +0700 Subject: [PATCH 01/16] Base work for import export csv --- .../work_items_csv_import_export_buttons.vue | 100 ++++++++++++ .../work_items_csv_import_modal.vue | 147 ++++++++++++++++++ .../work_items_csv_import.mutation.graphql | 6 + .../work_items/pages/work_items_list_app.vue | 6 + .../components/work_items_list_app_spec.js | 49 ++++++ 5 files changed, 308 insertions(+) create mode 100644 app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue create mode 100644 app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue create mode 100644 app/assets/javascripts/work_items/graphql/work_items_csv_import.mutation.graphql diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue new file mode 100644 index 00000000000000..a9c8f770c7a418 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue @@ -0,0 +1,100 @@ + + + diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue new file mode 100644 index 00000000000000..d2dec89b4d9cf7 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue @@ -0,0 +1,147 @@ + + + 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 00000000000000..825be38bed5d81 --- /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/pages/work_items_list_app.vue b/app/assets/javascripts/work_items/pages/work_items_list_app.vue index 5e65d0aa3a1363..5ff202fa5789c1 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 @@ -151,6 +151,7 @@ export default { components: { GlLoadingIcon, GlButton, + GlDisclosureDropdown, IssuableList, IssueCardStatistics, IssueCardTimeInfo, @@ -179,6 +180,9 @@ export default { 'autocompleteAwardEmojisPath', 'canBulkUpdate', 'canBulkEditEpics', + 'canEdit', + 'email', + 'hasAnyWorkItems', 'hasBlockedIssuesFeature', 'hasEpicsFeature', 'hasGroupBulkEditFeature', @@ -191,6 +195,7 @@ export default { 'initialSort', 'isGroup', 'isSignedIn', + 'maxAttachmentSize', 'showNewWorkItem', 'workItemType', 'canReadCrmOrganization', @@ -244,6 +249,7 @@ export default { showLocalBoard: false, namespaceId: null, displaySettings: {}, + showTooltip: false, }; }, apollo: { diff --git a/spec/frontend/work_items/list/components/work_items_list_app_spec.js b/spec/frontend/work_items/list/components/work_items_list_app_spec.js index 94e2b98acd2c50..8d9dea2cf516dd 100644 --- a/spec/frontend/work_items/list/components/work_items_list_app_spec.js +++ b/spec/frontend/work_items/list/components/work_items_list_app_spec.js @@ -60,6 +60,7 @@ import { import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import CreateWorkItemModal from '~/work_items/components/create_work_item_modal.vue'; import WorkItemUserPreferences from '~/work_items/components/shared/work_item_user_preferences.vue'; +import WorkItemsCsvImportExportButtons from '~/work_items/components/work_items_csv_import_export_buttons.vue'; import WorkItemsListApp from '~/work_items/pages/work_items_list_app.vue'; import getWorkItemStateCountsQuery from 'ee_else_ce/work_items/graphql/list/get_work_item_state_counts.query.graphql'; import getWorkItemsFullQuery from 'ee_else_ce/work_items/graphql/list/get_work_items_full.query.graphql'; @@ -1597,6 +1598,54 @@ describeSkipVue3(skipReason, () => { }); }); + describe('CSV functionality', () => { + const findCsvDropdown = () => + wrapper.find('[data-testid="work-items-list-more-actions-dropdown"]'); + const findCsvImportExportButtons = () => wrapper.findComponent(WorkItemsCsvImportExportButtons); + + it('shows CSV dropdown when user is signed in and not in group', async () => { + mountComponent({ + provide: { + isSignedIn: true, + isGroup: false, + hasAnyWorkItems: true, + email: 'test@example.com', + canEdit: true, + }, + }); + await waitForPromises(); + + expect(findCsvDropdown().exists()).toBe(true); + expect(findCsvImportExportButtons().exists()).toBe(true); + }); + + it('hides CSV dropdown when user is not signed in', async () => { + mountComponent({ + provide: { + isSignedIn: false, + isGroup: false, + hasAnyWorkItems: true, + }, + }); + await waitForPromises(); + + expect(findCsvDropdown().exists()).toBe(false); + }); + + it('hides CSV dropdown when in group context', async () => { + mountComponent({ + provide: { + isSignedIn: true, + isGroup: true, + hasAnyWorkItems: true, + }, + }); + await waitForPromises(); + + expect(findCsvDropdown().exists()).toBe(false); + }); + }); + describe('when workItemPlanningView flag is enabled', () => { it('renders the WorkItemListHeading component', async () => { mountComponent({ workItemPlanningView: true }); -- GitLab From a2b68c49964aa518a60a1c2878e957c59d51e645 Mon Sep 17 00:00:00 2001 From: Long Nguyen Date: Fri, 1 Aug 2025 02:58:11 +0700 Subject: [PATCH 02/16] Add i18n and prettier --- .../work_items_csv_import_export_buttons.vue | 2 +- locale/gitlab.pot | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue index a9c8f770c7a418..cf3511c6f688ba 100644 --- a/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue @@ -90,7 +90,7 @@ export default { :full-path="fullPath" :query-variables="queryVariables" /> - Date: Sun, 3 Aug 2025 13:59:46 +0700 Subject: [PATCH 03/16] Rework logic adding can_import_work_items --- .../components/work_item_metadata_provider.vue | 1 + .../work_items_csv_import_export_buttons.vue | 11 ++--------- .../graphql/work_item_metadata.query.graphql | 3 +++ .../work_items/pages/work_items_list_app.vue | 5 ----- app/graphql/types/permission_types/namespaces/base.rb | 2 +- .../graphql/work_item_metadata.query.graphql | 3 +++ 6 files changed, 10 insertions(+), 15 deletions(-) diff --git a/app/assets/javascripts/work_items/components/work_item_metadata_provider.vue b/app/assets/javascripts/work_items/components/work_item_metadata_provider.vue index 5425e9629561ee..5ac4485cd4b056 100644 --- a/app/assets/javascripts/work_items/components/work_item_metadata_provider.vue +++ b/app/assets/javascripts/work_items/components/work_item_metadata_provider.vue @@ -58,6 +58,7 @@ export default { return { ...(namespace.licensedFeatures || {}), ...(namespace.linkPaths || {}), + ...(namespace.userPermissions || {}), }; }, }, diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue index cf3511c6f688ba..0ac78278248cfd 100644 --- a/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue @@ -4,7 +4,6 @@ import { __ } from '~/locale'; import WorkItemsCsvExportModal from './work_items_csv_export_modal.vue'; import WorkItemsCsvImportModal from './work_items_csv_import_modal.vue'; -const TYPE_WORK_ITEM = 'work_item'; export default { components: { @@ -16,18 +15,12 @@ export default { GlModal: GlModalDirective, }, inject: { - issuableType: { - default: TYPE_WORK_ITEM, - }, showExportButton: { default: false, }, showImportButton: { default: false, }, - canEdit: { - default: false, - }, }, props: { workItemCount: { @@ -59,10 +52,10 @@ export default { }, computed: { exportModalId() { - return `${this.issuableType}-export-modal`; + return `work-item-export-modal`; }, importModalId() { - return `${this.issuableType}-import-modal`; + return `work-item-import-modal`; }, }, }; diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql index 465dad5d7d7fdb..964fa2b2a499c4 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql @@ -23,5 +23,8 @@ query workItemMetadata($fullPath: ID!) { signIn userExportEmail } + userPermissions { + importWorkItems + } } } 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 5ff202fa5789c1..891ddfc4438617 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 @@ -180,9 +180,6 @@ export default { 'autocompleteAwardEmojisPath', 'canBulkUpdate', 'canBulkEditEpics', - 'canEdit', - 'email', - 'hasAnyWorkItems', 'hasBlockedIssuesFeature', 'hasEpicsFeature', 'hasGroupBulkEditFeature', @@ -195,7 +192,6 @@ export default { 'initialSort', 'isGroup', 'isSignedIn', - 'maxAttachmentSize', 'showNewWorkItem', 'workItemType', 'canReadCrmOrganization', @@ -1354,7 +1350,6 @@ export default { :full-path="rootPageFullPath" :is-group="isGroup" :preselected-work-item-type="preselectedWorkItemType" - @workItemCreated="refetchItems" /> Date: Sun, 3 Aug 2025 17:15:17 +0700 Subject: [PATCH 04/16] Remove redundant export props --- .../components/work_items_csv_import_export_buttons.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue index 0ac78278248cfd..560ad068e09ffe 100644 --- a/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue @@ -80,7 +80,6 @@ export default { v-if="showExportButton" :modal-id="exportModalId" :work-item-count="workItemCount" - :full-path="fullPath" :query-variables="queryVariables" /> Date: Sun, 3 Aug 2025 17:49:47 +0700 Subject: [PATCH 05/16] Remove imported emit --- .../components/work_items_csv_import_export_buttons.vue | 1 - .../work_items/components/work_items_csv_import_modal.vue | 1 - 2 files changed, 2 deletions(-) diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue index 560ad068e09ffe..0f99b4049df3f9 100644 --- a/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue @@ -86,7 +86,6 @@ export default { v-if="showImportButton" :modal-id="importModalId" :full-path="fullPath" - @workItemsImported="$emit('workItemsImported')" /> diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue index d2dec89b4d9cf7..e975b7766d2b97 100644 --- a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue @@ -104,7 +104,6 @@ export default { this.$refs.modal.hide(); this.selectedFile = null; this.$refs.fileInput.value = ''; - this.$emit('workItemsImported'); } } catch (error) { createAlert({ -- GitLab From 6efd08773840f165998ed81a159b0ad16891a549 Mon Sep 17 00:00:00 2001 From: Long Nguyen Date: Sun, 3 Aug 2025 23:49:39 +0700 Subject: [PATCH 06/16] Add export email and maxattachmentsize --- app/helpers/work_items_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/helpers/work_items_helper.rb b/app/helpers/work_items_helper.rb index 50fb7716e427dc..d6cc6a6ef0b741 100644 --- a/app/helpers/work_items_helper.rb +++ b/app/helpers/work_items_helper.rb @@ -60,6 +60,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 -- GitLab From 81e8052d746755fce77d00c7ce3aaf0733eac38a Mon Sep 17 00:00:00 2001 From: Long Nguyen Date: Sun, 3 Aug 2025 23:54:17 +0700 Subject: [PATCH 07/16] Fix lint and prettier and add graphql doc --- .../components/work_items_csv_import_export_buttons.vue | 1 - doc/api/graphql/reference/_index.md | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue index 0f99b4049df3f9..fce6e9809f0e6c 100644 --- a/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue @@ -4,7 +4,6 @@ import { __ } from '~/locale'; import WorkItemsCsvExportModal from './work_items_csv_export_modal.vue'; import WorkItemsCsvImportModal from './work_items_csv_import_modal.vue'; - export default { components: { GlDisclosureDropdownItem, diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 5ff85b568796c3..f4f29084f741a3 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -36297,6 +36297,7 @@ Represents a namespace-cluster-agent mapping. | `createWorkItem` | [`Boolean!`](#boolean) | If `true`, the user can perform `create_work_item` on this resource. | | `generateDescription` | [`Boolean!`](#boolean) | If `true`, the user can perform `generate_description` on this resource. | | `importIssues` | [`Boolean!`](#boolean) | If `true`, the user can perform `import_issues` on this resource. | +| `importWorkItems` | [`Boolean!`](#boolean) | If `true`, the user can perform `import_work_items` on this resource. | | `readCrmContact` | [`Boolean!`](#boolean) | If `true`, the user can perform `read_crm_contact` on this resource. | | `readCrmOrganization` | [`Boolean!`](#boolean) | If `true`, the user can perform `read_crm_organization` on this resource. | | `readNamespace` | [`Boolean!`](#boolean) | If `true`, the user can perform `read_namespace` on this resource. | -- GitLab From 1ce09857950ac2a6ecc0ddf38e36ab06e856f88a Mon Sep 17 00:00:00 2001 From: Long Nguyen Date: Mon, 4 Aug 2025 01:01:49 +0700 Subject: [PATCH 08/16] Remove redundant hasupload --- .../work_items/components/work_items_csv_import_modal.vue | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue index e975b7766d2b97..9e42ea4cfda92f 100644 --- a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue @@ -84,10 +84,7 @@ export default { projectPath: this.fullPath, file: this.selectedFile, }, - }, - context: { - hasUpload: true, - }, + } }); const { message, errors } = data.workItemsCsvImport; -- GitLab From f171452b2a6c370c8c1fbd6cc0ae7ec72025c22d Mon Sep 17 00:00:00 2001 From: Long Nguyen Date: Mon, 4 Aug 2025 01:02:43 +0700 Subject: [PATCH 09/16] Fix prettier issue --- .../work_items/components/work_items_csv_import_modal.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue index 9e42ea4cfda92f..52e235315815cd 100644 --- a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue @@ -84,7 +84,7 @@ export default { projectPath: this.fullPath, file: this.selectedFile, }, - } + }, }); const { message, errors } = data.workItemsCsvImport; -- GitLab From 81a108ca1cdc8970aa5ca4e872fc9fa70994b7a9 Mon Sep 17 00:00:00 2001 From: long nguyen huy Date: Mon, 4 Aug 2025 22:03:54 +0000 Subject: [PATCH 10/16] Apply 3 suggestion(s) to 2 file(s) Co-authored-by: GitLab Duo --- .../work_items/components/work_items_csv_import_modal.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue index 52e235315815cd..79216401911e4e 100644 --- a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue @@ -98,9 +98,11 @@ export default { message, variant: 'success', }); - this.$refs.modal.hide(); + this.$refs.modal?.hide(); this.selectedFile = null; - this.$refs.fileInput.value = ''; + if (this.$refs.fileInput) { + this.$refs.fileInput.value = ''; + } } } catch (error) { createAlert({ -- GitLab From 7a002b6158caee76ebd26a0f1cd27deba27301a4 Mon Sep 17 00:00:00 2001 From: Long Nguyen Date: Tue, 5 Aug 2025 05:55:38 +0700 Subject: [PATCH 11/16] Add more test case --- ...rk_items_csv_import_export_buttons_spec.js | 114 ++++++++++++++++++ .../work_items_csv_import_modal_spec.js | 67 ++++++++++ .../components/work_items_list_app_spec.js | 44 +++++++ 3 files changed, 225 insertions(+) create mode 100644 spec/frontend/work_items/components/work_items_csv_import_export_buttons_spec.js create mode 100644 spec/frontend/work_items/components/work_items_csv_import_modal_spec.js diff --git a/spec/frontend/work_items/components/work_items_csv_import_export_buttons_spec.js b/spec/frontend/work_items/components/work_items_csv_import_export_buttons_spec.js new file mode 100644 index 00000000000000..8705ddc338ad49 --- /dev/null +++ b/spec/frontend/work_items/components/work_items_csv_import_export_buttons_spec.js @@ -0,0 +1,114 @@ +import { createMockDirective } from 'helpers/vue_mock_directive'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import WorkItemsCsvExportModal from '~/work_items/components/work_items_csv_export_modal.vue'; +import WorkItemsCsvImportExportButtons from '~/work_items/components/work_items_csv_import_export_buttons.vue'; +import WorkItemsCsvImportModal from '~/work_items/components/work_items_csv_import_modal.vue'; + +describe('WorkItemsCsvImportExportButtons', () => { + let wrapper; + let glModalDirective; + + const workItemCount = 10; + + function createComponent(injectedProperties = {}, props = {}) { + glModalDirective = jest.fn(); + return mountExtended(WorkItemsCsvImportExportButtons, { + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + provide: { + ...injectedProperties, + }, + propsData: { + fullPath: 'group/project', + workItemCount, + ...props, + }, + }); + } + + const findExportButton = () => wrapper.findByTestId('export-as-csv-button'); + const findImportButton = () => wrapper.findByTestId('import-from-csv-button'); + const findExportModal = () => wrapper.findComponent(WorkItemsCsvExportModal); + const findImportModal = () => wrapper.findComponent(WorkItemsCsvImportModal); + + describe('template', () => { + describe('when the showExportButton=true', () => { + beforeEach(() => { + wrapper = createComponent({ showExportButton: true }); + }); + + it('displays the export button', () => { + expect(findExportButton().exists()).toBe(true); + }); + + it('renders the export modal', () => { + expect(findExportModal().props()).toMatchObject({ + modalId: 'work-item-export-modal', + workItemCount, + }); + }); + + it('opens the export modal', () => { + findExportButton().trigger('click'); + + expect(glModalDirective).toHaveBeenCalled(); + }); + }); + + describe('when the showExportButton=false', () => { + beforeEach(() => { + wrapper = createComponent({ showExportButton: false }); + }); + + it('does not display the export button', () => { + expect(findExportButton().exists()).toBe(false); + }); + + it('does not render the export modal', () => { + expect(findExportModal().exists()).toBe(false); + }); + }); + + describe('when the showImportButton=true', () => { + it('renders the import csv menu item', () => { + wrapper = createComponent({ showImportButton: true }); + + expect(findImportButton().exists()).toBe(true); + }); + + it('renders the import modal', () => { + wrapper = createComponent({ showImportButton: true }); + + expect(findImportModal().exists()).toBe(true); + }); + + it('opens the import modal', () => { + wrapper = createComponent({ showImportButton: true }); + + findImportButton().trigger('click'); + + expect(glModalDirective).toHaveBeenCalled(); + }); + }); + + describe('when the showImportButton=false', () => { + beforeEach(() => { + wrapper = createComponent({ showImportButton: false }); + }); + + it('does not render the import csv menu item', () => { + expect(findImportButton().exists()).toBe(false); + }); + + it('does not render the import modal', () => { + expect(findImportModal().exists()).toBe(false); + }); + }); + }); +}); 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 00000000000000..fb3354a0f5056f --- /dev/null +++ b/spec/frontend/work_items/components/work_items_csv_import_modal_spec.js @@ -0,0 +1,67 @@ +import { GlModal } from '@gitlab/ui'; +import { stubComponent } from 'helpers/stub_component'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import WorkItemsCsvImportModal from '~/work_items/components/work_items_csv_import_modal.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('WorkItemsCsvImportModal', () => { + let wrapper; + + function createComponent(options = {}) { + const { injectedProperties = {}, props = {} } = options; + return mountExtended(WorkItemsCsvImportModal, { + propsData: { + modalId: 'csv-import-modal', + fullPath: 'group/project', + ...props, + }, + provide: { + maxAttachmentSize: '10MB', + ...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'); + }); + }); +}); diff --git a/spec/frontend/work_items/list/components/work_items_list_app_spec.js b/spec/frontend/work_items/list/components/work_items_list_app_spec.js index 8d9dea2cf516dd..6e724621cac3f7 100644 --- a/spec/frontend/work_items/list/components/work_items_list_app_spec.js +++ b/spec/frontend/work_items/list/components/work_items_list_app_spec.js @@ -1611,6 +1611,7 @@ describeSkipVue3(skipReason, () => { hasAnyWorkItems: true, email: 'test@example.com', canEdit: true, + canImportWorkItems: true, }, }); await waitForPromises(); @@ -1625,6 +1626,7 @@ describeSkipVue3(skipReason, () => { isSignedIn: false, isGroup: false, hasAnyWorkItems: true, + canImportWorkItems: false, }, }); await waitForPromises(); @@ -1638,12 +1640,54 @@ describeSkipVue3(skipReason, () => { isSignedIn: true, isGroup: true, hasAnyWorkItems: true, + canImportWorkItems: true, }, }); await waitForPromises(); expect(findCsvDropdown().exists()).toBe(false); }); + + it('passes correct props to CSV import/export buttons component', async () => { + mountComponent({ + provide: { + isSignedIn: true, + isGroup: false, + hasAnyWorkItems: true, + canImportWorkItems: true, + }, + }); + + await waitForPromises(); + + const csvButtons = findCsvImportExportButtons(); + expect(csvButtons.exists()).toBe(true); + expect(csvButtons.props('fullPath')).toBe('full/path'); + expect(csvButtons.props('workItemCount')).toBeDefined(); + expect(csvButtons.props('queryVariables')).toBeDefined(); + }); + + it('shows tooltip when dropdown is hidden', async () => { + mountComponent({ + provide: { + isSignedIn: true, + isGroup: false, + hasAnyWorkItems: true, + canImportWorkItems: true, + }, + }); + await waitForPromises(); + + expect(wrapper.vm.dropdownTooltip).toBe('Actions'); + + wrapper.vm.showDropdown(); + expect(wrapper.vm.showTooltip).toBe(true); + expect(wrapper.vm.dropdownTooltip).toBe(''); + + wrapper.vm.hideDropdown(); + expect(wrapper.vm.showTooltip).toBe(false); + expect(wrapper.vm.dropdownTooltip).toBe('Actions'); + }); }); describe('when workItemPlanningView flag is enabled', () => { -- GitLab From 0fcd992639e9a3743a80e26bafd6c68a5ac6a444 Mon Sep 17 00:00:00 2001 From: Long Nguyen Date: Tue, 5 Aug 2025 06:44:37 +0700 Subject: [PATCH 12/16] Fix spec test --- .../graphql/types/namespaces/link_paths_shared_examples.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/support/shared_examples/graphql/types/namespaces/link_paths_shared_examples.rb b/spec/support/shared_examples/graphql/types/namespaces/link_paths_shared_examples.rb index 50b9d264b9bfe6..f2e8712022a002 100644 --- a/spec/support/shared_examples/graphql/types/namespaces/link_paths_shared_examples.rb +++ b/spec/support/shared_examples/graphql/types/namespaces/link_paths_shared_examples.rb @@ -31,6 +31,7 @@ groupIssues labelsFetch issuesSettings + userExportEmail ]) end -- GitLab From 7fd62606b28c2a6c3b4e07aa0ad1faa6870b8bbe Mon Sep 17 00:00:00 2001 From: Long Nguyen Date: Sun, 10 Aug 2025 18:41:50 +0700 Subject: [PATCH 13/16] Fix wrong delete --- app/assets/javascripts/work_items/pages/work_items_list_app.vue | 1 + 1 file changed, 1 insertion(+) 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 891ddfc4438617..b9977822308df6 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 @@ -1350,6 +1350,7 @@ export default { :full-path="rootPageFullPath" :is-group="isGroup" :preselected-work-item-type="preselectedWorkItemType" + @workItemCreated="refetchItems" /> Date: Tue, 26 Aug 2025 15:26:30 +0530 Subject: [PATCH 14/16] Add CSV import functionality for work items Add the ability to import work items from CSV files with the following changes: - Create WorkItemsCsvImportModal component with file upload functionality - Add CSV import button to WorkItemListActions dropdown - Support feature flag toggling between work items and issues terminology - Include proper GraphQL mutation handling with file upload context - Add comprehensive test coverage for import modal and list actions - Implement proper error handling and success notifications - Add file size validation and user guidance text The import modal validates CSV format requirements and provides background processing with email confirmation upon completion. Changelog: added --- .../components/work_item_list_actions.vue | 35 +++++- .../work_items_csv_import_export_buttons.vue | 90 -------------- .../work_items_csv_import_modal.vue | 47 ++++++-- .../graphql/work_item_metadata.query.graphql | 3 - app/assets/javascripts/work_items/index.js | 4 + .../work_items/pages/work_items_list_app.vue | 4 +- .../types/permission_types/namespaces/base.rb | 2 +- app/helpers/work_items_helper.rb | 1 + doc/api/graphql/reference/_index.md | 1 - .../graphql/work_item_metadata.query.graphql | 3 - locale/gitlab.pot | 9 +- .../components/work_item_list_actions_spec.js | 41 +++++++ ...rk_items_csv_import_export_buttons_spec.js | 114 ------------------ .../work_items_csv_import_modal_spec.js | 103 +++++++++++++++- .../components/work_items_list_app_spec.js | 93 -------------- spec/helpers/work_items_helper_spec.rb | 3 +- 16 files changed, 227 insertions(+), 326 deletions(-) delete mode 100644 app/assets/javascripts/work_items/components/work_items_csv_import_export_buttons.vue delete mode 100644 spec/frontend/work_items/components/work_items_csv_import_export_buttons_spec.js 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 8ae810a0ee1ba8..0bb7fe817fa551 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 { GlDisclosureDropdownItem, GlModalDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import WorkItemsCsvExportModal from './work_items_csv_export_modal.vue'; -import WorkItemsCsvImportModal from './work_items_csv_import_modal.vue'; - -export default { - components: { - GlDisclosureDropdownItem, - WorkItemsCsvExportModal, - WorkItemsCsvImportModal, - }, - directives: { - GlModal: GlModalDirective, - }, - inject: { - showExportButton: { - default: false, - }, - showImportButton: { - default: false, - }, - }, - props: { - workItemCount: { - type: Number, - required: false, - default: undefined, - }, - fullPath: { - type: String, - required: true, - }, - queryVariables: { - type: Object, - required: false, - default: () => ({}), - }, - }, - data() { - return { - dropdownItems: { - exportAsCSV: { - text: __('Export as CSV'), - }, - importCSV: { - text: __('Import CSV'), - }, - }, - }; - }, - computed: { - exportModalId() { - return `work-item-export-modal`; - }, - importModalId() { - return `work-item-import-modal`; - }, - }, -}; - - - diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue index 79216401911e4e..afa7e4d4337e97 100644 --- a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue @@ -2,19 +2,27 @@ import { GlModal, GlFormGroup } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { __, 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: __('Import work items'), + importIssuesText: __('Import issues'), uploadCsvFileText: __('Upload CSV file'), - mainText: __( + workItemMainText: __( "Your work items will be imported in the background. Once finished, you'll get a confirmation email.", ), - helpText: __( + workItemHelpText: __( '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.', + ), }, actionPrimary: { text: __('Import work items'), @@ -26,6 +34,7 @@ export default { GlModal, GlFormGroup, }, + mixins: [glFeatureFlagMixin()], inject: { maxAttachmentSize: { default: 0, @@ -48,23 +57,36 @@ export default { }; }, computed: { + isPlanningViewsEnabled() { + return this.glFeatures.workItemPlanningView; + }, maxFileSizeText() { return sprintf(this.$options.i18n.maximumFileSizeText, { size: this.maxAttachmentSize }); }, - actionPrimaryWithLoading() { + actionPrimary() { return { - ...this.$options.actionPrimary, + 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; + }, }, methods: { onFileChange(event) { - const file = event.target.files[0]; - this.selectedFile = file || null; + const files = event.target?.files; + this.selectedFile = files && files.length > 0 ? files[0] : null; }, async importWorkItems() { if (!this.selectedFile) { @@ -85,6 +107,9 @@ export default { file: this.selectedFile, }, }, + context: { + hasUpload: true, + }, }); const { message, errors } = data.workItemsCsvImport; @@ -120,13 +145,15 @@ export default { -

{{ $options.i18n.mainText }}

+

+ {{ isPlanningViewsEnabled ? $options.i18n.workItemMainText : $options.i18n.issuesMainText }} +

- {{ $options.i18n.helpText }} + {{ isPlanningViewsEnabled ? $options.i18n.workItemHelpText : $options.i18n.issuesHelpText }} {{ maxFileSizeText }}

diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql index 964fa2b2a499c4..465dad5d7d7fdb 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql @@ -23,8 +23,5 @@ query workItemMetadata($fullPath: ID!) { signIn userExportEmail } - userPermissions { - importWorkItems - } } } diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 89cf188c450695..f8256b3b583b19 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 b9977822308df6..b3dda9bb6c0749 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 @@ -151,7 +151,6 @@ export default { components: { GlLoadingIcon, GlButton, - GlDisclosureDropdown, IssuableList, IssueCardStatistics, IssueCardTimeInfo, @@ -245,7 +244,6 @@ export default { showLocalBoard: false, namespaceId: null, displaySettings: {}, - showTooltip: false, }; }, apollo: { @@ -1320,6 +1318,7 @@ export default { :show-import-export-buttons="showImportExportButtons" :work-item-count="currentTabCount" :query-variables="csvExportQueryVariables" + :full-path="rootPageFullPath" /> @@ -1356,6 +1355,7 @@ export default { :show-import-export-buttons="showImportExportButtons" :work-item-count="currentTabCount" :query-variables="csvExportQueryVariables" + :full-path="rootPageFullPath" /> diff --git a/app/graphql/types/permission_types/namespaces/base.rb b/app/graphql/types/permission_types/namespaces/base.rb index 37f18673b88385..bdc2fba34ba600 100644 --- a/app/graphql/types/permission_types/namespaces/base.rb +++ b/app/graphql/types/permission_types/namespaces/base.rb @@ -7,7 +7,7 @@ class Base < BasePermissionType graphql_name 'NamespacePermissions' abilities :admin_label, :admin_issue, :create_work_item, - :import_issues, :read_crm_contact, :read_crm_organization, :create_projects, :import_work_items + :import_issues, :read_crm_contact, :read_crm_organization, :create_projects ability_field :read_namespace diff --git a/app/helpers/work_items_helper.rb b/app/helpers/work_items_helper.rb index d6cc6a6ef0b741..4b17874e1110fa 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 diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index f4f29084f741a3..5ff85b568796c3 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -36297,7 +36297,6 @@ Represents a namespace-cluster-agent mapping. | `createWorkItem` | [`Boolean!`](#boolean) | If `true`, the user can perform `create_work_item` on this resource. | | `generateDescription` | [`Boolean!`](#boolean) | If `true`, the user can perform `generate_description` on this resource. | | `importIssues` | [`Boolean!`](#boolean) | If `true`, the user can perform `import_issues` on this resource. | -| `importWorkItems` | [`Boolean!`](#boolean) | If `true`, the user can perform `import_work_items` on this resource. | | `readCrmContact` | [`Boolean!`](#boolean) | If `true`, the user can perform `read_crm_contact` on this resource. | | `readCrmOrganization` | [`Boolean!`](#boolean) | If `true`, the user can perform `read_crm_organization` on this resource. | | `readNamespace` | [`Boolean!`](#boolean) | If `true`, the user can perform `read_namespace` on this resource. | diff --git a/ee/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql b/ee/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql index 4f7923f3bfeadc..53b0f64e7484ad 100644 --- a/ee/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql +++ b/ee/app/assets/javascripts/work_items/graphql/work_item_metadata.query.graphql @@ -28,8 +28,5 @@ query workItemMetadataEE($fullPath: ID!) { issuesSettings @gl_introduced(version: "18.3.0-pre") userExportEmail } - userPermissions { - importWorkItems - } } } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index afd12cb9d43a49..8d16f8b538cd96 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7537,9 +7537,6 @@ msgstr "" msgid "An error occurred while enabling Service Desk." msgstr "" -msgid "An error occurred while exporting work items." -msgstr "" - msgid "An error occurred while fetching Markdown preview" msgstr "" @@ -27186,9 +27183,6 @@ msgstr "" msgid "Export this project with all its related data in order to move it to a new GitLab instance. When the exported file is ready, you can download it from this page or from the download link in the email notification you will receive. You can then import it when creating a new project. %{link_start}Learn more.%{link_end}" msgstr "" -msgid "Export work items" -msgstr "" - msgid "Exported requirements" msgstr "" @@ -72949,6 +72943,9 @@ msgstr "" msgid "WorkItem|How do I use custom fields?" msgstr "" +msgid "WorkItem|Import CSV" +msgstr "" + msgid "WorkItem|Import from Jira" 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 103e7aed4aaeff..5d800c03af3439 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_export_buttons_spec.js b/spec/frontend/work_items/components/work_items_csv_import_export_buttons_spec.js deleted file mode 100644 index 8705ddc338ad49..00000000000000 --- a/spec/frontend/work_items/components/work_items_csv_import_export_buttons_spec.js +++ /dev/null @@ -1,114 +0,0 @@ -import { createMockDirective } from 'helpers/vue_mock_directive'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import WorkItemsCsvExportModal from '~/work_items/components/work_items_csv_export_modal.vue'; -import WorkItemsCsvImportExportButtons from '~/work_items/components/work_items_csv_import_export_buttons.vue'; -import WorkItemsCsvImportModal from '~/work_items/components/work_items_csv_import_modal.vue'; - -describe('WorkItemsCsvImportExportButtons', () => { - let wrapper; - let glModalDirective; - - const workItemCount = 10; - - function createComponent(injectedProperties = {}, props = {}) { - glModalDirective = jest.fn(); - return mountExtended(WorkItemsCsvImportExportButtons, { - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - glModal: { - bind(_, { value }) { - glModalDirective(value); - }, - }, - }, - provide: { - ...injectedProperties, - }, - propsData: { - fullPath: 'group/project', - workItemCount, - ...props, - }, - }); - } - - const findExportButton = () => wrapper.findByTestId('export-as-csv-button'); - const findImportButton = () => wrapper.findByTestId('import-from-csv-button'); - const findExportModal = () => wrapper.findComponent(WorkItemsCsvExportModal); - const findImportModal = () => wrapper.findComponent(WorkItemsCsvImportModal); - - describe('template', () => { - describe('when the showExportButton=true', () => { - beforeEach(() => { - wrapper = createComponent({ showExportButton: true }); - }); - - it('displays the export button', () => { - expect(findExportButton().exists()).toBe(true); - }); - - it('renders the export modal', () => { - expect(findExportModal().props()).toMatchObject({ - modalId: 'work-item-export-modal', - workItemCount, - }); - }); - - it('opens the export modal', () => { - findExportButton().trigger('click'); - - expect(glModalDirective).toHaveBeenCalled(); - }); - }); - - describe('when the showExportButton=false', () => { - beforeEach(() => { - wrapper = createComponent({ showExportButton: false }); - }); - - it('does not display the export button', () => { - expect(findExportButton().exists()).toBe(false); - }); - - it('does not render the export modal', () => { - expect(findExportModal().exists()).toBe(false); - }); - }); - - describe('when the showImportButton=true', () => { - it('renders the import csv menu item', () => { - wrapper = createComponent({ showImportButton: true }); - - expect(findImportButton().exists()).toBe(true); - }); - - it('renders the import modal', () => { - wrapper = createComponent({ showImportButton: true }); - - expect(findImportModal().exists()).toBe(true); - }); - - it('opens the import modal', () => { - wrapper = createComponent({ showImportButton: true }); - - findImportButton().trigger('click'); - - expect(glModalDirective).toHaveBeenCalled(); - }); - }); - - describe('when the showImportButton=false', () => { - beforeEach(() => { - wrapper = createComponent({ showImportButton: false }); - }); - - it('does not render the import csv menu item', () => { - expect(findImportButton().exists()).toBe(false); - }); - - it('does not render the import modal', () => { - expect(findImportModal().exists()).toBe(false); - }); - }); - }); -}); 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 index fb3354a0f5056f..9034a6af2c5de8 100644 --- 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 @@ -1,16 +1,40 @@ +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: [], + }, + }, + }; + function createComponent(options = {}) { - const { injectedProperties = {}, props = {} } = 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', @@ -18,6 +42,9 @@ describe('WorkItemsCsvImportModal', () => { }, provide: { maxAttachmentSize: '10MB', + glFeatures: { + workItemPlanningView: true, + }, ...injectedProperties, }, stubs: { @@ -63,5 +90,79 @@ describe('WorkItemsCsvImportModal', () => { 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 () => { + const workItemsCsvImportHandler = jest.fn().mockResolvedValue(mockSuccessResponse); + wrapper = createComponent({ workItemsCsvImportHandler }); + + const file = new File(['content'], 'test.csv', { type: 'text/csv' }); + wrapper.vm.selectedFile = file; + + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(workItemsCsvImportHandler).toHaveBeenCalledWith({ + input: { + projectPath: 'group/project', + file, + }, + }); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Import started successfully', + variant: 'success', + }); + }); + + it('shows generic error message when import fails', async () => { + const workItemsCsvImportHandler = jest.fn().mockRejectedValue(new Error('Network error')); + wrapper = createComponent({ workItemsCsvImportHandler }); + + const file = new File(['content'], 'test.csv', { type: 'text/csv' }); + wrapper.vm.selectedFile = file; + + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while importing work items.', + }); + }); }); }); diff --git a/spec/frontend/work_items/list/components/work_items_list_app_spec.js b/spec/frontend/work_items/list/components/work_items_list_app_spec.js index 6e724621cac3f7..94e2b98acd2c50 100644 --- a/spec/frontend/work_items/list/components/work_items_list_app_spec.js +++ b/spec/frontend/work_items/list/components/work_items_list_app_spec.js @@ -60,7 +60,6 @@ import { import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import CreateWorkItemModal from '~/work_items/components/create_work_item_modal.vue'; import WorkItemUserPreferences from '~/work_items/components/shared/work_item_user_preferences.vue'; -import WorkItemsCsvImportExportButtons from '~/work_items/components/work_items_csv_import_export_buttons.vue'; import WorkItemsListApp from '~/work_items/pages/work_items_list_app.vue'; import getWorkItemStateCountsQuery from 'ee_else_ce/work_items/graphql/list/get_work_item_state_counts.query.graphql'; import getWorkItemsFullQuery from 'ee_else_ce/work_items/graphql/list/get_work_items_full.query.graphql'; @@ -1598,98 +1597,6 @@ describeSkipVue3(skipReason, () => { }); }); - describe('CSV functionality', () => { - const findCsvDropdown = () => - wrapper.find('[data-testid="work-items-list-more-actions-dropdown"]'); - const findCsvImportExportButtons = () => wrapper.findComponent(WorkItemsCsvImportExportButtons); - - it('shows CSV dropdown when user is signed in and not in group', async () => { - mountComponent({ - provide: { - isSignedIn: true, - isGroup: false, - hasAnyWorkItems: true, - email: 'test@example.com', - canEdit: true, - canImportWorkItems: true, - }, - }); - await waitForPromises(); - - expect(findCsvDropdown().exists()).toBe(true); - expect(findCsvImportExportButtons().exists()).toBe(true); - }); - - it('hides CSV dropdown when user is not signed in', async () => { - mountComponent({ - provide: { - isSignedIn: false, - isGroup: false, - hasAnyWorkItems: true, - canImportWorkItems: false, - }, - }); - await waitForPromises(); - - expect(findCsvDropdown().exists()).toBe(false); - }); - - it('hides CSV dropdown when in group context', async () => { - mountComponent({ - provide: { - isSignedIn: true, - isGroup: true, - hasAnyWorkItems: true, - canImportWorkItems: true, - }, - }); - await waitForPromises(); - - expect(findCsvDropdown().exists()).toBe(false); - }); - - it('passes correct props to CSV import/export buttons component', async () => { - mountComponent({ - provide: { - isSignedIn: true, - isGroup: false, - hasAnyWorkItems: true, - canImportWorkItems: true, - }, - }); - - await waitForPromises(); - - const csvButtons = findCsvImportExportButtons(); - expect(csvButtons.exists()).toBe(true); - expect(csvButtons.props('fullPath')).toBe('full/path'); - expect(csvButtons.props('workItemCount')).toBeDefined(); - expect(csvButtons.props('queryVariables')).toBeDefined(); - }); - - it('shows tooltip when dropdown is hidden', async () => { - mountComponent({ - provide: { - isSignedIn: true, - isGroup: false, - hasAnyWorkItems: true, - canImportWorkItems: true, - }, - }); - await waitForPromises(); - - expect(wrapper.vm.dropdownTooltip).toBe('Actions'); - - wrapper.vm.showDropdown(); - expect(wrapper.vm.showTooltip).toBe(true); - expect(wrapper.vm.dropdownTooltip).toBe(''); - - wrapper.vm.hideDropdown(); - expect(wrapper.vm.showTooltip).toBe(false); - expect(wrapper.vm.dropdownTooltip).toBe('Actions'); - }); - }); - describe('when workItemPlanningView flag is enabled', () => { it('renders the WorkItemListHeading component', async () => { mountComponent({ workItemPlanningView: true }); diff --git a/spec/helpers/work_items_helper_spec.rb b/spec/helpers/work_items_helper_spec.rb index d71451a90b0fd1..e49fc8c45e3fab 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 -- GitLab From 843c73db1370958aebcc484e35e94e7c1ac1aee9 Mon Sep 17 00:00:00 2001 From: vjain-gl Date: Wed, 27 Aug 2025 12:40:50 +0530 Subject: [PATCH 15/16] Remove redudant, use WorkItem namespace for locales and refactor the tests --- .../work_item_metadata_provider.vue | 1 - .../work_items_csv_import_modal.vue | 45 ++++++++++--------- locale/gitlab.pot | 33 +++++++------- .../work_items_csv_import_modal_spec.js | 28 +++++++++--- .../namespaces/link_paths_shared_examples.rb | 1 - 5 files changed, 64 insertions(+), 44 deletions(-) diff --git a/app/assets/javascripts/work_items/components/work_item_metadata_provider.vue b/app/assets/javascripts/work_items/components/work_item_metadata_provider.vue index 5ac4485cd4b056..5425e9629561ee 100644 --- a/app/assets/javascripts/work_items/components/work_item_metadata_provider.vue +++ b/app/assets/javascripts/work_items/components/work_item_metadata_provider.vue @@ -58,7 +58,6 @@ export default { return { ...(namespace.licensedFeatures || {}), ...(namespace.linkPaths || {}), - ...(namespace.userPermissions || {}), }; }, }, diff --git a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue index afa7e4d4337e97..a995a134f96f7b 100644 --- a/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue +++ b/app/assets/javascripts/work_items/components/work_items_csv_import_modal.vue @@ -1,21 +1,21 @@