diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table.stories.js b/app/assets/javascripts/vue_shared/components/import/import_history_table.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..b29865b3fd991b8780055e587049ccb1e168c18a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/import/import_history_table.stories.js @@ -0,0 +1,21 @@ +import { basic } from 'jest/vue_shared/components/import/history_mock_data'; +import ImportHistoryTable from './import_history_table.vue'; + +export default { + component: ImportHistoryTable, + title: 'vue_shared/import/import_history_table', +}; + +const defaultProps = basic; + +const Template = (args, { argTypes }) => ({ + components: { ImportHistoryTable }, + data() { + return {}; + }, + props: Object.keys(argTypes), + template: `<import-history-table v-bind="$props" />`, +}); + +export const Default = Template.bind({}); +Default.args = defaultProps; diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table.vue b/app/assets/javascripts/vue_shared/components/import/import_history_table.vue new file mode 100644 index 0000000000000000000000000000000000000000..b5ce549f31c8a37c16bb4fa29ea55ec74db1b6e6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/import/import_history_table.vue @@ -0,0 +1,163 @@ +<script> +import { GlAvatarLabeled } from '@gitlab/ui'; +import TimeAgoTooltip from '../time_ago_tooltip.vue'; + +import ImportHistoryTableHeader from './import_history_table_header.vue'; +import ImportHistoryTableRow from './import_history_table_row.vue'; +import ImportHistoryTableSource from './import_history_table_source.vue'; +import ImportHistoryTableRowDestination from './import_history_table_row_destination.vue'; +import ImportHistoryTableRowStats from './import_history_table_row_stats.vue'; +import ImportHistoryTableRowErrors from './import_history_table_row_errors.vue'; +import ImportHistoryStatusBadge from './import_history_status_badge.vue'; + +/** + * A flexible arrangement of import items, used for import history. + * + * **Note**: semantically this is *not* a table, as there are nested elements and disclosures. + */ +export default { + name: 'ImportHistoryTable', + components: { + GlAvatarLabeled, + ImportHistoryStatusBadge, + TimeAgoTooltip, + ImportHistoryTableRowDestination, + ImportHistoryTableHeader, + ImportHistoryTableSource, + ImportHistoryTableRow, + ImportHistoryTableRowStats, + ImportHistoryTableRowErrors, + }, + props: { + /** + * This should be able to accept the data that comes from the BulkImport API + * + * @typedef {Object} ImportItem + * @property {Object} source - Source project details + * @property {Object} destination - Target namespace details + * @property {Object} stats - Import statistics + * @property {number} stats.imported - Successfully imported count + * @property {number} stats.fetched - Total fetched count + * @property {Array<Object>} failures - Error details + * @property {string} failures[].correlation_id_value - Error correlation ID + * @property {string} failures[].exception_message - Human-readable error message + * @property {string} [failures[].link_text] - Optional link text for error details + * @property {string} [failures[].raw] - Raw error output + * + * @type {Array<ImportItem>} + */ + items: { + type: Array, + default: () => [], + required: true, + }, + /** Path for links to help docs on errors. Can be injected in parent. */ + detailsPath: { + type: String, + required: false, + default: null, + }, + }, + methods: { + hasStats(item) { + return Boolean(item.stats && Object.keys(item.stats).length); + }, + hasFailures(item) { + return item.has_failures; + }, + showToggle(item) { + return this.hasStats(item) || this.hasFailures(item) || Boolean(item.nestedRow); + }, + }, + gridClasses: 'gl-grid-cols-[repeat(2,1fr),200px,150px]', +}; +</script> + +<template> + <div> + <import-history-table-header :grid-classes="$options.gridClasses"> + <template #column-1> + {{ __('Source name') }} + </template> + <template #column-2>{{ __('Destination') }}</template> + <template #column-3>{{ __('Start date') }}</template> + <template #column-4>{{ __('Status') }}</template> + </import-history-table-header> + <import-history-table-row + v-for="item in items" + :key="item.id" + data-testid="import-history-table-row" + :show-toggle="showToggle(item)" + :grid-classes="$options.gridClasses" + > + <template #column-1> + <import-history-table-source :item="item" /> + </template> + <template #column-2> + <import-history-table-row-destination :item="item" /> + </template> + <template #column-3> + <div class="gl-flex gl-flex-col gl-gap-2"> + <gl-avatar-labeled v-if="item.userAvatarProps" v-bind="item.userAvatarProps" :size="16" /> + <time-ago-tooltip :time="item.created_at" /> + </div> + </template> + <template #column-4> + <import-history-status-badge v-if="item.status_name" :status="item.status_name" /> + </template> + <template v-if="item.nestedRow" #nested-row> + <import-history-table-row + data-testid="import-history-table-row-nested" + :is-nested="true" + :show-toggle="showToggle(item.nestedRow)" + :grid-classes="$options.gridClasses" + > + <template #column-1> + <import-history-table-source :item="item.nestedRow" /> + </template> + <template #column-2> + <import-history-table-row-destination :item="item.nestedRow" /> + </template> + <template #column-3> + <gl-avatar-labeled + v-if="item.nestedRow.userAvatarProps" + v-bind="item.nestedRow.userAvatarProps" + :size="16" + /> + <time-ago-tooltip :time="item.nestedRow.created_at" /> + </template> + <template #column-4> + <import-history-status-badge + v-if="item.nestedRow.status_name" + :status="item.nestedRow.status_name" + /> + </template> + <template #expanded-content> + <import-history-table-row-stats + v-if="hasStats(item.nestedRow)" + :item="item.nestedRow" + :details-path="detailsPath" + /> + <import-history-table-row-errors + v-else-if="hasFailures(item.nestedRow)" + :item="item.nestedRow" + :details-path="detailsPath" + /> + </template> + </import-history-table-row> + </template> + <template #expanded-content> + <import-history-table-row-stats + v-if="hasStats(item)" + :item="item" + :details-path="detailsPath" + /> + <import-history-table-row-errors + v-else-if="hasFailures(item)" + :item="item" + :details-path="detailsPath" + /> + </template> + </import-history-table-row> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_row.vue b/app/assets/javascripts/vue_shared/components/import/import_history_table_row.vue index c3615f2ab5caf3a058ac9d92dcc32b9f0316af1e..367032548f8194e80b15d4e1346f28aad41be36e 100644 --- a/app/assets/javascripts/vue_shared/components/import/import_history_table_row.vue +++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_row.vue @@ -65,7 +65,7 @@ export default { :class="appliedGridClasses" > <div - class="gl-flex gl-items-start gl-gap-3" + class="gl-flex gl-min-w-0 gl-items-start gl-gap-3" :class="[$options.defaultClasses, isNested && 'gl-pl-7']" > <gl-button @@ -85,12 +85,12 @@ export default { <!-- @slot Optionally use to provide a checkbox to show when `showToggle` is false for selecting the row --> <slot name="checkbox"></slot> </div> - <div class="gl-flex-grow"> + <div class="gl-min-w-0 gl-flex-grow"> <!-- @slot The content of the 1st column --> <slot name="column-1"></slot> </div> </div> - <div :class="$options.defaultClasses"> + <div :class="$options.defaultClasses" class="gl-min-w-0"> <div> <!-- @slot The content of the 2nd column --> <slot name="column-2"></slot> diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_destination.stories.js b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_destination.stories.js index 1fc15de705365c3db7aec7344a03e8d747fcbd61..d8eca3403bc755d773a69648c4c7e212f1ada115 100644 --- a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_destination.stories.js +++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_destination.stories.js @@ -1,4 +1,4 @@ -import { basic } from 'jest/vue_shared/components/import_history_table/mock_data'; +import { basic } from 'jest/vue_shared/components/import/history_mock_data'; import ImportHistoryTableRowDestination from './import_history_table_row_destination.vue'; diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_errors.stories.js b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_errors.stories.js index 47139c5303c354dfe7c8cb1a66fe4f8153f8c2ea..b4415cfb00a893e6ebbae6e4672d4e8c40e0f3eb 100644 --- a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_errors.stories.js +++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_errors.stories.js @@ -1,4 +1,4 @@ -import { basic } from 'jest/vue_shared/components/import_history_table/mock_data'; +import { basic } from 'jest/vue_shared/components/import/history_mock_data'; import ImportHistoryTableRowErrors from './import_history_table_row_errors.vue'; diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.stories.js b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.stories.js index a11860f32b6cf18aba00cfbd35fc83bf02c65021..4a95a86f5b57fb807f6f36155fbcbca441eef50f 100644 --- a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.stories.js +++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.stories.js @@ -1,4 +1,4 @@ -import { basic } from 'jest/vue_shared/components/import_history_table/mock_data'; +import { basic } from 'jest/vue_shared/components/import/history_mock_data'; import ImportHistoryTableRowStats from './import_history_table_row_stats.vue'; diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.vue b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.vue index 0c21c0880dbba16e69855bc0a9751f621181bcfd..d5259d74013d26efa1ba69db8ab55fbd67c89278 100644 --- a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.vue +++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.vue @@ -52,7 +52,10 @@ export default { return RELATION_STATUS_DATA.complete; } - if (this.item.status_name === IMPORT_HISTORY_TABLE_STATUS.complete) { + if ( + this.item.status_name === IMPORT_HISTORY_TABLE_STATUS.complete || + this.item.status_name === IMPORT_HISTORY_TABLE_STATUS.failed + ) { return RELATION_STATUS_DATA.failed; } diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_source.stories.js b/app/assets/javascripts/vue_shared/components/import/import_history_table_source.stories.js index da7d37eb01c26e86f8c50bfff25a8c98d32f10b7..7850b7f7fc2b92f35d672ef0344b0fd9bebb91cb 100644 --- a/app/assets/javascripts/vue_shared/components/import/import_history_table_source.stories.js +++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_source.stories.js @@ -1,4 +1,4 @@ -import { basic } from 'jest/vue_shared/components/import_history_table/mock_data'; +import { basic } from 'jest/vue_shared/components/import/history_mock_data'; import ImportHistoryTableSource from './import_history_table_source.vue'; diff --git a/app/assets/javascripts/vue_shared/components/import/import_source_list_table.stories.js b/app/assets/javascripts/vue_shared/components/import/import_source_list_table.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..3e68bbd9706ba039b801f1729a6822f8bb784d68 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/import/import_source_list_table.stories.js @@ -0,0 +1,127 @@ +import { GlButton, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui'; +import { basic } from 'jest/vue_shared/components/import/source_list_mock_data'; +import ImportSourceListTable from './import_source_list_table.vue'; + +export default { + component: ImportSourceListTable, + title: 'vue_shared/import/import_source_list_table', +}; + +const defaultProps = basic; + +const Template = (args, { argTypes }) => ({ + components: { ImportSourceListTable, GlButton }, + directives: { GlTooltip: GlTooltipDirective }, + props: Object.keys(argTypes), + placeholderClasses: + 'gl-flex gl-border gl-border-dashed gl-p-4 gl-text-subtle gl-rounded-base gl-text-sm gl-bg-subtle', + template: `<div> + <import-source-list-table v-bind="$props"> + <template #select-all-checkbox="{ items }"> + <div :class="$options.placeholderClasses" class="!gl-p-3" v-gl-tooltip title="CHECKBOX SLOT"></div> + </template> + <template #row-checkbox="{ item }"> + <div :class="$options.placeholderClasses" class="!gl-p-3" v-gl-tooltip title="CHECKBOX SLOT"></div> + </template> + <template #destination-input="{ item }"> + <div :class="$options.placeholderClasses">DESTINATION INPUT GOES HERE</div> + </template> + <template #action="{ item }"> + <div :class="$options.placeholderClasses">ACTION GOES HERE</div> + </template> + </import-source-list-table> +</div>`, +}); + +export const Default = Template.bind({}); +Default.args = defaultProps; +Default.parameters = { + docs: { + description: { + story: `This is a bare-bones example of the import source list table component.`, + }, + }, +}; + +const FunctionalExample = (args, { argTypes }) => ({ + components: { ImportSourceListTable, GlButton, GlFormCheckbox }, + data() { + return { + selectedItems: [], + }; + }, + props: Object.keys(argTypes), + computed: { + totalSelectable() { + return this.items.filter((i) => !i.full_path).length; + }, + selectAllIndeterminate() { + return this.selectedItems.length > 0 && this.selectedItems.length < this.totalSelectable; + }, + numberToImport() { + if (this.selectedItems.length === 0) { + return this.totalSelectable; + } + + return this.selectedItems.length; + }, + allSelected() { + return this.selectedItems.length === this.totalSelectable; + }, + }, + methods: { + isSelected(id) { + return this.selectedItems.includes(id); + }, + toggleSelect(id) { + if (this.isSelected(id)) { + this.selectedItems = this.selectedItems.filter((i) => i !== id); + } else { + this.selectedItems.push(id); + } + }, + selectAll(checked) { + if (checked) { + this.selectedItems = this.items.filter((i) => !i.full_path).map((i) => i.id); + } else { + this.selectedItems = []; + } + }, + }, + placeholderClasses: + 'gl-flex gl-border gl-border-dashed gl-p-4 gl-text-subtle gl-rounded-base gl-text-sm gl-bg-subtle', + template: `<div> + <div class="gl-flex gl-bg-subtle gl-justify-end gl-p-5"> + <gl-button variant="confirm">Import {{ numberToImport }} repositor{{ numberToImport === 1 ? 'y' : 'ies' }}</gl-button> + </div> + <import-source-list-table v-bind="$props"> + <template #select-all-checkbox="{ items }"> + <gl-form-checkbox class="gl-min-h-5 gl-w-0" @change="selectAll" :indeterminate="selectAllIndeterminate" :checked="allSelected" /> + </template> + <template #row-checkbox="{ item }"> + <gl-form-checkbox class="gl-min-h-5 gl-w-0" :checked="isSelected(item.id)" @change="toggleSelect(item.id)" /> + </template> + <template #destination-input="{ item }"> + <div :class="$options.placeholderClasses">DESTINATION INPUT GOES HERE</div> + </template> + <template #action="{ item }"> + <gl-button v-if="item.action" v-bind="item.action.buttonProps">{{ + item.action.label + }}</gl-button> + </template> + </import-source-list-table> +</div>`, +}); + +export const Functional = FunctionalExample.bind({}); +Functional.args = defaultProps; + +Functional.parameters = { + docs: { + description: { + story: `This example shows an import source table that includes functional multi-select and examples of the import action buttons. + +Note that the top bar with an Import button was added separately and is not part of the table.`, + }, + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/import/import_source_list_table.vue b/app/assets/javascripts/vue_shared/components/import/import_source_list_table.vue new file mode 100644 index 0000000000000000000000000000000000000000..f480bd02e9da9b0884996dc251f85c5f5681c79d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/import/import_source_list_table.vue @@ -0,0 +1,179 @@ +<script> +import ImportHistoryTableHeader from './import_history_table_header.vue'; +import ImportHistoryTableRow from './import_history_table_row.vue'; +import ImportHistoryTableSource from './import_history_table_source.vue'; +import ImportHistoryTableRowDestination from './import_history_table_row_destination.vue'; +import ImportHistoryTableRowStats from './import_history_table_row_stats.vue'; +import ImportHistoryTableRowErrors from './import_history_table_row_errors.vue'; +import ImportHistoryStatusBadge from './import_history_status_badge.vue'; + +/** + * A flexible arrangement of import items, used for displaying projects when importing from 3rd parties. + * + * **Note**: semantically this is *not* a table, as there are nested elements and disclosures. + */ +export default { + name: 'ImportSourceListTable', + components: { + ImportHistoryStatusBadge, + ImportHistoryTableRowDestination, + ImportHistoryTableHeader, + ImportHistoryTableSource, + ImportHistoryTableRow, + ImportHistoryTableRowStats, + ImportHistoryTableRowErrors, + }, + props: { + /** + * This should be able to accept the data that comes from the BulkImport API. + * An additional `action` key is accepted to define the label and buttonProps + * for the button that can appear in the rightmost column. + * + * @typedef {Object} ImportItem + * @property {Object} source - Source project details + * @property {Object} destination - Target namespace details + * @property {Object} stats - Import statistics + * @property {number} stats.imported - Successfully imported count + * @property {number} stats.fetched - Total fetched count + * @property {Array<Object>} failures - Error details + * @property {string} failures[].correlation_id_value - Error correlation ID + * @property {string} failures[].exception_message - Human-readable error message + * @property {string} [failures[].link_text] - Optional link text for error details + * @property {string} [failures[].raw] - Raw error output + * @property {Object} [action] - Action details + * @property {string} [action.label] - Button label + * @property {Object} [action.buttonProps] - Props passed to the action button + * + * @type {Array<ImportItem>} + */ + items: { + type: Array, + default: () => [], + required: true, + }, + /** Path for links to help docs on errors. Can be injected in parent. */ + detailsPath: { + type: String, + required: false, + default: null, + }, + }, + methods: { + hasStats(item) { + return item.stats && Object.keys(item.stats).length; + }, + hasFailures(item) { + return item.has_failures; + }, + showToggle(item) { + return Boolean(this.hasStats(item)) || this.hasFailures(item) || Boolean(item.nestedRow); + }, + }, + gridClasses: 'gl-grid-cols-[repeat(2,1fr),150px,250px]', +}; +</script> + +<template> + <div> + <import-history-table-header :grid-classes="$options.gridClasses"> + <template #checkbox> + <!-- + @slot Slot for passing a checkbox to select all selectable items. + @binding items All the items + --> + <slot :items="items" name="select-all-checkbox"></slot> + </template> + <template #column-1> + {{ __('Source name') }} + </template> + <template #column-2>{{ __('Destination path') }}</template> + <template #column-3>{{ __('Status') }}</template> + </import-history-table-header> + + <import-history-table-row + v-for="item in items" + :key="item.id" + :show-toggle="showToggle(item)" + :grid-classes="$options.gridClasses" + > + <template #checkbox> + <!-- + @slot Slot for passing a checkbox for each row that can be used to select it. Only displays if the row does not have a destination defined. Renders in place of the toggle button. + @binding item The item for this row + --> + <slot name="row-checkbox" :item="item"></slot> + </template> + <template #column-1> + <import-history-table-source :item="item" /> + </template> + <template #column-2> + <import-history-table-row-destination v-if="item.destination_slug" :item="item" /> + <!-- + @slot Slot for passing a destination input for items that do not have a destination defined. + @binding item The item for this row + --> + <slot v-else :item="item" name="destination-input"></slot> + </template> + <template #column-3> + <import-history-status-badge v-if="item.status_name" :status="item.status_name" /> + </template> + <template #column-4> + <!-- + @slot Slot for passing an action for the item. This will typically be a button that imports or re-imports. + @binding item The item for this row + --> + <slot :item="item" name="action"></slot> + </template> + <template v-if="item.nestedRow" #nested-row> + <import-history-table-row + data-testid="import-history-table-row-nested" + :is-nested="true" + :show-toggle="showToggle(item.nestedRow)" + :grid-classes="$options.gridClasses" + > + <template #column-1> + <import-history-table-source :item="item.nestedRow" /> + </template> + <template #column-2> + <import-history-table-row-destination + v-if="item.destination_slug" + :item="item.nestedRow" + /> + <slot v-else :item="item" name="nested-destination-input"></slot> + </template> + <template #column-3> + <import-history-status-badge + v-if="item.nestedRow.status_name" + :status="item.nestedRow.status_name" + /> + </template> + <template #column-4> </template> + <template #expanded-content> + <import-history-table-row-stats + v-if="hasStats(item.nestedRow)" + :item="item.nestedRow" + :details-path="detailsPath" + /> + <import-history-table-row-errors + v-else-if="hasFailures(item.nestedRow)" + :item="item.nestedRow" + :details-path="detailsPath" + /> + </template> + </import-history-table-row> + </template> + <template #expanded-content> + <import-history-table-row-stats + v-if="hasStats(item)" + :item="item" + :details-path="detailsPath" + /> + <import-history-table-row-errors + v-else-if="hasFailures(item)" + :item="item" + :details-path="detailsPath" + /> + </template> + </import-history-table-row> + </div> +</template> diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 55a774bce3b031986e13c7ca5625f30aaf717795..fcfd2b7f51a4a93ad6a3cda5b339e26ddf51a3bb 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -20898,6 +20898,12 @@ msgstr "" msgid "Designs" msgstr "" +msgid "Destination" +msgstr "" + +msgid "Destination path" +msgstr "" + msgid "Destroy" msgstr "" @@ -56112,6 +56118,9 @@ msgstr "" msgid "Source is not available" msgstr "" +msgid "Source name" +msgstr "" + msgid "Source name is required" msgstr "" diff --git a/spec/frontend/vue_shared/components/import_history_table/mock_data.js b/spec/frontend/vue_shared/components/import/history_mock_data.js similarity index 100% rename from spec/frontend/vue_shared/components/import_history_table/mock_data.js rename to spec/frontend/vue_shared/components/import/history_mock_data.js diff --git a/spec/frontend/vue_shared/components/import/import_history_table.spec.js b/spec/frontend/vue_shared/components/import/import_history_table.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..39f4f18cd5eb902eeb76667a1767178478c0a56f --- /dev/null +++ b/spec/frontend/vue_shared/components/import/import_history_table.spec.js @@ -0,0 +1,89 @@ +import { GlAvatarLabeled } from '@gitlab/ui'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import ImportHistoryTable from '~/vue_shared/components/import/import_history_table.vue'; +import ImportHistoryTableRow from '~/vue_shared/components/import/import_history_table_row.vue'; +import ImportHistoryTableRowDestination from '~/vue_shared/components/import/import_history_table_row_destination.vue'; +import ImportHistoryTableSource from '~/vue_shared/components/import/import_history_table_source.vue'; +import ImportHistoryTableRowStats from '~/vue_shared/components/import/import_history_table_row_stats.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import ImportHistoryStatusBadge from '~/vue_shared/components/import/import_history_status_badge.vue'; +import ImportHistoryTableRowErrors from '~/vue_shared/components/import/import_history_table_row_errors.vue'; + +import { apiItems } from './history_mock_data'; + +import { countItemsAndNested } from './utils'; + +describe('ImportHistoryTableRowStats component', () => { + let wrapper; + + const findAllDestinations = () => wrapper.findAllComponents(ImportHistoryTableRowDestination); + const findAllErrors = () => wrapper.findAllComponents(ImportHistoryTableRowErrors); + const findAllGlAvatarLabeled = () => wrapper.findAllComponents(GlAvatarLabeled); + const findAllNestedRows = () => wrapper.findAllByTestId('import-history-table-row-nested'); + const findAllSources = () => wrapper.findAllComponents(ImportHistoryTableSource); + const findAllStats = () => wrapper.findAllComponents(ImportHistoryTableRowStats); + const findallStatusBadges = () => wrapper.findAllComponents(ImportHistoryStatusBadge); + const findAllTableRows = () => wrapper.findAllComponents(ImportHistoryTableRow); + const findAllTimeago = () => wrapper.findAllComponents(TimeAgoTooltip); + const findAllTopLevelRows = () => wrapper.findAllByTestId('import-history-table-row'); + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(ImportHistoryTable, { + propsData: { + items: apiItems, + ...props, + }, + }); + }; + + const topLevelItems = apiItems.length; + const nestedItems = apiItems.filter((i) => i.nestedRow).length; + const totalDataRows = topLevelItems + nestedItems; + + describe('renders', () => { + beforeEach(() => { + createComponent(); + }); + it('renders a row for each item and each nested item', () => { + expect(findAllTableRows().length).toBe(totalDataRows); + + expect(findAllTopLevelRows().length).toBe(apiItems.length); + + const itemsWithNestedRows = countItemsAndNested(apiItems, (i) => i.nestedRow); + expect(findAllNestedRows().length).toBe(itemsWithNestedRows); + }); + it('renders correct number of sources', () => { + expect(findAllSources().length).toBe(totalDataRows); + }); + it('renders correct number of destinations', () => { + expect(findAllDestinations().length).toBe(totalDataRows); + }); + it('renders a GlAvatarLabeled for each item where userAvatarProps is defined', () => { + const itemsWithAvatarProps = countItemsAndNested(apiItems, (i) => i.userAvatarProps); + expect(findAllGlAvatarLabeled().length).toBe(itemsWithAvatarProps); + }); + it('renders a TimeAgoTooltip for each row', () => { + expect(findAllTimeago().length).toBe(totalDataRows); + }); + it('renders status icon for each row that has status_name defined', () => { + const itemsWithStatus = countItemsAndNested(apiItems, (i) => i.status_name); + expect(findallStatusBadges().length).toBe(itemsWithStatus); + }); + it('renders stats for all items that have at least 1 stat', () => { + const itemsWithStats = countItemsAndNested( + apiItems, + (i) => i.stats && Object.keys(i.stats).length, + ); + expect(findAllStats().length).toBe(itemsWithStats); + }); + it('renders errors for all items that have at least 1 error but no stats', () => { + const itemsWithErrors = countItemsAndNested( + apiItems, + (i) => i.has_failures && !(i.stats && Object.keys(i.stats).length), + ); + expect(findAllErrors().length).toBe(itemsWithErrors); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/import_history_table/import_history_table_row_errors.spec.js b/spec/frontend/vue_shared/components/import/import_history_table_row_errors.spec.js similarity index 97% rename from spec/frontend/vue_shared/components/import_history_table/import_history_table_row_errors.spec.js rename to spec/frontend/vue_shared/components/import/import_history_table_row_errors.spec.js index e76e4a2ebbf4e8d5e5fc1537a13bcec9e85ecad4..fdd5b7bfc22a6bfa443a264e06b201ac6e9daab6 100644 --- a/spec/frontend/vue_shared/components/import_history_table/import_history_table_row_errors.spec.js +++ b/spec/frontend/vue_shared/components/import/import_history_table_row_errors.spec.js @@ -2,7 +2,7 @@ import { GlAlert, GlLink } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ImportHistoryTableRowErrors from '~/vue_shared/components/import/import_history_table_row_errors.vue'; -import { apiItems } from './mock_data'; +import { apiItems } from './history_mock_data'; describe('ImportHistoryTableRowStats component', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/import_history_table/import_history_table_row_stats.spec.js b/spec/frontend/vue_shared/components/import/import_history_table_row_stats.spec.js similarity index 97% rename from spec/frontend/vue_shared/components/import_history_table/import_history_table_row_stats.spec.js rename to spec/frontend/vue_shared/components/import/import_history_table_row_stats.spec.js index 280660585ce4202ebaa03128fd996b3f1257e62f..769d4c235e6b11e90df5d8663b2afa1f61091b3d 100644 --- a/spec/frontend/vue_shared/components/import_history_table/import_history_table_row_stats.spec.js +++ b/spec/frontend/vue_shared/components/import/import_history_table_row_stats.spec.js @@ -4,7 +4,7 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ImportHistoryTableRowStats from '~/vue_shared/components/import/import_history_table_row_stats.vue'; -import { apiItems } from './mock_data'; +import { apiItems } from './history_mock_data'; describe('ImportHistoryTableRowStats component', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/import/import_source_list_table.spec.js b/spec/frontend/vue_shared/components/import/import_source_list_table.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4e6d0b3ce9d8fb40a03da8d4264a2482a47537c6 --- /dev/null +++ b/spec/frontend/vue_shared/components/import/import_source_list_table.spec.js @@ -0,0 +1,71 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import ImportSourceListTable from '~/vue_shared/components/import/import_source_list_table.vue'; +import ImportHistoryTableRow from '~/vue_shared/components/import/import_history_table_row.vue'; +import ImportHistoryTableRowDestination from '~/vue_shared/components/import/import_history_table_row_destination.vue'; +import ImportHistoryTableSource from '~/vue_shared/components/import/import_history_table_source.vue'; +import ImportHistoryTableRowStats from '~/vue_shared/components/import/import_history_table_row_stats.vue'; +import ImportHistoryStatusBadge from '~/vue_shared/components/import/import_history_status_badge.vue'; +import ImportHistoryTableRowErrors from '~/vue_shared/components/import/import_history_table_row_errors.vue'; + +import { apiItems } from './source_list_mock_data'; + +import { countItemsAndNested } from './utils'; + +describe('ImportHistoryTableRowStats component', () => { + let wrapper; + + const findAllDestinations = () => wrapper.findAllComponents(ImportHistoryTableRowDestination); + const findAllErrors = () => wrapper.findAllComponents(ImportHistoryTableRowErrors); + const findAllSources = () => wrapper.findAllComponents(ImportHistoryTableSource); + const findAllStats = () => wrapper.findAllComponents(ImportHistoryTableRowStats); + const findallStatusBadges = () => wrapper.findAllComponents(ImportHistoryStatusBadge); + const findAllTableRows = () => wrapper.findAllComponents(ImportHistoryTableRow); + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(ImportSourceListTable, { + propsData: { + items: apiItems, + ...props, + }, + }); + }; + + const topLevelItems = apiItems.length; + const nestedItems = apiItems.filter((i) => i.nestedRow).length; + const totalDataRows = topLevelItems + nestedItems; + + describe('renders', () => { + beforeEach(() => { + createComponent(); + }); + it('renders a row for each item and each nested item', () => { + expect(findAllTableRows().length).toBe(totalDataRows); + }); + it('renders correct number of sources', () => { + expect(findAllSources().length).toBe(totalDataRows); + }); + it('renders destination for rows that have destination_slug defined', () => { + const itemsWithDestinations = countItemsAndNested(apiItems, (i) => i.destination_slug); + expect(findAllDestinations().length).toBe(itemsWithDestinations); + }); + it('renders status icon for each row that has status_name defined', () => { + const itemsWithStatus = countItemsAndNested(apiItems, (i) => i.status_name); + expect(findallStatusBadges().length).toBe(itemsWithStatus); + }); + it('renders stats for all items that have at least 1 stat', () => { + const itemsWithStats = countItemsAndNested( + apiItems, + (i) => i.stats && Object.keys(i.stats).length, + ); + expect(findAllStats().length).toBe(itemsWithStats); + }); + it('renders errors for all items that have at least 1 error but no stats', () => { + const itemsWithErrors = countItemsAndNested( + apiItems, + (i) => i.has_failures && !(i.stats && Object.keys(i.stats).length), + ); + expect(findAllErrors().length).toBe(itemsWithErrors); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/import/source_list_mock_data.js b/spec/frontend/vue_shared/components/import/source_list_mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..bd8d9cc93c68fb9706596dce41c98ef5c0619bbb --- /dev/null +++ b/spec/frontend/vue_shared/components/import/source_list_mock_data.js @@ -0,0 +1,169 @@ +import { IMPORT_HISTORY_TABLE_STATUS } from '~/vue_shared/components/import/constants'; + +const sixMonthsAgo = () => new Date(new Date().getTime() - 190 * 24 * 60 * 60 * 1000); +const fiveMinutesAgo = () => new Date(new Date().getTime() - 5 * 60 * 1000); + +export const apiItems = [ + { + id: 1, + bulk_import_id: 0, + entity_type: 'group', + source_full_path: 'https://github.com/name/', + created_at: fiveMinutesAgo(), + failures: [], + migrate_projects: true, + migrate_memberships: true, + has_failures: false, + stats: {}, + action: { + label: 'Import', + buttonProps: {}, + }, + }, + { + id: 2, + bulk_import_id: 0, + entity_type: 'group', + source_full_path: 'https://github.com/somegroup', + parent_id: null, + namespace_id: 1, + project_id: null, + created_at: fiveMinutesAgo(), + updated_at: fiveMinutesAgo(), + failures: [], + migrate_projects: true, + migrate_memberships: true, + has_failures: false, + action: { + label: 'Import', + buttonProps: {}, + }, + }, + { + id: 0, + bulk_import_id: 0, + status_name: IMPORT_HISTORY_TABLE_STATUS.inProgress, + entity_type: 'group', + source_full_path: 'https://github.com/coolworld/', + full_path: 'my-group/coolworld', + destination_name: 'coolworld', + destination_slug: 'coolworld', + destination_namespace: 'my-group', + parent_id: 0, + namespace_id: 2, + project_id: 0, + created_at: fiveMinutesAgo(), + updated_at: fiveMinutesAgo(), + migrate_projects: true, + migrate_memberships: true, + has_failures: true, + nestedRow: { + id: 214, + bulk_import_id: 0, + status_name: IMPORT_HISTORY_TABLE_STATUS.failed, + entity_type: 'project', + source_full_path: 'https://github.com/name/project.git', + full_path: 'my-group/coolworld/project', + destination_name: 'coolworld/project', + destination_slug: 'coolworld/project', + destination_namespace: 'my-group/coolworld', + parent_id: 0, + namespace_id: 2, + project_id: 0, + created_at: fiveMinutesAgo(), + updated_at: fiveMinutesAgo(), + failures: [ + { + relation: 'design', + exception_message: 'custom error message', + exception_class: 'Exception', + correlation_id_value: 'dfcf583058ed4508e4c7c617bd7f0edd', + source_url: 'https://github.com/name/project.git', + source_title: 'some title', + }, + ], + migrate_projects: true, + migrate_memberships: true, + has_failures: true, + stats: { + milestones: { + source: 10, + fetched: 10, + imported: 10, + }, + labels: { + source: 10, + fetched: 10, + imported: 3, + }, + design: { + source: 10, + fetched: 10, + imported: 3, + }, + }, + }, + action: { + label: 'Re-import as a new group', + buttonProps: {}, + }, + }, + { + id: 4, + bulk_import_id: 0, + status_name: IMPORT_HISTORY_TABLE_STATUS.complete, + entity_type: 'group', + source_full_path: 'https://github.com/name', + full_path: 'my-group/name', + destination_name: 'name', + destination_slug: 'name', + destination_namespace: 'my-group', + parent_id: 0, + namespace_id: 2, + project_id: 0, + created_at: sixMonthsAgo(), + updated_at: sixMonthsAgo(), + migrate_projects: true, + migrate_memberships: true, + has_failures: true, + nestedRow: { + id: 214, + bulk_import_id: 0, + status_name: IMPORT_HISTORY_TABLE_STATUS.complete, + entity_type: 'project', + source_full_path: 'https://github.com/name/project.git', + full_path: 'my-group/project', + destination_name: 'project', + destination_slug: 'project', + destination_namespace: 'my-group', + parent_id: 0, + namespace_id: 2, + project_id: 0, + created_at: sixMonthsAgo(), + updated_at: sixMonthsAgo(), + migrate_projects: true, + migrate_memberships: true, + has_failures: true, + stats: { + labels: { + source: 10, + fetched: 10, + imported: 10, + }, + milestones: { + source: 10, + fetched: 10, + imported: 10, + }, + }, + }, + action: { + label: 'Re-import as a new group', + buttonProps: {}, + }, + }, +]; + +export const basic = { + items: apiItems, +}; diff --git a/spec/frontend/vue_shared/components/import/utils.js b/spec/frontend/vue_shared/components/import/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..11614840ba71e47896512089dd97998ca4aca3b3 --- /dev/null +++ b/spec/frontend/vue_shared/components/import/utils.js @@ -0,0 +1,14 @@ +/** + * Convenience function for searching for matches in top-level items and nested items in the Items data for import tables + * @param {Array} items Items to count + * @param {Function} filter Filter function to return matches + * @returns number + */ +export const countItemsAndNested = (items, filter) => { + const topLevelMatches = items.filter(filter); + const nestedMatches = items + .filter((i) => i.nestedRow) + .map((i) => i.nestedRow) + .filter(filter); + return topLevelMatches.length + nestedMatches.length; +};