Commit ed1cdf5a authored by Illya Klymov's avatar Illya Klymov

Implement multiple top groups select

* Implement checkboxes for bulk import select
* Allow selecting all groups
parent 89313c7d
...@@ -11,7 +11,7 @@ import { ...@@ -11,7 +11,7 @@ import {
GlSprintf, GlSprintf,
GlSafeHtmlDirective as SafeHtml, GlSafeHtmlDirective as SafeHtml,
GlTable, GlTable,
GlTooltip, GlFormCheckbox,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__, __, n__ } from '~/locale'; import { s__, __, n__ } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
...@@ -40,8 +40,8 @@ export default { ...@@ -40,8 +40,8 @@ export default {
GlLink, GlLink,
GlLoadingIcon, GlLoadingIcon,
GlSearchBoxByClick, GlSearchBoxByClick,
GlFormCheckbox,
GlSprintf, GlSprintf,
GlTooltip,
GlTable, GlTable,
ImportStatus, ImportStatus,
ImportTargetCell, ImportTargetCell,
...@@ -71,6 +71,7 @@ export default { ...@@ -71,6 +71,7 @@ export default {
filter: '', filter: '',
page: 1, page: 1,
perPage: DEFAULT_PAGE_SIZE, perPage: DEFAULT_PAGE_SIZE,
selectedGroups: [],
}; };
}, },
...@@ -85,11 +86,20 @@ export default { ...@@ -85,11 +86,20 @@ export default {
}, },
fields: [ fields: [
{
key: 'selected',
label: '',
// eslint-disable-next-line @gitlab/require-i18n-strings
thClass: `${DEFAULT_TH_CLASSES} gl-w-3 gl-pr-3!`,
// eslint-disable-next-line @gitlab/require-i18n-strings
tdClass: `${DEFAULT_TD_CLASSES} gl-pr-3!`,
},
{ {
key: 'web_url', key: 'web_url',
label: s__('BulkImport|From source group'), label: s__('BulkImport|From source group'),
thClass: `${DEFAULT_TH_CLASSES} import-jobs-from-col`, thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! import-jobs-from-col`,
tdClass: DEFAULT_TD_CLASSES, // eslint-disable-next-line @gitlab/require-i18n-strings
tdClass: `${DEFAULT_TD_CLASSES} gl-pl-0!`,
}, },
{ {
key: 'import_target', key: 'import_target',
...@@ -117,16 +127,16 @@ export default { ...@@ -117,16 +127,16 @@ export default {
return this.bulkImportSourceGroups?.nodes ?? []; return this.bulkImportSourceGroups?.nodes ?? [];
}, },
hasGroupsWithValidationError() { hasSelectedGroups() {
return this.groups.some((g) => g.validation_errors.length); return this.selectedGroups.length > 0;
}, },
availableGroupsForImport() { hasAllAvailableGroupsSelected() {
return this.groups.filter((g) => g.progress.status === STATUSES.NONE); return this.selectedGroups.length === this.availableGroupsForImport.length;
}, },
isImportAllButtonDisabled() { availableGroupsForImport() {
return this.hasGroupsWithValidationError || this.availableGroupsForImport.length === 0; return this.groups.filter((g) => g.progress.status === STATUSES.NONE && !this.isInvalid(g));
}, },
humanizedTotal() { humanizedTotal() {
...@@ -156,7 +166,7 @@ export default { ...@@ -156,7 +166,7 @@ export default {
total: 0, total: 0,
}; };
const start = (page - 1) * perPage + 1; const start = (page - 1) * perPage + 1;
const end = start + (this.bulkImportSourceGroups.nodes?.length ?? 0) - 1; const end = start + this.groups.length - 1;
return { start, end, total }; return { start, end, total };
}, },
...@@ -166,6 +176,17 @@ export default { ...@@ -166,6 +176,17 @@ export default {
filter() { filter() {
this.page = 1; this.page = 1;
}, },
groups() {
const table = this.getTableRef();
this.groups.forEach((g, idx) => {
if (this.selectedGroups.includes(g)) {
this.$nextTick(() => {
table.selectRow(idx);
});
}
});
this.selectedGroups = [];
},
}, },
methods: { methods: {
...@@ -203,20 +224,40 @@ export default { ...@@ -203,20 +224,40 @@ export default {
}); });
}, },
importGroups(sourceGroupIds) { async importGroups(sourceGroupIds) {
this.$apollo.mutate({ this.$apollo.mutate({
mutation: importGroupsMutation, mutation: importGroupsMutation,
variables: { sourceGroupIds }, variables: { sourceGroupIds },
}); });
}, },
importAllGroups() { importSelectedGroups() {
this.importGroups(this.availableGroupsForImport.map((g) => g.id)); this.importGroups(this.selectedGroups.map((g) => g.id));
}, },
setPageSize(size) { setPageSize(size) {
this.perPage = size; this.perPage = size;
}, },
getTableRef() {
// Acquire reference to BTable to manipulate selection
// issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531
// refs are not reactive, so do not use computed here
return this.$refs.table?.$children[0];
},
preventSelectingAlreadyImportedGroups(updatedSelection) {
if (updatedSelection) {
this.selectedGroups = updatedSelection;
}
const table = this.getTableRef();
this.groups.forEach((group, idx) => {
if (table.isRowSelected(idx) && (this.isAlreadyImported(group) || this.isInvalid(group))) {
table.unselectRow(idx);
}
});
},
}, },
gitlabLogo: window.gon.gitlab_logo, gitlabLogo: window.gon.gitlab_logo,
...@@ -231,28 +272,6 @@ export default { ...@@ -231,28 +272,6 @@ export default {
> >
<img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" />
{{ s__('BulkImport|Import groups from GitLab') }} {{ s__('BulkImport|Import groups from GitLab') }}
<div ref="importAllButtonWrapper" class="gl-ml-auto">
<gl-button
v-if="!$apollo.loading && hasGroups"
:disabled="isImportAllButtonDisabled"
variant="confirm"
@click="importAllGroups"
>
<gl-sprintf :message="s__('BulkImport|Import %{groups}')">
<template #groups>
{{ groupsCount(availableGroupsForImport.length) }}
</template>
</gl-sprintf>
</gl-button>
</div>
<gl-tooltip v-if="isImportAllButtonDisabled" :target="() => $refs.importAllButtonWrapper">
<template v-if="hasGroupsWithValidationError">
{{ s__('BulkImport|One or more groups has validation errors') }}
</template>
<template v-else>
{{ s__('BulkImport|No groups on this page are available for import') }}
</template>
</gl-tooltip>
</h1> </h1>
<div <div
class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex"
...@@ -298,19 +317,58 @@ export default { ...@@ -298,19 +317,58 @@ export default {
:description="s__('Check your source instance permissions.')" :description="s__('Check your source instance permissions.')"
/> />
<template v-else> <template v-else>
<div
class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-p-4 gl-display-flex gl-align-items-center"
>
<gl-sprintf :message="__('%{count} selected')">
<template #count>
{{ selectedGroups.length }}
</template>
</gl-sprintf>
<gl-button
category="primary"
variant="confirm"
class="gl-ml-4"
:disabled="!hasSelectedGroups"
@click="importSelectedGroups"
>{{ s__('BulkImport|Import selected') }}</gl-button
>
</div>
<gl-table <gl-table
ref="table"
class="gl-w-full" class="gl-w-full"
data-qa-selector="import_table" data-qa-selector="import_table"
tbody-tr-class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid" tbody-tr-class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid"
:tbody-tr-attr="qaRowAttributes" :tbody-tr-attr="qaRowAttributes"
:items="bulkImportSourceGroups.nodes" :items="groups"
:fields="$options.fields" :fields="$options.fields"
selectable
select-mode="multi"
selected-variant="primary"
@row-selected="preventSelectingAlreadyImportedGroups"
> >
<template #head(selected)="{ selectAllRows, clearSelected }">
<gl-form-checkbox
:key="`checkbox-${selectedGroups.length}`"
class="gl-h-7 gl-pt-3"
:checked="hasSelectedGroups"
:indeterminate="hasSelectedGroups && !hasAllAvailableGroupsSelected"
@change="hasAllAvailableGroupsSelected ? clearSelected() : selectAllRows()"
/>
</template>
<template #cell(selected)="{ rowSelected, selectRow, unselectRow, item: group }">
<gl-form-checkbox
class="gl-h-7 gl-pt-3"
:checked="rowSelected"
:disabled="isAlreadyImported(group) || isInvalid(group)"
@change="rowSelected ? unselectRow() : selectRow()"
/>
</template>
<template #cell(web_url)="{ value: web_url, item: { full_path } }"> <template #cell(web_url)="{ value: web_url, item: { full_path } }">
<gl-link <gl-link
:href="web_url" :href="web_url"
target="_blank" target="_blank"
class="gl-display-flex gl-align-items-center gl-h-7" class="gl-display-inline-flex gl-align-items-center gl-h-7"
> >
{{ full_path }} <gl-icon name="external-link" /> {{ full_path }} <gl-icon name="external-link" />
</gl-link> </gl-link>
...@@ -330,7 +388,7 @@ export default { ...@@ -330,7 +388,7 @@ export default {
/> />
</template> </template>
<template #cell(progress)="{ value: { status } }"> <template #cell(progress)="{ value: { status } }">
<import-status :status="status" class="gl-mt-2" /> <import-status :status="status" class="gl-line-height-32" />
</template> </template>
<template #cell(actions)="{ item: group }"> <template #cell(actions)="{ item: group }">
<gl-button <gl-button
......
...@@ -87,7 +87,7 @@ export default { ...@@ -87,7 +87,7 @@ export default {
<template> <template>
<gl-link <gl-link
v-if="isFinished" v-if="isFinished"
class="gl-display-flex gl-align-items-center gl-h-7" class="gl-display-inline-flex gl-align-items-center gl-h-7"
:href="absolutePath" :href="absolutePath"
> >
{{ fullPath }} {{ fullPath }}
......
...@@ -522,6 +522,9 @@ msgstr[1] "" ...@@ -522,6 +522,9 @@ msgstr[1] ""
msgid "%{count} related %{pluralized_subject}: %{links}" msgid "%{count} related %{pluralized_subject}: %{links}"
msgstr "" msgstr ""
msgid "%{count} selected"
msgstr ""
msgid "%{count} total weight" msgid "%{count} total weight"
msgstr "" msgstr ""
...@@ -5813,30 +5816,24 @@ msgstr "" ...@@ -5813,30 +5816,24 @@ msgstr ""
msgid "BulkImport|From source group" msgid "BulkImport|From source group"
msgstr "" msgstr ""
msgid "BulkImport|Import %{groups}"
msgstr ""
msgid "BulkImport|Import failed: Destination cannot be a subgroup of the source group. Change the destination and try again." msgid "BulkImport|Import failed: Destination cannot be a subgroup of the source group. Change the destination and try again."
msgstr "" msgstr ""
msgid "BulkImport|Import groups from GitLab" msgid "BulkImport|Import groups from GitLab"
msgstr "" msgstr ""
msgid "BulkImport|Importing the group failed" msgid "BulkImport|Import selected"
msgstr "" msgstr ""
msgid "BulkImport|Name already exists." msgid "BulkImport|Importing the group failed"
msgstr "" msgstr ""
msgid "BulkImport|No groups on this page are available for import" msgid "BulkImport|Name already exists."
msgstr "" msgstr ""
msgid "BulkImport|No parent" msgid "BulkImport|No parent"
msgstr "" msgstr ""
msgid "BulkImport|One or more groups has validation errors"
msgstr ""
msgid "BulkImport|Showing %{start}-%{end} of %{total}" msgid "BulkImport|Showing %{start}-%{end} of %{total}"
msgstr "" msgstr ""
......
...@@ -5,8 +5,10 @@ import { ...@@ -5,8 +5,10 @@ import {
GlSearchBoxByClick, GlSearchBoxByClick,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlTable,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils'; import { mount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import stubChildren from 'helpers/stub_children'; import stubChildren from 'helpers/stub_children';
...@@ -40,10 +42,15 @@ describe('import table', () => { ...@@ -40,10 +42,15 @@ describe('import table', () => {
]; ];
const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 }; const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 };
const findImportAllButton = () => wrapper.find('h1').find(GlButton); const findImportSelectedButton = () =>
wrapper.findAllComponents(GlButton).wrappers.find((w) => w.text() === 'Import selected');
const findPaginationDropdown = () => wrapper.findComponent(GlDropdown); const findPaginationDropdown = () => wrapper.findComponent(GlDropdown);
const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text(); const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text();
// TODO: remove this ugly approach when
// issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531
const findTable = () => wrapper.vm.getTableRef();
const createComponent = ({ bulkImportSourceGroups }) => { const createComponent = ({ bulkImportSourceGroups }) => {
apolloProvider = createMockApollo([], { apolloProvider = createMockApollo([], {
Query: { Query: {
...@@ -294,16 +301,20 @@ describe('import table', () => { ...@@ -294,16 +301,20 @@ describe('import table', () => {
}); });
}); });
describe('import all button', () => { describe('bulk operations', () => {
it('does not exists when no groups available', () => { it('import selected button is disabled when no groups selected', async () => {
createComponent({ createComponent({
bulkImportSourceGroups: () => new Promise(() => {}), bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS,
pageInfo: FAKE_PAGE_INFO,
}),
}); });
await waitForPromises();
expect(findImportAllButton().exists()).toBe(false); expect(findImportSelectedButton().props().disabled).toBe(true);
}); });
it('exists when groups are available for import', async () => { it('import selected button is enabled when groups were selected for import', async () => {
createComponent({ createComponent({
bulkImportSourceGroups: () => ({ bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS, nodes: FAKE_GROUPS,
...@@ -311,16 +322,14 @@ describe('import table', () => { ...@@ -311,16 +322,14 @@ describe('import table', () => {
}), }),
}); });
await waitForPromises(); await waitForPromises();
wrapper.find(GlTable).vm.$emit('row-selected', [FAKE_GROUPS[0]]);
await nextTick();
expect(findImportAllButton().exists()).toBe(true); expect(findImportSelectedButton().props().disabled).toBe(false);
}); });
it('counts only not-imported groups', async () => { it('does not allow selecting already started groups', async () => {
const NEW_GROUPS = [ const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.FINISHED })];
generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({ id: 2, status: STATUSES.NONE }),
generateFakeEntry({ id: 3, status: STATUSES.FINISHED }),
];
createComponent({ createComponent({
bulkImportSourceGroups: () => ({ bulkImportSourceGroups: () => ({
...@@ -330,17 +339,41 @@ describe('import table', () => { ...@@ -330,17 +339,41 @@ describe('import table', () => {
}); });
await waitForPromises(); await waitForPromises();
expect(findImportAllButton().text()).toMatchInterpolatedText('Import 2 groups'); findTable().selectRow(0);
await nextTick();
expect(findImportSelectedButton().props().disabled).toBe(true);
}); });
it('disables button when any group has validation errors', async () => { it('does not allow selecting groups with validation errors', async () => {
const NEW_GROUPS = [ const NEW_GROUPS = [
generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({ generateFakeEntry({
id: 2, id: 2,
status: STATUSES.NONE, status: STATUSES.NONE,
validation_errors: [{ field: 'new_name', message: 'test validation error' }], validation_errors: [{ field: 'new_name', message: 'FAKE_VALIDATION_ERROR' }],
}),
];
createComponent({
bulkImportSourceGroups: () => ({
nodes: NEW_GROUPS,
pageInfo: FAKE_PAGE_INFO,
}), }),
});
await waitForPromises();
// TODO: remove this ugly approach when
// issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531
findTable().selectRow(0);
await nextTick();
expect(findImportSelectedButton().props().disabled).toBe(true);
});
it('invokes importGroups mutation when import selected button is clicked', async () => {
const NEW_GROUPS = [
generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({ id: 2, status: STATUSES.NONE }),
generateFakeEntry({ id: 3, status: STATUSES.FINISHED }), generateFakeEntry({ id: 3, status: STATUSES.FINISHED }),
]; ];
...@@ -350,9 +383,19 @@ describe('import table', () => { ...@@ -350,9 +383,19 @@ describe('import table', () => {
pageInfo: FAKE_PAGE_INFO, pageInfo: FAKE_PAGE_INFO,
}), }),
}); });
jest.spyOn(apolloProvider.defaultClient, 'mutate');
await waitForPromises(); await waitForPromises();
expect(findImportAllButton().props().disabled).toBe(true); findTable().selectRow(0);
findTable().selectRow(1);
await nextTick();
findImportSelectedButton().vm.$emit('click');
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation: importGroupsMutation,
variables: { sourceGroupIds: [NEW_GROUPS[0].id, NEW_GROUPS[1].id] },
});
}); });
}); });
}); });
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