Commit 89313c7d authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'xanf-refactor-bulk-import-to-gl-table' into 'master'

Refactor bulk imports table to use GlTable

See merge request gitlab-org/gitlab!67932
parents 8495a557 3ed3f1dd
......@@ -28,7 +28,7 @@ export default {
<template>
<gl-dropdown
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1"
class="gl-h-7 gl-flex-fill-1"
data-qa-selector="target_namespace_selector_dropdown"
v-bind="$attrs"
>
......
......@@ -10,19 +10,25 @@ import {
GlSearchBoxByClick,
GlSprintf,
GlSafeHtmlDirective as SafeHtml,
GlTable,
GlTooltip,
} from '@gitlab/ui';
import { s__, __, n__ } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants';
import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql';
import setImportTargetMutation from '../graphql/mutations/set_import_target.mutation.graphql';
import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql';
import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql';
import ImportTableRow from './import_table_row.vue';
import { isInvalid } from '../utils';
import ImportTargetCell from './import_target_cell.vue';
const PAGE_SIZES = [20, 50, 100];
const DEFAULT_PAGE_SIZE = PAGE_SIZES[0];
const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!';
const DEFAULT_TD_CLASSES = 'gl-vertical-align-top!';
export default {
components: {
......@@ -36,7 +42,9 @@ export default {
GlSearchBoxByClick,
GlSprintf,
GlTooltip,
ImportTableRow,
GlTable,
ImportStatus,
ImportTargetCell,
PaginationLinks,
},
directives: {
......@@ -76,6 +84,34 @@ export default {
availableNamespaces: availableNamespacesQuery,
},
fields: [
{
key: 'web_url',
label: s__('BulkImport|From source group'),
thClass: `${DEFAULT_TH_CLASSES} import-jobs-from-col`,
tdClass: DEFAULT_TD_CLASSES,
},
{
key: 'import_target',
label: s__('BulkImport|To new group'),
thClass: `${DEFAULT_TH_CLASSES} import-jobs-to-col`,
tdClass: DEFAULT_TD_CLASSES,
},
{
key: 'progress',
label: __('Status'),
thClass: `${DEFAULT_TH_CLASSES} import-jobs-status-col`,
tdClass: DEFAULT_TD_CLASSES,
tdAttr: { 'data-qa-selector': 'import_status_indicator' },
},
{
key: 'actions',
label: '',
thClass: `${DEFAULT_TH_CLASSES} import-jobs-cta-col`,
tdClass: DEFAULT_TD_CLASSES,
},
],
computed: {
groups() {
return this.bulkImportSourceGroups?.nodes ?? [];
......@@ -133,6 +169,25 @@ export default {
},
methods: {
qaRowAttributes(group, type) {
if (type === 'row') {
return {
'data-qa-selector': 'import_item',
'data-qa-source-group': group.full_path,
};
}
return {};
},
isAlreadyImported(group) {
return group.progress.status !== STATUSES.NONE;
},
isInvalid(group) {
return isInvalid(group, this.groupPathRegex);
},
groupsCount(count) {
return n__('%d group', '%d groups', count);
},
......@@ -243,17 +298,25 @@ export default {
:description="s__('Check your source instance permissions.')"
/>
<template v-else>
<table class="gl-w-full" data-qa-selector="import_table">
<thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1">
<th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th>
<th class="gl-py-4 import-jobs-to-col">{{ s__('BulkImport|To new group') }}</th>
<th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th>
<th class="gl-py-4 import-jobs-cta-col"></th>
</thead>
<tbody class="gl-vertical-align-top">
<template v-for="group in bulkImportSourceGroups.nodes">
<import-table-row
:key="group.id"
<gl-table
class="gl-w-full"
data-qa-selector="import_table"
tbody-tr-class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid"
:tbody-tr-attr="qaRowAttributes"
:items="bulkImportSourceGroups.nodes"
:fields="$options.fields"
>
<template #cell(web_url)="{ value: web_url, item: { full_path } }">
<gl-link
:href="web_url"
target="_blank"
class="gl-display-flex gl-align-items-center gl-h-7"
>
{{ full_path }} <gl-icon name="external-link" />
</gl-link>
</template>
<template #cell(import_target)="{ item: group }">
<import-target-cell
:group="group"
:available-namespaces="availableNamespaces"
:group-path-regex="groupPathRegex"
......@@ -264,11 +327,24 @@ export default {
@update-new-name="
updateImportTarget(group.id, group.import_target.target_namespace, $event)
"
@import-group="importGroups([group.id])"
/>
</template>
</tbody>
</table>
<template #cell(progress)="{ value: { status } }">
<import-status :status="status" class="gl-mt-2" />
</template>
<template #cell(actions)="{ item: group }">
<gl-button
v-if="!isAlreadyImported(group)"
:disabled="isInvalid(group)"
variant="confirm"
category="secondary"
data-qa-selector="import_group_button"
@click="importGroups([group.id])"
>
{{ __('Import') }}
</gl-button>
</template>
</gl-table>
<div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center">
<pagination-links
:change="setPage"
......
<script>
import {
GlButton,
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
GlIcon,
GlLink,
GlFormInput,
} from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import ImportGroupDropdown from '../../components/group_dropdown.vue';
import ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants';
export default {
components: {
ImportStatus,
ImportGroupDropdown,
GlButton,
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
GlLink,
GlIcon,
GlFormInput,
},
props: {
group: {
type: Object,
required: true,
},
availableNamespaces: {
type: Array,
required: true,
},
groupPathRegex: {
type: RegExp,
required: true,
},
groupUrlErrorMessage: {
type: String,
required: true,
},
},
computed: {
availableNamespaceNames() {
return this.availableNamespaces.map((ns) => ns.full_path);
},
importTarget() {
return this.group.import_target;
},
invalidNameValidationMessage() {
return this.group.validation_errors.find(({ field }) => field === 'new_name')?.message;
},
isInvalid() {
return Boolean(!this.isNameValid || this.invalidNameValidationMessage);
},
isNameValid() {
return this.groupPathRegex.test(this.importTarget.new_name);
},
isAlreadyImported() {
return this.group.progress.status !== STATUSES.NONE;
},
isFinished() {
return this.group.progress.status === STATUSES.FINISHED;
},
fullPath() {
return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`;
},
absolutePath() {
return joinPaths(gon.relative_url_root || '/', this.fullPath);
},
},
};
</script>
<template>
<tr
class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid"
data-qa-selector="import_item"
:data-qa-source-group="group.full_path"
>
<td class="gl-p-4">
<gl-link
:href="group.web_url"
target="_blank"
class="gl-display-flex gl-align-items-center gl-h-7"
>
{{ group.full_path }} <gl-icon name="external-link" />
</gl-link>
</td>
<td class="gl-p-4">
<gl-link
v-if="isFinished"
class="gl-display-flex gl-align-items-center gl-h-7"
:href="absolutePath"
>
{{ fullPath }}
</gl-link>
<div
v-else
class="import-entities-target-select gl-display-flex gl-align-items-stretch"
:class="{
disabled: isAlreadyImported,
}"
>
<import-group-dropdown
#default="{ namespaces }"
:text="importTarget.target_namespace"
:disabled="isAlreadyImported"
:namespaces="availableNamespaceNames"
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="import-entities-namespace-dropdown gl-h-7 gl-flex-grow-1"
data-qa-selector="target_namespace_selector_dropdown"
>
<gl-dropdown-item @click="$emit('update-target-namespace', '')">{{
s__('BulkImport|No parent')
}}</gl-dropdown-item>
<template v-if="namespaces.length">
<gl-dropdown-divider />
<gl-dropdown-section-header>
{{ s__('BulkImport|Existing groups') }}
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="ns in namespaces"
:key="ns"
data-qa-selector="target_group_dropdown_item"
:data-qa-group-name="ns"
@click="$emit('update-target-namespace', ns)"
>
{{ ns }}
</gl-dropdown-item>
</template>
</import-group-dropdown>
<div
class="import-entities-target-select-separator gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
>
/
</div>
<div class="gl-flex-grow-1">
<gl-form-input
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:class="{ 'is-invalid': isInvalid && !isAlreadyImported }"
:disabled="isAlreadyImported"
:value="importTarget.new_name"
@input="$emit('update-new-name', $event)"
/>
<p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2">
<template v-if="!isNameValid">
{{ groupUrlErrorMessage }}
</template>
<template v-else-if="invalidNameValidationMessage">
{{ invalidNameValidationMessage }}
</template>
</p>
</div>
</div>
</td>
<td class="gl-p-4 gl-white-space-nowrap" data-qa-selector="import_status_indicator">
<import-status :status="group.progress.status" class="gl-mt-2" />
</td>
<td class="gl-p-4">
<gl-button
v-if="!isAlreadyImported"
:disabled="isInvalid"
variant="confirm"
category="secondary"
data-qa-selector="import_group_button"
@click="$emit('import-group')"
>{{ __('Import') }}</gl-button
>
</td>
</tr>
</template>
<script>
import {
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
GlLink,
GlFormInput,
} from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import ImportGroupDropdown from '../../components/group_dropdown.vue';
import { STATUSES } from '../../constants';
import { isInvalid, getInvalidNameValidationMessage, isNameValid } from '../utils';
export default {
components: {
ImportGroupDropdown,
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
GlLink,
GlFormInput,
},
props: {
group: {
type: Object,
required: true,
},
availableNamespaces: {
type: Array,
required: true,
},
groupPathRegex: {
type: RegExp,
required: true,
},
groupUrlErrorMessage: {
type: String,
required: true,
},
},
computed: {
availableNamespaceNames() {
return this.availableNamespaces.map((ns) => ns.full_path);
},
importTarget() {
return this.group.import_target;
},
invalidNameValidationMessage() {
return getInvalidNameValidationMessage(this.group);
},
isInvalid() {
return isInvalid(this.group, this.groupPathRegex);
},
isNameValid() {
return isNameValid(this.group, this.groupPathRegex);
},
isAlreadyImported() {
return this.group.progress.status !== STATUSES.NONE;
},
isFinished() {
return this.group.progress.status === STATUSES.FINISHED;
},
fullPath() {
return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`;
},
absolutePath() {
return joinPaths(gon.relative_url_root || '/', this.fullPath);
},
},
i18n: {
NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
},
};
</script>
<template>
<gl-link
v-if="isFinished"
class="gl-display-flex gl-align-items-center gl-h-7"
:href="absolutePath"
>
{{ fullPath }}
</gl-link>
<div
v-else
class="gl-display-flex gl-align-items-stretch"
:class="{
disabled: isAlreadyImported,
}"
>
<import-group-dropdown
#default="{ namespaces }"
:text="importTarget.target_namespace"
:disabled="isAlreadyImported"
:namespaces="availableNamespaceNames"
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="gl-h-7 gl-flex-grow-1"
data-qa-selector="target_namespace_selector_dropdown"
>
<gl-dropdown-item @click="$emit('update-target-namespace', '')">{{
s__('BulkImport|No parent')
}}</gl-dropdown-item>
<template v-if="namespaces.length">
<gl-dropdown-divider />
<gl-dropdown-section-header>
{{ s__('BulkImport|Existing groups') }}
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="ns in namespaces"
:key="ns"
data-qa-selector="target_group_dropdown_item"
:data-qa-group-name="ns"
@click="$emit('update-target-namespace', ns)"
>
{{ ns }}
</gl-dropdown-item>
</template>
</import-group-dropdown>
<div
class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10"
:class="{
'gl-text-gray-400 gl-border-gray-100': isAlreadyImported,
'gl-border-gray-200': !isAlreadyImported,
}"
>
/
</div>
<div class="gl-flex-grow-1">
<gl-form-input
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:class="{
'gl-inset-border-1-gray-200!': !isAlreadyImported,
'gl-inset-border-1-gray-100!': isAlreadyImported,
'is-invalid': isInvalid && !isAlreadyImported,
}"
:disabled="isAlreadyImported"
:value="importTarget.new_name"
@input="$emit('update-new-name', $event)"
/>
<p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2">
<template v-if="!isNameValid">
{{ groupUrlErrorMessage }}
</template>
<template v-else-if="invalidNameValidationMessage">
{{ invalidNameValidationMessage }}
</template>
</p>
</div>
</div>
</template>
......@@ -3,3 +3,5 @@ import { s__ } from '~/locale';
export const i18n = {
NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
};
export const NEW_NAME_FIELD = 'new_name';
......@@ -4,7 +4,7 @@ import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { STATUSES } from '../../constants';
import { i18n } from '../constants';
import { i18n, NEW_NAME_FIELD } from '../constants';
import bulkImportSourceGroupItemFragment from './fragments/bulk_import_source_group_item.fragment.graphql';
import addValidationErrorMutation from './mutations/add_validation_error.mutation.graphql';
import removeValidationErrorMutation from './mutations/remove_validation_error.mutation.graphql';
......@@ -61,7 +61,7 @@ async function checkImportTargetIsValid({ client, newName, targetNamespace, sour
});
const variables = {
field: 'new_name',
field: NEW_NAME_FIELD,
sourceGroupId,
};
......
import { NEW_NAME_FIELD } from './constants';
export function isNameValid(group, validationRegex) {
return validationRegex.test(group.import_target[NEW_NAME_FIELD]);
}
export function getInvalidNameValidationMessage(group) {
return group.validation_errors.find(({ field }) => field === NEW_NAME_FIELD)?.message;
}
export function isInvalid(group, validationRegex) {
return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group));
}
......@@ -22,16 +22,11 @@
.import-entities-target-select {
&.disabled {
.import-entities-target-select-separator,
.select2-container.select2-container-disabled .select2-choice {
.import-entities-target-select-separator {
color: var(--gray-400, $gray-400);
border-color: var(--gray-100, $gray-100);
background-color: var(--gray-10, $gray-10);
}
.select2-container.select2-container-disabled .select2-choice .select2-arrow {
background-color: var(--gray-10, $gray-10);
}
}
.import-entities-target-select-separator {
......@@ -39,20 +34,6 @@
background-color: var(--gray-10, $gray-10);
}
.select2-container {
> .select2-choice {
.select2-arrow {
background-color: var(--white, $white);
}
border-color: var(--gray-200, $gray-200);
color: var(--gray-900, $gray-900) !important;
background-color: var(--white, $white) !important;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.gl-form-input {
box-shadow: inset 0 0 0 1px var(--gray-200, $gray-200);
}
......
- add_to_breadcrumbs _('New group'), new_group_path
- add_page_specific_style 'page_bundles/import'
- breadcrumb_title _('Import groups')
- page_title _('Import groups')
#import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json),
available_namespaces_path: import_available_namespaces_path(format: :json),
......
......@@ -6,13 +6,13 @@ module QA
class BulkImport < Page::Base
view "app/assets/javascripts/import_entities/import_groups/components/import_table.vue" do
element :import_table
element :import_item
element :import_group_button
element :import_status_indicator
end
view "app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue" do
element :import_item
view "app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue" do
element :target_group_dropdown_item
element :import_status_indicator
element :import_group_button
end
view "app/assets/javascripts/import_entities/components/group_dropdown.vue" do
......
......@@ -3,18 +3,18 @@ import {
GlEmptyState,
GlLoadingIcon,
GlSearchBoxByClick,
GlSprintf,
GlDropdown,
GlDropdownItem,
} from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { mount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import stubChildren from 'helpers/stub_children';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import { STATUSES } from '~/import_entities/constants';
import ImportTable from '~/import_entities/import_groups/components/import_table.vue';
import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue';
import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
import setImportTargetMutation from '~/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
......@@ -57,15 +57,17 @@ describe('import table', () => {
},
});
wrapper = shallowMount(ImportTable, {
wrapper = mount(ImportTable, {
propsData: {
groupPathRegex: /.*/,
sourceUrl: SOURCE_URL,
groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.',
},
stubs: {
GlSprintf,
...stubChildren(ImportTable),
GlSprintf: false,
GlDropdown: GlDropdownStub,
GlTable: false,
},
localVue,
apolloProvider,
......@@ -115,7 +117,7 @@ describe('import table', () => {
});
await waitForPromises();
expect(wrapper.findAll(ImportTableRow)).toHaveLength(FAKE_GROUPS.length);
expect(wrapper.findAll('tbody tr')).toHaveLength(FAKE_GROUPS.length);
});
it('does not render status string when result list is empty', async () => {
......@@ -142,16 +144,29 @@ describe('import table', () => {
event | payload | mutation | variables
${'update-target-namespace'} | ${'new-namespace'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace', newName: 'group1' }}
${'update-new-name'} | ${'new-name'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'root', newName: 'new-name' }}
${'import-group'} | ${undefined} | ${importGroupsMutation} | ${{ sourceGroupIds: [FAKE_GROUP.id] }}
`('correctly maps $event to mutation', async ({ event, payload, mutation, variables }) => {
jest.spyOn(apolloProvider.defaultClient, 'mutate');
wrapper.find(ImportTableRow).vm.$emit(event, payload);
wrapper.find(ImportTargetCell).vm.$emit(event, payload);
await waitForPromises();
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation,
variables,
});
});
it('invokes importGroups mutation when row button is clicked', async () => {
jest.spyOn(apolloProvider.defaultClient, 'mutate');
const triggerImportButton = wrapper
.findAllComponents(GlButton)
.wrappers.find((w) => w.text() === 'Import');
triggerImportButton.vm.$emit('click');
await waitForPromises();
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation: importGroupsMutation,
variables: { sourceGroupIds: [FAKE_GROUP.id] },
});
});
});
describe('pagination', () => {
......
......@@ -4,7 +4,7 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue';
import { STATUSES } from '~/import_entities/constants';
import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue';
import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
import { availableNamespacesFixture } from '../graphql/fixtures';
Vue.use(VueApollo);
......@@ -22,19 +22,18 @@ const getFakeGroup = (status) => ({
progress: { status },
});
describe('import table row', () => {
describe('import target cell', () => {
let wrapper;
let group;
const findByText = (cmp, text) => {
return wrapper.findAll(cmp).wrappers.find((node) => node.text().indexOf(text) === 0);
};
const findImportButton = () => findByText(GlButton, 'Import');
const findNameInput = () => wrapper.find(GlFormInput);
const findNamespaceDropdown = () => wrapper.find(ImportGroupDropdown);
const createComponent = (props) => {
wrapper = shallowMount(ImportTableRow, {
wrapper = shallowMount(ImportTargetCell, {
stubs: { ImportGroupDropdown },
propsData: {
availableNamespaces: availableNamespacesFixture,
......@@ -56,14 +55,10 @@ describe('import table row', () => {
createComponent({ group });
});
it.each`
selector | sourceEvent | payload | event
${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'}
${findImportButton} | ${'click'} | ${undefined} | ${'import-group'}
`('invokes $event', ({ selector, sourceEvent, payload, event }) => {
selector().vm.$emit(sourceEvent, payload);
expect(wrapper.emitted(event)).toBeDefined();
expect(wrapper.emitted(event)[0][0]).toBe(payload);
it('invokes $event', () => {
findNameInput().vm.$emit('input', 'demo');
expect(wrapper.emitted('update-new-name')).toBeDefined();
expect(wrapper.emitted('update-new-name')[0][0]).toBe('demo');
});
it('emits update-target-namespace when dropdown option is clicked', () => {
......@@ -83,10 +78,6 @@ describe('import table row', () => {
createComponent({ group });
});
it('renders Import button', () => {
expect(findByText(GlButton, 'Import').exists()).toBe(true);
});
it('renders namespace dropdown as not disabled', () => {
expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined);
});
......
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